Garmin auth/sync routes move under /api/garmin/*; sync.Service becomes garmin.Sync with garmin.SyncConfig/ClientConfig; applog becomes internal/log; the test mock moves into the garmin package as MockClient (breaking the test-only import cycle the merge created); stale test URLs and type names updated to match. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
82 lines
2.8 KiB
Go
82 lines
2.8 KiB
Go
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
|
|
}
|