diff --git a/.gitignore b/.gitignore index 99c46c6..f26189a 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ !*.go !*.templ +!*.sql !go.sum !go.mod diff --git a/cmd/web/main.go b/cmd/web/main.go index 49626af..c27ce90 100644 --- a/cmd/web/main.go +++ b/cmd/web/main.go @@ -1,14 +1,18 @@ package main import ( + "atmoquest/config" + "atmoquest/internal/db" + "atmoquest/internal/oauthclient" + "atmoquest/internal/oauthstore" + "atmoquest/internal/session" + "atmoquest/nats" + "atmoquest/router" "context" "fmt" "log/slog" "net" "net/http" - "atmoquest/config" - "atmoquest/nats" - "atmoquest/router" "os" "os/signal" "time" @@ -16,7 +20,6 @@ import ( "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/httplog/v3" - "github.com/gorilla/sessions" "golang.org/x/sync/errgroup" ) @@ -37,18 +40,36 @@ func run(ctx context.Context) error { })) slog.SetDefault(logger) + if err := config.Global.Validate(); err != nil { + return fmt.Errorf("invalid config: %w", err) + } + + conn, err := db.Open(config.Global.DatabaseURL) + if err != nil { + return fmt.Errorf("open db: %w", err) + } + defer conn.Close() + if err := db.Migrate(conn); err != nil { + return fmt.Errorf("migrate db: %w", err) + } + slog.Info("db ready", "dsn", config.Global.DatabaseURL) + r := chi.NewMux() r.Use( httplog.RequestLogger(logger, nil), middleware.Recoverer, ) - sessionStore := sessions.NewCookieStore([]byte(config.Global.SessionSecret)) - sessionStore.MaxAge(86400 * 30) - sessionStore.Options.Path = "/" - sessionStore.Options.HttpOnly = true - sessionStore.Options.Secure = false - sessionStore.Options.SameSite = http.SameSiteLaxMode + sessionMgr, err := session.New(config.Global) + if err != nil { + return fmt.Errorf("session manager: %w", err) + } + + oauthApp, oauthClientID, err := oauthclient.Build(config.Global, oauthstore.New(conn)) + if err != nil { + return fmt.Errorf("build oauth client: %w", err) + } + slog.Info("oauth client ready", "client_id", oauthClientID, "localhost", config.Global.IsLocalhost()) ns, err := nats.SetupNATS(ctx) if err != nil { @@ -57,7 +78,7 @@ func run(ctx context.Context) error { eg, egctx := errgroup.WithContext(ctx) - if err := router.SetupRoutes(egctx, r, sessionStore, ns); err != nil { + if err := router.SetupRoutes(egctx, r, sessionMgr, oauthApp, ns, conn); err != nil { return fmt.Errorf("error setting up routes: %w", err) } diff --git a/config/config.go b/config/config.go index bf552e0..bee1c8e 100644 --- a/config/config.go +++ b/config/config.go @@ -1,8 +1,10 @@ package config import ( + "fmt" "log/slog" "os" + "strings" "sync" "github.com/joho/godotenv" @@ -16,11 +18,30 @@ const ( ) type Config struct { - Environment Environment - Host string - Port string - LogLevel slog.Level + Environment Environment + Host string + Port string + LogLevel slog.Level + + // SessionSecret is the HMAC key for gorilla cookie sessions. Required + // in prod; defaulted in dev so getting started is friction-free. SessionSecret string + + // PublicURL is the externally-visible origin of this server, used to + // derive OAuth client_id, redirect URIs, and cookie security defaults. + // Examples: "http://127.0.0.1:8080" (dev), "https://atmo.quest" (prod). + PublicURL string + + // DatabaseURL is the SQLite DSN for the local app DB (admin flags, event + // cache, OAuth sessions, etc.). Example: + // file:data/atmoquest.db?_pragma=journal_mode(WAL)&_pragma=foreign_keys(ON) + DatabaseURL string + + // OAuthPrivateKeyPath is the on-disk location of the ES256 (P-256) private + // key used to sign client_assertion JWTs when running as a confidential + // OAuth client. Auto-generated on first run if the file is missing. + // Ignored when IsLocalhost() is true (loopback dev uses a public client). + OAuthPrivateKeyPath string } var ( @@ -44,9 +65,13 @@ func getEnv(key, fallback string) string { func loadBase() *Config { godotenv.Load() + host := getEnv("HOST", "0.0.0.0") + port := getEnv("PORT", "8080") + defaultPublicURL := fmt.Sprintf("http://127.0.0.1:%s", port) + return &Config{ - Host: getEnv("HOST", "0.0.0.0"), - Port: getEnv("PORT", "8080"), + Host: host, + Port: port, LogLevel: func() slog.Level { switch os.Getenv("LOG_LEVEL") { case "DEBUG": @@ -61,6 +86,40 @@ func loadBase() *Config { return slog.LevelInfo } }(), - SessionSecret: getEnv("SESSION_SECRET", "session-secret"), + SessionSecret: getEnv("SESSION_SECRET", "session-secret"), + PublicURL: getEnv("PUBLIC_URL", defaultPublicURL), + DatabaseURL: getEnv("DATABASE_URL", "file:data/atmoquest.db?_pragma=journal_mode(WAL)&_pragma=foreign_keys(ON)"), + OAuthPrivateKeyPath: getEnv("OAUTH_PRIVATE_KEY_PATH", "data/oauth_key.pem"), + } +} + +// Validate enforces invariants that aren't safe to enforce in init(). Called +// from main() so misconfiguration surfaces as a clean fatal error with a +// message, not a mid-request panic. +func (c *Config) Validate() error { + if c.DatabaseURL == "" { + return fmt.Errorf("DATABASE_URL is required") + } + if c.PublicURL == "" { + return fmt.Errorf("PUBLIC_URL is required") } + if c.Environment == Prod { + if c.SessionSecret == "" || c.SessionSecret == "session-secret" { + return fmt.Errorf("SESSION_SECRET must be set to a real secret in production") + } + } + return nil +} + +// IsLocalhost reports whether PublicURL is a loopback origin. When true the +// OAuth client runs in loopback/public mode (no client_assertion, no signing +// key) because Authorization Servers reject http:// client_ids elsewhere. +func (c *Config) IsLocalhost() bool { + u := c.PublicURL + return strings.HasPrefix(u, "http://localhost") || strings.HasPrefix(u, "http://127.0.0.1") +} + +// IsSecure reports whether PublicURL is https — used to decide cookie Secure flag. +func (c *Config) IsSecure() bool { + return strings.HasPrefix(c.PublicURL, "https://") } diff --git a/features/auth/handlers.go b/features/auth/handlers.go new file mode 100644 index 0000000..0cf2f2a --- /dev/null +++ b/features/auth/handlers.go @@ -0,0 +1,244 @@ +// 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" + "log/slog" + "net/http" + "strings" + "time" + + "github.com/bluesky-social/indigo/atproto/auth/oauth" + "github.com/bluesky-social/indigo/atproto/syntax" + + "atmoquest/features/auth/pages" + "atmoquest/internal/connection" + "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} +} + +// 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().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: takes a handle / DID / PDS URL, calls +// indigo's StartAuthFlow, 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.OAuth.StartAuthFlow(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", + "identifier", identifier, + "client_id", h.OAuth.Config.ClientID, + "callback_url", h.OAuth.Config.CallbackURL, + "scopes", h.OAuth.Config.Scopes, + "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 start ok", "identifier", identifier, "redirect", redirectURL) + 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 + } + + // 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: 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, slog.Default()) + 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) + } + } + + 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 COALESCE preserves any previously-stored +// handle across enrichment-less re-logins. +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 { + s = 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) +} diff --git a/features/auth/pages/signin.templ b/features/auth/pages/signin.templ new file mode 100644 index 0000000..0d15765 --- /dev/null +++ b/features/auth/pages/signin.templ @@ -0,0 +1,50 @@ +package pages + +import "atmoquest/features/common/layouts" + +// Signin renders the chooser: existing Atmosphere account vs create a new one. +// The create path is a stub for now — we'll wire it later. +templ Signin() { + @layouts.Base("sign in — atmo.quest", "Sign in with your ATProto account.") { +
+ 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.+ you'll be redirected to your account host (usually a PDS) to approve. atmo.quest never sees your password. +
+you'll be redirected to your account host (usually a PDS) to approve. atmo.quest never sees your password.
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.