From a5ef8cf3ba2d0c80f103ffd00bcd910c18c34c00 Mon Sep 17 00:00:00 2001 From: Tronax Date: Wed, 5 Aug 2026 19:45:00 +0200 Subject: [PATCH] Backend Phase B (2/2): OIDC auth + config validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - internal/auth/oidc.go: OIDCService mit go-oidc v3 - id_token-Verifikation via JWKS (Signatur, iss, aud, exp) - Provider-Caching (sync.Map, lazy init per Issuer-URL) - Unterstützt Google + Generic OIDC - internal/auth/user.go: GetByOIDCSubject + CreateOIDCUser (find-or-create via (oidc_issuer, oidc_subject)) - internal/httpapi/auth.go: POST /auth/oidc Handler (id_token verifiziern → find-or-create User → issueSession) - internal/httpapi/api.go: /auth/oidc Route verdrahtet - internal/config/config.go: OIDC-Validierung (enabled → client_id + issuer Pflicht) - go.mod/go.sum: go-oidc/v3 + oauth2 Abhängigkeiten - AGENTS.md: Phase B vollständig als erledigt markiert Verifiziert: E2E gegen lokalen Mock-IdP (Discovery → JWKS → signiertes id_token → User angelegt → 2. Login gleicher User → tampered Token → 401). Alle Fehlerpfade geprüft. go build ./... && go vet ./... && go test ./internal/auth/... ✅ --- AGENTS.md | 47 +++++++----- backend/go.mod | 3 + backend/go.sum | 6 ++ backend/internal/auth/oidc.go | 123 ++++++++++++++++++++++++++++++ backend/internal/auth/user.go | 51 +++++++++++++ backend/internal/config/config.go | 24 ++++++ backend/internal/httpapi/api.go | 23 +++++- backend/internal/httpapi/auth.go | 68 ++++++++++++++++- 8 files changed, 322 insertions(+), 23 deletions(-) create mode 100644 backend/internal/auth/oidc.go diff --git a/AGENTS.md b/AGENTS.md index 9946fe0..5df3f60 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -90,18 +90,19 @@ mitbringsl/ │ │ ├── config/config.go # Config (caarlos0/env) │ │ ├── logging/logging.go # slog JSON-Setup │ │ ├── store/db.go # pgxpool-Setup -│ │ ├── auth/ # PHASE B – Password + Session +│ │ ├── auth/ # PHASE B – Password + Session + OIDC │ │ │ ├── password.go # Argon2id im PHC-Format (HashPassword/VerifyPassword) │ │ │ ├── password_test.go # PHC-Roundtrip-Tests -│ │ │ ├── user.go # UserStore: CreateUser/GetByEmail/GetByID +│ │ │ ├── user.go # UserStore: CreateUser/GetByEmail/GetByID/GetByOIDCSubject/CreateOIDCUser │ │ │ ├── session.go # SessionStore: opaque Tokens, SHA-256-Hash, Create/Lookup/Revoke +│ │ │ ├── oidc.go # OIDCService: id_token-Verifikation (JWKS/iss/aud/exp), Provider-Caching │ │ │ └── pgcode.go # isUniqueViolation (SQLSTATE 23505) │ │ └── httpapi/ │ │ ├── api.go # API-Objekt + Router (Health + /auth/* aktiv, Rest auskommentiert) │ │ ├── render.go # JSON-Render + Problem + Fehler-Sentinale + decodeJSON │ │ ├── middleware.go # requestID/logging/recover/cors + Chain │ │ ├── health.go # /healthz + /readyz -│ │ └── auth.go # Register/Login/Logout-Handler + RequireAuth-Middleware +│ │ └── auth.go # Register/Login/Logout/OIDC-Handler + RequireAuth-Middleware │ ├── migrations/ │ │ ├── embed.go # //go:embed *.sql │ │ ├── 000001_init_schema.up.sql # users/sessions/lists/list_members/items/op_log/item_names @@ -135,7 +136,12 @@ Legende: ✅ erledigt · 🚧 in Arbeit · ⬜ offen `Register`/`Login`/`Logout` + `RequireAuth`-Middleware. **Verifiziert:** `go test ./internal/auth/...` grün, E2E-Smoke-Test gegen echtes PostgreSQL via Docker (Register/Login/Logout/Duplicate/Short-PW/Wrong-PW alle korrekt). -- 🚧 **Phase B – OIDC:** go-oidc-Verifikation (Google + Generic) + `POST /auth/oidc`. +- ✅ **Phase B – OIDC:** `OIDCService` (go-oidc v3, JWKS-Signatur, iss/aud/exp, Provider-Caching) + + `POST /auth/oidc` (find-or-create User via `(oidc_issuer, oidc_subject)`, reuses `issueSession`) + + Config-Validierung (enabled → client_id/issuer Pflicht). + **Verifiziert:** E2E-Flow gegen lokalen Mock-IdP (Discovery → JWKS → signiertes id_token + → User angelegt → 2. Login findet gleichen User → tampered Token → 401). Alle Fehlerpfade + geprüft (disabled→400, unknown provider→400, missing fields→400, invalid→401). - ⬜ **Phase C – Sync-Kern:** `op_log`-Append (idempotent), HLC, Projektion op→items/lists (LWW+Tombstones). - ⬜ **Phase C – Endpoints:** `/api/lists`, `/api/lists/{id}/ops` (push+pull). - ⬜ **Phase C – Suggestions:** `item_names`-Trigger + `/api/suggestions`. @@ -149,23 +155,24 @@ Legende: ✅ erledigt · 🚧 in Arbeit · ⬜ offen - ⬜ **Phase F – README + docs** (ARCHITECTURE/SYNC/API). ### Wo genau weitermachen? -**Nächster Schritt = Phase B Teil 2 (OIDC)** – Teil 1 (Password-Auth) ist fertig ✅: +**Phase B ist komplett ✅. Nächster Schritt = Phase C (Sync-Kern).** -Teil 1 (erledigt): +Phase B erledigt: - ✅ `internal/auth/password.go` – Argon2id im PHC-Format (HashPassword/VerifyPassword). -- ✅ `internal/auth/user.go` – UserStore (CreateUser/GetByEmail/GetByID). +- ✅ `internal/auth/user.go` – UserStore (CreateUser/GetByEmail/GetByID/GetByOIDCSubject/CreateOIDCUser). - ✅ `internal/auth/session.go` – SessionStore (crypto/rand + base64url + SHA-256, Create/Lookup/Revoke). -- ✅ `internal/httpapi/auth.go` – Register/Login/Logout + RequireAuth-Middleware. -- ✅ In `api.go` sind `/auth/register`, `/auth/login`, `/auth/logout` aktiv verdrahtet. +- ✅ `internal/auth/oidc.go` – OIDCService (id_token-Verifikation via go-oidc v3, Provider-Caching). +- ✅ `internal/httpapi/auth.go` – Register/Login/Logout/OIDC + RequireAuth-Middleware. +- ✅ `config.go` – OIDC-Validierung (enabled → client_id/issuer Pflicht). +- ✅ Alle `/auth/*`-Routen aktiv verdrahtet. -Teil 2 (offen – OIDC): -1. `internal/auth/oidc.go` – Verifikation eines `id_token` via `github.com/coreos/go-oidc/v3` - (JWKS-Signatur, iss/aud/exp prüfen). Provider-Auswahl anhand `issuer` aus Config. -2. `POST /auth/oidc`-Handler in `internal/httpapi/auth.go`: empfängt `{provider, id_token}`, - verifiziert, findet/legt User an (oidc_issuer+oidc_subject UNIQUE), stellt Session aus - (gleicher `issueSession`-Pfad wie Login). -3. In `api.go` `mux.HandleFunc("POST /auth/oidc", a.auth.OIDC)` einkommentieren. -4. Config-Validierung: wenn `OIDC_GOOGLE_ENABLED=true`, muss `OIDC_GOOGLE_CLIENT_ID` gesetzt sein. +Phase C – Sync-Kern (offen): +1. `internal/sync/hlc.go` – Hybrid Logical Clock (client- + server-seitig, `(ts, counter)`). +2. `op_log`-Append in `internal/store/opstore.go`: `AppendOp` idempotent via `UNIQUE(client_id, client_seq)`, + zurück: server-seitiger `seq` + `now()`-basierte HLC. +3. Projektion op→items/lists: `internal/store/liststore.go`/`itemstore.go` mit LWW (`hlc_ts`) + Tombstones (`deleted_at`). +4. Endpoints `GET/POST /api/lists`, `GET/POST /api/lists/{id}/ops` (Pull `?since=`, Push idempotent). +5. Suggestions: `item_names`-Trigger + `GET /api/suggestions?q=`. --- @@ -208,5 +215,7 @@ docker run --rm --network \ ``` ## Git-Status -- Repo initialisiert, Branch `main`. **Remote ist noch NICHT konfiguriert.** -- Es wurde **noch nicht committet** (Stand beim Schreiben dieser Datei). +- Repo initialisiert, Branch `main`. Remote ist konfiguriert (`origin`). +- Phase A + Phase B (1/2: Password/Sessions) committed und gepusht. +- Phase B (2/2: OIDC) committed und gepusht. **Phase B vollständig ✅.** +- Nächster Schritt: **Phase C – Sync-Kern** (siehe Roadmap oben). diff --git a/backend/go.mod b/backend/go.mod index f086147..a94fd10 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -4,6 +4,7 @@ go 1.26 require ( github.com/caarlos0/env/v11 v11.4.1 + github.com/coreos/go-oidc/v3 v3.20.0 github.com/golang-migrate/migrate/v4 v4.18.2 github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.10.0 @@ -11,6 +12,7 @@ require ( ) require ( + github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect @@ -18,6 +20,7 @@ require ( github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/lib/pq v1.10.9 // indirect go.uber.org/atomic v1.7.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.40.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index c77ac78..c206836 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -4,6 +4,8 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/caarlos0/env/v11 v11.4.1 h1:fYwH0sWEsBSMPG7t4e/PEfTFzrWrpjyygXyUnWiSwEw= github.com/caarlos0/env/v11 v11.4.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= +github.com/coreos/go-oidc/v3 v3.20.0 h1:EtE0WIBHk03N+DqGkY4+UONzzZHk7amKt6IyNd7OsZE= +github.com/coreos/go-oidc/v3 v3.20.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -19,6 +21,8 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4 github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= @@ -75,6 +79,8 @@ go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= diff --git a/backend/internal/auth/oidc.go b/backend/internal/auth/oidc.go new file mode 100644 index 0000000..8c847fd --- /dev/null +++ b/backend/internal/auth/oidc.go @@ -0,0 +1,123 @@ +package auth + +import ( + "context" + "errors" + "fmt" + "sync" + "time" + + "github.com/coreos/go-oidc/v3/oidc" +) + +// OIDCClaims holds the fields this backend extracts from a verified id_token. +type OIDCClaims struct { + Subject string + Issuer string + Email string // may be empty if the IdP does not provide it + // EmailVerified is best-effort; some IdPs omit it. When missing we treat it + // as unverified rather than rejecting the login. + EmailVerified bool + Name string // display name, if present +} + +// ProviderConfig describes one OIDC provider this backend trusts. +type ProviderConfig struct { + Issuer string + ClientID string // expected audience +} + +// ErrProviderUnknown is returned when no configured provider matches a request. +var ErrProviderUnknown = errors.New("unknown oidc provider") + +// OIDCService verifies id_tokens for the configured providers. Provider +// discovery documents and JWKS keys are cached per issuer (the underlying +// oidc.IDTokenVerifier refreshes keys as needed). +type OIDCService struct { + providers map[string]ProviderConfig // key: provider name ("google" | "generic") + + mu sync.Mutex + verifiers map[string]*oidc.IDTokenVerifier // key: provider name +} + +// NewOIDCService constructs the service. providers may be empty (then every +// Verify call returns ErrProviderUnknown), which is the default when OIDC is +// disabled in config. +func NewOIDCService(providers map[string]ProviderConfig) *OIDCService { + if providers == nil { + providers = map[string]ProviderConfig{} + } + return &OIDCService{ + providers: providers, + verifiers: map[string]*oidc.IDTokenVerifier{}, + } +} + +// Providers returns the names of the configured providers (e.g. "google", +// "generic"). Callers use it to decide whether OIDC is available at all. +func (s *OIDCService) Providers() []string { + names := make([]string, 0, len(s.providers)) + for k := range s.providers { + names = append(names, k) + } + return names +} + +// Verify validates the id_token for the named provider and returns its claims. +// provider must be one of the keys passed to NewOIDCService ("google" or +// "generic"). The token signature is checked against the IdP JWKS, and the +// iss/aud/exp claims are validated by the oidc verifier. +func (s *OIDCService) Verify(ctx context.Context, provider, idToken string) (OIDCClaims, error) { + pc, ok := s.providers[provider] + if !ok { + return OIDCClaims{}, fmt.Errorf("%w: %s", ErrProviderUnknown, provider) + } + + v, err := s.verifier(ctx, provider, pc) + if err != nil { + return OIDCClaims{}, fmt.Errorf("build verifier: %w", err) + } + + tok, err := v.Verify(ctx, idToken) + if err != nil { + return OIDCClaims{}, fmt.Errorf("verify id_token: %w", err) + } + + // Extract the claim set. We use the generic claims map rather than a fixed + // struct so missing optional fields don't fail verification. + var raw struct { + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` + Name string `json:"name"` + } + if err := tok.Claims(&raw); err != nil { + return OIDCClaims{}, fmt.Errorf("parse claims: %w", err) + } + return OIDCClaims{ + Subject: tok.Subject, + Issuer: tok.Issuer, + Email: raw.Email, + EmailVerified: raw.EmailVerified, + Name: raw.Name, + }, nil +} + +// verifier returns the cached IDTokenVerifier for a provider, creating it (with +// discovery) on first use. The discovery round-trip is the reason we cache. +func (s *OIDCService) verifier(ctx context.Context, provider string, pc ProviderConfig) (*oidc.IDTokenVerifier, error) { + s.mu.Lock() + defer s.mu.Unlock() + if v := s.verifiers[provider]; v != nil { + return v, nil + } + + dctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + prov, err := oidc.NewProvider(dctx, pc.Issuer) + if err != nil { + return nil, fmt.Errorf("discover provider %s (%s): %w", provider, pc.Issuer, err) + } + v := prov.Verifier(&oidc.Config{ClientID: pc.ClientID}) + s.verifiers[provider] = v + return v, nil +} diff --git a/backend/internal/auth/user.go b/backend/internal/auth/user.go index 1c501b8..c6139ce 100644 --- a/backend/internal/auth/user.go +++ b/backend/internal/auth/user.go @@ -93,3 +93,54 @@ func (s *UserStore) GetUserByID(ctx context.Context, id uuid.UUID) (User, error) u.DisplayName = dn return u, nil } + +// GetByOIDCSubject loads a user by its (issuer, subject) pair. This is the key +// used to recognise a returning user across OIDC logins. +func (s *UserStore) GetByOIDCSubject(ctx context.Context, issuer, subject string) (User, error) { + const q = ` + SELECT id, email, password_hash, oidc_subject, oidc_issuer, display_name + FROM users WHERE oidc_issuer = $1 AND oidc_subject = $2` + var u User + var dn *string + err := s.pool.QueryRow(ctx, q, issuer, subject). + Scan(&u.ID, &u.Email, &u.PasswordHash, &u.OIDCSubject, &u.OIDCIssuer, &dn) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return User{}, ErrUserNotFound + } + return User{}, fmt.Errorf("get user by oidc subject: %w", err) + } + u.DisplayName = dn + return u, nil +} + +// CreateOIDCUser inserts a new user for an OIDC login. The user has no password +// (password_hash is NULL) and is identified by (issuer, subject). email may be +// empty if the IdP did not provide one; we store a synthesized placeholder so +// the NOT NULL + UNIQUE constraints hold and the account stays addressable. +func (s *UserStore) CreateOIDCUser(ctx context.Context, issuer, subject, email, displayName string) (User, error) { + if email == "" { + // Synthesize a stable, non-resolvable address for IdPs that don't return + // an email (rare for Google, possible for generic providers). + email = fmt.Sprintf("%s@oidc.local", subject) + } + const q = ` + INSERT INTO users (email, password_hash, oidc_subject, oidc_issuer, display_name) + VALUES ($1, NULL, $2, $3, NULLIF($4, '')) + RETURNING id, email, password_hash, oidc_subject, oidc_issuer, display_name` + var u User + var dn *string + err := s.pool.QueryRow(ctx, q, email, subject, issuer, displayName). + Scan(&u.ID, &u.Email, &u.PasswordHash, &u.OIDCSubject, &u.OIDCIssuer, &dn) + if err != nil { + if isUniqueViolation(err) { + // Could be a duplicate email (owned by a password account) or a + // race on the (issuer, subject) unique key. Surface a generic + // conflict; the handler decides the HTTP code. + return User{}, fmt.Errorf("%w: oidc account conflict", ErrEmailTaken) + } + return User{}, fmt.Errorf("create oidc user: %w", err) + } + u.DisplayName = dn + return u, nil +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index a3124a0..94d4d06 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -63,8 +63,32 @@ func Load() (*Config, error) { if err := env.Parse(&cfg); err != nil { return nil, fmt.Errorf("parse env: %w", err) } + if err := cfg.validate(); err != nil { + return nil, err + } return &cfg, nil } +// validate enforces invariants that env parsing alone can't express. +func (c *Config) validate() error { + if c.GoogleOIDC.Enabled { + if c.GoogleOIDC.ClientID == "" { + return fmt.Errorf("OIDC_GOOGLE_ENABLED=true requires OIDC_GOOGLE_CLIENT_ID") + } + if c.GoogleOIDC.Issuer == "" { + return fmt.Errorf("OIDC_GOOGLE_ENABLED=true requires OIDC_GOOGLE_ISSUER") + } + } + if c.GenericOIDC.Enabled { + if c.GenericOIDC.ClientID == "" { + return fmt.Errorf("OIDC_GENERIC_ENABLED=true requires OIDC_GENERIC_CLIENT_ID") + } + if c.GenericOIDC.Issuer == "" { + return fmt.Errorf("OIDC_GENERIC_ENABLED=true requires OIDC_GENERIC_ISSUER") + } + } + return nil +} + // IsProduction reports whether the app runs in production mode. func (c *Config) IsProduction() bool { return c.AppEnv == "production" } diff --git a/backend/internal/httpapi/api.go b/backend/internal/httpapi/api.go index 215fce6..a0fe417 100644 --- a/backend/internal/httpapi/api.go +++ b/backend/internal/httpapi/api.go @@ -26,14 +26,33 @@ func NewAPI(cfg *config.Config, pool *pgxpool.Pool) *API { TokenBytes: cfg.SessionTokenBytes, TTL: cfg.SessionTokenTTL, }) + oidcSvc := auth.NewOIDCService(buildProviders(cfg)) + // When no provider is enabled, pass nil so the endpoint returns a clear + // "OIDC disabled" message instead of "unknown provider" for every request. + if len(oidcSvc.Providers()) == 0 { + oidcSvc = nil + } return &API{ cfg: cfg, pool: pool, health: &HealthHandler{Pool: pool}, - auth: NewAuthHandler(users, sessions, cfg), + auth: NewAuthHandler(users, sessions, oidcSvc, cfg), } } +// buildProviders assembles the OIDC provider map from config. Only enabled +// providers with a non-empty issuer and client_id are included. +func buildProviders(cfg *config.Config) map[string]auth.ProviderConfig { + p := map[string]auth.ProviderConfig{} + if cfg.GoogleOIDC.Enabled && cfg.GoogleOIDC.Issuer != "" && cfg.GoogleOIDC.ClientID != "" { + p["google"] = auth.ProviderConfig{Issuer: cfg.GoogleOIDC.Issuer, ClientID: cfg.GoogleOIDC.ClientID} + } + if cfg.GenericOIDC.Enabled && cfg.GenericOIDC.Issuer != "" && cfg.GenericOIDC.ClientID != "" { + p["generic"] = auth.ProviderConfig{Issuer: cfg.GenericOIDC.Issuer, ClientID: cfg.GenericOIDC.ClientID} + } + return p +} + // Handler returns the fully wired http.Handler with all middleware applied. func (a *API) Handler() http.Handler { mux := http.NewServeMux() @@ -45,8 +64,8 @@ func (a *API) Handler() http.Handler { // --- auth endpoints --- mux.HandleFunc("POST /auth/register", a.auth.Register) mux.HandleFunc("POST /auth/login", a.auth.Login) + mux.HandleFunc("POST /auth/oidc", a.auth.OIDC) mux.HandleFunc("POST /auth/logout", a.auth.Logout) - // mux.HandleFunc("POST /auth/oidc", a.auth.OIDC) // Phase B part 2 // --- authenticated API endpoints (added in Phase C) --- // mux.HandleFunc("GET /api/lists", a.RequireAuth(http.HandlerFunc(a.lists.List))) diff --git a/backend/internal/httpapi/auth.go b/backend/internal/httpapi/auth.go index 8cd0486..a11a0b9 100644 --- a/backend/internal/httpapi/auth.go +++ b/backend/internal/httpapi/auth.go @@ -20,12 +20,15 @@ const passwordMinLen = 8 type AuthHandler struct { users *auth.UserStore sessions *auth.SessionStore + oidc *auth.OIDCService cfg *config.Config } // NewAuthHandler constructs an AuthHandler from the configured stores. -func NewAuthHandler(users *auth.UserStore, sessions *auth.SessionStore, cfg *config.Config) *AuthHandler { - return &AuthHandler{users: users, sessions: sessions, cfg: cfg} +// oidc may be nil when no provider is enabled; the OIDC endpoint then returns +// 400 for every request. +func NewAuthHandler(users *auth.UserStore, sessions *auth.SessionStore, oidc *auth.OIDCService, cfg *config.Config) *AuthHandler { + return &AuthHandler{users: users, sessions: sessions, oidc: oidc, cfg: cfg} } // --- request / response bodies ---------------------------------------------- @@ -41,6 +44,11 @@ type loginRequest struct { Password string `json:"password"` } +type oidcRequest struct { + Provider string `json:"provider"` // "google" | "generic" + IDToken string `json:"id_token"` +} + type authResponse struct { Token string `json:"token"` ExpiresAt time.Time `json:"expires_at"` @@ -136,6 +144,62 @@ func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } +// OIDC accepts an id_token that the Android app obtained via its own code+PKCE +// flow, verifies it against the configured provider, then finds-or-creates the +// user and issues a backend session (same shape as Login). +// +// Request body: {"provider": "google"|"generic", "id_token": ""} +func (h *AuthHandler) OIDC(w http.ResponseWriter, r *http.Request) { + var req oidcRequest + if !decodeJSON(w, r, &req) { + return + } + provider := strings.TrimSpace(req.Provider) + idToken := strings.TrimSpace(req.IDToken) + if provider == "" || idToken == "" { + renderError(w, http.StatusBadRequest, "Bad request", + "Both 'provider' and 'id_token' are required.") + return + } + if h.oidc == nil { + renderError(w, http.StatusBadRequest, "OIDC disabled", + "No OIDC provider is configured on this server.") + return + } + + claims, err := h.oidc.Verify(r.Context(), provider, idToken) + if err != nil { + if errors.Is(err, auth.ErrProviderUnknown) { + renderError(w, http.StatusBadRequest, "Unknown provider", err.Error()) + return + } + slog.Info("oidc verify failed", "provider", provider, "error", err) + renderError(w, http.StatusUnauthorized, "Invalid id_token", + "The id_token could not be verified.") + return + } + + // find-or-create user by (issuer, subject) + user, err := h.users.GetByOIDCSubject(r.Context(), claims.Issuer, claims.Subject) + if err != nil { + if !errors.Is(err, auth.ErrUserNotFound) { + slog.Error("oidc user lookup failed", "error", err, "issuer", claims.Issuer) + renderError(w, http.StatusInternalServerError, "Internal error", "Could not load user.") + return + } + user, err = h.users.CreateOIDCUser(r.Context(), claims.Issuer, claims.Subject, claims.Email, claims.Name) + if err != nil { + slog.Error("oidc user create failed", "error", err, "issuer", claims.Issuer) + renderError(w, http.StatusConflict, "Account conflict", + "This account cannot be linked automatically. Contact support.") + return + } + slog.Info("oidc user created", "user_id", user.ID, "issuer", claims.Issuer) + } + + h.issueSession(w, r, user, http.StatusOK) +} + // --- helpers ---------------------------------------------------------------- // issueSession creates a session, sets the cookie (for browsers) and writes the