From 7c55bdf115fc5b7fea89ef5cd3c79dc926ddf5ac Mon Sep 17 00:00:00 2001 From: Elias Schneider Date: Sun, 2 Aug 2026 15:59:21 +0200 Subject: [PATCH] feat: make oauth access tokens RFC 9068 compliant --- backend/go.mod | 2 +- backend/go.sum | 4 +-- backend/internal/oidc/access_token_scope.go | 8 +++++ backend/internal/oidc/preview_test.go | 1 + backend/internal/oidc/provider.go | 14 +++++---- backend/internal/oidc/provider_test.go | 30 +++++++++++++++---- backend/internal/oidc/token_handler_test.go | 9 +++++- .../internal/oidc/userinfo_handler_test.go | 23 +++++++++----- .../internal/service/custom_claim_service.go | 1 + backend/internal/service/e2etest_service.go | 2 +- 10 files changed, 70 insertions(+), 24 deletions(-) diff --git a/backend/go.mod b/backend/go.mod index d4dc1292..353160fb 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -248,4 +248,4 @@ require ( modernc.org/memory v1.11.0 // indirect ) -replace github.com/ory/fosite => github.com/pocket-id/fosite v1.0.0 +replace github.com/ory/fosite => github.com/pocket-id/fosite v1.2.0 diff --git a/backend/go.sum b/backend/go.sum index 97490457..96b2aec9 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -428,8 +428,8 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/pocket-id/fosite v1.0.0 h1:e0NwOBTm/PaPCI7qDocHL3yte1p5t+EZySxDaM5H/mk= -github.com/pocket-id/fosite v1.0.0/go.mod h1:KeQ7tTIBm3DyeBnKcKLnPbSdrd6ttM6w3TD3yy9x8rM= +github.com/pocket-id/fosite v1.2.0 h1:Tk0ZIIChuyD8yprlYr95FMZsd0yq64FHj47sxkM2cQI= +github.com/pocket-id/fosite v1.2.0/go.mod h1:v0FwUcx6Xd7xu/V6hLbIqtFfqRSWcJRJl1f8t0cnIuU= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= diff --git a/backend/internal/oidc/access_token_scope.go b/backend/internal/oidc/access_token_scope.go index 54212624..e9260024 100644 --- a/backend/internal/oidc/access_token_scope.go +++ b/backend/internal/oidc/access_token_scope.go @@ -8,6 +8,14 @@ import ( fositeoauth2 "github.com/ory/fosite/handler/oauth2" ) +// NewAccessTokenStrategy applies Pocket ID's identity-audience policy to the provided access token strategy +func NewAccessTokenStrategy(coreStrategy fositeoauth2.CoreStrategy, issuer string) fositeoauth2.CoreStrategy { + return identityAudienceAccessTokenStrategy{ + CoreStrategy: coreStrategy, + issuer: issuer, + } +} + // isIdentityScope reports whether the scope is an OIDC identity scope whose presence lets a token be presented to Pocket ID's own identity endpoints such as /userinfo // offline_access is deliberately excluded: it only requests a refresh token and is not tied to any resource server func isIdentityScope(scope string) bool { diff --git a/backend/internal/oidc/preview_test.go b/backend/internal/oidc/preview_test.go index 44b4ba62..95059346 100644 --- a/backend/internal/oidc/preview_test.go +++ b/backend/internal/oidc/preview_test.go @@ -45,6 +45,7 @@ func TestClientPreviewBuilderUsesFositeTokenStrategies(t *testing.T) { require.NoError(t, err) require.Equal(t, "https://issuer.example.com", preview.AccessToken["iss"]) + require.Equal(t, clientID, preview.AccessToken["client_id"]) require.ElementsMatch(t, []string{"openid", "email"}, stringSliceClaim(t, preview.AccessToken["scp"])) // The identity scopes add the issuer to the audience so the previewed token would also work at /userinfo require.ElementsMatch(t, []string{clientID, "https://issuer.example.com"}, stringSliceClaim(t, preview.AccessToken["aud"])) diff --git a/backend/internal/oidc/provider.go b/backend/internal/oidc/provider.go index 671c1917..6b0ce792 100644 --- a/backend/internal/oidc/provider.go +++ b/backend/internal/oidc/provider.go @@ -64,15 +64,17 @@ func newProvider(store *Store, authenticator *federatedClientAuthenticator, sign sig := newJWTSigner(keyGetter) coreStrategy := compose.NewOAuth2HMACStrategy(fositeConfig) deviceStrategy := &deviceStrategy{DefaultDeviceStrategy: compose.NewDeviceStrategy(fositeConfig)} - accessTokenStrategy := &fositeoauth2.DefaultJWTStrategy{ + defaultAccessTokenStrategy := &fositeoauth2.DefaultJWTStrategy{ Signer: sig, HMACSHAStrategy: coreStrategy, Config: fositeConfig, } + rfc9068AccessTokenStrategy := &fositeoauth2.RFC9068JWTStrategy{ + DefaultJWTStrategy: defaultAccessTokenStrategy, + } - // Wrap the access token strategy so an access token granted an identity scope also lists the issuer in its audience - // This lets it be presented to Pocket ID's own identity endpoints such as /userinfo, while a token audienced only to a custom API is not accepted there - apiAccessTokenStrategy := identityAudienceAccessTokenStrategy{CoreStrategy: accessTokenStrategy, issuer: config.BaseURL} + // Apply Pocket ID's identity-audience policy outside Fosite's reusable RFC 9068 token profile + accessTokenStrategy := NewAccessTokenStrategy(rfc9068AccessTokenStrategy, config.BaseURL) idTokenStrategy := &openid.DefaultStrategy{ Signer: sig, Config: fositeConfig, @@ -81,7 +83,7 @@ func newProvider(store *Store, authenticator *federatedClientAuthenticator, sign fositeConfig, store, &compose.CommonStrategy{ - CoreStrategy: apiAccessTokenStrategy, + CoreStrategy: accessTokenStrategy, RFC8628CodeStrategy: deviceStrategy, OpenIDConnectTokenStrategy: idTokenStrategy, Signer: sig, @@ -104,7 +106,7 @@ func newProvider(store *Store, authenticator *federatedClientAuthenticator, sign OAuth2Provider: provider, deviceStrategy: deviceStrategy, tokenStrategies: tokenStrategies{ - accessToken: apiAccessTokenStrategy, + accessToken: accessTokenStrategy, idToken: idTokenStrategy, config: fositeConfig, }, diff --git a/backend/internal/oidc/provider_test.go b/backend/internal/oidc/provider_test.go index e60c6279..261468d1 100644 --- a/backend/internal/oidc/provider_test.go +++ b/backend/internal/oidc/provider_test.go @@ -18,6 +18,7 @@ import ( "github.com/lestrrat-go/jwx/v3/jwa" "github.com/ory/fosite" + fositeoauth2 "github.com/ory/fosite/handler/oauth2" "github.com/pocket-id/pocket-id/backend/internal/model" testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing" "github.com/stretchr/testify/require" @@ -71,19 +72,33 @@ func TestProviderIssuesJWTAccessTokens(t *testing.T) { request.ID = "test-request" request.Client = Client{OidcClient: model.OidcClient{Base: model.Base{ID: "test-client"}}} request.GrantTypes = fosite.Arguments{string(fosite.GrantTypeClientCredentials)} - request.RequestedScope = fosite.Arguments{"openid"} - request.GrantedScope = fosite.Arguments{"openid"} - request.RequestedAudience = fosite.Arguments{"test-client"} - request.GrantedAudience = fosite.Arguments{"test-client"} + request.RequestedScope = fosite.Arguments{"read:orders"} + request.GrantedScope = fosite.Arguments{"read:orders"} + request.RequestedAudience = fosite.Arguments{"https://api.orders.example.com"} + request.GrantedAudience = fosite.Arguments{"https://api.orders.example.com"} response, err := provider.NewAccessResponse(t.Context(), request) require.NoError(t, err) require.Len(t, strings.Split(response.GetAccessToken(), "."), 3) - // The issued JWT must carry a `kid` header matching the signing key so RPs can - // select the verification key from the published JWKS (esp. after key rotation). header := decodeJWTPart(t, response.GetAccessToken(), 0) + claims := decodeJWTPart(t, response.GetAccessToken(), 1) + + // The access token header explicitly distinguishes RFC 9068 access tokens from other JWT types + require.Equal(t, fositeoauth2.RFC9068JWTType, header["typ"]) require.Equal(t, "test-key-id", header["kid"]) + require.Equal(t, "ES256", header["alg"]) + + // The payload carries every claim required by RFC 9068 section 2.2 + require.Equal(t, "https://issuer.example.com", claims["iss"]) + require.Equal(t, "test-user", claims["sub"]) + require.Equal(t, "test-client", claims["client_id"]) + require.Equal(t, []string{"https://api.orders.example.com"}, jwtAudience(claims)) + require.Equal(t, "read:orders", claims["scope"]) + require.NotEmpty(t, claims["jti"]) + require.NotZero(t, claims["iat"]) + require.NotZero(t, claims["exp"]) + require.NotContains(t, claims, "azp") } func TestRedirectSecureChecker(t *testing.T) { @@ -409,6 +424,9 @@ func TestProviderIssuesAndValidatesTokensForSupportedAlgorithms(t *testing.T) { require.Len(t, strings.Split(accessToken, "."), 3) header := decodeJWTPart(t, accessToken, 0) require.Equal(t, tc.alg.String(), header["alg"]) + require.Equal(t, fositeoauth2.RFC9068JWTType, header["typ"]) + claims := decodeJWTPart(t, accessToken, 1) + require.Equal(t, "test-client", claims["client_id"]) tokenUse, introspected, err := provider.IntrospectToken(t.Context(), accessToken, fosite.AccessToken, NewEmptySession()) require.NoError(t, err) diff --git a/backend/internal/oidc/token_handler_test.go b/backend/internal/oidc/token_handler_test.go index ed5f2391..abaacd33 100644 --- a/backend/internal/oidc/token_handler_test.go +++ b/backend/internal/oidc/token_handler_test.go @@ -17,6 +17,7 @@ import ( "github.com/gin-gonic/gin" "github.com/ory/fosite" "github.com/ory/fosite/compose" + fositeoauth2 "github.com/ory/fosite/handler/oauth2" fositejwt "github.com/ory/fosite/token/jwt" "github.com/stretchr/testify/require" "golang.org/x/crypto/bcrypt" @@ -79,9 +80,14 @@ func TestTokenHandlerClientCredentialsGrant(t *testing.T) { require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) require.NotEmpty(t, body["access_token"], "client_credentials must issue a token, got error: %v", body["error"]) - claims := decodeJWTPart(t, body["access_token"].(string), 1) + accessToken := body["access_token"].(string) + header := decodeJWTPart(t, accessToken, 0) + claims := decodeJWTPart(t, accessToken, 1) // With no resource requested, the client_credentials token is a plain token bound to the requesting client + require.Equal(t, fositeoauth2.RFC9068JWTType, header["typ"]) require.Contains(t, jwtAudience(claims), clientID, "access token must be audience-bound to the client") + require.Equal(t, "client-"+clientID, claims["sub"]) + require.Equal(t, clientID, claims["client_id"]) } // TestTokenHandlerClientCredentialsDropsIdentityScopes guards that a machine token never @@ -599,6 +605,7 @@ func TestTokenHandlerRefreshGrantPreservesAudienceAndScope(t *testing.T) { claims := decodeJWTPart(t, body["access_token"].(string), 1) // The refreshed access token stays bound to the original API audience and re-adds the issuer so it keeps working at userinfo, never widening to any other API require.ElementsMatch(t, []string{apiResource, baseURL}, jwtAudience(claims)) + require.Equal(t, clientID, claims["client_id"]) // A token that requested openid alongside the API keeps the identity scope on the access token, matching what it was granted require.Equal(t, []string{"openid", "read:orders"}, jwtScopes(claims)) }) diff --git a/backend/internal/oidc/userinfo_handler_test.go b/backend/internal/oidc/userinfo_handler_test.go index b65701d8..20083252 100644 --- a/backend/internal/oidc/userinfo_handler_test.go +++ b/backend/internal/oidc/userinfo_handler_test.go @@ -204,12 +204,21 @@ func TestUserInfoHandler(t *testing.T) { require.Contains(t, rec.Header().Get("WWW-Authenticate"), `Bearer error=`) }) - t.Run("token without a resource owner is rejected", func(t *testing.T) { - // client_credentials-style token: valid, but no subject -> must not return PII. - token := issueAccessToken(t, "req-no-subject", "", "openid") - rec, c := call(t, token) - require.Empty(t, c.Errors) - require.Equal(t, http.StatusUnauthorized, rec.Code) - require.Contains(t, rec.Header().Get("WWW-Authenticate"), `Bearer error="request_unauthorized"`) + t.Run("access token without a subject is not issued", func(t *testing.T) { + // Rejecting a subjectless token at issuance enforces the RFC 9068 invariant before it can reach UserInfo + session := NewEmptySession() + session.SetExpiresAt(fosite.AccessToken, time.Now().UTC().Add(time.Hour)) + + request := fosite.NewAccessRequest(session) + request.ID = "req-no-subject" + request.Client = Client{OidcClient: model.OidcClient{Base: model.Base{ID: clientID}}} + request.GrantTypes = fosite.Arguments{string(fosite.GrantTypeClientCredentials)} + request.RequestedScope = fosite.Arguments{"openid"} + request.GrantedScope = fosite.Arguments{"openid"} + request.RequestedAudience = fosite.Arguments{clientID} + request.GrantedAudience = fosite.Arguments{clientID} + + _, err := provider.NewAccessResponse(t.Context(), request) + require.ErrorContains(t, err, "without a subject") }) } diff --git a/backend/internal/service/custom_claim_service.go b/backend/internal/service/custom_claim_service.go index 41cf1e6c..aec9bc8b 100644 --- a/backend/internal/service/custom_claim_service.go +++ b/backend/internal/service/custom_claim_service.go @@ -39,6 +39,7 @@ func isReservedClaim(key string) bool { "acr", "amr", "azp", + "client_id", "nbf", "jti": return true diff --git a/backend/internal/service/e2etest_service.go b/backend/internal/service/e2etest_service.go index 55af7c0e..05bc4071 100644 --- a/backend/internal/service/e2etest_service.go +++ b/backend/internal/service/e2etest_service.go @@ -864,7 +864,7 @@ func (s *TestService) SignAccessToken(ctx context.Context, userID, clientID stri keyGetter := func(context.Context) (interface{}, error) { return oidc.SigningKeyFromSigner(s.jwtService) } - strategy := compose.NewOAuth2JWTStrategy(keyGetter, coreStrategy, fositeConfig) + strategy := oidc.NewAccessTokenStrategy(compose.NewOAuth2RFC9068JWTStrategy(keyGetter, coreStrategy, fositeConfig), common.EnvConfig.AppURL) expiresAt := time.Now().UTC().Add(AccessTokenDuration) if expired {