From c1cc781279f09bc19398a85c3ad64c98e04bc274 Mon Sep 17 00:00:00 2001 From: etalon Date: Fri, 12 Jun 2026 18:31:25 +0200 Subject: [PATCH] feat: authentication with local accounts, sessions, CSRF, RBAC, and OIDC PKCE (phase 3) - argon2id (t=3, m=64MiB, p=2) PHC hashing honoring embedded params - token-bucket rate limiting (10/15min per IP+email) on login/register - opaque 32B session tokens in Mongo, 30-day sliding expiry, logout-all - CSRF double-submit cookie/header on authenticated mutations - bootstrap admin from env with forced first-login password change - requireAuth/requireRole middleware with disabled-account enforcement - OIDC code flow with PKCE: lazy discovery, account linking only on verified email, auto-created developer accounts - unit tests (RBAC matrix, CSRF, password, rate limiter) + integration suite covering the full auth matrix incl. an in-test fake IdP Co-Authored-By: Claude Fable 5 --- DECISIONS.md | 25 ++ PROGRESS.md | 3 +- cmd/app/main.go | 7 +- docker-compose.yml | 2 + go.mod | 14 +- go.sum | 20 +- internal/auth/bootstrap.go | 51 +++ internal/auth/oidc.go | 116 ++++++ internal/auth/password.go | 73 ++++ internal/auth/password_test.go | 75 ++++ internal/auth/ratelimit.go | 70 ++++ internal/auth/ratelimit_test.go | 62 ++++ internal/auth/token.go | 18 + internal/domain/session.go | 18 + internal/domain/user.go | 82 +++++ internal/http/auth.go | 258 ++++++++++++++ internal/http/auth_integration_test.go | 472 +++++++++++++++++++++++++ internal/http/authmw_test.go | 93 +++++ internal/http/oidc.go | 173 +++++++++ internal/http/oidc_integration_test.go | 303 ++++++++++++++++ internal/http/server.go | 31 +- internal/http/server_test.go | 2 +- internal/http/session.go | 167 +++++++++ internal/store/sessions.go | 81 +++++ internal/store/users.go | 126 +++++++ 25 files changed, 2318 insertions(+), 24 deletions(-) create mode 100644 internal/auth/bootstrap.go create mode 100644 internal/auth/oidc.go create mode 100644 internal/auth/password.go create mode 100644 internal/auth/password_test.go create mode 100644 internal/auth/ratelimit.go create mode 100644 internal/auth/ratelimit_test.go create mode 100644 internal/auth/token.go create mode 100644 internal/domain/session.go create mode 100644 internal/domain/user.go create mode 100644 internal/http/auth.go create mode 100644 internal/http/auth_integration_test.go create mode 100644 internal/http/authmw_test.go create mode 100644 internal/http/oidc.go create mode 100644 internal/http/oidc_integration_test.go create mode 100644 internal/http/session.go create mode 100644 internal/store/sessions.go create mode 100644 internal/store/users.go diff --git a/DECISIONS.md b/DECISIONS.md index 5da9604..5ace9b3 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -57,3 +57,28 @@ Spec-silent choices, recorded as required by the build instructions. - **Signed file tokens** are `base64url(exp || hmac-sha256(fileID|exp))` with a key derived as `sha256("bountyboard/file-url/v1" + SESSION_SECRET)` so file tokens can never collide with other uses of the session secret. + +## Phase 3 + +- **OIDC linking requires `email_verified=true`.** An unverified IdP email + matching an existing local account is rejected (`oidc_email_unverified`) + instead of linking or creating a duplicate — linking on unverified email + would allow account takeover via a rogue IdP account. +- **OIDC provider discovery is lazy** (first login attempt, retried on + failure) so an unreachable IdP cannot block app startup. +- **CSRF protection applies to authenticated mutations only**; login and + register are pre-session and protected by rate limiting + SameSite=Lax. + The CSRF cookie is intentionally JS-readable (double-submit pattern). +- **Password change revokes all other sessions** of the user (recovery + semantics) but keeps the current one. `SetPassword` is deliberately not + version-checked: a password change must never lose an optimistic-locking + race against a profile edit. +- **Forced password change (`mustChange`)** is enforced in `requireAuth` via + a path allowlist (`me`, `logout`, `logout-all`, `change-password`). +- **Sessions slide at most once per hour** (refreshedAt watermark) instead of + updating expiry on every request. +- **Login rate-limit key is `clientIP|email`** (and `register|clientIP` for + registration), using the trusted-proxy-resolved IP. +- **Mongo runs with `--wiredTigerCacheSizeGB 0.5`** in compose — the target + host has 4 GB RAM; default cache (≈50% of RAM) caused a restart under the + integration-test load. diff --git a/PROGRESS.md b/PROGRESS.md index 32e46ae..10d9fff 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -1,5 +1,6 @@ # Progress + - Phase 1 (scaffold): repo layout, Makefile, .env loader + validated config, slog JSON, ULID pkg, trusted-proxy middleware, /healthz /readyz /metricsz, Dockerfile + compose (mongo+app, healthchecks, localhost-bound 8787), graceful shutdown — build/vet/tests green, smoke PASS. - Phase 2 (store layer): mongo-driver v2 connection with startup retry, §4.9 indexes idempotent, optimistic-concurrency UpdateVersioned (ErrVersionConflict/ErrNotFound), AES-256-GCM credential crypto, HMAC-signed file URL tokens (1h TTL), GridFS store with MIME sniffing + size cap + sha256, /readyz mongo check (verified 503 on stopped Mongo) — unit + integration tests green. - +- Phase 3 (auth): argon2id (PHC, spec params), login/register rate limiting, opaque Mongo sessions (30d sliding, TTL index), CSRF double-submit, bootstrap admin with forced password change, RBAC middleware + matrix tests, OIDC code flow with PKCE + email linking (verified-only), full integration suite incl. fake IdP — all green. diff --git a/cmd/app/main.go b/cmd/app/main.go index 15d6eee..500f6d8 100644 --- a/cmd/app/main.go +++ b/cmd/app/main.go @@ -10,6 +10,7 @@ import ( "syscall" "time" + "bountyboard/internal/auth" "bountyboard/internal/config" httpx "bountyboard/internal/http" "bountyboard/internal/metrics" @@ -50,8 +51,12 @@ func run() error { } }() + if err := auth.EnsureBootstrapAdmin(ctx, st, cfg, log); err != nil { + return err + } + reg := metrics.NewRegistry() - srv := httpx.New(cfg, log, reg) + srv := httpx.New(cfg, log, reg, st) srv.AddReadinessCheck(httpx.ReadinessCheck{ Name: "mongo", Required: true, diff --git a/docker-compose.yml b/docker-compose.yml index b6b7ef2..2ade5c6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,6 +2,8 @@ services: mongo: image: mongo:7 restart: unless-stopped + # modest cache so the whole stack fits comfortably on small hosts + command: ["mongod", "--wiredTigerCacheSizeGB", "0.5"] # Mongo is reachable only on the compose network; never published to the # host (§12). volumes: diff --git a/go.mod b/go.mod index dee3b1d..7bd3100 100644 --- a/go.mod +++ b/go.mod @@ -2,15 +2,21 @@ module bountyboard go 1.26 -require go.mongodb.org/mongo-driver/v2 v2.6.0 +require ( + github.com/coreos/go-oidc/v3 v3.18.0 + go.mongodb.org/mongo-driver/v2 v2.6.0 + golang.org/x/crypto v0.53.0 + golang.org/x/oauth2 v0.36.0 +) require ( + github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/klauspost/compress v1.17.6 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect github.com/xdg-go/scram v1.2.0 // indirect github.com/xdg-go/stringprep v1.0.4 // indirect github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect - golang.org/x/crypto v0.33.0 // indirect - golang.org/x/sync v0.11.0 // indirect - golang.org/x/text v0.22.0 // indirect + golang.org/x/sync v0.21.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.38.0 // indirect ) diff --git a/go.sum b/go.sum index 763709c..e1a29a8 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,9 @@ +github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A= +github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= 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= +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/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/klauspost/compress v1.17.6 h1:60eq2E/jlfwQXtvZEeBUYADs+BwKBWURIY+Gj2eRGjI= @@ -17,29 +21,33 @@ go.mongodb.org/mongo-driver/v2 v2.6.0 h1:b9sJOYrkmt4l8bY43ZenFBcPlhYIjaOfYHLtbB/ go.mongodb.org/mongo-driver/v2 v2.6.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= -golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +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.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= -golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= -golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= -golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= diff --git a/internal/auth/bootstrap.go b/internal/auth/bootstrap.go new file mode 100644 index 0000000..b30c815 --- /dev/null +++ b/internal/auth/bootstrap.go @@ -0,0 +1,51 @@ +package auth + +import ( + "context" + "fmt" + "log/slog" + + "bountyboard/internal/config" + "bountyboard/internal/domain" + "bountyboard/internal/store" + "bountyboard/internal/ulid" +) + +// EnsureBootstrapAdmin creates the initial admin from ADMIN_EMAIL / +// ADMIN_INITIAL_PASSWORD when the users collection is empty (§3). The +// password change is forced at first login. +func EnsureBootstrapAdmin(ctx context.Context, st *store.Store, cfg *config.Config, log *slog.Logger) error { + n, err := st.CountUsers(ctx) + if err != nil { + return err + } + if n > 0 { + return nil + } + if cfg.AdminEmail == "" || cfg.AdminInitialPassword == "" { + log.Warn("users collection is empty but ADMIN_EMAIL/ADMIN_INITIAL_PASSWORD are not set; no admin bootstrapped") + return nil + } + hash, err := HashPassword(cfg.AdminInitialPassword) + if err != nil { + return fmt.Errorf("hash bootstrap password: %w", err) + } + u := &domain.User{ + ID: ulid.New(), + Email: cfg.AdminEmail, + Name: "Administrator", + Roles: domain.Roles{Admin: true}, + Auth: domain.Auth{ + Local: &domain.LocalAuth{PasswordHash: hash, MustChange: true}, + }, + Settings: domain.UserSettings{ + Theme: "light", + Notifications: domain.NotificationPrefs{Email: true, InApp: true}, + }, + } + if err := st.CreateUser(ctx, u); err != nil { + return fmt.Errorf("bootstrap admin: %w", err) + } + log.Info("bootstrap admin created; password change is forced at first login", "email", u.Email) + return nil +} diff --git a/internal/auth/oidc.go b/internal/auth/oidc.go new file mode 100644 index 0000000..b769d4c --- /dev/null +++ b/internal/auth/oidc.go @@ -0,0 +1,116 @@ +package auth + +import ( + "context" + "errors" + "fmt" + "log/slog" + "sync" + + "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" + + "bountyboard/internal/config" +) + +// OIDCClient implements the §7 SSO code flow with PKCE. The provider is +// discovered lazily on first use (and retried on failure) so a temporarily +// unreachable IdP cannot prevent the app from booting. +type OIDCClient struct { + cfg *config.Config + log *slog.Logger + + mu sync.Mutex + provider *oidc.Provider +} + +// OIDCClaims is what the rest of the app needs from a verified ID token. +type OIDCClaims struct { + Issuer string + Subject string + Email string + EmailVerified bool + Name string +} + +func NewOIDCClient(cfg *config.Config, log *slog.Logger) *OIDCClient { + return &OIDCClient{cfg: cfg, log: log} +} + +func (o *OIDCClient) Enabled() bool { return o.cfg.OIDC.Enabled() } + +func (o *OIDCClient) getProvider(ctx context.Context) (*oidc.Provider, error) { + o.mu.Lock() + defer o.mu.Unlock() + if o.provider != nil { + return o.provider, nil + } + p, err := oidc.NewProvider(ctx, o.cfg.OIDC.IssuerURL) + if err != nil { + return nil, fmt.Errorf("oidc discovery for %s: %w", o.cfg.OIDC.IssuerURL, err) + } + o.provider = p + return p, nil +} + +func (o *OIDCClient) oauthConfig(p *oidc.Provider) *oauth2.Config { + return &oauth2.Config{ + ClientID: o.cfg.OIDC.ClientID, + ClientSecret: o.cfg.OIDC.ClientSecret, + Endpoint: p.Endpoint(), + RedirectURL: o.cfg.AppBaseURL + "/api/v1/auth/oidc/callback", + Scopes: []string{oidc.ScopeOpenID, "email", "profile"}, + } +} + +// AuthURL starts the flow: returns the IdP redirect plus the state and PKCE +// verifier the caller must persist (short-lived cookies). +func (o *OIDCClient) AuthURL(ctx context.Context) (url, state, verifier string, err error) { + if !o.Enabled() { + return "", "", "", errors.New("oidc is not configured") + } + p, err := o.getProvider(ctx) + if err != nil { + return "", "", "", err + } + state = NewToken() + verifier = oauth2.GenerateVerifier() + url = o.oauthConfig(p).AuthCodeURL(state, oauth2.S256ChallengeOption(verifier)) + return url, state, verifier, nil +} + +// Exchange swaps the code (with PKCE verifier) for tokens and returns the +// verified ID-token claims. +func (o *OIDCClient) Exchange(ctx context.Context, code, verifier string) (*OIDCClaims, error) { + p, err := o.getProvider(ctx) + if err != nil { + return nil, err + } + tok, err := o.oauthConfig(p).Exchange(ctx, code, oauth2.VerifierOption(verifier)) + if err != nil { + return nil, fmt.Errorf("oidc code exchange: %w", err) + } + rawID, ok := tok.Extra("id_token").(string) + if !ok { + return nil, errors.New("token response missing id_token") + } + idToken, err := p.Verifier(&oidc.Config{ClientID: o.cfg.OIDC.ClientID}).Verify(ctx, rawID) + if err != nil { + return nil, fmt.Errorf("verify id_token: %w", err) + } + var claims struct { + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` + Name string `json:"name"` + } + if err := idToken.Claims(&claims); err != nil { + return nil, fmt.Errorf("decode id_token claims: %w", err) + } + return &OIDCClaims{ + Issuer: idToken.Issuer, + Subject: idToken.Subject, + Email: claims.Email, + EmailVerified: claims.EmailVerified, + Name: claims.Name, + }, nil +} diff --git a/internal/auth/password.go b/internal/auth/password.go new file mode 100644 index 0000000..3a9a036 --- /dev/null +++ b/internal/auth/password.go @@ -0,0 +1,73 @@ +// Package auth implements authentication primitives: argon2id password +// hashing, opaque token generation, login rate limiting, bootstrap admin +// creation, and the OIDC login flow. +package auth + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "errors" + "fmt" + "strings" + + "golang.org/x/crypto/argon2" +) + +// Argon2id parameters fixed by spec §7: t=3, m=64MiB, p=2. +const ( + argonTime = 3 + argonMemory = 64 * 1024 // KiB + argonThreads = 2 + argonKeyLen = 32 + argonSaltLen = 16 +) + +// MinPasswordLen is the minimum accepted password length. +const MinPasswordLen = 8 + +// HashPassword returns a PHC-format argon2id hash. +func HashPassword(password string) (string, error) { + salt := make([]byte, argonSaltLen) + if _, err := rand.Read(salt); err != nil { + return "", fmt.Errorf("salt: %w", err) + } + key := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen) + return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", + argon2.Version, argonMemory, argonTime, argonThreads, + base64.RawStdEncoding.EncodeToString(salt), + base64.RawStdEncoding.EncodeToString(key)), nil +} + +// VerifyPassword checks password against a PHC argon2id hash in constant +// time. The parameters embedded in the hash are honored so stored hashes +// survive future parameter changes. +func VerifyPassword(password, encoded string) (bool, error) { + parts := strings.Split(encoded, "$") + if len(parts) != 6 || parts[1] != "argon2id" { + return false, errors.New("not an argon2id hash") + } + var version int + if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil { + return false, fmt.Errorf("hash version: %w", err) + } + if version != argon2.Version { + return false, fmt.Errorf("unsupported argon2 version %d", version) + } + var m uint32 + var t uint32 + var p uint8 + if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &m, &t, &p); err != nil { + return false, fmt.Errorf("hash params: %w", err) + } + salt, err := base64.RawStdEncoding.DecodeString(parts[4]) + if err != nil { + return false, fmt.Errorf("hash salt: %w", err) + } + want, err := base64.RawStdEncoding.DecodeString(parts[5]) + if err != nil { + return false, fmt.Errorf("hash key: %w", err) + } + got := argon2.IDKey([]byte(password), salt, t, m, p, uint32(len(want))) + return subtle.ConstantTimeCompare(got, want) == 1, nil +} diff --git a/internal/auth/password_test.go b/internal/auth/password_test.go new file mode 100644 index 0000000..859e985 --- /dev/null +++ b/internal/auth/password_test.go @@ -0,0 +1,75 @@ +package auth + +import ( + "encoding/base64" + "fmt" + "strings" + "testing" + + "golang.org/x/crypto/argon2" +) + +func TestHashVerifyRoundTrip(t *testing.T) { + hash, err := HashPassword("correct horse battery staple") + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(hash, "$argon2id$v=19$m=65536,t=3,p=2$") { + t.Fatalf("hash %q does not embed the spec parameters", hash) + } + + ok, err := VerifyPassword("correct horse battery staple", hash) + if err != nil || !ok { + t.Fatalf("correct password: ok=%v err=%v", ok, err) + } + ok, err = VerifyPassword("wrong password", hash) + if err != nil || ok { + t.Fatalf("wrong password: ok=%v err=%v", ok, err) + } +} + +func TestHashesAreSalted(t *testing.T) { + a, _ := HashPassword("same") + b, _ := HashPassword("same") + if a == b { + t.Fatal("two hashes of the same password must differ") + } +} + +func TestVerifyRejectsGarbage(t *testing.T) { + for _, bad := range []string{ + "", + "plaintext", + "$bcrypt$whatever", + "$argon2id$v=19$m=65536,t=3,p=2$!!!$AAA", + "$argon2id$v=18$m=65536,t=3,p=2$c2FsdA$AAA", + } { + if ok, err := VerifyPassword("x", bad); ok || err == nil { + t.Errorf("VerifyPassword(%q): ok=%v err=%v, want rejection", bad, ok, err) + } + } +} + +func TestVerifyHonorsEmbeddedParams(t *testing.T) { + // A hash created with different (weaker) params must still verify: + // params come from the hash string, not the current constants. + salt := []byte("saltsaltsaltsalt") + key := argon2.IDKey([]byte("pw"), salt, 1, 16*1024, 1, 32) + legacy := fmt.Sprintf("$argon2id$v=19$m=16384,t=1,p=1$%s$%s", + base64.RawStdEncoding.EncodeToString(salt), + base64.RawStdEncoding.EncodeToString(key)) + ok, err := VerifyPassword("pw", legacy) + if err != nil || !ok { + t.Fatalf("legacy-param hash: ok=%v err=%v", ok, err) + } +} + +func TestNewTokenShape(t *testing.T) { + a, b := NewToken(), NewToken() + if a == b { + t.Fatal("tokens must be unique") + } + if len(a) != 43 { // 32 bytes base64url, unpadded + t.Fatalf("token length = %d, want 43", len(a)) + } +} diff --git a/internal/auth/ratelimit.go b/internal/auth/ratelimit.go new file mode 100644 index 0000000..fef3b5e --- /dev/null +++ b/internal/auth/ratelimit.go @@ -0,0 +1,70 @@ +package auth + +import ( + "sync" + "time" +) + +// RateLimiter is an in-memory token bucket per key (§7: 10 attempts per +// 15 min per IP+email). Buckets refill continuously and idle entries are +// swept opportunistically. +type RateLimiter struct { + mu sync.Mutex + buckets map[string]*bucket + capacity float64 + window time.Duration + now func() time.Time // injectable for tests + lastGC time.Time +} + +type bucket struct { + tokens float64 + last time.Time +} + +func NewRateLimiter(attempts int, window time.Duration) *RateLimiter { + return &RateLimiter{ + buckets: make(map[string]*bucket), + capacity: float64(attempts), + window: window, + now: time.Now, + } +} + +// Allow consumes one attempt for key, reporting whether it was within the +// limit. +func (rl *RateLimiter) Allow(key string) bool { + rl.mu.Lock() + defer rl.mu.Unlock() + + now := rl.now() + rl.gcLocked(now) + + b, ok := rl.buckets[key] + if !ok { + b = &bucket{tokens: rl.capacity, last: now} + rl.buckets[key] = b + } + refill := now.Sub(b.last).Seconds() * (rl.capacity / rl.window.Seconds()) + b.tokens = min(rl.capacity, b.tokens+refill) + b.last = now + if b.tokens < 1 { + return false + } + b.tokens-- + return true +} + +// gcLocked drops buckets that have fully refilled (idle for >= one window); +// runs at most once per window to keep Allow O(1) amortized. +func (rl *RateLimiter) gcLocked(now time.Time) { + if now.Sub(rl.lastGC) < rl.window { + return + } + rl.lastGC = now + for k, b := range rl.buckets { + if now.Sub(b.last) >= rl.window { + delete(rl.buckets, k) + } + } +} diff --git a/internal/auth/ratelimit_test.go b/internal/auth/ratelimit_test.go new file mode 100644 index 0000000..19f6273 --- /dev/null +++ b/internal/auth/ratelimit_test.go @@ -0,0 +1,62 @@ +package auth + +import ( + "testing" + "time" +) + +func TestRateLimiterExhaustionAndRefill(t *testing.T) { + rl := NewRateLimiter(10, 15*time.Minute) + now := time.Date(2026, 6, 12, 12, 0, 0, 0, time.UTC) + rl.now = func() time.Time { return now } + + for i := 0; i < 10; i++ { + if !rl.Allow("ip|a@b.c") { + t.Fatalf("attempt %d should be allowed", i+1) + } + } + if rl.Allow("ip|a@b.c") { + t.Fatal("11th attempt must be blocked") + } + + // Other keys are unaffected. + if !rl.Allow("ip|other@b.c") { + t.Fatal("different key must have its own bucket") + } + + // After 1.5 minutes one token has refilled (10 per 15 min). + now = now.Add(90 * time.Second) + if !rl.Allow("ip|a@b.c") { + t.Fatal("one attempt should have refilled") + } + if rl.Allow("ip|a@b.c") { + t.Fatal("bucket must be empty again") + } + + // A full window refills completely, capped at capacity. + now = now.Add(time.Hour) + for i := 0; i < 10; i++ { + if !rl.Allow("ip|a@b.c") { + t.Fatalf("post-refill attempt %d should be allowed", i+1) + } + } + if rl.Allow("ip|a@b.c") { + t.Fatal("refill must cap at capacity") + } +} + +func TestRateLimiterGC(t *testing.T) { + rl := NewRateLimiter(10, 15*time.Minute) + now := time.Date(2026, 6, 12, 12, 0, 0, 0, time.UTC) + rl.now = func() time.Time { return now } + + rl.Allow("stale") + now = now.Add(16 * time.Minute) + rl.Allow("fresh") // triggers gc + rl.mu.Lock() + _, staleExists := rl.buckets["stale"] + rl.mu.Unlock() + if staleExists { + t.Fatal("idle bucket should have been swept") + } +} diff --git a/internal/auth/token.go b/internal/auth/token.go new file mode 100644 index 0000000..a5a2176 --- /dev/null +++ b/internal/auth/token.go @@ -0,0 +1,18 @@ +package auth + +import ( + "crypto/rand" + "encoding/base64" +) + +// NewToken returns 32 bytes of cryptographic randomness, base64url-encoded — +// used for session tokens, CSRF tokens, and OIDC state. +func NewToken() string { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + // Same stance as ulid: a process that cannot read crypto/rand must + // not mint security tokens. + panic("auth: crypto/rand failed: " + err.Error()) + } + return base64.RawURLEncoding.EncodeToString(b) +} diff --git a/internal/domain/session.go b/internal/domain/session.go new file mode 100644 index 0000000..160c12a --- /dev/null +++ b/internal/domain/session.go @@ -0,0 +1,18 @@ +package domain + +import "time" + +// Session is an opaque server-side session (§4.8). The random token is the +// document id; the Mongo TTL index on expiresAt reaps stale rows. +type Session struct { + Token string `bson:"_id"` + UserID string `bson:"userId"` + CreatedAt time.Time `bson:"createdAt"` + ExpiresAt time.Time `bson:"expiresAt"` + RefreshedAt time.Time `bson:"refreshedAt"` + IP string `bson:"ip"` + UA string `bson:"ua"` +} + +// SessionTTL is the sliding session lifetime (§7). +const SessionTTL = 30 * 24 * time.Hour diff --git a/internal/domain/user.go b/internal/domain/user.go new file mode 100644 index 0000000..4a857fc --- /dev/null +++ b/internal/domain/user.go @@ -0,0 +1,82 @@ +// Package domain holds the core data model types and business rules shared +// across handlers, stores, and workers. It has no Mongo or HTTP dependencies +// beyond bson struct tags. +package domain + +import "time" + +type Roles struct { + Admin bool `bson:"admin" json:"admin"` + Consultant bool `bson:"consultant" json:"consultant"` + Developer bool `bson:"developer" json:"developer"` +} + +// Has reports whether the role named r is set. Known names: admin, +// consultant, developer. +func (r Roles) Has(role string) bool { + switch role { + case "admin": + return r.Admin + case "consultant": + return r.Consultant + case "developer": + return r.Developer + default: + return false + } +} + +type LocalAuth struct { + PasswordHash string `bson:"passwordHash" json:"-"` + MustChange bool `bson:"mustChange" json:"mustChange"` +} + +type OIDCAuth struct { + Issuer string `bson:"issuer" json:"issuer"` + Subject string `bson:"subject" json:"subject"` +} + +type Auth struct { + Local *LocalAuth `bson:"local" json:"local,omitempty"` + OIDC *OIDCAuth `bson:"oidc" json:"oidc,omitempty"` +} + +type Contact struct { + Phone string `bson:"phone" json:"phone"` + Location string `bson:"location" json:"location"` + Links []string `bson:"links" json:"links"` +} + +type NotificationPrefs struct { + Email bool `bson:"email" json:"email"` + InApp bool `bson:"inApp" json:"inApp"` +} + +type UserSettings struct { + Theme string `bson:"theme" json:"theme"` + Notifications NotificationPrefs `bson:"notifications" json:"notifications"` +} + +type User struct { + ID string `bson:"_id" json:"id"` + Email string `bson:"email" json:"email"` + Name string `bson:"name" json:"name"` + AvatarFileID string `bson:"avatarFileId,omitempty" json:"avatarFileId,omitempty"` + Bio string `bson:"bio" json:"bio"` + Contact Contact `bson:"contact" json:"contact"` + Extra map[string]any `bson:"extra" json:"extra"` + Roles Roles `bson:"roles" json:"roles"` + Auth Auth `bson:"auth" json:"-"` + Settings UserSettings `bson:"settings" json:"settings"` + Disabled bool `bson:"disabled" json:"disabled"` + LastSeenAt time.Time `bson:"lastSeenAt" json:"lastSeenAt"` + CreatedAt time.Time `bson:"createdAt" json:"createdAt"` + UpdatedAt time.Time `bson:"updatedAt" json:"updatedAt"` + Version int64 `bson:"version" json:"version"` +} + +// MustChangePassword reports whether the user is locked to the +// password-change flow. +func (u *User) MustChangePassword() bool { + return u.Auth.Local != nil && u.Auth.Local.MustChange +} diff --git a/internal/http/auth.go b/internal/http/auth.go new file mode 100644 index 0000000..14eb1c0 --- /dev/null +++ b/internal/http/auth.go @@ -0,0 +1,258 @@ +package httpx + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "net/mail" + "strings" + "time" + + "bountyboard/internal/auth" + "bountyboard/internal/domain" + "bountyboard/internal/store" + "bountyboard/internal/ulid" +) + +func (s *Server) routesAuth(mux *http.ServeMux) { + mux.HandleFunc("POST /api/v1/auth/register", s.handleRegister) + mux.HandleFunc("POST /api/v1/auth/login", s.handleLogin) + mux.Handle("POST /api/v1/auth/logout", s.authed(s.handleLogout)) + mux.Handle("POST /api/v1/auth/logout-all", s.authed(s.handleLogoutAll)) + mux.Handle("POST /api/v1/auth/change-password", s.authed(s.handleChangePassword)) + mux.Handle("GET /api/v1/auth/me", s.requireAuth(http.HandlerFunc(s.handleMe))) + mux.HandleFunc("GET /api/v1/auth/oidc/login", s.handleOIDCLogin) + mux.HandleFunc("GET /api/v1/auth/oidc/callback", s.handleOIDCCallback) +} + +// decodeJSON reads a bounded JSON body into dst, rejecting unknown fields. +func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool { + dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)) + dec.DisallowUnknownFields() + if err := dec.Decode(dst); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "invalid JSON body: "+err.Error()) + return false + } + return true +} + +func validEmail(email string) bool { + a, err := mail.ParseAddress(email) + return err == nil && a.Address == email +} + +func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) { + var req struct { + Email string `json:"email"` + Name string `json:"name"` + Password string `json:"password"` + } + if !decodeJSON(w, r, &req) { + return + } + req.Email = store.NormalizeEmail(req.Email) + req.Name = strings.TrimSpace(req.Name) + switch { + case !validEmail(req.Email): + writeError(w, http.StatusBadRequest, "invalid_email", "a valid email address is required") + return + case req.Name == "": + writeError(w, http.StatusBadRequest, "invalid_name", "name is required") + return + case len(req.Password) < auth.MinPasswordLen: + writeError(w, http.StatusBadRequest, "weak_password", + fmt.Sprintf("password must be at least %d characters", auth.MinPasswordLen)) + return + } + if !s.loginLimiter.Allow("register|" + ClientIP(r.Context()).String()) { + writeError(w, http.StatusTooManyRequests, "rate_limited", "too many attempts, try again later") + return + } + + hash, err := auth.HashPassword(req.Password) + if err != nil { + s.internalError(w, r, "hash password", err) + return + } + u := &domain.User{ + ID: ulid.New(), + Email: req.Email, + Name: req.Name, + // Open self-registration always creates a developer account (§3). + Roles: domain.Roles{Developer: true}, + Auth: domain.Auth{Local: &domain.LocalAuth{PasswordHash: hash}}, + Settings: domain.UserSettings{ + Theme: "light", + Notifications: domain.NotificationPrefs{Email: true, InApp: true}, + }, + } + if err := s.store.CreateUser(r.Context(), u); err != nil { + if errors.Is(err, store.ErrDuplicate) { + writeError(w, http.StatusConflict, "email_taken", "an account with this email already exists") + return + } + s.internalError(w, r, "create user", err) + return + } + s.metrics.Inc("auth_registrations_total", 1) + s.startSession(w, r, u, http.StatusCreated) +} + +func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { + var req struct { + Email string `json:"email"` + Password string `json:"password"` + } + if !decodeJSON(w, r, &req) { + return + } + req.Email = store.NormalizeEmail(req.Email) + if !s.loginLimiter.Allow(ClientIP(r.Context()).String() + "|" + req.Email) { + s.metrics.Inc("auth_rate_limited_total", 1) + writeError(w, http.StatusTooManyRequests, "rate_limited", "too many attempts, try again later") + return + } + + denyInvalid := func() { + s.metrics.Inc("auth_login_failures_total", 1) + writeError(w, http.StatusUnauthorized, "invalid_credentials", "invalid email or password") + } + u, err := s.store.UserByEmail(r.Context(), req.Email) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + denyInvalid() + return + } + s.internalError(w, r, "find user", err) + return + } + if u.Auth.Local == nil { + denyInvalid() + return + } + ok, err := auth.VerifyPassword(req.Password, u.Auth.Local.PasswordHash) + if err != nil { + s.internalError(w, r, "verify password", err) + return + } + if !ok { + denyInvalid() + return + } + if u.Disabled { + writeError(w, http.StatusForbidden, "account_disabled", "account is disabled") + return + } + s.startSession(w, r, u, http.StatusOK) +} + +// newSessionFor persists a fresh session for u and returns it with a new +// CSRF token. +func (s *Server) newSessionFor(r *http.Request, u *domain.User) (*domain.Session, string, error) { + now := time.Now().UTC() + sess := &domain.Session{ + Token: auth.NewToken(), + UserID: u.ID, + CreatedAt: now, + ExpiresAt: now.Add(domain.SessionTTL), + RefreshedAt: now, + IP: ClientIP(r.Context()).String(), + UA: r.UserAgent(), + } + if err := s.store.CreateSession(r.Context(), sess); err != nil { + return nil, "", err + } + if err := s.store.TouchLastSeen(r.Context(), u.ID); err != nil { + s.log.Warn("touch lastSeenAt", "err", err) + } + s.metrics.Inc("auth_logins_total", 1) + return sess, auth.NewToken(), nil +} + +// startSession mints session + CSRF cookies and writes the login response. +func (s *Server) startSession(w http.ResponseWriter, r *http.Request, u *domain.User, status int) { + sess, csrf, err := s.newSessionFor(r, u) + if err != nil { + s.internalError(w, r, "create session", err) + return + } + s.setAuthCookies(w, sess.Token, csrf) + writeJSON(w, status, map[string]any{ + "user": u, + "mustChangePassword": u.MustChangePassword(), + }) +} + +func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { + if err := s.store.DeleteSession(r.Context(), CurrentSession(r.Context()).Token); err != nil { + s.internalError(w, r, "delete session", err) + return + } + s.clearAuthCookies(w) + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) handleLogoutAll(w http.ResponseWriter, r *http.Request) { + n, err := s.store.DeleteUserSessions(r.Context(), CurrentUser(r.Context()).ID) + if err != nil { + s.internalError(w, r, "delete sessions", err) + return + } + s.clearAuthCookies(w) + writeJSON(w, http.StatusOK, map[string]any{"revoked": n}) +} + +func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) { + var req struct { + CurrentPassword string `json:"currentPassword"` + NewPassword string `json:"newPassword"` + } + if !decodeJSON(w, r, &req) { + return + } + u := CurrentUser(r.Context()) + if u.Auth.Local == nil { + writeError(w, http.StatusBadRequest, "no_local_auth", + "this account signs in via SSO and has no password") + return + } + if len(req.NewPassword) < auth.MinPasswordLen { + writeError(w, http.StatusBadRequest, "weak_password", + fmt.Sprintf("password must be at least %d characters", auth.MinPasswordLen)) + return + } + ok, err := auth.VerifyPassword(req.CurrentPassword, u.Auth.Local.PasswordHash) + if err != nil { + s.internalError(w, r, "verify password", err) + return + } + if !ok { + writeError(w, http.StatusUnauthorized, "invalid_credentials", "current password is incorrect") + return + } + hash, err := auth.HashPassword(req.NewPassword) + if err != nil { + s.internalError(w, r, "hash password", err) + return + } + if err := s.store.SetPassword(r.Context(), u.ID, hash, false); err != nil { + s.internalError(w, r, "set password", err) + return + } + // Revoke every other session: a password change is the recovery action + // after credential leak. + if _, err := s.store.DeleteUserSessionsExcept(r.Context(), u.ID, CurrentSession(r.Context()).Token); err != nil { + s.log.Warn("revoke other sessions", "err", err) + } + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) { + u := CurrentUser(r.Context()) + writeJSON(w, http.StatusOK, map[string]any{ + "user": u, + "mustChangePassword": u.MustChangePassword(), + "oidcEnabled": s.cfg.OIDC.Enabled(), + }) +} diff --git a/internal/http/auth_integration_test.go b/internal/http/auth_integration_test.go new file mode 100644 index 0000000..2da336e --- /dev/null +++ b/internal/http/auth_integration_test.go @@ -0,0 +1,472 @@ +//go:build integration + +package httpx + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/cookiejar" + "net/http/httptest" + "net/url" + "os" + "testing" + "time" + + "bountyboard/internal/auth" + "bountyboard/internal/config" + "bountyboard/internal/domain" + "bountyboard/internal/metrics" + "bountyboard/internal/store" + "bountyboard/internal/testutil" +) + +// newAuthStack builds a full server backed by a per-run Mongo database. +// extraEnv is applied before config.Load. +func newAuthStack(t *testing.T, extraEnv map[string]string) (*httptest.Server, *Server, *store.Store, *config.Config) { + t.Helper() + db := testutil.MongoDB(t) + if err := store.EnsureIndexes(t.Context(), db); err != nil { + t.Fatal(err) + } + st := &store.Store{Client: db.Client(), DB: db} + + t.Setenv("SESSION_SECRET", "0123456789abcdef0123456789abcdef") + t.Setenv("CREDENTIALS_ENC_KEY", base64.StdEncoding.EncodeToString(make([]byte, 32))) + for k, v := range extraEnv { + t.Setenv(k, v) + } + cfg, err := config.Load() + if err != nil { + t.Fatal(err) + } + logOut := io.Writer(io.Discard) + if os.Getenv("TEST_LOG") == "1" { + logOut = os.Stderr + } + srv := New(cfg, slog.New(slog.NewTextHandler(logOut, nil)), metrics.NewRegistry(), st) + ts := httptest.NewServer(srv.Handler()) + t.Cleanup(ts.Close) + return ts, srv, st, cfg +} + +// client returns an http client with a cookie jar that does not follow +// redirects (so OIDC hops can be inspected). +func newClient(t *testing.T) *http.Client { + t.Helper() + jar, err := cookiejar.New(nil) + if err != nil { + t.Fatal(err) + } + return &http.Client{ + Jar: jar, + CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, + } +} + +func postJSON(t *testing.T, c *http.Client, rawURL string, body any, csrf string) *http.Response { + t.Helper() + b, err := json.Marshal(body) + if err != nil { + t.Fatal(err) + } + req, err := http.NewRequest(http.MethodPost, rawURL, bytes.NewReader(b)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/json") + if csrf != "" { + req.Header.Set(csrfHeader, csrf) + } + resp, err := c.Do(req) + if err != nil { + t.Fatal(err) + } + return resp +} + +func bodyJSON(t *testing.T, resp *http.Response, dst any) { + t.Helper() + defer resp.Body.Close() + if err := json.NewDecoder(resp.Body).Decode(dst); err != nil { + t.Fatalf("decode response: %v", err) + } +} + +func csrfFrom(t *testing.T, c *http.Client, baseURL string) string { + t.Helper() + u, _ := url.Parse(baseURL) + for _, ck := range c.Jar.Cookies(u) { + if ck.Name == csrfCookie { + return ck.Value + } + } + return "" +} + +func errCode(t *testing.T, resp *http.Response) string { + t.Helper() + var env struct { + Error struct { + Code string `json:"code"` + } `json:"error"` + } + bodyJSON(t, resp, &env) + return env.Error.Code +} + +func TestRegisterLoginLogoutFlow(t *testing.T) { + ts, _, _, _ := newAuthStack(t, nil) + c := newClient(t) + + // invalid inputs + for _, tc := range []struct { + body map[string]string + want string + }{ + {map[string]string{"email": "not-an-email", "name": "X", "password": "longenough"}, "invalid_email"}, + {map[string]string{"email": "a@b.co", "name": "", "password": "longenough"}, "invalid_name"}, + {map[string]string{"email": "a@b.co", "name": "X", "password": "short"}, "weak_password"}, + } { + resp := postJSON(t, c, ts.URL+"/api/v1/auth/register", tc.body, "") + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("register %v: code %d", tc.body, resp.StatusCode) + } + if got := errCode(t, resp); got != tc.want { + t.Errorf("error code %q, want %q", got, tc.want) + } + } + + // successful registration creates a developer and a session + resp := postJSON(t, c, ts.URL+"/api/v1/auth/register", + map[string]string{"email": "Dev@Example.com", "name": "Dev One", "password": "password123"}, "") + if resp.StatusCode != http.StatusCreated { + t.Fatalf("register: %d", resp.StatusCode) + } + var reg struct { + User domain.User `json:"user"` + } + bodyJSON(t, resp, ®) + if reg.User.Email != "dev@example.com" { + t.Errorf("email not normalized: %q", reg.User.Email) + } + if !reg.User.Roles.Developer || reg.User.Roles.Admin || reg.User.Roles.Consultant { + t.Errorf("self-registration roles wrong: %+v", reg.User.Roles) + } + + // session works + meResp, err := c.Get(ts.URL + "/api/v1/auth/me") + if err != nil || meResp.StatusCode != http.StatusOK { + t.Fatalf("me after register: %v %d", err, meResp.StatusCode) + } + meResp.Body.Close() + + // duplicate email + resp = postJSON(t, newClient(t), ts.URL+"/api/v1/auth/register", + map[string]string{"email": "dev@example.com", "name": "Imposter", "password": "password123"}, "") + if resp.StatusCode != http.StatusConflict { + t.Fatalf("duplicate register: %d", resp.StatusCode) + } + resp.Body.Close() + + // logout requires CSRF + resp = postJSON(t, c, ts.URL+"/api/v1/auth/logout", map[string]string{}, "") + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("logout without csrf: %d", resp.StatusCode) + } + resp.Body.Close() + + csrf := csrfFrom(t, c, ts.URL) + if csrf == "" { + t.Fatal("csrf cookie not set") + } + resp = postJSON(t, c, ts.URL+"/api/v1/auth/logout", map[string]string{}, csrf) + if resp.StatusCode != http.StatusNoContent { + t.Fatalf("logout: %d", resp.StatusCode) + } + resp.Body.Close() + + meResp, err = c.Get(ts.URL + "/api/v1/auth/me") + if err != nil || meResp.StatusCode != http.StatusUnauthorized { + t.Fatalf("me after logout: %v %d", err, meResp.StatusCode) + } + meResp.Body.Close() + + // fresh login + c2 := newClient(t) + resp = postJSON(t, c2, ts.URL+"/api/v1/auth/login", + map[string]string{"email": "dev@example.com", "password": "password123"}, "") + if resp.StatusCode != http.StatusOK { + t.Fatalf("login: %d", resp.StatusCode) + } + resp.Body.Close() + + // wrong password and unknown email are indistinguishable 401s + for _, body := range []map[string]string{ + {"email": "dev@example.com", "password": "wrong-password"}, + {"email": "ghost@example.com", "password": "password123"}, + } { + resp = postJSON(t, newClient(t), ts.URL+"/api/v1/auth/login", body, "") + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("login %v: %d", body, resp.StatusCode) + } + if got := errCode(t, resp); got != "invalid_credentials" { + t.Errorf("error code %q", got) + } + } +} + +func TestLoginRateLimit(t *testing.T) { + ts, _, _, _ := newAuthStack(t, nil) + for i := 0; i < 10; i++ { + resp := postJSON(t, newClient(t), ts.URL+"/api/v1/auth/login", + map[string]string{"email": "victim@example.com", "password": "guess"}, "") + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("attempt %d: %d", i+1, resp.StatusCode) + } + resp.Body.Close() + } + resp := postJSON(t, newClient(t), ts.URL+"/api/v1/auth/login", + map[string]string{"email": "victim@example.com", "password": "guess"}, "") + if resp.StatusCode != http.StatusTooManyRequests { + t.Fatalf("11th attempt: %d, want 429", resp.StatusCode) + } + resp.Body.Close() + + // other accounts from the same IP are unaffected (key includes email) + resp = postJSON(t, newClient(t), ts.URL+"/api/v1/auth/login", + map[string]string{"email": "other@example.com", "password": "guess"}, "") + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("different email after limit: %d", resp.StatusCode) + } + resp.Body.Close() +} + +func TestDisabledAccount(t *testing.T) { + ts, _, st, _ := newAuthStack(t, nil) + c := newClient(t) + resp := postJSON(t, c, ts.URL+"/api/v1/auth/register", + map[string]string{"email": "d@example.com", "name": "D", "password": "password123"}, "") + var reg struct { + User domain.User `json:"user"` + } + bodyJSON(t, resp, ®) + + // disable directly in the store + if _, err := st.DB.Collection("users").UpdateOne(t.Context(), + map[string]any{"_id": reg.User.ID}, map[string]any{"$set": map[string]any{"disabled": true}}); err != nil { + t.Fatal(err) + } + + // existing session is revoked on next use + meResp, _ := c.Get(ts.URL + "/api/v1/auth/me") + if meResp.StatusCode != http.StatusForbidden { + t.Fatalf("me as disabled: %d", meResp.StatusCode) + } + meResp.Body.Close() + + // fresh login rejected + resp = postJSON(t, newClient(t), ts.URL+"/api/v1/auth/login", + map[string]string{"email": "d@example.com", "password": "password123"}, "") + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("login as disabled: %d", resp.StatusCode) + } + if got := errCode(t, resp); got != "account_disabled" { + t.Errorf("error code %q", got) + } +} + +func TestBootstrapAdminAndForcedPasswordChange(t *testing.T) { + ts, srv, st, cfg := newAuthStack(t, map[string]string{ + "ADMIN_EMAIL": "root@example.com", + "ADMIN_INITIAL_PASSWORD": "initial-password", + }) + if err := auth.EnsureBootstrapAdmin(t.Context(), st, cfg, srv.log); err != nil { + t.Fatal(err) + } + // idempotent + if err := auth.EnsureBootstrapAdmin(t.Context(), st, cfg, srv.log); err != nil { + t.Fatal(err) + } + if n, _ := st.CountUsers(t.Context()); n != 1 { + t.Fatalf("user count = %d, want 1", n) + } + + c := newClient(t) + resp := postJSON(t, c, ts.URL+"/api/v1/auth/login", + map[string]string{"email": "root@example.com", "password": "initial-password"}, "") + if resp.StatusCode != http.StatusOK { + t.Fatalf("admin login: %d", resp.StatusCode) + } + var login struct { + User domain.User `json:"user"` + MustChangePassword bool `json:"mustChangePassword"` + } + bodyJSON(t, resp, &login) + if !login.User.Roles.Admin || !login.MustChangePassword { + t.Fatalf("bootstrap admin login: %+v must=%v", login.User.Roles, login.MustChangePassword) + } + + // while mustChange is set, non-allowlisted endpoints are blocked + csrf := csrfFrom(t, c, ts.URL) + probe := httptest.NewServer(srv.requireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }))) + defer probe.Close() + req, _ := http.NewRequest(http.MethodGet, probe.URL+"/api/v1/anything", nil) + u, _ := url.Parse(ts.URL) + for _, ck := range c.Jar.Cookies(u) { + req.AddCookie(ck) + } + pResp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + if pResp.StatusCode != http.StatusForbidden { + t.Fatalf("protected endpoint under mustChange: %d, want 403", pResp.StatusCode) + } + pResp.Body.Close() + + // wrong current password + resp = postJSON(t, c, ts.URL+"/api/v1/auth/change-password", + map[string]string{"currentPassword": "nope", "newPassword": "fresh-password-1"}, csrf) + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("change with wrong current: %d", resp.StatusCode) + } + resp.Body.Close() + + // correct change clears the flag + resp = postJSON(t, c, ts.URL+"/api/v1/auth/change-password", + map[string]string{"currentPassword": "initial-password", "newPassword": "fresh-password-1"}, csrf) + if resp.StatusCode != http.StatusNoContent { + t.Fatalf("change password: %d", resp.StatusCode) + } + resp.Body.Close() + + req, _ = http.NewRequest(http.MethodGet, probe.URL+"/api/v1/anything", nil) + for _, ck := range c.Jar.Cookies(u) { + req.AddCookie(ck) + } + pResp, err = http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + if pResp.StatusCode != http.StatusOK { + t.Fatalf("protected endpoint after change: %d", pResp.StatusCode) + } + pResp.Body.Close() + + // old password dead, new one works + resp = postJSON(t, newClient(t), ts.URL+"/api/v1/auth/login", + map[string]string{"email": "root@example.com", "password": "initial-password"}, "") + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("old password still works: %d", resp.StatusCode) + } + resp.Body.Close() + resp = postJSON(t, newClient(t), ts.URL+"/api/v1/auth/login", + map[string]string{"email": "root@example.com", "password": "fresh-password-1"}, "") + if resp.StatusCode != http.StatusOK { + t.Fatalf("new password rejected: %d", resp.StatusCode) + } + resp.Body.Close() +} + +func TestPasswordChangeRevokesOtherSessions(t *testing.T) { + ts, _, _, _ := newAuthStack(t, nil) + + c1, c2 := newClient(t), newClient(t) + resp := postJSON(t, c1, ts.URL+"/api/v1/auth/register", + map[string]string{"email": "u@example.com", "name": "U", "password": "password123"}, "") + resp.Body.Close() + resp = postJSON(t, c2, ts.URL+"/api/v1/auth/login", + map[string]string{"email": "u@example.com", "password": "password123"}, "") + resp.Body.Close() + + resp = postJSON(t, c1, ts.URL+"/api/v1/auth/change-password", + map[string]string{"currentPassword": "password123", "newPassword": "password456"}, csrfFrom(t, c1, ts.URL)) + if resp.StatusCode != http.StatusNoContent { + t.Fatalf("change: %d", resp.StatusCode) + } + resp.Body.Close() + + // c1 (the changer) still works, c2 is revoked + r1, _ := c1.Get(ts.URL + "/api/v1/auth/me") + if r1.StatusCode != http.StatusOK { + t.Errorf("changer session: %d", r1.StatusCode) + } + r1.Body.Close() + r2, _ := c2.Get(ts.URL + "/api/v1/auth/me") + if r2.StatusCode != http.StatusUnauthorized { + t.Errorf("other session after password change: %d, want 401", r2.StatusCode) + } + r2.Body.Close() +} + +func TestExpiredSessionRejected(t *testing.T) { + ts, _, st, _ := newAuthStack(t, nil) + c := newClient(t) + resp := postJSON(t, c, ts.URL+"/api/v1/auth/register", + map[string]string{"email": "e@example.com", "name": "E", "password": "password123"}, "") + resp.Body.Close() + + // force-expire every session for the user + if _, err := st.DB.Collection("sessions").UpdateMany(t.Context(), + map[string]any{}, map[string]any{"$set": map[string]any{"expiresAt": time.Now().Add(-time.Minute)}}); err != nil { + t.Fatal(err) + } + meResp, _ := c.Get(ts.URL + "/api/v1/auth/me") + if meResp.StatusCode != http.StatusUnauthorized { + t.Fatalf("expired session: %d", meResp.StatusCode) + } + meResp.Body.Close() +} + +func TestLogoutAll(t *testing.T) { + ts, _, _, _ := newAuthStack(t, nil) + c1, c2 := newClient(t), newClient(t) + resp := postJSON(t, c1, ts.URL+"/api/v1/auth/register", + map[string]string{"email": "la@example.com", "name": "LA", "password": "password123"}, "") + resp.Body.Close() + resp = postJSON(t, c2, ts.URL+"/api/v1/auth/login", + map[string]string{"email": "la@example.com", "password": "password123"}, "") + resp.Body.Close() + + resp = postJSON(t, c1, ts.URL+"/api/v1/auth/logout-all", map[string]string{}, csrfFrom(t, c1, ts.URL)) + if resp.StatusCode != http.StatusOK { + t.Fatalf("logout-all: %d", resp.StatusCode) + } + var out struct { + Revoked int `json:"revoked"` + } + bodyJSON(t, resp, &out) + if out.Revoked != 2 { + t.Errorf("revoked = %d, want 2", out.Revoked) + } + for i, c := range []*http.Client{c1, c2} { + r, _ := c.Get(ts.URL + "/api/v1/auth/me") + if r.StatusCode != http.StatusUnauthorized { + t.Errorf("client %d after logout-all: %d", i+1, r.StatusCode) + } + r.Body.Close() + } +} + +func TestOIDCOnlyAccountCannotPasswordLogin(t *testing.T) { + ts, _, st, _ := newAuthStack(t, nil) + if err := st.CreateUser(t.Context(), &domain.User{ + ID: "01JOIDCONLY0000000000000US", Email: "sso@example.com", Name: "SSO", + Roles: domain.Roles{Developer: true}, + Auth: domain.Auth{OIDC: &domain.OIDCAuth{Issuer: "https://idp", Subject: "x"}}, + }); err != nil { + t.Fatal(err) + } + resp := postJSON(t, newClient(t), ts.URL+"/api/v1/auth/login", + map[string]string{"email": "sso@example.com", "password": "whatever123"}, "") + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("oidc-only password login: %d", resp.StatusCode) + } + resp.Body.Close() +} diff --git a/internal/http/authmw_test.go b/internal/http/authmw_test.go new file mode 100644 index 0000000..7b00fb7 --- /dev/null +++ b/internal/http/authmw_test.go @@ -0,0 +1,93 @@ +package httpx + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "bountyboard/internal/domain" +) + +func userCtx(r *http.Request, u *domain.User) *http.Request { + return r.WithContext(context.WithValue(r.Context(), ctxKeyUser, u)) +} + +// TestRBACMatrix exercises requireRole for every role/requirement combination. +func TestRBACMatrix(t *testing.T) { + s := newTestServer(t) + roles := func(admin, consultant, developer bool) domain.Roles { + return domain.Roles{Admin: admin, Consultant: consultant, Developer: developer} + } + tests := []struct { + name string + user *domain.User + required string + wantCode int + }{ + {"admin passes admin gate", &domain.User{Roles: roles(true, false, false)}, "admin", http.StatusOK}, + {"developer blocked from admin gate", &domain.User{Roles: roles(false, false, true)}, "admin", http.StatusForbidden}, + {"consultant blocked from admin gate", &domain.User{Roles: roles(false, true, false)}, "admin", http.StatusForbidden}, + {"consultant passes consultant gate", &domain.User{Roles: roles(false, true, false)}, "consultant", http.StatusOK}, + {"developer blocked from consultant gate", &domain.User{Roles: roles(false, false, true)}, "consultant", http.StatusForbidden}, + {"admin blocked from consultant gate without flag", &domain.User{Roles: roles(true, false, false)}, "consultant", http.StatusForbidden}, + {"developer passes developer gate", &domain.User{Roles: roles(false, false, true)}, "developer", http.StatusOK}, + {"multi-role user passes both gates", &domain.User{Roles: roles(false, true, true)}, "developer", http.StatusOK}, + {"no user in context", nil, "developer", http.StatusForbidden}, + {"unknown role never passes", &domain.User{Roles: roles(true, true, true)}, "superuser", http.StatusForbidden}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + h := s.requireRole(tt.required, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + r := httptest.NewRequest(http.MethodGet, "/probe", nil) + if tt.user != nil { + r = userCtx(r, tt.user) + } + rec := httptest.NewRecorder() + h.ServeHTTP(rec, r) + if rec.Code != tt.wantCode { + t.Errorf("code = %d, want %d", rec.Code, tt.wantCode) + } + }) + } +} + +func TestCSRFDoubleSubmit(t *testing.T) { + s := newTestServer(t) + ok := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) + + tests := []struct { + name string + method string + cookie string + header string + wantCode int + }{ + {"GET passes without token", http.MethodGet, "", "", http.StatusOK}, + {"HEAD passes without token", http.MethodHead, "", "", http.StatusOK}, + {"POST without anything blocked", http.MethodPost, "", "", http.StatusForbidden}, + {"POST with matching pair passes", http.MethodPost, "tok123", "tok123", http.StatusOK}, + {"POST with mismatched header blocked", http.MethodPost, "tok123", "other", http.StatusForbidden}, + {"POST with header but no cookie blocked", http.MethodPost, "", "tok123", http.StatusForbidden}, + {"POST with cookie but no header blocked", http.MethodPost, "tok123", "", http.StatusForbidden}, + {"DELETE requires token", http.MethodDelete, "tok123", "tok123", http.StatusOK}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := httptest.NewRequest(tt.method, "/probe", nil) + if tt.cookie != "" { + r.AddCookie(&http.Cookie{Name: csrfCookie, Value: tt.cookie}) + } + if tt.header != "" { + r.Header.Set(csrfHeader, tt.header) + } + rec := httptest.NewRecorder() + s.requireCSRF(ok).ServeHTTP(rec, r) + if rec.Code != tt.wantCode { + t.Errorf("code = %d, want %d", rec.Code, tt.wantCode) + } + }) + } +} diff --git a/internal/http/oidc.go b/internal/http/oidc.go new file mode 100644 index 0000000..a3dfaec --- /dev/null +++ b/internal/http/oidc.go @@ -0,0 +1,173 @@ +package httpx + +import ( + "errors" + "net/http" + "net/url" + "strings" + + "bountyboard/internal/domain" + "bountyboard/internal/store" + "bountyboard/internal/ulid" +) + +const ( + oidcStateCookie = "bb_oidc_state" + oidcVerifierCookie = "bb_oidc_verifier" + oidcCookieTTL = 300 // seconds; the hop to the IdP and back +) + +func (s *Server) oidcFlowCookie(name, value string) *http.Cookie { + maxAge := oidcCookieTTL + if value == "" { + maxAge = -1 + } + return &http.Cookie{ + Name: name, Value: value, Path: "/api/v1/auth/oidc", + MaxAge: maxAge, HttpOnly: true, Secure: s.cfg.CookieSecure(), + SameSite: http.SameSiteLaxMode, + } +} + +func (s *Server) handleOIDCLogin(w http.ResponseWriter, r *http.Request) { + if !s.oidc.Enabled() { + writeError(w, http.StatusNotFound, "oidc_disabled", "SSO is not configured") + return + } + authURL, state, verifier, err := s.oidc.AuthURL(r.Context()) + if err != nil { + s.internalError(w, r, "oidc auth url", err) + return + } + http.SetCookie(w, s.oidcFlowCookie(oidcStateCookie, state)) + http.SetCookie(w, s.oidcFlowCookie(oidcVerifierCookie, verifier)) + http.Redirect(w, r, authURL, http.StatusFound) +} + +// loginRedirect sends browser-flow errors back to the login page (the API +// has no JSON consumer mid-redirect). +func (s *Server) loginRedirect(w http.ResponseWriter, r *http.Request, errCode string) { + target := "/login" + if errCode != "" { + target += "?error=" + url.QueryEscape(errCode) + } + http.Redirect(w, r, target, http.StatusFound) +} + +func (s *Server) handleOIDCCallback(w http.ResponseWriter, r *http.Request) { + if !s.oidc.Enabled() { + writeError(w, http.StatusNotFound, "oidc_disabled", "SSO is not configured") + return + } + // Always clear the one-shot flow cookies. + defer func() { + http.SetCookie(w, s.oidcFlowCookie(oidcStateCookie, "")) + http.SetCookie(w, s.oidcFlowCookie(oidcVerifierCookie, "")) + }() + + stateCookie, err1 := r.Cookie(oidcStateCookie) + verifierCookie, err2 := r.Cookie(oidcVerifierCookie) + if err1 != nil || err2 != nil || stateCookie.Value == "" || + r.URL.Query().Get("state") != stateCookie.Value { + s.loginRedirect(w, r, "oidc_state_mismatch") + return + } + code := r.URL.Query().Get("code") + if code == "" { + s.loginRedirect(w, r, "oidc_denied") + return + } + + claims, err := s.oidc.Exchange(r.Context(), code, verifierCookie.Value) + if err != nil { + s.log.Error("oidc exchange", "err", err, "requestId", RequestID(r.Context())) + s.loginRedirect(w, r, "oidc_failed") + return + } + + u, err := s.resolveOIDCUser(r, claims.Issuer, claims.Subject, claims.Email, claims.EmailVerified, claims.Name) + if err != nil { + var code *oidcLoginError + if errors.As(err, &code) { + s.loginRedirect(w, r, code.code) + return + } + s.internalError(w, r, "oidc user resolution", err) + return + } + + sess, csrf, err := s.newSessionFor(r, u) + if err != nil { + s.internalError(w, r, "create session", err) + return + } + s.setAuthCookies(w, sess.Token, csrf) + http.Redirect(w, r, "/", http.StatusFound) +} + +type oidcLoginError struct{ code string } + +func (e *oidcLoginError) Error() string { return "oidc login rejected: " + e.code } + +// resolveOIDCUser implements §7 linking: by (issuer,subject) first, then by +// verified email, otherwise a fresh developer account. +func (s *Server) resolveOIDCUser(r *http.Request, issuer, subject, email string, emailVerified bool, name string) (*domain.User, error) { + ctx := r.Context() + + u, err := s.store.UserByOIDC(ctx, issuer, subject) + switch { + case err == nil: + if u.Disabled { + return nil, &oidcLoginError{code: "account_disabled"} + } + return u, nil + case !errors.Is(err, store.ErrNotFound): + return nil, err + } + + email = store.NormalizeEmail(email) + if email == "" { + return nil, &oidcLoginError{code: "oidc_no_email"} + } + + existing, err := s.store.UserByEmail(ctx, email) + switch { + case err == nil: + // Linking an SSO identity to a pre-existing local account requires + // the IdP to vouch for the address, otherwise anyone controlling the + // IdP account could take over the local one. + if !emailVerified { + return nil, &oidcLoginError{code: "oidc_email_unverified"} + } + if existing.Disabled { + return nil, &oidcLoginError{code: "account_disabled"} + } + if err := s.store.LinkOIDC(ctx, existing.ID, issuer, subject); err != nil { + return nil, err + } + existing.Auth.OIDC = &domain.OIDCAuth{Issuer: issuer, Subject: subject} + return existing, nil + case !errors.Is(err, store.ErrNotFound): + return nil, err + } + + if strings.TrimSpace(name) == "" { + name = email + } + u = &domain.User{ + ID: ulid.New(), + Email: email, + Name: name, + Roles: domain.Roles{Developer: true}, // first OIDC login = developer (§7) + Auth: domain.Auth{OIDC: &domain.OIDCAuth{Issuer: issuer, Subject: subject}}, + Settings: domain.UserSettings{ + Theme: "light", + Notifications: domain.NotificationPrefs{Email: true, InApp: true}, + }, + } + if err := s.store.CreateUser(ctx, u); err != nil { + return nil, err + } + s.metrics.Inc("auth_oidc_signups_total", 1) + return u, nil +} diff --git a/internal/http/oidc_integration_test.go b/internal/http/oidc_integration_test.go new file mode 100644 index 0000000..f5a9084 --- /dev/null +++ b/internal/http/oidc_integration_test.go @@ -0,0 +1,303 @@ +//go:build integration + +package httpx + +import ( + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "math/big" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "bountyboard/internal/domain" +) + +// fakeIdP is a minimal OIDC provider: discovery, JWKS, and a token endpoint +// that mints RS256 id_tokens for whatever identity the test configures. +type fakeIdP struct { + srv *httptest.Server + key *rsa.PrivateKey + clientID string + + // identity returned by the next token exchange + sub string + email string + emailVerified bool + name string +} + +func newFakeIdP(t *testing.T, clientID string) *fakeIdP { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + idp := &fakeIdP{key: key, clientID: clientID} + + mux := http.NewServeMux() + mux.HandleFunc("GET /.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "issuer": idp.srv.URL, + "authorization_endpoint": idp.srv.URL + "/authorize", + "token_endpoint": idp.srv.URL + "/token", + "jwks_uri": idp.srv.URL + "/jwks", + "id_token_signing_alg_values_supported": []string{"RS256"}, + "response_types_supported": []string{"code"}, + "subject_types_supported": []string{"public"}, + }) + }) + mux.HandleFunc("GET /jwks", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + pub := key.Public().(*rsa.PublicKey) + json.NewEncoder(w).Encode(map[string]any{ + "keys": []map[string]any{{ + "kty": "RSA", "alg": "RS256", "use": "sig", "kid": "test", + "n": base64.RawURLEncoding.EncodeToString(pub.N.Bytes()), + "e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(pub.E)).Bytes()), + }}, + }) + }) + mux.HandleFunc("POST /token", func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + // PKCE: a verifier must be present (the app always sends one). + if r.PostForm.Get("code_verifier") == "" { + http.Error(w, "missing code_verifier", http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "access_token": "fake-access-token", + "token_type": "Bearer", + "id_token": idp.mintIDToken(t), + }) + }) + idp.srv = httptest.NewServer(mux) + t.Cleanup(idp.srv.Close) + return idp +} + +func (idp *fakeIdP) mintIDToken(t *testing.T) string { + t.Helper() + header, _ := json.Marshal(map[string]string{"alg": "RS256", "kid": "test", "typ": "JWT"}) + payload, _ := json.Marshal(map[string]any{ + "iss": idp.srv.URL, + "sub": idp.sub, + "aud": idp.clientID, + "exp": time.Now().Add(time.Hour).Unix(), + "iat": time.Now().Unix(), + "email": idp.email, + "email_verified": idp.emailVerified, + "name": idp.name, + }) + signingInput := base64.RawURLEncoding.EncodeToString(header) + "." + + base64.RawURLEncoding.EncodeToString(payload) + digest := sha256.Sum256([]byte(signingInput)) + sig, err := rsa.SignPKCS1v15(rand.Reader, idp.key, crypto.SHA256, digest[:]) + if err != nil { + t.Fatal(err) + } + return signingInput + "." + base64.RawURLEncoding.EncodeToString(sig) +} + +// oidcLogin walks the full browser flow and returns the final redirect +// location after the callback. +func oidcLogin(t *testing.T, c *http.Client, appURL string) string { + t.Helper() + resp, err := c.Get(appURL + "/api/v1/auth/oidc/login") + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusFound { + t.Fatalf("oidc login: %d", resp.StatusCode) + } + loc, err := url.Parse(resp.Header.Get("Location")) + if err != nil { + t.Fatal(err) + } + q := loc.Query() + if q.Get("code_challenge") == "" || q.Get("code_challenge_method") != "S256" { + t.Fatalf("authorize URL missing PKCE: %s", loc) + } + state := q.Get("state") + if state == "" { + t.Fatal("authorize URL missing state") + } + + cb := fmt.Sprintf("%s/api/v1/auth/oidc/callback?code=fake-code&state=%s", + appURL, url.QueryEscape(state)) + resp, err = c.Get(cb) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusFound { + t.Fatalf("callback: %d", resp.StatusCode) + } + return resp.Header.Get("Location") +} + +func meUser(t *testing.T, c *http.Client, appURL string) (*domain.User, int) { + t.Helper() + resp, err := c.Get(appURL + "/api/v1/auth/me") + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != http.StatusOK { + resp.Body.Close() + return nil, resp.StatusCode + } + var out struct { + User domain.User `json:"user"` + } + bodyJSON(t, resp, &out) + return &out.User, http.StatusOK +} + +func TestOIDCFullFlow(t *testing.T) { + const clientID = "bountyboard-test" + idp := newFakeIdP(t, clientID) + ts, _, _, _ := newAuthStack(t, map[string]string{ + "OIDC_ISSUER_URL": idp.srv.URL, + "OIDC_CLIENT_ID": clientID, + "OIDC_CLIENT_SECRET": "shhh", + "APP_BASE_URL": "http://localhost:8787", // redirect URI base; IdP doesn't validate it here + }) + + idp.sub, idp.email, idp.emailVerified, idp.name = "subject-1", "sso-dev@example.com", true, "SSO Dev" + + c := newClient(t) + if loc := oidcLogin(t, c, ts.URL); loc != "/" { + t.Fatalf("post-login redirect = %q, want /", loc) + } + u, code := meUser(t, c, ts.URL) + if code != http.StatusOK { + t.Fatalf("me after oidc login: %d", code) + } + if u.Email != "sso-dev@example.com" || !u.Roles.Developer || u.Roles.Admin { + t.Fatalf("oidc-created user wrong: %+v", u) + } + firstID := u.ID + + // second login with the same subject reuses the account + c2 := newClient(t) + oidcLogin(t, c2, ts.URL) + u2, _ := meUser(t, c2, ts.URL) + if u2.ID != firstID { + t.Fatalf("second oidc login created a new user: %s vs %s", u2.ID, firstID) + } +} + +func TestOIDCLinksVerifiedEmailToLocalAccount(t *testing.T) { + const clientID = "bountyboard-test" + idp := newFakeIdP(t, clientID) + ts, _, _, _ := newAuthStack(t, map[string]string{ + "OIDC_ISSUER_URL": idp.srv.URL, + "OIDC_CLIENT_ID": clientID, + "OIDC_CLIENT_SECRET": "shhh", + }) + + // existing local account + c := newClient(t) + resp := postJSON(t, c, ts.URL+"/api/v1/auth/register", + map[string]string{"email": "linked@example.com", "name": "Linked", "password": "password123"}, "") + var reg struct { + User domain.User `json:"user"` + } + bodyJSON(t, resp, ®) + + idp.sub, idp.email, idp.emailVerified, idp.name = "subject-link", "Linked@Example.com", true, "Linked" + c2 := newClient(t) + if loc := oidcLogin(t, c2, ts.URL); loc != "/" { + t.Fatalf("link flow redirect = %q", loc) + } + u, _ := meUser(t, c2, ts.URL) + if u.ID != reg.User.ID { + t.Fatalf("oidc login did not link: got user %s, want %s", u.ID, reg.User.ID) + } + + // local password still works after linking + resp = postJSON(t, newClient(t), ts.URL+"/api/v1/auth/login", + map[string]string{"email": "linked@example.com", "password": "password123"}, "") + if resp.StatusCode != http.StatusOK { + t.Fatalf("local login after link: %d", resp.StatusCode) + } + resp.Body.Close() +} + +func TestOIDCUnverifiedEmailDoesNotLink(t *testing.T) { + const clientID = "bountyboard-test" + idp := newFakeIdP(t, clientID) + ts, _, _, _ := newAuthStack(t, map[string]string{ + "OIDC_ISSUER_URL": idp.srv.URL, + "OIDC_CLIENT_ID": clientID, + "OIDC_CLIENT_SECRET": "shhh", + }) + + c := newClient(t) + resp := postJSON(t, c, ts.URL+"/api/v1/auth/register", + map[string]string{"email": "victim@example.com", "name": "V", "password": "password123"}, "") + resp.Body.Close() + + idp.sub, idp.email, idp.emailVerified = "attacker", "victim@example.com", false + c2 := newClient(t) + loc := oidcLogin(t, c2, ts.URL) + if !strings.Contains(loc, "error=oidc_email_unverified") { + t.Fatalf("unverified email link must be refused, redirect = %q", loc) + } + if _, code := meUser(t, c2, ts.URL); code != http.StatusUnauthorized { + t.Fatalf("attacker must not get a session, me = %d", code) + } +} + +func TestOIDCStateMismatchRejected(t *testing.T) { + const clientID = "bountyboard-test" + idp := newFakeIdP(t, clientID) + ts, _, _, _ := newAuthStack(t, map[string]string{ + "OIDC_ISSUER_URL": idp.srv.URL, + "OIDC_CLIENT_ID": clientID, + "OIDC_CLIENT_SECRET": "shhh", + }) + idp.sub, idp.email, idp.emailVerified = "s", "s@example.com", true + + c := newClient(t) + resp, err := c.Get(ts.URL + "/api/v1/auth/oidc/login") + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + + resp, err = c.Get(ts.URL + "/api/v1/auth/oidc/callback?code=fake-code&state=forged-state") + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if loc := resp.Header.Get("Location"); !strings.Contains(loc, "error=oidc_state_mismatch") { + t.Fatalf("forged state redirect = %q", loc) + } +} + +func TestOIDCDisabledByDefault(t *testing.T) { + ts, _, _, _ := newAuthStack(t, nil) + resp, err := http.Get(ts.URL + "/api/v1/auth/oidc/login") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("oidc login without config: %d, want 404", resp.StatusCode) + } +} diff --git a/internal/http/server.go b/internal/http/server.go index 0006d7f..413ea62 100644 --- a/internal/http/server.go +++ b/internal/http/server.go @@ -8,34 +8,43 @@ import ( "net/http" "time" + "bountyboard/internal/auth" "bountyboard/internal/config" "bountyboard/internal/metrics" + "bountyboard/internal/store" ) // shutdownBudget is the §12 graceful-shutdown drain window. const shutdownBudget = 15 * time.Second type Server struct { - cfg *config.Config - log *slog.Logger - metrics *metrics.Registry - checks []ReadinessCheck - startedAt time.Time - httpSrv *http.Server + cfg *config.Config + log *slog.Logger + metrics *metrics.Registry + store *store.Store + oidc *auth.OIDCClient + loginLimiter *auth.RateLimiter + checks []ReadinessCheck + startedAt time.Time + httpSrv *http.Server } -func New(cfg *config.Config, log *slog.Logger, reg *metrics.Registry) *Server { +func New(cfg *config.Config, log *slog.Logger, reg *metrics.Registry, st *store.Store) *Server { s := &Server{ - cfg: cfg, - log: log, - metrics: reg, - startedAt: time.Now(), + cfg: cfg, + log: log, + metrics: reg, + store: st, + oidc: auth.NewOIDCClient(cfg, log), + loginLimiter: auth.NewRateLimiter(10, 15*time.Minute), + startedAt: time.Now(), } mux := http.NewServeMux() mux.HandleFunc("GET /healthz", s.handleHealthz) mux.HandleFunc("GET /readyz", s.handleReadyz) mux.HandleFunc("GET /metricsz", s.handleMetricsz) + s.routesAuth(mux) s.httpSrv = &http.Server{ Addr: fmt.Sprintf(":%d", cfg.AppPort), diff --git a/internal/http/server_test.go b/internal/http/server_test.go index babe95b..b40c8fe 100644 --- a/internal/http/server_test.go +++ b/internal/http/server_test.go @@ -25,7 +25,7 @@ func newTestServer(t *testing.T) *Server { t.Fatalf("config: %v", err) } log := slog.New(slog.NewTextHandler(io.Discard, nil)) - return New(cfg, log, metrics.NewRegistry()) + return New(cfg, log, metrics.NewRegistry(), nil) } func get(t *testing.T, h http.Handler, path string) *httptest.ResponseRecorder { diff --git a/internal/http/session.go b/internal/http/session.go new file mode 100644 index 0000000..05de1aa --- /dev/null +++ b/internal/http/session.go @@ -0,0 +1,167 @@ +package httpx + +import ( + "context" + "errors" + "net/http" + "time" + + "bountyboard/internal/domain" + "bountyboard/internal/store" +) + +const ( + sessionCookie = "bb_session" + csrfCookie = "bb_csrf" + csrfHeader = "X-CSRF-Token" + // sessions slide forward at most once per hour to avoid a write per + // request + refreshInterval = time.Hour +) + +const ( + ctxKeyUser ctxKey = 100 + iota + ctxKeySession +) + +// CurrentUser returns the authenticated user, or nil outside requireAuth. +func CurrentUser(ctx context.Context) *domain.User { + u, _ := ctx.Value(ctxKeyUser).(*domain.User) + return u +} + +// CurrentSession returns the active session, or nil outside requireAuth. +func CurrentSession(ctx context.Context) *domain.Session { + s, _ := ctx.Value(ctxKeySession).(*domain.Session) + return s +} + +func (s *Server) setAuthCookies(w http.ResponseWriter, sessionToken, csrfToken string) { + secure := s.cfg.CookieSecure() + http.SetCookie(w, &http.Cookie{ + Name: sessionCookie, Value: sessionToken, Path: "/", + MaxAge: int(domain.SessionTTL.Seconds()), + HttpOnly: true, Secure: secure, SameSite: http.SameSiteLaxMode, + }) + // Readable by JS so the frontend can mirror it into X-CSRF-Token. + http.SetCookie(w, &http.Cookie{ + Name: csrfCookie, Value: csrfToken, Path: "/", + MaxAge: int(domain.SessionTTL.Seconds()), + HttpOnly: false, Secure: secure, SameSite: http.SameSiteLaxMode, + }) +} + +func (s *Server) clearAuthCookies(w http.ResponseWriter) { + secure := s.cfg.CookieSecure() + http.SetCookie(w, &http.Cookie{ + Name: sessionCookie, Value: "", Path: "/", MaxAge: -1, + HttpOnly: true, Secure: secure, SameSite: http.SameSiteLaxMode, + }) + http.SetCookie(w, &http.Cookie{ + Name: csrfCookie, Value: "", Path: "/", MaxAge: -1, + HttpOnly: false, Secure: secure, SameSite: http.SameSiteLaxMode, + }) +} + +// mustChangeAllowed are the only API paths reachable while a forced password +// change is pending. +var mustChangeAllowed = map[string]bool{ + "/api/v1/auth/me": true, + "/api/v1/auth/logout": true, + "/api/v1/auth/logout-all": true, + "/api/v1/auth/change-password": true, +} + +// requireAuth resolves the session cookie into a user, enforces the disabled +// flag and forced password changes, slides session expiry, and stores both +// in the request context. +func (s *Server) requireAuth(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c, err := r.Cookie(sessionCookie) + if err != nil || c.Value == "" { + writeError(w, http.StatusUnauthorized, "unauthenticated", "authentication required") + return + } + sess, err := s.store.SessionByToken(r.Context(), c.Value) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + s.clearAuthCookies(w) + writeError(w, http.StatusUnauthorized, "unauthenticated", "session expired") + return + } + s.internalError(w, r, "load session", err) + return + } + user, err := s.store.UserByID(r.Context(), sess.UserID) + if err != nil { + s.clearAuthCookies(w) + writeError(w, http.StatusUnauthorized, "unauthenticated", "account no longer exists") + return + } + if user.Disabled { + _ = s.store.DeleteSession(r.Context(), sess.Token) + s.clearAuthCookies(w) + writeError(w, http.StatusForbidden, "account_disabled", "account is disabled") + return + } + if user.MustChangePassword() && !mustChangeAllowed[r.URL.Path] { + writeError(w, http.StatusForbidden, "password_change_required", + "password change required before continuing") + return + } + if time.Since(sess.RefreshedAt) > refreshInterval { + if err := s.store.RefreshSession(r.Context(), sess.Token); err != nil { + s.log.Warn("refresh session", "err", err) + } + } + + ctx := context.WithValue(r.Context(), ctxKeyUser, user) + ctx = context.WithValue(ctx, ctxKeySession, sess) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// requireCSRF enforces the double-submit check on mutating methods: the +// non-httpOnly cookie value must be echoed in the X-CSRF-Token header. +func (s *Server) requireCSRF(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet, http.MethodHead, http.MethodOptions: + next.ServeHTTP(w, r) + return + } + c, err := r.Cookie(csrfCookie) + if err != nil || c.Value == "" || r.Header.Get(csrfHeader) != c.Value { + writeError(w, http.StatusForbidden, "csrf", "missing or mismatched CSRF token") + return + } + next.ServeHTTP(w, r) + }) +} + +// requireRole gates a handler on a role flag; must run inside requireAuth. +func (s *Server) requireRole(role string, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + u := CurrentUser(r.Context()) + if u == nil || !u.Roles.Has(role) { + writeError(w, http.StatusForbidden, "forbidden", "requires "+role+" role") + return + } + next.ServeHTTP(w, r) + }) +} + +// authed wires the standard chain for an authenticated JSON endpoint. +func (s *Server) authed(h http.HandlerFunc) http.Handler { + return s.requireAuth(s.requireCSRF(h)) +} + +// authedRole wires authentication + CSRF + a role gate. +func (s *Server) authedRole(role string, h http.HandlerFunc) http.Handler { + return s.requireAuth(s.requireCSRF(s.requireRole(role, h))) +} + +func (s *Server) internalError(w http.ResponseWriter, r *http.Request, what string, err error) { + s.log.Error(what, "err", err, "requestId", RequestID(r.Context()), "path", r.URL.Path) + writeError(w, http.StatusInternalServerError, "internal", "internal server error") +} diff --git a/internal/store/sessions.go b/internal/store/sessions.go new file mode 100644 index 0000000..f77c1a6 --- /dev/null +++ b/internal/store/sessions.go @@ -0,0 +1,81 @@ +package store + +import ( + "context" + "errors" + "fmt" + "time" + + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + + "bountyboard/internal/domain" +) + +func (s *Store) sessions() *mongo.Collection { return s.DB.Collection("sessions") } + +func (s *Store) CreateSession(ctx context.Context, sess *domain.Session) error { + if _, err := s.sessions().InsertOne(ctx, sess); err != nil { + return fmt.Errorf("insert session: %w", err) + } + return nil +} + +// SessionByToken returns a live session. Expired-but-not-yet-reaped rows +// (Mongo TTL sweeps roughly once a minute) are treated as absent. +func (s *Store) SessionByToken(ctx context.Context, token string) (*domain.Session, error) { + var sess domain.Session + err := s.sessions().FindOne(ctx, bson.M{ + "_id": token, + "expiresAt": bson.M{"$gt": time.Now().UTC()}, + }).Decode(&sess) + if errors.Is(err, mongo.ErrNoDocuments) { + return nil, ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("find session: %w", err) + } + return &sess, nil +} + +// RefreshSession slides the expiry window (§7: 30-day sliding expiry). +func (s *Store) RefreshSession(ctx context.Context, token string) error { + now := time.Now().UTC() + _, err := s.sessions().UpdateOne(ctx, bson.M{"_id": token}, bson.M{ + "$set": bson.M{"expiresAt": now.Add(domain.SessionTTL), "refreshedAt": now}, + }) + if err != nil { + return fmt.Errorf("refresh session: %w", err) + } + return nil +} + +func (s *Store) DeleteSession(ctx context.Context, token string) error { + if _, err := s.sessions().DeleteOne(ctx, bson.M{"_id": token}); err != nil { + return fmt.Errorf("delete session: %w", err) + } + return nil +} + +// DeleteUserSessions revokes every session of a user (logout-all, disable, +// password change). +func (s *Store) DeleteUserSessions(ctx context.Context, userID string) (int64, error) { + res, err := s.sessions().DeleteMany(ctx, bson.M{"userId": userID}) + if err != nil { + return 0, fmt.Errorf("delete user sessions: %w", err) + } + return res.DeletedCount, nil +} + +// DeleteUserSessionsExcept revokes all sessions of a user except one (used +// after a password change to keep the current session alive). +func (s *Store) DeleteUserSessionsExcept(ctx context.Context, userID, keepToken string) (int64, error) { + res, err := s.sessions().DeleteMany(ctx, bson.M{ + "userId": userID, + "_id": bson.M{"$ne": keepToken}, + }) + if err != nil { + return 0, fmt.Errorf("delete user sessions: %w", err) + } + return res.DeletedCount, nil +} diff --git a/internal/store/users.go b/internal/store/users.go new file mode 100644 index 0000000..8a1338b --- /dev/null +++ b/internal/store/users.go @@ -0,0 +1,126 @@ +package store + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + + "bountyboard/internal/domain" +) + +// ErrDuplicate is returned when a unique index rejects an insert/update +// (e.g. email already registered). +var ErrDuplicate = errors.New("duplicate value") + +func (s *Store) users() *mongo.Collection { return s.DB.Collection("users") } + +// NormalizeEmail is the canonical form stored and queried everywhere. +func NormalizeEmail(email string) string { + return strings.ToLower(strings.TrimSpace(email)) +} + +func (s *Store) CreateUser(ctx context.Context, u *domain.User) error { + now := time.Now().UTC() + u.Email = NormalizeEmail(u.Email) + u.CreatedAt, u.UpdatedAt, u.Version = now, now, 1 + if u.LastSeenAt.IsZero() { + u.LastSeenAt = now + } + if _, err := s.users().InsertOne(ctx, u); err != nil { + if mongo.IsDuplicateKeyError(err) { + return fmt.Errorf("user %s: %w", u.Email, ErrDuplicate) + } + return fmt.Errorf("insert user: %w", err) + } + return nil +} + +func (s *Store) userBy(ctx context.Context, filter bson.M) (*domain.User, error) { + var u domain.User + err := s.users().FindOne(ctx, filter).Decode(&u) + if errors.Is(err, mongo.ErrNoDocuments) { + return nil, ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("find user: %w", err) + } + return &u, nil +} + +func (s *Store) UserByID(ctx context.Context, id string) (*domain.User, error) { + return s.userBy(ctx, bson.M{"_id": id}) +} + +func (s *Store) UserByEmail(ctx context.Context, email string) (*domain.User, error) { + return s.userBy(ctx, bson.M{"email": NormalizeEmail(email)}) +} + +func (s *Store) UserByOIDC(ctx context.Context, issuer, subject string) (*domain.User, error) { + return s.userBy(ctx, bson.M{"auth.oidc.issuer": issuer, "auth.oidc.subject": subject}) +} + +func (s *Store) CountUsers(ctx context.Context) (int64, error) { + n, err := s.users().CountDocuments(ctx, bson.M{}) + if err != nil { + return 0, fmt.Errorf("count users: %w", err) + } + return n, nil +} + +// SetPassword replaces the local credential and clears/sets the forced +// change flag. Not version-checked: password changes must never lose to a +// concurrent profile edit. +func (s *Store) SetPassword(ctx context.Context, userID, hash string, mustChange bool) error { + res, err := s.users().UpdateOne(ctx, bson.M{"_id": userID}, bson.M{ + "$set": bson.M{ + "auth.local.passwordHash": hash, + "auth.local.mustChange": mustChange, + "updatedAt": time.Now().UTC(), + }, + "$inc": bson.M{"version": 1}, + }) + if err != nil { + return fmt.Errorf("set password: %w", err) + } + if res.MatchedCount == 0 { + return fmt.Errorf("user %s: %w", userID, ErrNotFound) + } + return nil +} + +// LinkOIDC attaches an OIDC identity to an existing account (login linking +// by verified email, §7). +func (s *Store) LinkOIDC(ctx context.Context, userID, issuer, subject string) error { + res, err := s.users().UpdateOne(ctx, bson.M{"_id": userID}, bson.M{ + "$set": bson.M{ + "auth.oidc": domain.OIDCAuth{Issuer: issuer, Subject: subject}, + "updatedAt": time.Now().UTC(), + }, + "$inc": bson.M{"version": 1}, + }) + if err != nil { + if mongo.IsDuplicateKeyError(err) { + return fmt.Errorf("oidc identity: %w", ErrDuplicate) + } + return fmt.Errorf("link oidc: %w", err) + } + if res.MatchedCount == 0 { + return fmt.Errorf("user %s: %w", userID, ErrNotFound) + } + return nil +} + +// TouchLastSeen updates lastSeenAt without bumping version (pure telemetry). +func (s *Store) TouchLastSeen(ctx context.Context, userID string) error { + _, err := s.users().UpdateOne(ctx, bson.M{"_id": userID}, + bson.M{"$set": bson.M{"lastSeenAt": time.Now().UTC()}}) + if err != nil { + return fmt.Errorf("touch lastSeenAt: %w", err) + } + return nil +}