mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-08-19 19:36:24 +00:00
feat: make oauth access tokens RFC 9068 compliant
This commit is contained in:
+1
-1
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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"]))
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user