feat(auth): AuthenticatedUser accepts OIDC at+jwt alongside legacy HS256 (BUNYIP-55) #69

Merged
YousifShkara merged 1 commit from feat/bunyip-55-authenticated-user-accept-atjwt into main 2026-06-06 02:48:47 +02:00
Owner

Until now, bunyip's AuthenticatedUser / AdminUser / MemberUser / OptionalUser extractors only validated legacy HS256 access tokens via JwtService::verify_access_token. The OIDC EdDSA at+jwt access tokens that bunyip-oidc's /oauth2/token endpoint mints itself were rejected by the same extractors with a 401, meaning external relying parties (and bunyip's own SPAs that authenticate through OIDC) could not call first-party bunyip endpoints with the token bunyip issued them. This blew up concretely on mokosh-clients' calendar page, where every page load logged a 401 on GET /v1/auth/memberships.

The extractor now peeks at the JWT header's typ claim and routes accordingly: typ == "at+jwt" goes through an AtJwtVerifier trait object pulled from app data, anything else continues through the legacy HS256 verifier. Both paths produce the same AccessTokenClaims shape downstream handlers consume, so AdminUser and MemberUser predicates work identically on either token shape. The HS256 latency path is unchanged when the token is HS256-shaped (no extra crypto, no DB lookup).

The trait AtJwtVerifier is defined in bunyip-domain (the leaf crate) so the dependency direction stays bunyip-api -> bunyip-oidc -> bunyip-domain. bunyip-oidc::OidcProvider implements it: verify EdDSA signature + typ + iss + exp against its own JWKS, then resolve sub to a User row, then map to the legacy AccessTokenClaims shape via a new AccessTokenClaims::from_atjwt_and_user constructor. When OIDC is disabled (no OIDC_ISSUER env), bunyip-api/main.rs registers a DisabledAtJwtVerifier stub that always rejects, so an at+jwt presented to a non-OIDC bunyip deployment fails with Unauthorized (identical to the pre-BUNYIP-55 behaviour) while HS256 tokens still work.

Refactored verify_at_jwt_get_sub out of bunyip-oidc/src/handlers/oidc.rs into a pub method OidcProvider::verify_at_jwt_claims returning the full AtClaims; the userinfo handler now calls that method, and the new AtJwtVerifier impl reuses it. Added validate_aud = false (RFC 9068 does not require an aud check for inbound bunyip-API calls; per-handler authorization stays the handler's job).

Tests in crates/bunyip-domain/src/middleware/auth.rs:

  • token_is_atjwt_recognises_rfc9068_typ / _rejects_legacy_jwt_typ / _rejects_malformed_token: routing predicate pinned.
  • verify_either_routes_atjwt_to_verifier_when_present: at+jwt hits the AtJwtVerifier exactly once.
  • verify_either_rejects_atjwt_when_no_verifier_registered: at+jwt with no verifier returns Unauthorized (the no-OIDC fallback case).
  • verify_either_routes_legacy_jwt_to_jwt_service: non-at+jwt routes to JwtService; missing service surfaces the pre-BUNYIP-55 internal-error message.
  • disabled_atjwt_verifier_always_rejects: stub behaviour pinned.
  • access_token_claims_from_atjwt_and_user_maps_fields: User -> AccessTokenClaims mapping pinned (including the locked_price_id -> price_id rename and trial_ends_at timestamp coercion).

Once this lands, the mokosh-clients use_memberships_loader synthesis workaround (introduced in mokosh-apps PR fix/logout-profile-memberships-noise) can be reverted to a real network call against GET /v1/auth/memberships.

Notes:

  • cargo test --workspace shows one pre-existing failure (config::tests::test_config_defaults) that reproduces on main independently of this change. Filed/tracked separately.
Until now, bunyip's `AuthenticatedUser` / `AdminUser` / `MemberUser` / `OptionalUser` extractors only validated legacy HS256 access tokens via `JwtService::verify_access_token`. The OIDC EdDSA at+jwt access tokens that `bunyip-oidc`'s `/oauth2/token` endpoint mints itself were rejected by the same extractors with a 401, meaning external relying parties (and bunyip's own SPAs that authenticate through OIDC) could not call first-party bunyip endpoints with the token bunyip issued them. This blew up concretely on mokosh-clients' calendar page, where every page load logged a 401 on `GET /v1/auth/memberships`. The extractor now peeks at the JWT header's `typ` claim and routes accordingly: `typ == "at+jwt"` goes through an `AtJwtVerifier` trait object pulled from app data, anything else continues through the legacy HS256 verifier. Both paths produce the same `AccessTokenClaims` shape downstream handlers consume, so `AdminUser` and `MemberUser` predicates work identically on either token shape. The HS256 latency path is unchanged when the token is HS256-shaped (no extra crypto, no DB lookup). The trait `AtJwtVerifier` is defined in `bunyip-domain` (the leaf crate) so the dependency direction stays `bunyip-api -> bunyip-oidc -> bunyip-domain`. `bunyip-oidc::OidcProvider` implements it: verify EdDSA signature + typ + iss + exp against its own JWKS, then resolve `sub` to a `User` row, then map to the legacy `AccessTokenClaims` shape via a new `AccessTokenClaims::from_atjwt_and_user` constructor. When OIDC is disabled (no `OIDC_ISSUER` env), `bunyip-api/main.rs` registers a `DisabledAtJwtVerifier` stub that always rejects, so an at+jwt presented to a non-OIDC bunyip deployment fails with `Unauthorized` (identical to the pre-BUNYIP-55 behaviour) while HS256 tokens still work. Refactored `verify_at_jwt_get_sub` out of `bunyip-oidc/src/handlers/oidc.rs` into a pub method `OidcProvider::verify_at_jwt_claims` returning the full `AtClaims`; the userinfo handler now calls that method, and the new `AtJwtVerifier` impl reuses it. Added `validate_aud = false` (RFC 9068 does not require an `aud` check for inbound bunyip-API calls; per-handler authorization stays the handler's job). Tests in `crates/bunyip-domain/src/middleware/auth.rs`: - `token_is_atjwt_recognises_rfc9068_typ` / `_rejects_legacy_jwt_typ` / `_rejects_malformed_token`: routing predicate pinned. - `verify_either_routes_atjwt_to_verifier_when_present`: at+jwt hits the AtJwtVerifier exactly once. - `verify_either_rejects_atjwt_when_no_verifier_registered`: at+jwt with no verifier returns Unauthorized (the no-OIDC fallback case). - `verify_either_routes_legacy_jwt_to_jwt_service`: non-at+jwt routes to JwtService; missing service surfaces the pre-BUNYIP-55 internal-error message. - `disabled_atjwt_verifier_always_rejects`: stub behaviour pinned. - `access_token_claims_from_atjwt_and_user_maps_fields`: User -> AccessTokenClaims mapping pinned (including the `locked_price_id -> price_id` rename and trial_ends_at timestamp coercion). Once this lands, the mokosh-clients `use_memberships_loader` synthesis workaround (introduced in mokosh-apps PR `fix/logout-profile-memberships-noise`) can be reverted to a real network call against `GET /v1/auth/memberships`. Notes: - `cargo test --workspace` shows one pre-existing failure (`config::tests::test_config_defaults`) that reproduces on `main` independently of this change. Filed/tracked separately.
feat(auth): AuthenticatedUser accepts OIDC at+jwt alongside legacy HS256 (BUNYIP-55)
All checks were successful
Create release / Create release from merged PR (pull_request) Has been skipped
Check / fmt / clippy / build / test (pull_request) Successful in 1m9s
23c9abce03
Until now, bunyip's `AuthenticatedUser` / `AdminUser` / `MemberUser` / `OptionalUser` extractors only validated legacy HS256 access tokens via `JwtService::verify_access_token`. The OIDC EdDSA at+jwt access tokens that `bunyip-oidc`'s `/oauth2/token` endpoint mints itself were rejected by the same extractors with a 401, meaning external relying parties (and bunyip's own SPAs that authenticate through OIDC) could not call first-party bunyip endpoints with the token bunyip issued them. This blew up concretely on mokosh-clients' calendar page, where every page load logged a 401 on `GET /v1/auth/memberships`.

The extractor now peeks at the JWT header's `typ` claim and routes accordingly: `typ == "at+jwt"` goes through an `AtJwtVerifier` trait object pulled from app data, anything else continues through the legacy HS256 verifier. Both paths produce the same `AccessTokenClaims` shape downstream handlers consume, so `AdminUser` and `MemberUser` predicates work identically on either token shape. The HS256 latency path is unchanged when the token is HS256-shaped (no extra crypto, no DB lookup).

The trait `AtJwtVerifier` is defined in `bunyip-domain` (the leaf crate) so the dependency direction stays `bunyip-api -> bunyip-oidc -> bunyip-domain`. `bunyip-oidc::OidcProvider` implements it: verify EdDSA signature + typ + iss + exp against its own JWKS, then resolve `sub` to a `User` row, then map to the legacy `AccessTokenClaims` shape via a new `AccessTokenClaims::from_atjwt_and_user` constructor. When OIDC is disabled (no `OIDC_ISSUER` env), `bunyip-api/main.rs` registers a `DisabledAtJwtVerifier` stub that always rejects, so an at+jwt presented to a non-OIDC bunyip deployment fails with `Unauthorized` (identical to the pre-BUNYIP-55 behaviour) while HS256 tokens still work.

Refactored `verify_at_jwt_get_sub` out of `bunyip-oidc/src/handlers/oidc.rs` into a pub method `OidcProvider::verify_at_jwt_claims` returning the full `AtClaims`; the userinfo handler now calls that method, and the new `AtJwtVerifier` impl reuses it. Added `validate_aud = false` (RFC 9068 does not require an `aud` check for inbound bunyip-API calls; per-handler authorization stays the handler's job).

Tests in `crates/bunyip-domain/src/middleware/auth.rs`:
- `token_is_atjwt_recognises_rfc9068_typ` / `_rejects_legacy_jwt_typ` / `_rejects_malformed_token`: routing predicate pinned.
- `verify_either_routes_atjwt_to_verifier_when_present`: at+jwt hits the AtJwtVerifier exactly once.
- `verify_either_rejects_atjwt_when_no_verifier_registered`: at+jwt with no verifier returns Unauthorized (the no-OIDC fallback case).
- `verify_either_routes_legacy_jwt_to_jwt_service`: non-at+jwt routes to JwtService; missing service surfaces the pre-BUNYIP-55 internal-error message.
- `disabled_atjwt_verifier_always_rejects`: stub behaviour pinned.
- `access_token_claims_from_atjwt_and_user_maps_fields`: User -> AccessTokenClaims mapping pinned (including the `locked_price_id -> price_id` rename and trial_ends_at timestamp coercion).

Once this lands, the mokosh-clients `use_memberships_loader` synthesis workaround (introduced in mokosh-apps PR `fix/logout-profile-memberships-noise`) can be reverted to a real network call against `GET /v1/auth/memberships`.

Notes:
- `cargo test --workspace` shows one pre-existing failure (`config::tests::test_config_defaults`) that reproduces on `main` independently of this change. Filed/tracked separately.
YousifShkara deleted branch feat/bunyip-55-authenticated-user-accept-atjwt 2026-06-06 02:48:47 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
psa-systems/bunyip!69
No description provided.