Skip to content

fix(auth): GET /saml/providers/{idp}/users always returns 401 - blanket /saml/ public prefix starves it of JWT context #650

Description

@ericfitz

Summary

GET /saml/providers/{idp}/users is unreachable. Every request returns
401 {"error":"unauthorized","error_description":"Authentication required"}, including requests
carrying a valid bearer token that works on every other authenticated endpoint.

Found while triaging CATS run 20260730T220551Z: 69 of 69 requests to this path returned 401,
across HappyPath, ExtraHeaders, CheckSecurityHeaders, RandomResources,
AcceptLanguageHeaders and NewFields. There were no 200s and no 403s — the endpoint has never
worked.

Reproduction

$ TOK=<valid bearer token for charlie>
$ curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $TOK" http://rp2:30080/me
200
$ curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $TOK" http://rp2:30080/threat_models
200
$ curl -s -H "Authorization: Bearer $TOK" http://rp2:30080/saml/providers/tmi/users
{"error":"unauthorized","error_description":"Authentication required"}

Root cause

cmd/server/main.go listed "/saml/" in publicPathPrefixes, marking every /saml/* path
as not requiring authentication. PublicPathsMiddleware therefore set isPublicPath=true, the
JWT middleware skipped token validation, and userEmail / userID were never placed in the
request context.

ListSAMLUsers (api/saml_user_handlers.go:19) opens with GetAuthenticatedUser(c), which reads
exactly those context keys and returns 401 when they are absent. The handler is documented as
requiring auth because it exposes user emails, names and login times:

// SECURITY FIX: Validate authenticated user (prevents unauthorized access to user data)
// This endpoint exposes user emails, names, and login times - authentication is required
user, err := GetAuthenticatedUser(c)

So the endpoint failed closed — this is an availability defect, not a data exposure. The
handler's own check is what prevented the blanket public prefix from becoming a real hole.

Contract disagreement

The OpenAPI spec is unambiguous that only one /saml/* operation requires auth, and the
middleware contradicted it:

operation x-public-endpoint security
POST /saml/acs true []
GET /saml/slo true []
POST /saml/slo true []
GET /saml/providers true []
GET /saml/{provider}/login true []
GET /saml/{provider}/metadata true []
GET /saml/providers/{idp}/users absent [{"bearerAuth":[]}]

Fix

Replace the blanket /saml/ prefix with the exact public paths (/saml/acs, /saml/slo,
/saml/providers) plus an isPublicSAMLPath helper for the two provider-scoped routes
(/saml/{provider}/login, /saml/{provider}/metadata), which have a variable segment and so
cannot be expressed as a prefix. Matching exactly three segments means any future /saml/ route
stays private until it is added deliberately, instead of silently inheriting public status.

Notes

The latent fragility is the more important half: with the blanket prefix, any authenticated
endpoint added under /saml/ would silently lose its JWT context. Whether it then fails closed
(401, like this one) or leaks depends entirely on whether the individual handler happens to
re-check auth.

Fixing this also removes 19 true positives from the CATS baseline, which had been misattributed
to "SAML not configured in dev".

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions