package httpapi import ( "context" "errors" "log/slog" "net/http" "strings" "time" "github.com/mitbringsl/backend/internal/auth" "github.com/mitbringsl/backend/internal/config" ) // passwordMinLen is the minimum acceptable password length for new accounts. const passwordMinLen = 8 // AuthHandler exposes the email/password + session endpoints. OIDC is added in // Phase B part 2. type AuthHandler struct { users *auth.UserStore sessions *auth.SessionStore oidc *auth.OIDCService cfg *config.Config } // NewAuthHandler constructs an AuthHandler from the configured stores. // 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 ---------------------------------------------- type registerRequest struct { Email string `json:"email"` Password string `json:"password"` DisplayName string `json:"display_name"` } type loginRequest struct { Email string `json:"email"` 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"` User userDTO `json:"user"` } type userDTO struct { ID string `json:"id"` Email string `json:"email"` DisplayName string `json:"display_name,omitempty"` } // --- handlers --------------------------------------------------------------- // Register creates a new email/password account and immediately issues a session. func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) { var req registerRequest if !decodeJSON(w, r, &req) { return } email := strings.TrimSpace(strings.ToLower(req.Email)) if !isValidEmail(email) { renderError(w, http.StatusBadRequest, "Invalid email", "A valid email address is required.") return } if len(req.Password) < passwordMinLen { renderError(w, http.StatusBadRequest, "Password too short", "Password must be at least 8 characters.") return } hash, err := auth.HashPassword(req.Password, auth.DefaultArgon2Params) if err != nil { slog.Error("hash password failed", "error", err) renderError(w, http.StatusInternalServerError, "Internal error", "Could not hash password.") return } user, err := h.users.CreateUser(r.Context(), email, hash, strings.TrimSpace(req.DisplayName)) if err != nil { if errors.Is(err, auth.ErrEmailTaken) { renderError(w, http.StatusConflict, "Email already registered", err.Error()) return } slog.Error("create user failed", "error", err) renderError(w, http.StatusInternalServerError, "Internal error", "Could not create user.") return } h.issueSession(w, r, user, http.StatusCreated) } // Login verifies credentials and issues a session. Uses a constant-shape error // path so a wrong password and an unknown email yield the same response. func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) { var req loginRequest if !decodeJSON(w, r, &req) { return } email := strings.TrimSpace(strings.ToLower(req.Email)) // Pre-hash a dummy so the timing path is similar regardless of user existence. dummyHash := dummyHashForTiming() user, err := h.users.GetUserByEmail(r.Context(), email) realHash := "" if err == nil && user.PasswordHash != nil { realHash = *user.PasswordHash } compareHash := realHash if compareHash == "" { compareHash = dummyHash } verifyErr := auth.VerifyPassword(req.Password, compareHash) if err != nil || user.PasswordHash == nil || verifyErr != nil { _ = auth.VerifyPassword(req.Password, dummyHash) // absorb dummy cost renderError(w, http.StatusUnauthorized, "Invalid credentials", "Email or password is incorrect.") return } h.issueSession(w, r, user, http.StatusOK) } // Logout revokes the caller's current session. Always returns 204, even if no // session was present, so the client can treat logout as best-effort. func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) { if token := tokenFromRequest(r, h.cfg.SessionCookieName); token != "" { if err := h.sessions.Revoke(r.Context(), token); err != nil { slog.Warn("revoke session failed", "error", err) } } clearSessionCookie(w, h.cfg) 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 // JSON body containing the raw token (for native clients like the Android app). func (h *AuthHandler) issueSession(w http.ResponseWriter, r *http.Request, u auth.User, status int) { token, sess, err := h.sessions.Create(r.Context(), u.ID, r.UserAgent(), clientIP(r)) if err != nil { slog.Error("create session failed", "error", err, "user_id", u.ID) renderError(w, http.StatusInternalServerError, "Internal error", "Could not create session.") return } setSessionCookie(w, h.cfg, token, sess.ExpiresAt) renderJSON(w, status, authResponse{ Token: token, ExpiresAt: sess.ExpiresAt, User: toUserDTO(u), }) } func toUserDTO(u auth.User) userDTO { dto := userDTO{ID: u.ID.String(), Email: u.Email} if u.DisplayName != nil { dto.DisplayName = *u.DisplayName } return dto } // tokenFromRequest extracts the bearer token from the Authorization header, or // falls back to the session cookie. func tokenFromRequest(r *http.Request, cookieName string) string { if h := r.Header.Get("Authorization"); h != "" { if scheme, cred, ok := strings.Cut(h, " "); ok && strings.EqualFold(scheme, "Bearer") { return strings.TrimSpace(cred) } } if c, err := r.Cookie(cookieName); err == nil { return c.Value } return "" } func setSessionCookie(w http.ResponseWriter, cfg *config.Config, token string, expires time.Time) { http.SetCookie(w, &http.Cookie{ Name: cfg.SessionCookieName, Value: token, Path: "/", Expires: expires, MaxAge: int(time.Until(expires).Seconds()), HttpOnly: true, Secure: cfg.IsProduction(), SameSite: http.SameSiteLaxMode, }) } func clearSessionCookie(w http.ResponseWriter, cfg *config.Config) { http.SetCookie(w, &http.Cookie{ Name: cfg.SessionCookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, Secure: cfg.IsProduction(), SameSite: http.SameSiteLaxMode, }) } // isValidEmail does a pragmatic shape check; full RFC validation is not worth it // here – the verification round-trip happens at the IdP / app layer. func isValidEmail(s string) bool { at := strings.IndexByte(s, '@') if at <= 0 || at == len(s)-1 { return false } return strings.IndexByte(s[at+1:], '.') >= 0 } // clientIP extracts the peer IP, preferring X-Forwarded-For (Caddy is the only // upstream in front of the backend). func clientIP(r *http.Request) string { if xff := r.Header.Get("X-Forwarded-For"); xff != "" { if i := strings.IndexByte(xff, ','); i >= 0 { return strings.TrimSpace(xff[:i]) } return strings.TrimSpace(xff) } host := r.RemoteAddr if i := strings.LastIndex(host, ":"); i > 0 { host = host[:i] } return host } // dummyHashForTiming returns a precomputed Argon2id PHC string used to give the // same cost when an email is unknown, narrowing user-enumereration timing gaps. // Generated once with DefaultArgon2Params. var dummyHashForTiming = func() func() string { // Generated at init so we always have a valid PHC string available. h, err := auth.HashPassword("dummy-timing-padding-password", auth.DefaultArgon2Params) if err != nil { // Should never happen with a working crypto/rand; fall back to a // syntactically valid but never-matching hash. return func() string { return "$argon2id$v=19$m=65536,t=3,p=4$AAAAAAAAAAAAAAAAAAAAAA$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" } } return func() string { return h } }() // --- auth middleware -------------------------------------------------------- // RequireAuth wraps next so that it only runs for authenticated requests. On // success the userID and sessionID are placed in the request context. func (a *API) RequireAuth(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { token := tokenFromRequest(r, a.cfg.SessionCookieName) sess, err := a.auth.sessions.Lookup(r.Context(), token) if err != nil { renderError(w, http.StatusUnauthorized, "Unauthorized", "Valid session required.") return } ctx := context.WithValue(r.Context(), ctxKeyUserID, sess.UserID) ctx = context.WithValue(ctx, ctxKeySessionID, sess.ID) next.ServeHTTP(w, r.WithContext(ctx)) }) }