Skip to content

Commit af57ed2

Browse files
Merge remote-tracking branch 'origin/master' into fix/p1-api-bundle-2026-05-29
2 parents a7da7e0 + f176c40 commit af57ed2

17 files changed

Lines changed: 1323 additions & 11 deletions

internal/handlers/api_keys.go

Lines changed: 128 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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

1324
import (
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

Comments
 (0)