Files
etalon c1cc781279 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 <noreply@anthropic.com>
2026-06-12 18:31:25 +02:00

174 lines
4.9 KiB
Go

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
}