Skip to content

fix(security): enforce JWT aud validation per route; M2M gets own audience (#84) - #94

Open
EM-SubhamDas wants to merge 1 commit into
masterfrom
feat/issue-84-jwt-audience-validation
Open

fix(security): enforce JWT aud validation per route; M2M gets own audience (#84)#94
EM-SubhamDas wants to merge 1 commit into
masterfrom
feat/issue-84-jwt-audience-validation

Conversation

@EM-SubhamDas

Copy link
Copy Markdown
Contributor

Summary

The aud (audience) claim was written on every JWT but never validated, and M2M client_credentials tokens shared the same audience value as user login tokens. Token-type boundaries were therefore unenforced at runtime — a machine or API-key-derived token was accepted on user self-service routes (/auth/me, /auth/otp/*) where no user exists behind the token. This PR enforces audience per route group, so machine and management tokens keep working on the admin API but are refused on user-facing routes.

Closes #84

Type of change

  • Feature (new capability — maps to a roadmap phase/plan)
  • Bug fix (non-breaking)
  • Security fix
  • Refactor (no behaviour change)
  • Infra / CI / config change
  • Documentation only

Not ticked as "non-breaking": there is an intentional behaviour change (M2M/management tokens now 401 on user self-service routes) plus a one-time token invalidation on deploy. See Rollout below.

Phase / Plan reference

Issue #84 — no roadmap plan document. Hardening of Phase 2 (Auth Engine), contributes to Phase 7 (Testing & Hardening).

Changes made

  • Add AudienceAPI / AudienceM2M / AudienceManagement / AudienceAgent constants, replacing scattered string literals; add sentinel errors ErrUnexpectedAudience and ErrNoAudienceAllowed
  • Add VerifyForAudience(ctx, token, allowed...) as the single verification funnel; Verify() becomes AudienceAPI-only, plus new VerifyM2M(); require exactly one audience so a multi-valued aud cannot satisfy a route by carrying one acceptable value among others
  • issueTokenPairAudienceAPI; IssueServiceTokenAudienceM2M; SAML JIT login → AudienceAPI (not in the issue text, but it shared the same literal and would have broken)
  • JWTRequired(jwtSvc, allowedAudiences...) is now audience-parameterised. adminGroup accepts {API, Management, M2M} so machine clients keep working; JWTRenew user routes accept AudienceAPI only
  • Hardening in the same code path: pin the algorithm to exactly HS256 (was HMAC family) and enforce the iss claim, which was set on every token but never checked
  • Add emc_auth_token_audience_rejections_total{audience, route}, wired in both JWTRequired and JWTRenew
  • Tests: 8 audience cases in jwt_test.go using real SignManagement/SignAgent tokens rather than synthetic stand-ins; new middleware/jwt_test.go with a 9-case token × route matrix, a fail-closed test, and a metric regression guard

Deviation from the issue's stated plan — please read

The issue's acceptance criterion said "Verify() rejects any token whose aud is not AudienceAPI", assuming M2M tokens had no legitimate consumer. That assumption is wrong for this codebase, and implementing it literally would have returned 401 to every machine integration using the admin API:

  • IssueServiceToken's own doc: "Scopes are loaded from the oauth_clients.scopes column so downstream permission checks receive the correct grants."
  • auditAdminApp (the shared admin audit helper): "Service tokens carry the public client_id in the UserID claim, which is not a users.id…"

Machine-to-machine access to the admin API is the Auth0 client_credentials Management API pattern — the parity feature this issue is labelled for. Audience is therefore enforced per route group rather than as one global rule, which meets the security objective without the regression.

Security considerations

  • No new SQL queries without parameterized args — no new SQL at all
  • No secrets or credentials in code or comments — diff and new file both scanned
  • Auth middleware is not bypassed or weakened — strengthened; fails closed if a route is mounted without declaring audiences (ErrNoAudienceAllowed)
  • New migrations are backward-compatible — N/A, no migrations
  • Audit events added for any new security-relevant action — deliberately not added, see note
  • Rate limiting behaviour unchanged or explicitly documented — unchanged

On audit events: a Prometheus counter was chosen over audit rows. These rejections occur on unauthenticated requests, so writing an audit row per rejection would give an attacker control over write volume. The counter gives operators the same signal without that amplification. A well-behaved client never triggers it, so any sustained non-zero rate is worth alerting on:

emc_auth_token_audience_rejections_total{audience="emc-auth-m2m",route="/api/v1/auth/me"} 1
emc_auth_token_audience_rejections_total{audience="emc-auth-management",route="/api/v1/auth/me"} 1

The HTTP response stays a generic 401 {"code":"token_invalid"} on purpose — a caller must not be able to probe which token types a route accepts.

Test plan

  • go build ./... passes (also go vet and gofmt clean)
  • go test ./... -p 1 -count=1 passes for all packages touched here
  • Manual test: 18-check scripted walkthrough against a live server — 18/18
  • No regressions in existing endpoints (health + login + /me verified, plus SAML path reviewed)

Note on the suite: run with -p 1 (existing test-isolation limitation). 8 failures in email_flows_test.go / user_management_test.go were confirmed pre-existing by running the identical tests on untouched master in a throwaway worktree — same 8 failures. Root cause is local dev-DB schema drift (missing failed_login_attempts, blocked_at, breach_notified_at; the migration file is correct but the DB records it as applied). CI builds a fresh DB from migrations, so these should not appear here.

Manual verification highlights (all observed on a running server):

Token Route Result
user (emc-auth-api) /auth/me, /tenants 200 / 200 — unchanged
m2m (emc-auth-m2m) /users (its users:read scope) 200 — no regression
m2m /auth/me, /auth/otp/status 401 token_invalid ← previously 200
management /tenants 200 — no regression
management /auth/me 401 token_invalid ← previously 200
garbage / tampered signature /auth/me 401

Rollout

Deploy as a single restart — this ships as one self-hosted binary, so the issue's suggested staged 15-minute rollout (which assumes a multi-instance fleet) does not apply.

Tokens minted before the restart carry the old aud: "emc-auth-server", which no verify path accepts. Access tokens live 15 minutes, so the impact is one forced re-login for users active in that window. Refresh-token cookies are unaffected (opaque, DB-hashed, not JWTs), so no data is lost.

Checklist

  • Branch name follows convention (feat/issue-84-jwt-audience-validation)
  • Commit messages are atomic and descriptive
  • Swagger annotations updated if handler signatures changed — N/A, no handler signature or response shape changed (only middleware and service internals)
  • README / ROADMAP updated if a phase plan is completed — N/A, no phase completed
  • No fmt.Println or debug statements left in

Reviewer notes — pre-existing CI risks

Two items may show red through no fault of this PR, both already tracked as deferred follow-ups:

  1. govulncheck — a real, reachable vuln in pgx v5.6.0 (sanitize.SanitizeSQL). Predates this work; needs its own chore PR to bump to ≥ v5.9.2.
  2. TestSQLInjection_LoginPassword — flaky hard 3s timing threshold that trips on slower machines against Login's deliberate bcrypt-cost-12 anti-timing padding. Payloads are correctly rejected.

Follow-ups identified (not in this PR)

  • aud here is not Auth0's audience. Ours carries the token type; Auth0's names the target API. Per-API audience is a future feature — needed once tenant apps verify tokens themselves, since JWT libraries validate aud automatically while nothing validates our app_id claim. Recommended migration: move token type to a gty claim and free aud. Do not make token-type audiences tenant-configurable — a tenant able to claim emc-auth-api re-opens this issue.
  • Agent-token verification is unimplemented. SignAgent mints aud=emc-auth-agent tokens that nothing verifies. Not an exposure — no verify path allows that audience — but the feature is half-built.

…ience (#84)

The aud claim was written on every token but never validated, and M2M
client_credentials tokens shared the user-token audience. Token-type
boundaries were therefore unenforced: a machine or API-key token was
accepted on user self-service routes (/auth/me, /auth/otp/*), where no
user exists behind it.

- Add AudienceAPI/M2M/Management/Agent constants; sentinel errors
  ErrUnexpectedAudience and ErrNoAudienceAllowed
- Add VerifyForAudience(ctx, token, allowed...); Verify() is now
  AudienceAPI-only, plus VerifyM2M(); require exactly one audience
- issueTokenPair -> AudienceAPI, IssueServiceToken -> AudienceM2M,
  SAML JIT login -> AudienceAPI
- JWTRequired(jwtSvc, allowedAudiences...) is audience-parameterised;
  adminGroup accepts {API, Management, M2M} so machine clients keep
  working, while JWTRenew user routes accept AudienceAPI only
- Harden: pin HS256 exactly (was HMAC family) and enforce iss
- Add emc_auth_token_audience_rejections_total{audience,route}, wired in
  both JWTRequired and JWTRenew
- Tests: 8 audience cases using real SignManagement/SignAgent tokens, a
  9-case middleware token x route matrix, fail-closed and metric guards

Deploying invalidates tokens minted before restart (old aud); access
tokens live 15 minutes, so impact is one forced re-login for users
active in that window. Refresh cookies are unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 29, 2026 19:51
@EM-SubhamDas
EM-SubhamDas removed the request for review from Copilot July 29, 2026 19:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(security): JWT audience claim set but never validated — M2M tokens indistinguishable from user tokens

1 participant