package api import ( "context" "errors" "strings" "github.com/danielgtaylor/huma/v2" "github.com/jackc/pgx/v5" "completed/internal/auth" "completed/internal/db" ) // sessionCookieName is also used by the auth routes when setting/clearing // the cookie, so it lives here as the one shared constant. const sessionCookieName = "session" // bearerToken extracts the token from "Authorization: Bearer ", or // the empty string if the header is missing or malformed. func bearerToken(header string) string { const prefix = "Bearer " if !strings.HasPrefix(header, prefix) { return "" } return strings.TrimPrefix(header, prefix) } // tokenFromRequest looks for a session token in the Authorization header // first (used by the TUI and Fyne clients), falling back to the session // cookie (used by the web client). func tokenFromRequest(ctx huma.Context) string { if t := bearerToken(ctx.Header("Authorization")); t != "" { return t } if c, err := huma.ReadCookie(ctx, sessionCookieName); err == nil && c != nil { return c.Value } return "" } // newAuthMiddleware returns Huma middleware that requires a valid session, // set via AuthMiddleware on individual operations. On success it stores the // authenticated user in the request context for userFromContext to read. func newAuthMiddleware(api huma.API, queries db.Querier) func(huma.Context, func(huma.Context)) { return func(ctx huma.Context, next func(huma.Context)) { token := tokenFromRequest(ctx) if token == "" { huma.WriteErr(api, ctx, 401, "authentication required") return } row, err := queries.GetSessionAndUser(ctx.Context(), auth.HashSessionToken(token)) if err != nil { if !errors.Is(err, pgx.ErrNoRows) { // Fail closed: an unreachable DB fails auth, not through. huma.WriteErr(api, ctx, 401, "authentication required") return } huma.WriteErr(api, ctx, 401, "session expired or invalid") return } // Best-effort; a failure to record activity shouldn't fail the request. _ = queries.TouchSession(ctx.Context(), auth.HashSessionToken(token)) user := authedUser{ID: row.ID, Email: row.Email, Name: row.Name, Bio: row.Bio.String} newCtx := huma.WithValue(ctx, userCtxKey, user) newCtx = huma.WithValue(newCtx, tokenCtxKey, token) next(newCtx) } } // addAuth attaches the auth middleware to op, so it requires a valid // session. Call it on the *huma.Operation value before huma.Register. func addAuth(op *huma.Operation, api huma.API, queries db.Querier) { op.Middlewares = append(op.Middlewares, newAuthMiddleware(api, queries)) } // tokenCtxKey holds the raw bearer/cookie token for the current request, so // the logout handler can hash and delete the exact session that was used // without re-parsing the request. const tokenCtxKey ctxKey = "completed.token" func tokenFromContext(ctx context.Context) string { t, _ := ctx.Value(tokenCtxKey).(string) return t }