package web import ( "context" "errors" "net/http" "strings" ) // Identity is the narrow port the web layer uses to resolve a Dex subject to a // tapir user and to register new ones (ADR-012). *store.Store satisfies it; tests // can substitute a fake. It is deliberately separate from Store: identity // resolution runs pre-scope (un-RLS'd map), whereas Store runs user-scoped. type Identity interface { UserBySubject(ctx context.Context, subject string) (userID string, found bool, err error) RegisterUser(ctx context.Context, subject, displayName string) (userID string, err error) } // errNoCurrentUser indicates a scoped handler ran without a resolved user_id — // only possible if it was reached outside the registration gate (a wiring bug). var errNoCurrentUser = errors.New("web: no current user in request context") // userIDCtxKey types the per-request resolved tapir user_id stored by the // registration gate. Unexported so only this package can set it. type userIDCtxKey struct{} func withUserID(ctx context.Context, id string) context.Context { return context.WithValue(ctx, userIDCtxKey{}, id) } // CurrentUserID returns the tapir user_id (UUID) the registration gate resolved // for the request from the authenticated Dex subject. ok is false for requests // that never passed the gate (e.g. /register, /auth/*). This is the seam handlers // — and downstream features (per-user YouTube connect, account management) — // scope every store access by. func CurrentUserID(r *http.Request) (string, bool) { id, ok := r.Context().Value(userIDCtxKey{}).(string) return id, ok && id != "" } // registrationGate sits inside Auth.Middleware. For a gated request it resolves // the authenticated subject → tapir user_id once and stashes it for handlers; a // subject with no tapir user is redirected to /register. Exempt paths pass // straight through (/register so an unregistered user can reach the form; /auth/* // and /healthz are already public but listed for safety). func (a *App) registrationGate(h http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if isRegistrationExempt(r.URL.Path) { h.ServeHTTP(w, r) return } user, ok := a.Auth.CurrentUser(r) if !ok { // Auth.Middleware should have caught this; redirect defensively. http.Redirect(w, r, loginPath, http.StatusFound) return } userID, found, err := a.Identity.UserBySubject(r.Context(), user.Subject) if err != nil { a.serverError(w, r, "resolve identity", err) return } if !found { http.Redirect(w, r, registerPath, http.StatusFound) return } h.ServeHTTP(w, r.WithContext(withUserID(r.Context(), userID))) }) } // handleRegisterForm renders the registration form for an authenticated, not-yet- // registered subject. An already-registered subject is sent to the app root. func (a *App) handleRegisterForm(w http.ResponseWriter, r *http.Request) { user, ok := a.Auth.CurrentUser(r) if !ok { http.Redirect(w, r, loginPath, http.StatusFound) return } if _, found, err := a.Identity.UserBySubject(r.Context(), user.Subject); err != nil { a.serverError(w, r, "resolve identity", err) return } else if found { http.Redirect(w, r, "/", http.StatusSeeOther) return } a.render(w, r, RegisterPage(user.Email, "")) } // handleRegister creates the tapir user for the authenticated subject from the // submitted display name (terms must be accepted), then redirects to the app // root. A double-submit by an already-registered subject is idempotent. func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) { user, ok := a.Auth.CurrentUser(r) if !ok { http.Redirect(w, r, loginPath, http.StatusFound) return } if _, found, err := a.Identity.UserBySubject(r.Context(), user.Subject); err != nil { a.serverError(w, r, "resolve identity", err) return } else if found { http.Redirect(w, r, "/", http.StatusSeeOther) return } if err := r.ParseForm(); err != nil { http.Error(w, "bad form", http.StatusBadRequest) return } displayName := strings.TrimSpace(r.FormValue("display_name")) accepted := r.FormValue("accept_terms") != "" if displayName == "" || !accepted { a.renderStatus(w, r, http.StatusBadRequest, RegisterPage(user.Email, "Enter a display name and accept the terms to continue.")) return } if _, err := a.Identity.RegisterUser(r.Context(), user.Subject, displayName); err != nil { a.serverError(w, r, "register user", err) return } setFlash(w, flashRegistered) http.Redirect(w, r, "/", http.StatusSeeOther) } const ( registerPath = "/register" loginPath = "/auth/login" ) func isRegistrationExempt(p string) bool { return p == registerPath || p == "/healthz" || strings.HasPrefix(p, "/auth/") }