diff --git a/features/api/auth.go b/features/api/auth.go index 4f0cf59..4d78686 100644 --- a/features/api/auth.go +++ b/features/api/auth.go @@ -3,12 +3,26 @@ package api import ( "log/slog" "net/http" + "net/url" "strings" + "sync" "atmoquest/internal/connection" + "atmoquest/internal/oauthclient" "atmoquest/internal/users" ) +// upgradeState tracks a mobile API scope upgrade in flight so the callback +// can clean up the old OAuth session after the new one is established. +type upgradeState struct { + OldSid string +} + +var ( + upgradeMu sync.RWMutex + pendingUpgrade = make(map[string]upgradeState) // OAuth state → old session info +) + // sessionResponse is the JSON shape for GET /api/v1/auth/session. type sessionResponse struct { DID string `json:"did"` @@ -55,9 +69,9 @@ func (h *Handlers) OAuthLogin(w http.ResponseWriter, r *http.Request) { return } - redirectURL, err := h.Auth.OAuth.StartAuthFlow(r.Context(), identifier) + redirectURL, err := h.Auth.StartMinimalAuthFlow(r.Context(), identifier) if err != nil { - slog.Warn("api: oauth start", + slog.Warn("api: oauth login", "identifier", identifier, "err", err, ) @@ -70,11 +84,84 @@ func (h *Handlers) OAuthLogin(w http.ResponseWriter, r *http.Request) { }) } +// OAuthUpgrade starts a scope upgrade flow for mobile clients. +// POST /api/v1/oauth/upgrade +// +// Response: {"authUrl": "https://...", "state": "..."} +func (h *Handlers) OAuthUpgrade(w http.ResponseWriter, r *http.Request) { + did, sess, ok := h.requireSession(w, r) + if !ok { + return + } + + if oauthclient.HasBskyProfileScope(sess) { + writeJSON(w, http.StatusOK, map[string]string{ + "status": "already_granted", + "message": "repo:app.bsky.actor.profile scope already granted", + }) + return + } + + pdsURL := sess.Data.HostURL + if pdsURL == "" { + writeError(w, http.StatusInternalServerError, "no PDS host URL available") + return + } + + authserverURL, err := h.Auth.OAuth.Resolver.ResolveAuthServerURL(r.Context(), pdsURL) + if err != nil { + slog.Error("api: oauth upgrade resolve URL", "did", did.String(), "err", err) + writeError(w, http.StatusInternalServerError, "couldn't resolve authorization server") + return + } + + authMeta, err := h.Auth.OAuth.Resolver.ResolveAuthServerMetadata(r.Context(), authserverURL) + if err != nil { + slog.Error("api: oauth upgrade resolve meta", "did", did.String(), "err", err) + writeError(w, http.StatusInternalServerError, "couldn't resolve authorization server metadata") + return + } + + info, err := h.Auth.OAuth.SendAuthRequest(r.Context(), authMeta, oauthclient.FullScopes, did.String()) + if err != nil { + slog.Error("api: oauth upgrade send", "did", did.String(), "err", err) + writeError(w, http.StatusInternalServerError, "couldn't start upgrade flow") + return + } + + info.AccountDID = &did + + if err := h.Auth.OAuth.Store.SaveAuthRequestInfo(r.Context(), *info); err != nil { + slog.Error("api: oauth upgrade save", "did", did.String(), "err", err) + writeError(w, http.StatusInternalServerError, "couldn't persist auth request") + return + } + + params := make(url.Values) + params.Set("client_id", h.Auth.OAuth.Config.ClientID) + params.Set("request_uri", info.RequestURI) + redirectURL := authMeta.AuthorizationEndpoint + "?" + params.Encode() + + upgradeMu.Lock() + pendingUpgrade[info.State] = upgradeState{OldSid: sess.Data.SessionID} + upgradeMu.Unlock() + + slog.Info("api: oauth upgrade started", "did", did.String()) + writeJSON(w, http.StatusOK, map[string]string{ + "authUrl": redirectURL, + }) +} + // OAuthCallback completes the OAuth flow for mobile clients. // GET /api/v1/oauth/callback?code=...&state=... // +// When called as part of a scope upgrade (state matches pendingUpgrade), +// the old OAuth session is cleaned up and a new bearer token is issued. +// // Response: {"token": "...", "did": "...", "handle": "..."} func (h *Handlers) OAuthCallback(w http.ResponseWriter, r *http.Request) { + state := r.URL.Query().Get("state") + sessData, err := h.Auth.OAuth.ProcessCallback(r.Context(), r.URL.Query()) if err != nil { slog.Warn("api: oauth callback", "err", err) @@ -82,25 +169,41 @@ func (h *Handlers) OAuthCallback(w http.ResponseWriter, r *http.Request) { return } - // Issue a Bearer token for the mobile app. - token := h.Tokens.Issue(sessData.AccountDID.String(), sessData.SessionID) + // Check if this is a scope upgrade — clean up old session. + upgradeMu.Lock() + old, isUpgrade := pendingUpgrade[state] + if isUpgrade { + delete(pendingUpgrade, state) + } + upgradeMu.Unlock() - // Best-effort: record login in users table. - go h.Auth.RecordUserLogin(sessData) - - // Best-effort: drain pending connections. - if h.ConnQueue != nil { - sess, err := h.Auth.OAuth.ResumeSession(r.Context(), sessData.AccountDID, sessData.SessionID) - if err == nil { - res, err := connection.Drain(r.Context(), h.ConnQueue, sess, sess.Data.HostURL, slog.Default(), nil) - if err != nil { - slog.Warn("api: connect drain", "did", sessData.AccountDID.String(), "err", err) - } else if res.Written > 0 || res.Skipped > 0 { - slog.Info("api: connect drain", "did", sessData.AccountDID.String(), "written", res.Written, "skipped", res.Skipped) + if isUpgrade && old.OldSid != "" { + if err := h.Auth.OAuth.Store.DeleteSession(r.Context(), sessData.AccountDID, old.OldSid); err != nil { + slog.Warn("api: oauth upgrade cleanup", "did", sessData.AccountDID.String(), "err", err) + } else { + slog.Info("api: oauth upgrade complete", "did", sessData.AccountDID.String()) + } + } else { + // Best-effort: record login in users table (skip for upgrades). + go h.Auth.RecordUserLogin(sessData) + + // Best-effort: drain pending connections. + if h.ConnQueue != nil { + sess, err := h.Auth.OAuth.ResumeSession(r.Context(), sessData.AccountDID, sessData.SessionID) + if err == nil { + res, err := connection.Drain(r.Context(), h.ConnQueue, sess, sess.Data.HostURL, slog.Default(), nil) + if err != nil { + slog.Warn("api: connect drain", "did", sessData.AccountDID.String(), "err", err) + } else if res.Written > 0 || res.Skipped > 0 { + slog.Info("api: connect drain", "did", sessData.AccountDID.String(), "written", res.Written, "skipped", res.Skipped) + } } } } + // Issue a Bearer token for the mobile app (always, regardless of upgrade). + token := h.Tokens.Issue(sessData.AccountDID.String(), sessData.SessionID) + handle := h.resolveHandle(r, sessData.AccountDID.String()) writeJSON(w, http.StatusOK, map[string]string{ diff --git a/features/api/routes.go b/features/api/routes.go index 2f27e79..f546587 100644 --- a/features/api/routes.go +++ b/features/api/routes.go @@ -24,6 +24,7 @@ func SetupRoutes(router chi.Router, conn *sql.DB, authH *auth.Handlers, connQueu // Auth r.Get("/auth/session", h.GetSession) r.Post("/oauth/login", h.OAuthLogin) + r.Post("/oauth/upgrade", h.OAuthUpgrade) r.Get("/oauth/callback", h.OAuthCallback) r.Post("/oauth/logout", h.Logout) diff --git a/features/auth/handlers.go b/features/auth/handlers.go index 12e7c1f..f781e23 100644 --- a/features/auth/handlers.go +++ b/features/auth/handlers.go @@ -8,6 +8,7 @@ import ( "database/sql" "encoding/json" "errors" + "fmt" "io" "log/slog" "net/http" @@ -21,6 +22,7 @@ import ( "atmoquest/features/auth/pages" "atmoquest/internal/connection" + "atmoquest/internal/oauthclient" "atmoquest/internal/profile" "atmoquest/internal/session" "atmoquest/internal/users" @@ -97,8 +99,9 @@ func (h *Handlers) SigninATProto(w http.ResponseWriter, r *http.Request) { } } -// OAuthLogin starts the OAuth flow: takes a handle / DID / PDS URL, calls -// indigo's StartAuthFlow, redirects to the AS. +// OAuthLogin starts the OAuth flow with the minimal scope set. Takes a +// handle / DID / PDS URL, resolves identity, sends PAR with DefaultScopes, +// and redirects to the AS. func (h *Handlers) OAuthLogin(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { http.Error(w, "invalid form", http.StatusBadRequest) @@ -112,17 +115,13 @@ func (h *Handlers) OAuthLogin(w http.ResponseWriter, r *http.Request) { return } - redirectURL, err := h.OAuth.StartAuthFlow(r.Context(), identifier) + redirectURL, err := h.StartMinimalAuthFlow(r.Context(), identifier) if err != nil { - // Indigo already emits a slog.Warn with the full AS response body - // from parseAuthErrorReason — check the line above this one in the - // log for the AS's error_description. We log identifier + client_id - // + callback_url here so the two log entries are easy to correlate. - slog.Warn("oauth start failed", + slog.Warn("oauth login failed", "identifier", identifier, "client_id", h.OAuth.Config.ClientID, "callback_url", h.OAuth.Config.CallbackURL, - "scopes", h.OAuth.Config.Scopes, + "scopes", oauthclient.DefaultScopes, "err", err, ) w.Header().Set("Content-Type", "text/html; charset=utf-8") @@ -130,7 +129,132 @@ func (h *Handlers) OAuthLogin(w http.ResponseWriter, r *http.Request) { _ = pages.SigninATProto("couldn't start sign-in: "+sanitizeAuthError(err)).Render(r.Context(), w) return } - slog.Info("oauth start ok", "identifier", identifier, "redirect", redirectURL) + slog.Info("oauth login ok", "identifier", identifier, "redirect", redirectURL) + http.Redirect(w, r, redirectURL, http.StatusFound) +} + +// StartMinimalAuthFlow replicates indigo's StartAuthFlow logic but sends +// only the DefaultScopes (without repo:app.bsky.actor.profile). The client +// config's FullScopes is still used for client metadata so the AS knows the +// client may request the broader set on upgrade. +func (h *Handlers) StartMinimalAuthFlow(ctx context.Context, identifier string) (string, error) { + var authserverURL string + var accountDID syntax.DID + + if strings.HasPrefix(identifier, "https://") { + authserverURL = identifier + identifier = "" + } else { + atid, err := syntax.ParseAtIdentifier(identifier) + if err != nil { + return "", fmt.Errorf("not a valid account identifier (%s): %w", identifier, err) + } + ident, err := h.OAuth.Dir.Lookup(ctx, atid) + if err != nil { + return "", fmt.Errorf("failed to resolve username (%s): %w", identifier, err) + } + accountDID = ident.DID + host := ident.PDSEndpoint() + if host == "" { + return "", fmt.Errorf("identity does not link to an atproto host (PDS)") + } + authserverURL, err = h.OAuth.Resolver.ResolveAuthServerURL(ctx, host) + if err != nil { + return "", fmt.Errorf("resolving auth server: %w", err) + } + } + + authserverMeta, err := h.OAuth.Resolver.ResolveAuthServerMetadata(ctx, authserverURL) + if err != nil { + return "", fmt.Errorf("fetching auth server metadata: %w", err) + } + + info, err := h.OAuth.SendAuthRequest(ctx, authserverMeta, oauthclient.DefaultScopes, identifier) + if err != nil { + return "", fmt.Errorf("auth request failed: %w", err) + } + + if accountDID != "" { + info.AccountDID = &accountDID + } + + if err := h.OAuth.Store.SaveAuthRequestInfo(ctx, *info); err != nil { + return "", fmt.Errorf("persist auth request: %w", err) + } + + params := url.Values{} + params.Set("client_id", h.OAuth.Config.ClientID) + params.Set("request_uri", info.RequestURI) + return authserverMeta.AuthorizationEndpoint + "?" + params.Encode(), nil +} + +// OAuthUpgrade starts an OAuth flow with the full scope set so the user +// can grant repo:app.bsky.actor.profile access to edit display name/avatar. +// The existing session remains valid until the callback completes. +func (h *Handlers) OAuthUpgrade(w http.ResponseWriter, r *http.Request) { + did, sess, err := h.ResumeSession(r) + if err != nil { + http.Redirect(w, r, "/signin", http.StatusFound) + return + } + + if oauthclient.HasBskyProfileScope(sess) { + http.Redirect(w, r, "/profile/edit", http.StatusFound) + return + } + + http.SetCookie(w, &http.Cookie{ + Name: "upgrade_pending", + Value: sess.Data.SessionID, + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + Secure: r.TLS != nil, + MaxAge: 600, + }) + + pdsURL := sess.Data.HostURL + if pdsURL == "" { + slog.Error("oauth upgrade: no host URL", "did", did.String()) + http.Error(w, "no PDS host URL available", http.StatusInternalServerError) + return + } + + authserverURL, err := h.OAuth.Resolver.ResolveAuthServerURL(r.Context(), pdsURL) + if err != nil { + slog.Error("oauth upgrade: resolve auth server URL", "did", did.String(), "pds", pdsURL, "err", err) + http.Error(w, "couldn't resolve authorization server", http.StatusInternalServerError) + return + } + + authMeta, err := h.OAuth.Resolver.ResolveAuthServerMetadata(r.Context(), authserverURL) + if err != nil { + slog.Error("oauth upgrade: resolve auth server metadata", "did", did.String(), "err", err) + http.Error(w, "couldn't resolve authorization server metadata", http.StatusInternalServerError) + return + } + + info, err := h.OAuth.SendAuthRequest(r.Context(), authMeta, oauthclient.FullScopes, did.String()) + if err != nil { + slog.Error("oauth upgrade: send auth request", "did", did.String(), "err", err) + http.Error(w, "couldn't start upgrade flow", http.StatusInternalServerError) + return + } + + info.AccountDID = &did + + if err := h.OAuth.Store.SaveAuthRequestInfo(r.Context(), *info); err != nil { + slog.Error("oauth upgrade: save auth request info", "did", did.String(), "err", err) + http.Error(w, "couldn't persist auth request", http.StatusInternalServerError) + return + } + + params := url.Values{} + params.Set("client_id", h.OAuth.Config.ClientID) + params.Set("request_uri", info.RequestURI) + redirectURL := authMeta.AuthorizationEndpoint + "?" + params.Encode() + + slog.Info("oauth upgrade started", "did", did.String()) http.Redirect(w, r, redirectURL, http.StatusFound) } @@ -157,6 +281,24 @@ func (h *Handlers) OAuthCallback(w http.ResponseWriter, r *http.Request) { return } + // Check for upgrade flow — clean up old session and redirect to profile + // editor so the user can immediately use the newly-granted scope. + if upgradeCookie, err := r.Cookie("upgrade_pending"); err == nil && upgradeCookie.Value != "" { + oldSid := upgradeCookie.Value + http.SetCookie(w, &http.Cookie{ + Name: "upgrade_pending", + Value: "", + Path: "/", + MaxAge: -1, + }) + slog.Info("oauth upgrade complete", "did", sessData.AccountDID.String()) + if err := h.OAuth.Store.DeleteSession(r.Context(), sessData.AccountDID, oldSid); err != nil { + slog.Warn("oauth upgrade: delete old session", "did", sessData.AccountDID.String(), "old_sid", oldSid, "err", err) + } + http.Redirect(w, r, "/profile/edit", http.StatusFound) + return + } + // Best-effort: touch the users table so the admin UI knows about this DID. // Enrichment (handle resolution, Bluesky profile fetch) lands when the // profile + admin features are ported. diff --git a/features/auth/pages/signin_templ.go b/features/auth/pages/signin_templ.go index 49bdf33..ad76401 100644 --- a/features/auth/pages/signin_templ.go +++ b/features/auth/pages/signin_templ.go @@ -5,12 +5,13 @@ package pages //lint:file-ignore SA4006 This context is only used if a nested component is present. +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + import ( - "atmoquest/features/common/layouts" "net/url" - "github.com/a-h/templ" - templruntime "github.com/a-h/templ/runtime" + "atmoquest/features/common/layouts" ) // Signin renders the chooser: existing Atmosphere account vs create a new one. @@ -61,26 +62,26 @@ func Signin(next string) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "atmo.quest:~$ auth --pick
atmo.quest is built on ATProto. Your records live in your repo, not ours. You'll sign in with the same identity you use anywhere on the open social web.
you.bsky.social or any ATProto PDS? Go this way.atmo.quest is built on ATProto. Your records live in your repo, not ours. You'll sign in with the same identity you use anywhere on the open social web.
you.bsky.social or any ATProto PDS? Go this way.