feat: make oauth access tokens RFC 9068 compliant

This commit is contained in:
Elias Schneider
2026-08-02 15:59:21 +02:00
parent b08ac7bf7d
commit 7c55bdf115
10 changed files with 70 additions and 24 deletions
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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=
@@ -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 {
+1
View File
@@ -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"]))
+8 -6
View File
@@ -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,
},
+24 -6
View File
@@ -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)
+8 -1
View File
@@ -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))
})
+16 -7
View File
@@ -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")
})
}
@@ -39,6 +39,7 @@ func isReservedClaim(key string) bool {
"acr",
"amr",
"azp",
"client_id",
"nbf",
"jti":
return true
+1 -1
View File
@@ -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 {