api: add user-resolution middleware and POST /api/setup

resolveUser attaches the session's provisioned geniusrun user (if any) to
request context without blocking; requireProvisionedUser (wired fully in
Task 13) 403s routes that need one. GET /api/session/me now reports
has_profile/display_name so the frontend can show the setup screen.
This commit is contained in:
2026-07-25 17:55:13 +02:00
parent 9140a00da7
commit 4dceb03fc0
6 changed files with 255 additions and 3 deletions

View File

@@ -0,0 +1,81 @@
package api
import (
"context"
"net/http"
"geniusrun/backend/internal/auth"
)
type userContextKey int
const resolvedUserContextKey userContextKey = iota
// resolvedUser is the geniusrun account (if any) bound to the current
// session's OIDC subject.
type resolvedUser struct {
ID int64
DisplayName string
}
// resolveUser runs after auth.RequireSession on every request and looks up
// whether the session's OIDC subject has a provisioned geniusrun user. It
// never blocks the request itself -- it only attaches the result (found or
// not) to context -- since a couple of routes (session/me, setup) must stay
// reachable for an authorized-but-not-yet-provisioned session. Routes that
// require a provisioned user are wrapped in requireProvisionedUser as well.
func (s *Server) resolveUser(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
claims, ok := auth.ClaimsFromContext(r.Context())
if !ok {
// Unreachable in practice -- RequireSession already 401s before
// this middleware runs -- but fail closed rather than panic.
next.ServeHTTP(w, r)
return
}
u, found, err := s.DB.GetUserBySub(r.Context(), claims.Sub)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
ctx := r.Context()
if found {
ctx = context.WithValue(ctx, resolvedUserContextKey, resolvedUser{ID: u.ID, DisplayName: u.DisplayName})
}
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// requireProvisionedUser wraps routes that operate on a user's data: it
// 403s if the session's OIDC identity has no provisioned geniusrun user yet
// (resolveUser must run earlier in the chain). This -- not any
// client-supplied id -- is the only source of truth for "which user's data"
// a request may touch.
func requireProvisionedUser(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if _, ok := userFromContext(r.Context()); !ok {
writeError(w, http.StatusForbidden, "no profile provisioned for this account yet")
return
}
next.ServeHTTP(w, r)
})
}
// userFromContext returns the resolved user for the current session, as
// populated by resolveUser.
func userFromContext(ctx context.Context) (resolvedUser, bool) {
u, ok := ctx.Value(resolvedUserContextKey).(resolvedUser)
return u, ok
}
// userIDFromContext is a convenience for the overwhelming majority of
// handlers, which only need the id. Panics if called somewhere
// requireProvisionedUser didn't already guarantee a resolved user -- that
// would be a routing bug, not a runtime condition to handle gracefully.
func userIDFromContext(ctx context.Context) int64 {
u, ok := userFromContext(ctx)
if !ok {
panic("api: userIDFromContext called without requireProvisionedUser in the middleware chain")
}
return u.ID
}