Skip to content

feat(oidc)!: phase 1 spec conformance fixes (with /userinfo breaking change)#591

Merged
lakhansamani merged 11 commits intomainfrom
feat/oidc-phase1-conformance
Apr 7, 2026
Merged

feat(oidc)!: phase 1 spec conformance fixes (with /userinfo breaking change)#591
lakhansamani merged 11 commits intomainfrom
feat/oidc-phase1-conformance

Conversation

@lakhansamani
Copy link
Copy Markdown
Contributor

@lakhansamani lakhansamani commented Apr 7, 2026

Summary

OIDC Phase 1 spec conformance — fixes driven by an audit against the OpenID Connect Handbook and the underlying OIDC Core 1.0 / OAuth 2.0 RFCs.

⚠️ Contains one BREAKING CHANGE to /userinfo. See below.

Each task has been individually spec-reviewed + code-quality-reviewed, plus a final end-of-phase holistic review (all green, zero critical/important issues).

⚠️ BREAKING CHANGE

/userinfo now strictly filters claims by scope per OIDC Core §5.4. The endpoint returns only sub plus the claims permitted by the standard scope groups (profile, email, phone, address) encoded on the access token.

Previously, /userinfo returned the full user object regardless of scopes. Clients that request only the openid scope but read profile/email claims from /userinfo must now request those scopes explicitly.

Migration for clients:

  • Requesting only openid → receive only sub
  • Requesting openid email → receive sub, email, email_verified
  • Requesting openid profile → receive sub + all profile claim keys (values may be JSON null when the user has no value for that claim — keys are present so callers have a stable schema)
  • Requesting openid profile email → receive both groups

See docs/oauth2-oidc-endpoints.md for the full scope→claim mapping and example responses.

Earlier iterations of this branch introduced an opt-in --oidc-strict-userinfo-scopes flag (default false). That flag was removed in the latest commit: spec correctness wins.

Other Changes

Fixed (non-breaking)

  • ID token at_hash — previously set to the nonce value in the implicit/token branch instead of base64url(sha256(access_token)[:16]). Now correctly computed for all flows per OIDC Core §3.2.2.10. Regression test asserts the exact computed value and that it is not equal to the nonce.
  • ID token nonce echo — now returned in the ID token whenever supplied in the auth request, regardless of flow (OIDC Core §2).
  • Discovery registration_endpoint — removed. Previously pointed at /app (the signup HTML UI); spec-compliant OIDC clients interpret this field as an RFC 7591 dynamic client registration endpoint and would fail. Will return when RFC 7591 is actually implemented.

Added (non-breaking)

  • Discovery grant_types_supported — now includes implicit to honestly reflect that /authorize accepts response_type=token and response_type=id_token.

Test plan

  • TestIDTokenClaimsCompliance — 3 sub-tests covering at_hash correctness, nonce echo, c_hash absence
  • TestUserInfoScopeFiltering — 5 sub-tests: only openid → only sub; openid+email; openid+profile; openid+profile+email; unknown custom scope
  • TestOpenIDDiscoveryCompliance — new sub-tests for grant_types_supported_includes_implicit and registration_endpoint_absent
  • Existing TestUserInfoEndpointCompliance still green (error paths unchanged)
  • Full make test-sqlite green

Out of scope / follow-ups

  • Phase 2 (pending separate PR): id_token_hint+state polish on /logout, prompt/max_age/login_hint on /authorize, auth_time/acr/amr ID token claims
  • Phase 3 (pending separate PR): Token Introspection (RFC 7662), hybrid response_types, back-channel logout, minimal JWKS multi-key support
  • Phase 4 (2027 roadmap): RFC 7591 dynamic client registration, RFC 9101 JAR / Request Object, OIDC Session Management iframe, front-channel logout, automated JWKS key rotation

- at_hash was being set to the nonce value in the implicit/token branch
  instead of cfg.AtHash (the correct sha256 left-half of the access token).
- nonce was never echoed in the auth_code branch even when present in the
  auth request, violating OIDC Core §2.
- Replace the if/else with three independent claim assignments driven by
  cfg.AtHash, cfg.CodeHash, and cfg.Nonce, matching OIDC Core §3.1.3.6 /
  §3.2.2.10. c_hash branch is reserved for Phase 3 hybrid flows.
- Add TestIDTokenClaimsCompliance covering all three properties.
- Expose TokenProvider on the integration test setup so tests can
  exercise CreateAuthToken / ParseJWTToken directly.
The previous comment encoded the misconception that drove the at_hash bug
(treating nonce as flow-dependent and mutually exclusive with at_hash).
Replace with a one-line pointer to the in-function rules block.
Backward-compatible bool flag (default false) that will be wired to the
/userinfo handler in the next commit to enable OIDC Core §5.4 scope-based
claim filtering. Default false preserves existing lenient behavior so the
upgrade does not break clients that request only the openid scope but
consume profile/email claims.
…ency

Codebase consistently uses 'UserInfo' (camel case, two words) elsewhere
(AppleUserInfo, processGoogleUserInfo, etc). Align the new field to that
convention before Task 3 references it. Also tighten the --help string and
drop the non-ASCII section sign for terminal portability.
Implements OIDC Core §5.4 scope-based claim filtering on the /userinfo
endpoint. Wired to OIDCStrictUserInfoScopes from the previous commit:

- false (default): existing lenient behavior preserved — full user object
  returned regardless of scopes. sub is always present.
- true: only sub plus the standard claim groups for the scopes encoded in
  the access token (profile, email, phone, address) are returned. Keys in
  a granted scope group are always emitted (with JSON null when the user
  has no value) so callers see a stable schema.

Adds extractScopesFromAccessToken (handles string and array forms of the
scope claim) and filterUserInfoByScopes. New integration tests cover the
lenient default and five strict-mode scope combinations.
…ranch

Code review feedback on Task 3:

- Convert userInfoProfileClaims / Email / Phone / Address from
  map[string]struct{} to []string. The only operation is iteration; the
  map-set shape advertised a membership test that was never used and
  added reader cognitive load. Slices are also iteration-safe against
  future accidental delete/assign mutations of a shared package var.
- Remove the dead 'case []string:' branch in extractScopesFromAccessToken.
  golang-jwt v4 MapClaims always decodes JSON arrays as []interface{};
  no path in this codebase ever lands a []string in claims['scope'].
- Tighten doc comments on both the claim-group vars (MUST NOT be
  mutated) and extractScopesFromAccessToken (cite RFC 6749 §3.3 and
  clarify which form produces which Go type).
- Reference OIDC Core §5.3.2 explicitly in the null-emission comment.
…tion_endpoint

- grant_types_supported now includes 'implicit'. /authorize already
  accepts response_type=token and response_type=id_token, so the prior
  list (authorization_code, refresh_token) was misleading.
- registration_endpoint was pointing at /app (the signup UI) which is
  not an RFC 7591 dynamic client registration endpoint. Spec-compliant
  OIDC clients would fail trying to use it. Removed until RFC 7591 is
  actually implemented (tracked in Phase 4 roadmap).
Code review feedback on Task 4:

- Add require.Equal(200) + require.NoError(json.Unmarshal) to both new
  TestOpenIDDiscoveryCompliance sub-tests. The registration_endpoint_absent
  test in particular had a latent false-positive mode: a non-JSON response
  would decode to an empty map and trivially pass the key-absence check.
  Guarding the status and unmarshal error closes that gap.
- Add an inline comment in openid_config.go pointing at the invariant
  between response_types_supported and grant_types_supported (implicit
  grant ↔ response_type=token/id_token).
CHANGELOG entries for the 5 Phase 1 items (at_hash fix, nonce echo,
registration_endpoint removal, implicit grant advertised, new
--oidc-strict-userinfo-scopes flag).

docs/oauth2-oidc-endpoints.md gains a subsection explaining the new
strict userinfo scope filtering mode, including the scope → claim
mapping table and the null-for-missing-key schema stability note.
…n flag

BREAKING CHANGE: /userinfo now always filters returned claims by the
scope set encoded in the access token, per OIDC Core §5.4. There is no
longer a lenient mode or opt-in flag.

Previously this branch introduced a --oidc-strict-userinfo-scopes flag
(default false) to preserve backward compatibility for clients that
requested only 'openid' but read profile/email claims from /userinfo.
That flag is now removed: spec correctness wins. Clients must request
the scopes they actually consume.

Impact:
- Clients requesting 'openid' only now receive only 'sub' from /userinfo.
- Clients requesting 'openid email' receive sub + email + email_verified.
- Clients requesting 'openid profile' receive sub + all profile claim
  keys (values may be JSON null when the user has no value for a claim;
  keys are present so response schema is stable).
- 'phone' and 'address' scope groups are defined but the underlying
  User schema currently has no address claim, so address is effectively
  a no-op until the schema gains an address field.

Changes:
- Remove Config.OIDCStrictUserInfoScopes field.
- Remove --oidc-strict-userinfo-scopes CLI flag binding.
- Remove the conditional branch in UserInfoHandler — scope filtering
  is now the only code path.
- Collapse TestUserInfoScopeFilteringLenient + TestUserInfoScopeFilteringStrict
  into a single TestUserInfoScopeFiltering with the 5 strict sub-tests.
- Update CHANGELOG to mark this as a BREAKING change in the Changed
  section, removing the prior Added entry for the flag.
- Update docs/oauth2-oidc-endpoints.md /userinfo section: remove the
  opt-in flag discussion, document the scope→claim mapping as the
  default unconditional behavior, add three example responses
  (openid, openid email, openid profile email).
@lakhansamani lakhansamani changed the title feat(oidc): phase 1 spec conformance fixes feat(oidc)!: phase 1 spec conformance fixes (with /userinfo breaking change) Apr 7, 2026
@lakhansamani lakhansamani merged commit 57fbd54 into main Apr 7, 2026
@lakhansamani lakhansamani deleted the feat/oidc-phase1-conformance branch April 7, 2026 11:06
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.

1 participant