// Package auth wires the OAuth-backed sign-in / callback / logout endpoints, // the signin page chooser, and a ResumeSession helper that other features // use to look up the current viewer's indigo OAuth session from the cookie. package auth import ( "context" "database/sql" "encoding/json" "errors" "fmt" "io" "log/slog" "net/http" "net/url" "strings" "time" "github.com/bluesky-social/indigo/atproto/auth/oauth" "github.com/bluesky-social/indigo/atproto/syntax" "github.com/google/uuid" "atmoquest/features/auth/pages" "atmoquest/internal/connection" "atmoquest/internal/oauthclient" "atmoquest/internal/profile" "atmoquest/internal/session" "atmoquest/internal/users" ) // Handlers holds the dependencies the auth feature needs. type Handlers struct { DB *sql.DB OAuth *oauth.ClientApp Sessions *session.Manager // ConnQueue is the SQLite-backed pending-connection queue. May be nil in // tests; when non-nil, OAuthCallback drains it on successful login. ConnQueue *connection.Queue } // NewHandlers wires the auth feature. func NewHandlers(conn *sql.DB, oauthApp *oauth.ClientApp, sess *session.Manager) *Handlers { return &Handlers{DB: conn, OAuth: oauthApp, Sessions: sess} } // HandleSearch proxies app.bsky.actor.searchActorsTypeahead for the sign-in // autocomplete. Routing through our backend avoids CORS issues and gives us a // single place to add other AppView sources in the future. // // The Bluesky public AppView indexes the full ATProto relay, so results include // users on any federated PDS — not just bsky.social. func (h *Handlers) HandleSearch(w http.ResponseWriter, r *http.Request) { q := r.URL.Query().Get("q") if len(q) < 2 { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"actors":[]}`)) return } upstream := "https://public.api.bsky.app/xrpc/app.bsky.actor.searchActorsTypeahead?q=" + url.QueryEscape(q) + "&limit=8" ctx, cancel := context.WithTimeout(r.Context(), 4*time.Second) defer cancel() req, err := http.NewRequestWithContext(ctx, http.MethodGet, upstream, nil) if err != nil { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"actors":[]}`)) return } resp, err := http.DefaultClient.Do(req) if err != nil { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"actors":[]}`)) return } defer resp.Body.Close() w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") _, _ = io.Copy(w, resp.Body) } // Signin renders the chooser page: sign in vs create new Atmosphere account. func (h *Handlers) Signin(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := pages.Signin(r.URL.Query().Get("next")).Render(r.Context(), w); err != nil { slog.Error("render signin", "err", err) } } // SigninATProto renders the handle-entry form for the existing-account path. func (h *Handlers) SigninATProto(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := pages.SigninATProto("").Render(r.Context(), w); err != nil { slog.Error("render signin/atproto", "err", err) } } // 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) return } identifier := strings.TrimSpace(r.FormValue("handle")) if identifier == "" { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusBadRequest) _ = pages.SigninATProto("enter a handle, DID, or PDS URL").Render(r.Context(), w) return } redirectURL, err := h.StartMinimalAuthFlow(r.Context(), identifier) if err != nil { slog.Warn("oauth login failed", "identifier", identifier, "client_id", h.OAuth.Config.ClientID, "callback_url", h.OAuth.Config.CallbackURL, "scopes", oauthclient.DefaultScopes, "err", err, ) w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusBadRequest) _ = pages.SigninATProto("couldn't start sign-in: "+sanitizeAuthError(err)).Render(r.Context(), w) return } 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) } // OAuthCallback completes the OAuth flow: indigo verifies the code, persists // the session, and we set the user-facing session cookie. func (h *Handlers) OAuthCallback(w http.ResponseWriter, r *http.Request) { sessData, err := h.OAuth.ProcessCallback(r.Context(), r.URL.Query()) if err != nil { slog.Warn("oauth callback", "err", err) var asErr *oauth.AuthRequestCallbackError msg := "sign-in failed" if errors.As(err, &asErr) { msg = "sign-in failed: " + sanitizeASCode(asErr.ErrorCode) } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusBadRequest) _ = pages.SigninATProto(msg).Render(r.Context(), w) return } if err := h.Sessions.Set(w, r, sessData.AccountDID.String(), sessData.SessionID); err != nil { slog.Error("set session cookie", "err", err) http.Error(w, "internal error", http.StatusInternalServerError) 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. go h.RecordUserLogin(sessData) // Best-effort: crawl the user's PDS for checkin and connection records, // resolve referenced events, and upsert everything into local SQLite. // This ensures past events the user attended are available in the DB // without requiring admin intervention. go h.SyncPDSHistory(sessData) // Best-effort: flush any reciprocal connection writes queued while // this user was offline. Runs synchronously so the user lands on // /profile with their freshly-flushed connections visible. Errors here // don't block the redirect — Drain swallows per-row failures and // leaves them in the queue for the next login. if h.ConnQueue != nil { sess, err := h.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(), AutoCheckinDrainHook(h.DB)) if err != nil { slog.Warn("connect drain", "did", sessData.AccountDID.String(), "err", err) } else if res.Written > 0 || res.Skipped > 0 { slog.Info("connect drain", "did", sessData.AccountDID.String(), "written", res.Written, "skipped", res.Skipped) } } else { slog.Warn("connect drain: resume session", "did", sessData.AccountDID.String(), "err", err) } } // Check if there's a local account to link // We need to do this after the cookie is set, but we can't call // LinkLocalToATProto directly because it writes headers. // Instead, we'll set a flag and redirect to a link endpoint. if h.Sessions.GetLocal(r) != "" { http.Redirect(w, r, "/auth/link", http.StatusFound) return } http.Redirect(w, r, "/profile", http.StatusFound) } // OAuthLogout revokes tokens (best-effort), clears the session, redirects home. func (h *Handlers) OAuthLogout(w http.ResponseWriter, r *http.Request) { did, sid := h.Sessions.Get(r) if did != "" && sid != "" { if parsed, err := syntax.ParseDID(did); err == nil { if err := h.OAuth.Logout(r.Context(), parsed, sid); err != nil { slog.Warn("oauth logout", "did", did, "err", err) } } } h.Sessions.Clear(w, r) http.Redirect(w, r, "/", http.StatusFound) } // OAuthClientMetadata serves the public /oauth/client-metadata.json that the // AS fetches to learn about us. In localhost-dev mode the URL is never // actually fetched, but we serve it anyway for parity. func (h *Handlers) OAuthClientMetadata(w http.ResponseWriter, _ *http.Request) { doc := h.OAuth.Config.ClientMetadata() w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "public, max-age=300") if err := json.NewEncoder(w).Encode(doc); err != nil { slog.Error("encode client metadata", "err", err) } } // OAuthJWKS serves the public JWKS for confidential clients. Returns an empty // keys array for public clients. func (h *Handlers) OAuthJWKS(w http.ResponseWriter, _ *http.Request) { jwks := h.OAuth.Config.PublicJWKS() w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "public, max-age=300") if err := json.NewEncoder(w).Encode(jwks); err != nil { slog.Error("encode jwks", "err", err) } } // RecordUserLogin upserts a row in the users table for this DID and tries // to enrich it with the user's current Bluesky display name. Runs in a // goroutine off the OAuth callback's request context — uses // context.Background() with a short timeout so a slow PDS doesn't keep the // goroutine alive forever. All errors are logged; nothing is returned. // // Handle resolution (via an atproto identity directory) is deferred until // the admin step; the users.Touch COALESCEs. func (h *Handlers) RecordUserLogin(sessData *oauth.ClientSessionData) { if h.DB == nil || sessData == nil { return } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() did := sessData.AccountDID displayName := "" // Best-effort: fetch the public Bluesky profile from the user's PDS to // pick up display_name. A miss is fine — users.Touch COALESCEs. if sessData.HostURL != "" { if bsky, err := profile.FetchBluesky(ctx, sessData.HostURL, did); err == nil && bsky != nil { displayName = bsky.DisplayName } else if err != nil { slog.Debug("recordUserLogin: bluesky fetch", "did", did.String(), "err", err) } } if err := users.Touch(ctx, h.DB, did, "", displayName); err != nil { slog.Warn("recordUserLogin: users.Touch", "did", did.String(), "err", err) } } // sanitizeAuthError strips anything that looks like a token or a long opaque // string from an error message before showing it in HTML. Belt-and-braces; // templ also escapes the resulting string. func sanitizeAuthError(err error) string { s := err.Error() if len(s) > 200 { return s[:200] + "…" } return s } // sanitizeASCode whitelists characters allowed in an OAuth error code so we // can render an AS-provided string without worrying about it. func sanitizeASCode(code string) string { if code == "" { return "unknown error" } out := make([]byte, 0, len(code)) for i := 0; i < len(code) && i < 64; i++ { c := code[i] switch { case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '_', c == '-': out = append(out, c) } } if len(out) == 0 { return "unknown error" } return string(out) } // ContinueWithoutAccount renders the local account creation form (GET) or // validates and creates the account (POST). Requires a non-empty display name // and email. Optionally stores a follow-up flag. func (h *Handlers) ContinueWithoutAccount(w http.ResponseWriter, r *http.Request) { nextDest := r.URL.Query().Get("next") if nextDest == "" { nextDest = "/profile" } // Check if already authenticated (ATProto or local) if _, _, err := h.ResumeSession(r); err == nil { http.Redirect(w, r, nextDest, http.StatusFound) return } // Check current local cookie first. existingLocalID := h.Sessions.GetLocal(r) if existingLocalID != "" { var exists string err := h.DB.QueryRowContext(r.Context(), ` SELECT did FROM users WHERE did = ? `, existingLocalID).Scan(&exists) if err == nil { http.Redirect(w, r, nextDest, http.StatusFound) return } } // Check known-accounts cookie for recovery. known := h.Sessions.GetKnownLocals(r) for _, id := range known { if id == "" { continue } var exists string err := h.DB.QueryRowContext(r.Context(), ` SELECT did FROM users WHERE did = ? `, id).Scan(&exists) if err == nil { slog.Info("continue without account: recovering known local account", "local_id", id) if err := h.Sessions.SetLocal(w, id); err != nil { slog.Error("continue without account: set local cookie", "err", err) http.Error(w, "failed to set cookie", http.StatusInternalServerError) return } http.Redirect(w, r, nextDest, http.StatusFound) return } } // No recoverable account — show form on GET, create on POST. if r.Method == http.MethodGet { view := pages.ContinueLocalView{Next: nextDest} w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := pages.ContinueLocal(view).Render(r.Context(), w); err != nil { slog.Error("continue without account: render form", "err", err) } return } // POST: validate and create. displayName := strings.TrimSpace(r.FormValue("display_name")) email := strings.TrimSpace(r.FormValue("email")) followUp := r.FormValue("follow_up") == "1" view := pages.ContinueLocalView{ Next: nextDest, DisplayName: displayName, Email: email, FollowUp: followUp, } if displayName == "" { view.Error = "display name is required" w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusBadRequest) if err := pages.ContinueLocal(view).Render(r.Context(), w); err != nil { slog.Error("continue without account: render error", "err", err) } return } if email == "" { view.Error = "email is required" w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusBadRequest) if err := pages.ContinueLocal(view).Render(r.Context(), w); err != nil { slog.Error("continue without account: render error", "err", err) } return } // Create the account. localID := "local_" + uuid.New().String() followUpVal := 0 if followUp { followUpVal = 1 } _, err := h.DB.ExecContext(r.Context(), ` INSERT OR IGNORE INTO users (did, handle, user_type, display_name, email, follow_up) VALUES (?, '', 'local', ?, ?, ?) `, localID, displayName, email, followUpVal) if err != nil { slog.Error("continue without account: create user", "err", err) view.Error = "failed to create account" w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusInternalServerError) if err := pages.ContinueLocal(view).Render(r.Context(), w); err != nil { slog.Error("continue without account: render error", "err", err) } return } // Update display_name if INSERT OR IGNORE matched an existing row // (shouldn't happen with UUID-based IDs, but be safe). _, _ = h.DB.ExecContext(r.Context(), ` UPDATE users SET display_name = ?, email = ?, follow_up = ? WHERE did = ? AND display_name = '' `, displayName, email, followUpVal, localID) var exists string err = h.DB.QueryRowContext(r.Context(), ` SELECT did FROM users WHERE did = ? `, localID).Scan(&exists) if err != nil { slog.Error("continue without account: verify user", "err", err) http.Error(w, "user not found after creation", http.StatusInternalServerError) return } if err := h.Sessions.SetLocal(w, localID); err != nil { slog.Error("continue without account: set local cookie", "err", err) http.Error(w, "failed to set cookie", http.StatusInternalServerError) return } h.Sessions.RememberLocal(w, localID) http.Redirect(w, r, nextDest, http.StatusFound) } // LocalLogout clears the local session cookie and redirects home. // Before clearing, the local ID is saved to the known-accounts cookie so the // user can recover their account on the next visit. func (h *Handlers) LocalLogout(w http.ResponseWriter, r *http.Request) { if id := h.Sessions.GetLocal(r); id != "" { h.Sessions.RememberLocal(w, id) } h.Sessions.ClearLocal(w) http.Redirect(w, r, "/", http.StatusFound) }