diff --git a/backend/internal/api/server.go b/backend/internal/api/server.go index c9b3658..6464d33 100644 --- a/backend/internal/api/server.go +++ b/backend/internal/api/server.go @@ -54,6 +54,9 @@ func (s *Server) Router() http.Handler { r.Get("/session/me", s.handleSessionMe) r.Post("/session/logout", s.handleSessionLogout) + r.Use(s.resolveUser) + r.Post("/setup", s.handleSetup) + r.Route("/profile", func(r chi.Router) { r.Get("/", s.handleGetProfile) r.Put("/", s.handleUpdateProfile) diff --git a/backend/internal/api/session.go b/backend/internal/api/session.go index 4d0aefe..50e3e53 100644 --- a/backend/internal/api/session.go +++ b/backend/internal/api/session.go @@ -24,8 +24,10 @@ type SessionConfig struct { } type sessionMeResponse struct { - Name string `json:"name"` - Email string `json:"email"` + Name string `json:"name"` + Email string `json:"email"` + HasProfile bool `json:"has_profile"` + DisplayName string `json:"display_name,omitempty"` } func (s *Server) handleSessionLogin(w http.ResponseWriter, r *http.Request) { @@ -92,5 +94,10 @@ func (s *Server) handleSessionMe(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusUnauthorized, "not authenticated") return } - writeJSON(w, http.StatusOK, sessionMeResponse{Name: claims.Name, Email: claims.Email}) + resp := sessionMeResponse{Name: claims.Name, Email: claims.Email} + if u, found := userFromContext(r.Context()); found { + resp.HasProfile = true + resp.DisplayName = u.DisplayName + } + writeJSON(w, http.StatusOK, resp) } diff --git a/backend/internal/api/setup.go b/backend/internal/api/setup.go new file mode 100644 index 0000000..5143d8c --- /dev/null +++ b/backend/internal/api/setup.go @@ -0,0 +1,39 @@ +package api + +import ( + "encoding/json" + "net/http" + + "geniusrun/backend/internal/auth" +) + +func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request) { + claims, ok := auth.ClaimsFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "not authenticated") + return + } + if _, found := userFromContext(r.Context()); found { + writeError(w, http.StatusConflict, "profile already exists for this account") + return + } + + var body struct { + DisplayName string `json:"display_name"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + if body.DisplayName == "" { + writeError(w, http.StatusBadRequest, "display_name is required") + return + } + + userID, err := s.DB.ProvisionUser(r.Context(), claims.Sub, body.DisplayName) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{"user_id": userID, "display_name": body.DisplayName}) +} diff --git a/backend/internal/api/setup_test.go b/backend/internal/api/setup_test.go new file mode 100644 index 0000000..0379664 --- /dev/null +++ b/backend/internal/api/setup_test.go @@ -0,0 +1,65 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestSetup_ProvisionsNewUserWithDisplayName(t *testing.T) { + s, db := newTestServer(t) + router := s.Router() + + rec := doJSON(t, router, http.MethodGet, "/api/session/me", nil) + var me sessionMeResponse + unmarshalBody(t, rec, &me) + if me.HasProfile { + t.Fatal("expected a brand-new session to have no profile yet") + } + + rec = doJSON(t, router, http.MethodPost, "/api/setup", map[string]any{"display_name": "Lucie"}) + if rec.Code != http.StatusOK { + t.Fatalf("setup status = %d, body = %s", rec.Code, rec.Body.String()) + } + + rec = doJSON(t, router, http.MethodGet, "/api/session/me", nil) + unmarshalBody(t, rec, &me) + if !me.HasProfile || me.DisplayName != "Lucie" { + t.Fatalf("session/me after setup = %+v, want HasProfile=true DisplayName=Lucie", me) + } + + u, found, err := db.GetUserBySub(newCtx(), "test-user") // doJSON's test cookie's Sub + if err != nil || !found { + t.Fatalf("GetUserBySub: found=%v err=%v", found, err) + } + if u.DisplayName != "Lucie" { + t.Errorf("stored DisplayName = %q, want Lucie", u.DisplayName) + } +} + +func TestSetup_RejectsEmptyDisplayName(t *testing.T) { + s, _ := newTestServer(t) + rec := doJSON(t, s.Router(), http.MethodPost, "/api/setup", map[string]any{"display_name": ""}) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } +} + +func TestSetup_RejectsWhenAlreadyProvisioned(t *testing.T) { + s, db := newTestServer(t) + if _, err := db.ProvisionUser(newCtx(), "test-user", "Already Here"); err != nil { + t.Fatalf("ProvisionUser: %v", err) + } + rec := doJSON(t, s.Router(), http.MethodPost, "/api/setup", map[string]any{"display_name": "Someone Else"}) + if rec.Code != http.StatusConflict { + t.Fatalf("status = %d, want 409, body = %s", rec.Code, rec.Body.String()) + } +} + +func unmarshalBody(t *testing.T, rec *httptest.ResponseRecorder, v any) { + t.Helper() + if err := json.Unmarshal(rec.Body.Bytes(), v); err != nil { + t.Fatalf("unmarshal response body %q: %v", rec.Body.String(), err) + } +} diff --git a/backend/internal/api/usercontext.go b/backend/internal/api/usercontext.go new file mode 100644 index 0000000..7595bf4 --- /dev/null +++ b/backend/internal/api/usercontext.go @@ -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 +} diff --git a/backend/internal/api/usercontext_test.go b/backend/internal/api/usercontext_test.go new file mode 100644 index 0000000..8513d77 --- /dev/null +++ b/backend/internal/api/usercontext_test.go @@ -0,0 +1,57 @@ +package api + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "geniusrun/backend/internal/auth" +) + +func TestResolveUser_AttachesResolvedUserWhenProvisioned(t *testing.T) { + s, db := newTestServer(t) + userID, err := db.ProvisionUser(context.Background(), "test-user", "Test User") + if err != nil { + t.Fatalf("ProvisionUser: %v", err) + } + + var gotUserID int64 + var gotOK bool + handler := auth.RequireSession(testSessionConfig.Secret)(s.resolveUser(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + u, ok := userFromContext(r.Context()) + gotUserID, gotOK = u.ID, ok + }))) + + rec := doJSON(t, handler, http.MethodGet, "/", nil) // doJSON's test cookie carries Sub: "test-user" + _ = rec + if !gotOK || gotUserID != userID { + t.Fatalf("resolveUser: ok=%v userID=%d, want ok=true userID=%d", gotOK, gotUserID, userID) + } +} + +func TestResolveUser_LeavesContextEmptyWhenNotProvisioned(t *testing.T) { + s, _ := newTestServer(t) + + var gotOK bool + handler := auth.RequireSession(testSessionConfig.Secret)(s.resolveUser(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, gotOK = userFromContext(r.Context()) + }))) + + doJSON(t, handler, http.MethodGet, "/", nil) // "test-user" sub, never provisioned in this test + if gotOK { + t.Fatal("expected userFromContext to report not-found for an unprovisioned sub") + } +} + +func TestRequireProvisionedUser_RejectsWhenNoUserResolved(t *testing.T) { + handler := requireProvisionedUser(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("handler should not be reached") + })) + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403", rec.Code) + } +}