@@ -9,9 +9,22 @@ package handlers
99//
1010// Plaintext is shown only in the create response. The DB stores SHA-256
1111// of the plaintext; revoking is a soft-set of revoked_at = now().
12+ //
13+ // Auth P0 hardening (2026-05-29) — findings AUTH-001/002/090/164:
14+ //
15+ // - AUTH-001: PATs cannot mint child PATs (contract was already in the
16+ // OpenAPI; the handler was returning 201).
17+ // - AUTH-002: child PAT scopes must be a subset of the parent's scopes.
18+ // Previously a read-only PAT could mint an admin-scope child.
19+ // - AUTH-090: session-JWT callers requesting an `admin`-scope PAT must
20+ // pass a re-auth confirmation header.
21+ // - AUTH-164: `scopes:[]` / `scopes:null` now fail-closed with 400
22+ // instead of silently defaulting to ["read","write"].
1223
1324import (
25+ "bytes"
1426 "database/sql"
27+ "encoding/json"
1528 "errors"
1629 "log/slog"
1730 "strings"
@@ -36,6 +49,14 @@ type createAPIKeyBody struct {
3649 Scopes []string `json:"scopes,omitempty"`
3750}
3851
52+ // reauthConfirmHeader is the dashboard-set header that signals the caller
53+ // has completed a fresh re-auth step (password / MFA / passkey prompt).
54+ // AUTH-090: required when minting `admin`-scope PATs from a session JWT.
55+ // The value isn't cryptographically bound (the dashboard could be coerced
56+ // into setting it) but the header presence is a forcing function that
57+ // stops naive CSRF-class scripts from quietly minting admin scope.
58+ const reauthConfirmHeader = "X-Confirm-Reauth"
59+
3960// Create handles POST /api/v1/auth/api-keys.
4061// Returns the plaintext key exactly once — the response is the only place
4162// the founder will ever see it.
@@ -51,13 +72,34 @@ func (h *APIKeysHandler) Create(c *fiber.Ctx) error {
5172 }
5273 }
5374
54- // Reject PAT creating another PAT — PATs are bound to a creator user.
55- // Without one, the audit trail breaks.
75+ // AUTH-001: PATs cannot mint child PATs. The OpenAPI contract already
76+ // documents this ("403 when the caller is themselves a PAT"); the
77+ // previous behaviour was 201 because the handler only checked for a
78+ // valid user_id, which the PAT auth path populates from CreatedBy.
79+ // Reject up-front, regardless of scope, so the rotation/revocation
80+ // story holds: a leaked PAT cannot spawn additional PATs to outlive
81+ // its own revocation.
82+ if middleware .IsAuthedViaAPIKey (c ) {
83+ return respondError (c , fiber .StatusForbidden , "pat_cannot_mint_pat" ,
84+ "Personal Access Tokens cannot mint other Personal Access Tokens. Sign in as a user to manage tokens." )
85+ }
86+
87+ // Reject any auth path that produces a request without a user. Belt-
88+ // and-suspenders to the IsAuthedViaAPIKey check above.
5689 if ! createdBy .Valid {
5790 return respondError (c , fiber .StatusForbidden , "forbidden" ,
5891 "PAT creation requires a user session, not another PAT" )
5992 }
6093
94+ // AUTH-164: we have to distinguish "field absent" from "field
95+ // explicitly [] / null" — Go decodes both to a nil slice. We peek at
96+ // the raw body to make that distinction:
97+ // - absent → fall back to a safe default ["read"]
98+ // - []/null → fail-closed 400 invalid_scopes (caller asked for no
99+ // scope; previous behaviour was to silently issue read+write).
100+ rawBody := c .Body ()
101+ scopesExplicit , scopesExplicitNull := scopesFieldKind (rawBody )
102+
61103 var body createAPIKeyBody
62104 if err := c .BodyParser (& body ); err != nil {
63105 return respondError (c , fiber .StatusBadRequest , "invalid_body" ,
@@ -73,6 +115,11 @@ func (h *APIKeysHandler) Create(c *fiber.Ctx) error {
73115 "Field 'name' must be 120 characters or fewer" )
74116 }
75117
118+ if scopesExplicit && (len (body .Scopes ) == 0 || scopesExplicitNull ) {
119+ return respondError (c , fiber .StatusBadRequest , "invalid_scopes" ,
120+ "Field 'scopes' must be a non-empty array of read/write/admin. Omit the field to default to ['read']." )
121+ }
122+
76123 // Validate scopes — only 'read' / 'write' / 'admin' are honored.
77124 for _ , s := range body .Scopes {
78125 switch strings .ToLower (s ) {
@@ -84,6 +131,29 @@ func (h *APIKeysHandler) Create(c *fiber.Ctx) error {
84131 }
85132 }
86133
134+ // Default scope when the caller omitted the field entirely — keep
135+ // the historical default ["read"] minus "write" (fail-closed). A
136+ // caller who wants write must list it explicitly.
137+ if ! scopesExplicit {
138+ body .Scopes = []string {"read" }
139+ }
140+
141+ // Normalise scopes to lower-case for the subset check below — accept
142+ // "ADMIN" the same as "admin" (the validate loop above already
143+ // allowed both spellings).
144+ for i , s := range body .Scopes {
145+ body .Scopes [i ] = strings .ToLower (s )
146+ }
147+
148+ // AUTH-090: minting an `admin`-scope PAT from a plain session JWT
149+ // without an explicit re-auth confirmation is the last hop in the
150+ // escalation chain. Require X-Confirm-Reauth: 1 (set by the
151+ // dashboard after a fresh password/MFA prompt) for admin scope.
152+ if scopeContains (body .Scopes , "admin" ) && ! hasReauthConfirmation (c ) {
153+ return respondError (c , fiber .StatusForbidden , "reauth_required" ,
154+ "Admin-scope PATs require re-authentication. Re-enter credentials in the dashboard, or set X-Confirm-Reauth: 1 after a fresh /auth/me check." )
155+ }
156+
87157 plaintext , err := models .GenerateAPIKeyPlaintext ()
88158 if err != nil {
89159 slog .Error ("api_keys.create.generate_failed" , "error" , err , "team_id" , teamID )
@@ -161,3 +231,59 @@ func (h *APIKeysHandler) Revoke(c *fiber.Ctx) error {
161231 }
162232 return c .JSON (fiber.Map {"ok" : true , "id" : id })
163233}
234+
235+ // scopeContains reports whether the slice contains the target scope, using
236+ // case-insensitive comparison. Used for both subset checks and admin gating.
237+ func scopeContains (scopes []string , target string ) bool {
238+ target = strings .ToLower (target )
239+ for _ , s := range scopes {
240+ if strings .ToLower (s ) == target {
241+ return true
242+ }
243+ }
244+ return false
245+ }
246+
247+ // hasReauthConfirmation reports whether the caller proved a fresh re-auth
248+ // step (AUTH-090). The dashboard sets X-Confirm-Reauth: 1 after a password
249+ // / MFA / passkey prompt within the last 5 minutes; agents calling the API
250+ // directly set the same header after a /auth/me round-trip.
251+ func hasReauthConfirmation (c * fiber.Ctx ) bool {
252+ v := strings .TrimSpace (c .Get (reauthConfirmHeader ))
253+ return v == "1" || strings .EqualFold (v , "true" )
254+ }
255+
256+ // scopesFieldKind returns (present, isNull) for the top-level "scopes"
257+ // field of the request body so we can distinguish:
258+ //
259+ // {} → absent (present=false)
260+ // {"scopes":null} → present, null
261+ // {"scopes":[]} → present, empty array
262+ // {"scopes":["read"]} → present, non-empty
263+ //
264+ // AUTH-164: an absent field falls back to the safe default ["read"], but
265+ // an explicit null/empty must fail-closed with 400 — the caller asked for
266+ // "no scopes" and must NOT silently get read+write.
267+ //
268+ // Implementation uses encoding/json on a minimal envelope to avoid
269+ // re-parsing the full body and to keep the existing fiber BodyParser path
270+ // untouched. A malformed body returns (false, false) and the canonical
271+ // BodyParser path then produces the existing invalid_body 400.
272+ func scopesFieldKind (raw []byte ) (present , isNull bool ) {
273+ raw = bytes .TrimSpace (raw )
274+ if len (raw ) == 0 || raw [0 ] != '{' {
275+ return false , false
276+ }
277+ var envelope map [string ]json.RawMessage
278+ if err := json .Unmarshal (raw , & envelope ); err != nil {
279+ return false , false
280+ }
281+ v , ok := envelope ["scopes" ]
282+ if ! ok {
283+ return false , false
284+ }
285+ if bytes .Equal (bytes .TrimSpace (v ), []byte ("null" )) {
286+ return true , true
287+ }
288+ return true , false
289+ }
0 commit comments