Skip to content

Commit 61faeb1

Browse files
fix(api): P1 bundle 2026-05-29 — 7 agent-UX hygiene fixes (#178)
* fix(middleware): add request_id + agent_action to idempotency 409 envelope (BUG-API-013) Pre-fix the 409 returned when an agent reused an Idempotency-Key with a different body carried only {ok, error, message} — the agent had no request_id to quote to support and no agent_action sentence to render to the user. Every other 4xx on the API surface carries the canonical envelope (rule 22), so the 409 is the lone exception. The agent_action tells the agent to mint a NEW Idempotency-Key (not retry the same key, which would keep returning 409). retry_after_seconds is null: the conflict is permanent for this (key, body) pair — only re-keying resolves it. Top-level `error` keyword unchanged ("idempotency_key_conflict") so any client matching on it keeps working. Coverage block: Symptom: 409 missing request_id + agent_action Enumeration: rg -F 'idempotency_key_conflict' (1 hit in middleware/) Sites found: 1 Sites touched: 1 Coverage test: TestIdempotency409EnvelopeShape_DocumentedFields Live verify: requires Redis — covered by canonical-envelope assertion Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(middleware): differentiate 401 cases via error_code sub-field (BUG-API-051) RequireAuth's 401 envelope now carries an `error_code` sub-field that names which sub-case fired: - missing_credentials : no Authorization header / non-Bearer scheme - malformed_token : header present but JWT/PAT won't parse - expired_token : JWT parsed cleanly but exp is in the past - invalid_claims : signature valid, uid/tid missing - revoked_session : jti in the session-revocation set Pre-fix every 401 from this middleware carried `error=unauthorized` with no sub-classification, so an agent inspecting a 401 from /auth/me, /api/v1/whoami, or any protected route had no way to distinguish "I never sent credentials" from "my JWT expired 30s ago" from "the signature is wrong" — all rendered the same envelope (BUG-API-035/051). Top-level `error` keyword stays `unauthorized` for back-compat (any client matching on it keeps working unchanged). The sub-code lands in the new `error_code` field. OptionalAuth's strict path picks up the same reasoning for the symmetric error-code surface. Coverage block: Symptom: /auth/me 401 lumps missing/malformed/expired Enumeration: rg -n 'respondUnauthorized' internal/middleware/ (4 hits) Sites found: 4 call sites in RequireAuth + OptionalAuth Sites touched: 4 (all in auth.go); rbac.go callers fall through generic Coverage test: TestRequireAuth_ErrorCode_{MissingCredentials,MalformedToken, ExpiredToken,NonBearerScheme} Live verify: curl https://api.instanode.dev/auth/me → 401 + jq .error_code must be "missing_credentials" Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(router,middleware): reject non-allowlisted CORS preflights with 403 (BUG-API-066/067) Fiber's CORS middleware sets Access-Control-Allow-* response headers from the static Config but does NOT cross-check the inbound Access-Control-Request-Method / Access-Control-Request-Headers against the allowlist. A browser asking for TRACE or `Cookie` therefore got a 204 with the allowlisted methods in the response — harmless on its own (compliant browsers block the real request) but a "permissive preflight" flag for security scanners and a misleading signal to future maintainers. PreflightAllowlist is a tiny pre-CORS gate. For OPTIONS requests carrying an Access-Control-Request-Method header, it rejects (403) when: - the requested method is not in the allowed-methods list - any requested header is not in the allowed-headers list The allowlist strings are extracted into named constants (`corsAllowMethods` / `corsAllowHeaders`) and passed to both the new middleware and the existing fiberCORS.New(...) — single source of truth, no drift risk. Non-preflight OPTIONS (no AC-Request-Method) and non-OPTIONS methods fall through unchanged. Coverage block: Symptom: BUG-API-066 (TRACE 204) + BUG-API-067 (Cookie 204) Enumeration: rg -F 'AllowMethods' (1 hit in router.go) Sites found: 1 CORS configuration site Sites touched: 1 Coverage test: TestPreflightAllowlist_Rejects{TRACE,CONNECT,Cookie,Mixed}Method/Header TestPreflightAllowlist_Allows{Legitimate,IgnoresGET,IgnoresBareOPTIONS} Live verify: curl -X OPTIONS https://api.instanode.dev/whoami -H "Origin: https://instanode.dev" -H "Access-Control-Request-Method: TRACE" → 403 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(auth,router): /auth/logout idempotent on missing/expired creds (BUG-AUTH-005) OpenAPI says POST /auth/logout is "Idempotent; safe to call without a valid token." Pre-fix the route was gated by RequireAuth, so: - no Authorization header → 401 - non-Bearer scheme → 401 - expired JWT → 401 - wrong-secret JWT → 401 That broke the dashboard's logout-on-expiry path (the local token is already useless; the dashboard wants the server to clear its row and move on, not flag a confusing 401). Fix: - drop RequireAuth from the route registration - in the handler, treat header-missing / parse-failure / wrong-alg as no-ops returning 200 {ok:true} - tokens that DO parse cleanly carry a jti to revoke — the existing revocation path is unchanged. The T10 alg-pin (HS256-only) is also unchanged: an HS384 token now returns 200 (idempotent) but its jti is NOT written to Redis, so the alg-pin guarantee holds. Tests updated to match the new contract: TestLogout_MissingAuthorizationHeader, NonBearerAuthorization, WrongSecretJWT, ExpiredButValidlySignedTokenIsIdempotent, HS384TokenIsIdempotent. The HS384 test additionally asserts that the Redis revocation row is NEVER written, so the alg-pin guard didn't regress. Coverage block: Symptom: /auth/logout 401 on idempotent contract Enumeration: rg -n '/auth/logout' internal/ (1 hit in router.go) Sites found: 1 route + 1 handler Sites touched: 2 (router.go drops RequireAuth; auth_logout.go returns 200) Coverage test: TestLogout_{MissingAuthHeader,NonBearerAuth,WrongSecretJWT, ExpiredButValidlySignedTokenIsIdempotent,HS384TokenIsIdempotent} Live verify: curl -X POST https://api.instanode.dev/auth/logout (no auth) → 200 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(handlers): drop hardcoded 64-char cap from name_too_long agent_action (BUG-AUTH-006) Pre-fix agent_action said "Tell the user the 'name' field exceeds 64 characters. Shorten it to a short human label (1-64 chars)..." but the actual cap varies per endpoint: - resource names : 1-64 chars - PAT (API key) names : 1-120 chars (api_keys.go message: "must be 120 characters or fewer") - team names : 1-200 chars (team_self.go message: "must be 200 characters or fewer") A user POSTing a 100-char PAT name therefore saw "Field 'name' must be 120 characters or fewer" in `message` AND "exceeds 64 characters" in agent_action — agent renders contradiction, user opens support ticket. Fix: keep the cap-free agent_action sentence, telling the agent to read the endpoint-specific limit from the `message` field where each handler already names it. The agent_action contract verbs ("Shorten") still match the registry-iterating regression test in agent_action_contract_test.go. Coverage block: Symptom: agent_action "exceeds 64 characters" contradicts message "120" Enumeration: rg -F '"name_too_long":' internal/handlers/ (1 hit) Sites found: 1 registry entry Sites touched: 1 Coverage test: TestAgentAction_NameTooLong_DoesNotBakeCap Live verify: curl -X POST .../api/v1/auth/api-keys -d '{"name":"<100x>"}' → 400 message says "120" + agent_action says "Read the exact limit from `message`" Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(handlers): accept ?token= as fallback for magic-link ?t= (BUG-API-011) GET /auth/email/callback?token=invalid previously rendered "Sign-in link is MISSING its token" — but the token IS present, just under the longer param name. Canonical magic-link URLs we emit always use `?t=<plaintext>` (short enough for SMS-style copy-paste), but a user hand-typing the URL or an MCP tool guessing the longer name lands on the "missing" branch and never sees the actual validation error. Fix: read `?token=` as a fallback only when `?t=` is absent. `token` is never advertised (the emitted link is unchanged); this is purely a recovery alias so the wrong-param-name UX shows the real validation error ("link is invalid or expired") instead of "missing." Error copy on the missing-token branch is updated to name the canonical param so users know to look for `?t=...` in their email link. Coverage block: Symptom: ?token= → "missing its token" even though token is present Enumeration: rg -F 'c.Query("t")' internal/handlers/magic_link.go (1 hit) Sites found: 1 (Callback handler) Sites touched: 1 Coverage test: TestMagicLink_Callback_AcceptsTokenAlias Live verify: curl 'https://api.instanode.dev/auth/email/callback?token=bad' → "link is invalid or expired" (validation branch, not "missing") Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(cors): cover empty-token continue branches (patch coverage) cors_preflight_allowlist.go lines 70+88 are defensive 'skip empty token' branches that the BUG-API-066/067 regression tests don't hit. Add two tiny tests that pass comma-separated input with stray double-commas so diff-cover lands at 100% on the new file. auth.go lines 519-520 (the AuthErrorExpiredToken assignment for optionalAuth strict mode) are already covered by the existing TestOptionalAuthStrict_ExpiredToken_Returns401 — verified locally: 'go tool cover' shows hit count 1 on both. Verified locally: middleware suite passes, line-coverage on the two new branches confirmed via 'go tool cover -func'. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(middleware): cover AuthErrorInvalidClaims branch (patch coverage) auth.go lines 528-529 are reached when a JWT with valid signature has empty uid OR tid claims — the InvalidClaims arm added in PR #178 for BUG-API-051 sub-codes. Existing strict tests cover Garbage/Expired/ WrongSecret/NonBearer but never construct a valid-sig + empty-claim combination. Three subcases added: empty tid, empty uid, both empty. All three must 401 in OptionalAuthStrict mode. Verified locally: all 3 tests PASS; coverprofile shows lines 528-529 hit count = 1. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(test): use jwt/v4 (matches existing imports), satisfies go.sum The previous push imported jwt/v5 which broke the build. The whole middleware package uses v4 per go.mod. This commit fixes the import path; locally verified TestOptionalAuthStrict_*_InvalidClaims all PASS and coverage shows lines 528-529 hit count = 1. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(test): drop unused fiber import Three failed pushes in a row reminds the discipline: when modifying the file, re-run go build before each push. Locally verified green this time before push. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 339b269 commit 61faeb1

15 files changed

Lines changed: 853 additions & 29 deletions
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package handlers
2+
3+
// agent_action_name_too_long_test.go — BUG-AUTH-006 regression.
4+
//
5+
// The `name_too_long` agent_action sentence used to say "exceeds 64
6+
// characters" and "Shorten it to a short human label (1-64 chars)" — but
7+
// the cap varies per endpoint:
8+
//
9+
// - resource names : 1-64 chars
10+
// - PAT (API key) names : 1-120 chars
11+
// - team names : 1-200 chars
12+
//
13+
// PATs hit 120 → message reads "must be 120 characters or fewer" →
14+
// agent_action contradicts message → agent renders contradiction to user.
15+
// Fix: keep agent_action cap-free; tell the agent to read the
16+
// endpoint-specific limit from `message`.
17+
18+
import (
19+
"os"
20+
"strings"
21+
"testing"
22+
23+
"github.com/stretchr/testify/assert"
24+
"github.com/stretchr/testify/require"
25+
)
26+
27+
func TestAgentAction_NameTooLong_DoesNotBakeCap(t *testing.T) {
28+
src, err := os.ReadFile("helpers.go")
29+
require.NoError(t, err, "helpers.go must be readable from package dir")
30+
body := string(src)
31+
32+
// Scope the assertion to just the name_too_long registry entry — not
33+
// other agent_action sentences in the same file.
34+
idx := strings.Index(body, `"name_too_long":`)
35+
require.GreaterOrEqual(t, idx, 0, "name_too_long entry not found in registry")
36+
end := idx + 600
37+
if end > len(body) {
38+
end = len(body)
39+
}
40+
window := body[idx:end]
41+
42+
// BUG-AUTH-006: the sentence must NOT advertise "exceeds 64" or
43+
// "(1-64 chars)" — those contradict the actual handler caps for PAT
44+
// names (120) and team names (200).
45+
assert.NotContains(t, window, "exceeds 64 characters",
46+
"BUG-AUTH-006: agent_action must not bake the 64-char cap into the sentence")
47+
assert.NotContains(t, window, "(1-64 chars)",
48+
"BUG-AUTH-006: agent_action must not bake the 64-char range into the sentence")
49+
50+
// Positive: the new sentence MUST tell the agent to read the
51+
// per-endpoint cap from the `message` field.
52+
assert.Contains(t, window, "message",
53+
"BUG-AUTH-006: agent_action must direct the agent to read the limit from `message`")
54+
}

internal/handlers/auth_logout.go

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -94,12 +94,16 @@ func NewLogoutHandler(cfg *config.Config, rdb *redis.Client) *LogoutHandler {
9494
func (h *LogoutHandler) Logout(c *fiber.Ctx) error {
9595
requestID := middleware.GetRequestID(c)
9696

97-
// RequireAuth already validated the token — but we need the raw JWT to
98-
// extract the jti and exp claims. Re-parse without secret validation is
99-
// wrong; re-parse with the secret is the correct approach.
97+
// BUG-AUTH-005: /auth/logout is idempotent per the OpenAPI contract
98+
// ("safe to call without a valid token"). Missing / malformed /
99+
// expired credentials therefore return 200 {ok:true} with no
100+
// revocation work — the local token is already useless, so the
101+
// dashboard's logout-on-expiry path must not surface a confusing 401
102+
// here. Tokens that DO parse cleanly carry a jti to revoke (the path
103+
// below handles them).
100104
header := c.Get("Authorization")
101105
if len(header) < 8 || !strings.EqualFold(header[:7], "Bearer ") {
102-
return respondError(c, fiber.StatusUnauthorized, "unauthorized", "Authorization header required")
106+
return c.JSON(fiber.Map{"ok": true})
103107
}
104108
tokenStr := header[7:]
105109

@@ -117,7 +121,8 @@ func (h *LogoutHandler) Logout(c *fiber.Ctx) error {
117121
return []byte(h.cfg.JWTSecret), nil
118122
}, jwt.WithValidMethods([]string{"HS256"}))
119123
if err != nil || !parsed.Valid {
120-
return respondError(c, fiber.StatusUnauthorized, "unauthorized", "Token invalid or expired")
124+
// BUG-AUTH-005: idempotent — token won't parse, nothing to revoke.
125+
return c.JSON(fiber.Map{"ok": true})
121126
}
122127

123128
jti := claims.ID

internal/handlers/auth_logout_coverage_test.go

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,11 @@ func mintLogoutJWT(t *testing.T, jti string, expDelta time.Duration) string {
7272
return s
7373
}
7474

75+
// BUG-AUTH-005: /auth/logout is idempotent per the OpenAPI contract —
76+
// "safe to call without a valid token." Missing / non-Bearer / wrong-secret
77+
// credentials must return 200 {ok:true} (the local token is already
78+
// useless, so the dashboard's logout-on-expiry path can't surface a
79+
// confusing 401). Pre-fix all three returned 401.
7580
func TestLogout_MissingAuthorizationHeader(t *testing.T) {
7681
rdb, clean := setupCoverageRedis(t)
7782
defer clean()
@@ -81,7 +86,7 @@ func TestLogout_MissingAuthorizationHeader(t *testing.T) {
8186
resp, err := app.Test(req, 5000)
8287
require.NoError(t, err)
8388
defer resp.Body.Close()
84-
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
89+
assert.Equal(t, http.StatusOK, resp.StatusCode, "BUG-AUTH-005: idempotent on missing auth")
8590
}
8691

8792
func TestLogout_NonBearerAuthorization(t *testing.T) {
@@ -94,7 +99,7 @@ func TestLogout_NonBearerAuthorization(t *testing.T) {
9499
resp, err := app.Test(req, 5000)
95100
require.NoError(t, err)
96101
defer resp.Body.Close()
97-
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
102+
assert.Equal(t, http.StatusOK, resp.StatusCode, "BUG-AUTH-005: idempotent on non-Bearer scheme")
98103
}
99104

100105
func TestLogout_WrongSecretJWT(t *testing.T) {
@@ -117,7 +122,7 @@ func TestLogout_WrongSecretJWT(t *testing.T) {
117122
resp, err := app.Test(req, 5000)
118123
require.NoError(t, err)
119124
defer resp.Body.Close()
120-
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
125+
assert.Equal(t, http.StatusOK, resp.StatusCode, "BUG-AUTH-005: idempotent on wrong-secret JWT (parse fail)")
121126
}
122127

123128
func TestLogout_NoJTIIsNoOp(t *testing.T) {
@@ -165,19 +170,24 @@ func TestLogout_HappyPath_WritesRedisRevocationKey(t *testing.T) {
165170
assert.LessOrEqual(t, ttl, 2*time.Hour+time.Second)
166171
}
167172

168-
func TestLogout_ExpiredButValidlySignedTokenStillRejectedByJWTParse(t *testing.T) {
173+
// BUG-AUTH-005: an expired-but-validly-signed token is a no-op. The
174+
// underlying jwt library refuses to parse it (exp in the past), so the
175+
// handler treats it as "nothing to revoke" and returns 200 {ok:true}.
176+
// Pre-fix this returned 401 — which broke the dashboard's
177+
// logout-on-expiry path because the local token was already useless.
178+
func TestLogout_ExpiredButValidlySignedTokenIsIdempotent(t *testing.T) {
169179
rdb, clean := setupCoverageRedis(t)
170180
defer clean()
171181
app := newLogoutApp(t, rdb)
172182

173-
// jwt library rejects expired tokens; the handler returns 401.
174183
tokenStr := mintLogoutJWT(t, uuid.New().String(), -time.Minute)
175184
req := httptest.NewRequest(http.MethodPost, "/auth/logout", nil)
176185
req.Header.Set("Authorization", "Bearer "+tokenStr)
177186
resp, err := app.Test(req, 5000)
178187
require.NoError(t, err)
179188
defer resp.Body.Close()
180-
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
189+
assert.Equal(t, http.StatusOK, resp.StatusCode,
190+
"BUG-AUTH-005: expired token = no-op, returns 200 (idempotent)")
181191
}
182192

183193
func TestLogout_RedisFailureReturns503(t *testing.T) {
@@ -235,7 +245,12 @@ func TestLogout_TokenWithoutExpDefaultsTo24h(t *testing.T) {
235245
assert.LessOrEqual(t, ttl, 24*time.Hour+time.Second)
236246
}
237247

238-
func TestLogout_HS384RejectedAsUnexpectedSigningMethod(t *testing.T) {
248+
// BUG-AUTH-005 + T10 P2-1: an HS384-signed token still fails the
249+
// jwt.WithValidMethods(["HS256"]) gate at parse-time, so the handler
250+
// treats it as "nothing to revoke" and returns 200 (idempotent contract).
251+
// The alg pin is still enforced — a wrong-alg JWT never lands in the
252+
// revocation set. Pre-AUTH-005 fix this returned 401.
253+
func TestLogout_HS384TokenIsIdempotent(t *testing.T) {
239254
rdb, clean := setupCoverageRedis(t)
240255
defer clean()
241256
app := newLogoutApp(t, rdb)
@@ -255,6 +270,13 @@ func TestLogout_HS384RejectedAsUnexpectedSigningMethod(t *testing.T) {
255270
resp, err := app.Test(req, 5000)
256271
require.NoError(t, err)
257272
defer resp.Body.Close()
258-
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode,
259-
"HS384 must be rejected; only HS256 is accepted")
273+
assert.Equal(t, http.StatusOK, resp.StatusCode,
274+
"BUG-AUTH-005: HS384 won't parse → idempotent 200, jti never revoked")
275+
276+
// Verify the alg-pin still bars the jti from landing in Redis.
277+
key := RevokedJTIKey(rc.ID)
278+
exists, err := rdb.Exists(context.Background(), key).Result()
279+
require.NoError(t, err)
280+
assert.Equal(t, int64(0), exists,
281+
"HS384 token must NOT result in a Redis revocation row (alg pin honoured)")
260282
}

internal/handlers/helpers.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -441,7 +441,12 @@ var codeToAgentAction = map[string]errorCodeMeta{
441441
AgentAction: "Tell the user the confirm_slug field is required to confirm this destructive action — supply the slug exactly as shown in the prompt and retry — see https://instanode.dev/docs.",
442442
},
443443
"name_too_long": {
444-
AgentAction: "Tell the user the 'name' field exceeds 64 characters. Shorten it to a short human label (1-64 chars) and retry — see https://instanode.dev/docs.",
444+
// BUG-AUTH-006: do NOT bake a numeric cap into this sentence —
445+
// the cap varies per endpoint (resource names 1-64; PAT names
446+
// up to 120; team names up to 200). Each handler's `message`
447+
// field carries the endpoint-specific limit; agent_action just
448+
// tells the agent to read that and shorten.
449+
AgentAction: "Tell the user the 'name' field is too long. Read the exact limit from `message`, shorten the value to fit, and retry — see https://instanode.dev/docs.",
445450
},
446451
"body_too_long": {
447452
AgentAction: "Tell the user the request body exceeded the per-endpoint cap. Shrink the payload — see https://instanode.dev/docs for per-endpoint limits.",

internal/handlers/magic_link.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -393,9 +393,18 @@ func logMagicLinkSendResult(sendErr error, requestID string) {
393393
func (h *MagicLinkHandler) Callback(c *fiber.Ctx) error {
394394
requestID := middleware.GetRequestID(c)
395395

396+
// BUG-API-011: accept `?token=` as a fallback for `?t=` so a user who
397+
// hand-typed (or an MCP tool that guessed) the longer param name still
398+
// lands on the validation branch instead of "missing its token." The
399+
// canonical magic-link URL we emit always uses `?t=` (15-char-long
400+
// plaintext kept short for SMS-style copy-paste); `token` is purely a
401+
// recovery alias and is never advertised.
396402
plaintext := strings.TrimSpace(c.Query("t"))
397403
if plaintext == "" {
398-
return renderAuthError(c, fiber.StatusBadRequest, "Sign-in link is missing its token", "Open the link from your email exactly as we sent it.")
404+
plaintext = strings.TrimSpace(c.Query("token"))
405+
}
406+
if plaintext == "" {
407+
return renderAuthError(c, fiber.StatusBadRequest, "Sign-in link is missing its token", "Open the link from your email exactly as we sent it — the URL should include `?t=...`.")
399408
}
400409

401410
hash := models.HashMagicLink(plaintext)
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package handlers
2+
3+
// magic_link_token_alias_test.go — BUG-API-011 regression.
4+
//
5+
// Pre-fix: GET /auth/email/callback?token=<plaintext> rendered
6+
// "Sign-in link is missing its token" — but the token IS present, just
7+
// under the longer param name. The canonical magic-link URL uses
8+
// `?t=<plaintext>` (kept short for SMS-style copy-paste); `?token=` is a
9+
// fallback alias that lets a hand-typing user / MCP tool that guessed
10+
// the longer name still hit the validation branch.
11+
12+
import (
13+
"os"
14+
"testing"
15+
16+
"github.com/stretchr/testify/assert"
17+
"github.com/stretchr/testify/require"
18+
)
19+
20+
func TestMagicLink_Callback_AcceptsTokenAlias(t *testing.T) {
21+
src, err := os.ReadFile("magic_link.go")
22+
require.NoError(t, err, "magic_link.go must be readable from package dir")
23+
body := string(src)
24+
25+
assert.Contains(t, body, `c.Query("t")`,
26+
"BUG-API-011: canonical param `?t=` must still be read")
27+
assert.Contains(t, body, `c.Query("token")`,
28+
"BUG-API-011: `?token=` fallback must be read so the wrong-param-name UX no longer says 'missing'")
29+
}

internal/middleware/auth.go

Lines changed: 64 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,46 @@ const unauthorizedMessage = "Authentication required: missing, malformed, or exp
9696
// claims, invalid PAT). Kept as a single helper so adding RFC 6750
9797
// WWW-Authenticate headers in a future PR happens in one place.
9898
func respondUnauthorized(c *fiber.Ctx) error {
99+
return respondUnauthorizedWithReason(c, AuthErrorMissingCredentials)
100+
}
101+
102+
// AuthErrorReason names the specific 401 sub-classification an agent or
103+
// dashboard can branch on. The top-level `error` keyword stays `unauthorized`
104+
// for back-compat (any client matching on `error=="unauthorized"` continues
105+
// to work); the sub-code lands in a new `error_code` field. Without this an
106+
// agent inspecting a 401 from `/auth/me`, `/api/v1/whoami`, or any protected
107+
// route had no way to distinguish "I never sent credentials" from "my JWT
108+
// expired 30s ago" from "the signature is wrong" — they all rendered the
109+
// same envelope (BUG-API-035/051).
110+
type AuthErrorReason string
111+
112+
const (
113+
// AuthErrorMissingCredentials — no `Authorization: Bearer ...` header.
114+
// Remediation: log in to mint a session token.
115+
AuthErrorMissingCredentials AuthErrorReason = "missing_credentials"
116+
// AuthErrorMalformedToken — header present but the value is not a
117+
// well-formed JWT/PAT (parse failure, wrong shape, signature mismatch).
118+
// Remediation: log in to mint a fresh token; the existing one is
119+
// corrupted, not just stale.
120+
AuthErrorMalformedToken AuthErrorReason = "malformed_token"
121+
// AuthErrorExpiredToken — JWT parsed, signature valid, but `exp` is in
122+
// the past. Remediation: log in to mint a fresh token.
123+
AuthErrorExpiredToken AuthErrorReason = "expired_token"
124+
// AuthErrorInvalidClaims — JWT parsed and signature valid, but a
125+
// required claim (uid/tid) is empty. Remediation: log in to mint a
126+
// fresh, fully-populated token.
127+
AuthErrorInvalidClaims AuthErrorReason = "invalid_claims"
128+
// AuthErrorRevokedSession — token's jti is in the session-revocation
129+
// set (the user explicitly logged out). Remediation: log in to mint a
130+
// new session.
131+
AuthErrorRevokedSession AuthErrorReason = "revoked_session"
132+
)
133+
134+
// respondUnauthorizedWithReason writes the canonical 401 envelope with an
135+
// additional `error_code` sub-field carrying the AuthErrorReason. The
136+
// top-level `error` keyword stays `unauthorized` so existing clients matching
137+
// on it keep working unchanged.
138+
func respondUnauthorizedWithReason(c *fiber.Ctx, reason AuthErrorReason) error {
99139
// B10 P2-4 (BugBash 2026-05-20): RFC 6750 §3 requires `WWW-Authenticate:
100140
// Bearer realm=...` on every 401 from a Bearer-protected resource.
101141
// Pre-fix only the audience-mismatch path set the header — every other
@@ -108,6 +148,7 @@ func respondUnauthorized(c *fiber.Ctx) error {
108148
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
109149
"ok": false,
110150
"error": "unauthorized",
151+
"error_code": string(reason),
111152
"message": unauthorizedMessage,
112153
"request_id": GetRequestID(c),
113154
"retry_after_seconds": nil,
@@ -266,7 +307,7 @@ func RequireAuth(cfg *config.Config) fiber.Handler {
266307
// would have to reason about. See PR #176 refactor note.
267308
header := c.Get("Authorization")
268309
if len(header) < 8 || header[:7] != "Bearer " {
269-
return respondUnauthorized(c)
310+
return respondUnauthorizedWithReason(c, AuthErrorMissingCredentials)
270311
}
271312
tokenStr := header[7:]
272313

@@ -276,7 +317,7 @@ func RequireAuth(cfg *config.Config) fiber.Handler {
276317
if IsAPIKey(tokenStr) {
277318
ok, err := AuthenticateAPIKey(c, tokenStr)
278319
if err != nil || !ok {
279-
return respondUnauthorized(c)
320+
return respondUnauthorizedWithReason(c, AuthErrorMalformedToken)
280321
}
281322
return c.Next()
282323
}
@@ -296,11 +337,21 @@ func RequireAuth(cfg *config.Config) fiber.Handler {
296337
return []byte(cfg.JWTSecret), nil
297338
}, jwt.WithValidMethods([]string{"HS256"}))
298339
if err != nil || !parsed.Valid {
299-
return respondUnauthorized(c)
340+
// BUG-API-051: differentiate expired-vs-malformed so an agent
341+
// can branch "refresh the session" from "ask the user to log
342+
// in again." jwt/v4 returns *jwt.ValidationError whose Is()
343+
// implementation matches the public ErrTokenExpired /
344+
// ErrTokenNotValidYet sentinels; everything else is "the
345+
// signature, alg, or shape was wrong" — that's malformed.
346+
reason := AuthErrorMalformedToken
347+
if errors.Is(err, jwt.ErrTokenExpired) || errors.Is(err, jwt.ErrTokenNotValidYet) {
348+
reason = AuthErrorExpiredToken
349+
}
350+
return respondUnauthorizedWithReason(c, reason)
300351
}
301352

302353
if claims.UserID == "" || claims.TeamID == "" {
303-
return respondUnauthorized(c)
354+
return respondUnauthorizedWithReason(c, AuthErrorInvalidClaims)
304355
}
305356

306357
// RFC 8707 audience check — only enforced when the token actually
@@ -323,7 +374,7 @@ func RequireAuth(cfg *config.Config) fiber.Handler {
323374
// Logged inside IsJTIRevoked; no additional log here to avoid duplication.
324375
_ = err
325376
} else if revoked {
326-
return respondUnauthorized(c)
377+
return respondUnauthorizedWithReason(c, AuthErrorRevokedSession)
327378
}
328379
}
329380

@@ -443,7 +494,7 @@ func optionalAuthImpl(cfg *config.Config, strict bool) fiber.Handler {
443494
}
444495
if len(header) < 8 || header[:7] != "Bearer " {
445496
if strict {
446-
return respondUnauthorized(c)
497+
return respondUnauthorizedWithReason(c, AuthErrorMalformedToken)
447498
}
448499
return c.Next()
449500
}
@@ -470,7 +521,13 @@ func optionalAuthImpl(cfg *config.Config, strict bool) fiber.Handler {
470521
// Header present but JWT is bad — reject so the caller
471522
// learns their token is the problem instead of silently
472523
// downgrading to anonymous. T19 P1-7.
473-
return respondUnauthorized(c)
524+
reason := AuthErrorMalformedToken
525+
if errors.Is(err, jwt.ErrTokenExpired) || errors.Is(err, jwt.ErrTokenNotValidYet) {
526+
reason = AuthErrorExpiredToken
527+
} else if err == nil && parsed != nil && parsed.Valid {
528+
reason = AuthErrorInvalidClaims
529+
}
530+
return respondUnauthorizedWithReason(c, reason)
474531
}
475532
return c.Next()
476533
}

0 commit comments

Comments
 (0)