diff --git a/backend/internal/oauth/init.go b/backend/internal/oauth/init.go index f6d5388e7e..93c541efa5 100644 --- a/backend/internal/oauth/init.go +++ b/backend/internal/oauth/init.go @@ -64,21 +64,22 @@ func Initialize( resolver := jwksresolver.Initialize(httpClient) scopeValidator := scope.Initialize() discoveryService := discovery.Initialize(mux, runtimeCrypto, jweService, cfg) + jtiStore := jti.Initialize(runtimeStore) // The revocation services are constructed by the service manager, not here: the session service // needs the same criteria revoker, and it is wired before the OAuth engine. This registers the // RFC 7009 routes against the already-built service. if cfg.OAuth.TokenRevocation.IsEnabled() { - revocation.RegisterRoutes(mux, jwtService, actorProvider, authnProvider, discoveryService, revocationSvc) + revocation.RegisterRoutes(mux, jwtService, actorProvider, authnProvider, discoveryService, + revocationSvc, jtiStore, cfg.JWT.Leeway) } else { enforcementService = nil revocationSvc = nil } - jtiStore := jti.Initialize(runtimeStore) tokenBuilder, tokenValidator := tokenservice.Initialize( cfg, jwtService, jweService, resolver, idpService, enforcementService, jtiStore) parService := par.Initialize(mux, actorProvider, authnProvider, jwtService, discoveryService, - resourceService, dpopVerifier, cfg, runtimeStore) + resourceService, dpopVerifier, cfg, runtimeStore, jtiStore) oauth2AuthzService, err := oauth2authz.Initialize(mux, actorProvider, resourceService, jwtService, flowExecService, parService, revocationSvc, cfg, runtimeStore, transactioner) if err != nil { @@ -89,7 +90,7 @@ func Initialize( if len(cfg.OAuth.AllowedGrantTypes) == 0 || slices.Contains(cfg.OAuth.AllowedGrantTypes, string(providers.GrantTypeCIBA)) { cibaService = ciba.Initialize(mux, jwtService, actorProvider, authnProvider, flowExecService, - discoveryService, resourceService, runtimeStore, cfg) + discoveryService, resourceService, runtimeStore, jtiStore, cfg) } grantHandlerProvider := granthandlers.Initialize( @@ -98,8 +99,9 @@ func Initialize( cibaService, revocationSvc, revocationSvc, cfg) token.Initialize(mux, jwtService, actorProvider, authnProvider, grantHandlerProvider, - scopeValidator, observabilitySvc, discoveryService, dpopVerifier, cfg) - introspect.Initialize(mux, jwtService, actorProvider, authnProvider, discoveryService, tokenValidator) + scopeValidator, observabilitySvc, discoveryService, dpopVerifier, jtiStore, cfg) + introspect.Initialize(mux, jwtService, actorProvider, authnProvider, discoveryService, tokenValidator, + jtiStore, cfg.JWT.Leeway) userinfo.Initialize(mux, jwtService, jweService, resolver, tokenValidator, actorProvider, attributeCacheSvc, discoveryService, dpopVerifier, cfg) diff --git a/backend/internal/oauth/oauth2/ciba/init.go b/backend/internal/oauth/oauth2/ciba/init.go index 729735012d..983ccb1b66 100644 --- a/backend/internal/oauth/oauth2/ciba/init.go +++ b/backend/internal/oauth/oauth2/ciba/init.go @@ -12,6 +12,7 @@ import ( "github.com/thunder-id/thunderid/internal/oauth/oauth2/clientauth" "github.com/thunder-id/thunderid/internal/oauth/oauth2/constants" "github.com/thunder-id/thunderid/internal/oauth/oauth2/discovery" + "github.com/thunder-id/thunderid/internal/oauth/oauth2/jti" "github.com/thunder-id/thunderid/internal/system/jose/jwt" "github.com/thunder-id/thunderid/internal/system/middleware" "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" @@ -29,12 +30,14 @@ func Initialize( discoveryService discovery.DiscoveryServiceInterface, resourceService providers.ResourceServerProvider, runtimeStore providers.RuntimeStoreProvider, + jtiStore jti.JTIStoreInterface, cfg oauthconfig.Config, ) CIBAServiceInterface { store := newCIBAStore(runtimeStore) cibaSvc := newCIBAService(store, flowExecService, jwtService, actorProvider, resourceService, cfg) cibaHandler := newCIBAHandler(cibaSvc) - registerRoutes(mux, cibaHandler, actorProvider, authnProvider, jwtService, discoveryService) + registerRoutes(mux, cibaHandler, actorProvider, authnProvider, jwtService, discoveryService, + jtiStore, cfg.JWT.Leeway) return cibaSvc } @@ -47,6 +50,8 @@ func registerRoutes( authnProvider providers.AuthnProviderManager, jwtService jwt.JWTServiceInterface, discoveryService discovery.DiscoveryServiceInterface, + jtiStore jti.JTIStoreInterface, + leeway int64, ) { corsOpts := middleware.CORSOptions{ AllowedMethods: []string{"POST"}, @@ -56,7 +61,8 @@ func registerRoutes( } issuer := discoveryService.GetOAuth2AuthorizationServerMetadata(context.Background()).Issuer - clientAuthMiddleware := clientauth.ClientAuthMiddleware(actorProvider, authnProvider, jwtService, issuer) + clientAuthMiddleware := clientauth.ClientAuthMiddleware(actorProvider, authnProvider, jwtService, + jtiStore, issuer, leeway) authHandler := clientAuthMiddleware(http.HandlerFunc(cibaHandler.HandleBackchannelAuthRequest)) authPattern, wrappedAuthHandler := middleware.WithCORS( diff --git a/backend/internal/oauth/oauth2/clientauth/clientauth.go b/backend/internal/oauth/oauth2/clientauth/clientauth.go index f83ab07e86..5b0199ac57 100644 --- a/backend/internal/oauth/oauth2/clientauth/clientauth.go +++ b/backend/internal/oauth/oauth2/clientauth/clientauth.go @@ -12,9 +12,11 @@ import ( "net/http" "net/url" "strings" + "time" "github.com/thunder-id/thunderid/internal/cert" "github.com/thunder-id/thunderid/internal/oauth/oauth2/constants" + "github.com/thunder-id/thunderid/internal/oauth/oauth2/jti" serverconst "github.com/thunder-id/thunderid/internal/system/constants" "github.com/thunder-id/thunderid/internal/system/jose/jwt" "github.com/thunder-id/thunderid/internal/system/log" @@ -22,6 +24,9 @@ import ( "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" ) +// jtiNamespace identifies private_key_jwt client assertions in the shared JTI replay store. +const jtiNamespace = "client_assertion" + // authenticate authenticates the OAuth2 client from the request. // It extracts credentials, validates them, and returns OAuthClientInfo on success. // The issuer is the audience value accepted when validating client assertion JWTs. @@ -32,7 +37,9 @@ func authenticate( actorProvider providers.ActorProvider, authnProvider providers.AuthnProviderManager, jwtService jwt.JWTServiceInterface, + jtiStore jti.JTIStoreInterface, issuer string, + leeway int64, ) (*OAuthClientInfo, *authError) { logger := log.GetLogger().With(log.String(log.LoggerKeyComponentName, "ClientAuthMiddleware")) @@ -134,8 +141,8 @@ func authenticate( switch detectedMethod { // TODO: Move this to authnProvider.Authenticate case providers.TokenEndpointAuthMethodPrivateKeyJWT: - if err := validateClientAssertion(ctx, oauthApp, jwtService, issuer, clientID, - clientAssertion); err != nil { + if err := validateClientAssertion(ctx, oauthApp, jwtService, jtiStore, issuer, clientID, + clientAssertion, leeway); err != nil { logger.Debug(ctx, "Invalid client assertion: "+err.Error()) return nil, errInvalidClientAssertion } @@ -223,8 +230,10 @@ func extractClientIDFromAssertion(ctx context.Context, assertion string) (string func validateClientAssertion(ctx context.Context, oauthApp *providers.OAuthClient, jwtService jwt.JWTServiceInterface, + jtiStore jti.JTIStoreInterface, issuer string, - clientID, clientAssertion string) error { + clientID, clientAssertion string, + leeway int64) error { if oauthApp.Certificate == nil { return fmt.Errorf("no certificate configured for client assertion validation") } @@ -243,6 +252,20 @@ func validateClientAssertion(ctx context.Context, return fmt.Errorf("client assertion 'aud' claim %q does not match the issuer", aud) } + if err := verifyAssertionSignature(ctx, oauthApp, jwtService, issuer, clientID, clientAssertion); err != nil { + return err + } + + // Replay protection: record the assertion's jti so it cannot be reused within its validity window. + return recordAssertionJTI(ctx, jtiStore, payload, leeway) +} + +// verifyAssertionSignature verifies the client assertion's signature against the client's configured +// certificate, resolving the verification key from either a JWKS URI or an inline JWKS. +func verifyAssertionSignature(ctx context.Context, + oauthApp *providers.OAuthClient, + jwtService jwt.JWTServiceInterface, + issuer, clientID, clientAssertion string) error { if oauthApp.Certificate.Type == cert.CertificateTypeJWKSURI { if err := jwtService.VerifyJWTWithJWKS(ctx, clientAssertion, oauthApp.Certificate.Value, issuer, clientID); err != nil { @@ -285,3 +308,28 @@ func validateClientAssertion(ctx context.Context, return nil } + +// recordAssertionJTI enforces one-time use of a verified client assertion by recording its jti in +// the shared replay store. +func recordAssertionJTI(ctx context.Context, jtiStore jti.JTIStoreInterface, + payload map[string]interface{}, leeway int64) error { + jtiValue, ok := payload[constants.ClaimJTI].(string) + if !ok || jtiValue == "" { + return fmt.Errorf("client assertion missing 'jti' claim or 'jti' is not a string") + } + exp, ok := payload[constants.ClaimExp].(float64) + if !ok { + return fmt.Errorf("client assertion missing 'exp' claim or 'exp' is not a number") + } + + expiry := time.Unix(int64(exp)+leeway, 0) + inserted, err := jtiStore.RecordJTI(ctx, jtiNamespace, jtiValue, expiry) + if err != nil { + return fmt.Errorf("failed to record client assertion jti: %w", err) + } + if !inserted { + return fmt.Errorf("client assertion replay detected") + } + + return nil +} diff --git a/backend/internal/oauth/oauth2/clientauth/clientauth_test.go b/backend/internal/oauth/oauth2/clientauth/clientauth_test.go index e0f6ad978b..7fc8b151cf 100644 --- a/backend/internal/oauth/oauth2/clientauth/clientauth_test.go +++ b/backend/internal/oauth/oauth2/clientauth/clientauth_test.go @@ -32,12 +32,14 @@ import ( "github.com/thunder-id/thunderid/tests/mocks/entityprovidermock" "github.com/thunder-id/thunderid/tests/mocks/inboundclientmock" "github.com/thunder-id/thunderid/tests/mocks/jose/jwtmock" + "github.com/thunder-id/thunderid/tests/mocks/oauth/oauth2/jtimock" ) const ( - testClientID = "test-client-id" - testClientSecret = "test-secret" - testIssuer = "https://localhost:9443" + testClientID = "test-client-id" + testClientSecret = "test-secret" + testIssuer = "https://localhost:9443" + testLeeway int64 = 60 ) type ClientAuthTestSuite struct { @@ -46,6 +48,7 @@ type ClientAuthTestSuite struct { mockEntityProvider *entityprovidermock.EntityProviderInterfaceMock mockAuthnProvider *managermock.AuthnProviderManagerMock mockJwtService *jwtmock.JWTServiceInterfaceMock + mockJtiStore *jtimock.JTIStoreInterfaceMock } func TestClientAuthTestSuite(t *testing.T) { @@ -61,6 +64,7 @@ func (suite *ClientAuthTestSuite) SetupTest() { suite.mockEntityProvider = entityprovidermock.NewEntityProviderInterfaceMock(suite.T()) suite.mockAuthnProvider = managermock.NewAuthnProviderManagerMock(suite.T()) suite.mockJwtService = jwtmock.NewJWTServiceInterfaceMock(suite.T()) + suite.mockJtiStore = jtimock.NewJTIStoreInterfaceMock(suite.T()) // Default authn mock: return success for client secret authentication. // Tests that need failure override this with a fresh mock. @@ -68,6 +72,12 @@ func (suite *ClientAuthTestSuite) SetupTest() { mock.Anything, mock.Anything, mock.Anything). Return(providers.AuthUser{}, providers.AuthenticatedClaims{"userId": testClientID}, (*tidcommon.ServiceError)(nil)).Maybe() + + // Default jti store mock: treat every assertion as first use. Replay tests pass a dedicated + // store returning (false, nil) so this default does not interfere. + suite.mockJtiStore.EXPECT(). + RecordJTI(mock.Anything, jtiNamespace, mock.Anything, mock.Anything). + Return(true, nil).Maybe() } func (suite *ClientAuthTestSuite) TestAuthenticate_Success_ClientSecretPost() { @@ -91,7 +101,8 @@ func (suite *ClientAuthTestSuite) TestAuthenticate_Success_ClientSecretPost() { clientInfo, authErr := authenticate( req.Context(), req, - suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, testIssuer) + suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, suite.mockJtiStore, + testIssuer, testLeeway) assert.Nil(suite.T(), authErr) assert.NotNil(suite.T(), clientInfo) @@ -119,7 +130,8 @@ func (suite *ClientAuthTestSuite) TestAuthenticate_Success_ClientSecretBasic() { clientInfo, authErr := authenticate( req.Context(), req, - suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, testIssuer) + suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, suite.mockJtiStore, + testIssuer, testLeeway) assert.Nil(suite.T(), authErr) assert.NotNil(suite.T(), clientInfo) @@ -150,7 +162,8 @@ func (suite *ClientAuthTestSuite) TestAuthenticate_Success_ClientSecretBasic_URL clientInfo, authErr := authenticate( req.Context(), req, - suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, testIssuer) + suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, suite.mockJtiStore, + testIssuer, testLeeway) assert.Nil(suite.T(), authErr) assert.NotNil(suite.T(), clientInfo) @@ -168,7 +181,8 @@ func (suite *ClientAuthTestSuite) TestAuthenticate_InvalidBasicAuth_BadPercentEn clientInfo, authErr := authenticate( req.Context(), req, - suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, testIssuer) + suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, suite.mockJtiStore, + testIssuer, testLeeway) assert.NotNil(suite.T(), authErr) assert.Nil(suite.T(), clientInfo) @@ -183,7 +197,8 @@ func (suite *ClientAuthTestSuite) TestAuthenticate_InvalidBasicAuth_BadPercentEn clientInfo, authErr := authenticate( req.Context(), req, - suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, testIssuer) + suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, suite.mockJtiStore, + testIssuer, testLeeway) assert.NotNil(suite.T(), authErr) assert.Nil(suite.T(), clientInfo) @@ -210,7 +225,8 @@ func (suite *ClientAuthTestSuite) TestAuthenticate_Success_PublicClient() { clientInfo, authErr := authenticate( req.Context(), req, - suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, testIssuer) + suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, suite.mockJtiStore, + testIssuer, testLeeway) assert.Nil(suite.T(), authErr) assert.NotNil(suite.T(), clientInfo) @@ -226,7 +242,8 @@ func (suite *ClientAuthTestSuite) TestAuthenticate_MissingClientID() { clientInfo, authErr := authenticate( req.Context(), req, - suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, testIssuer) + suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, suite.mockJtiStore, + testIssuer, testLeeway) assert.NotNil(suite.T(), authErr) assert.Nil(suite.T(), clientInfo) @@ -242,7 +259,8 @@ func (suite *ClientAuthTestSuite) TestAuthenticate_EmptyClientIDInBasicAuth() { clientInfo, authErr := authenticate( req.Context(), req, - suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, testIssuer) + suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, suite.mockJtiStore, + testIssuer, testLeeway) assert.NotNil(suite.T(), authErr) assert.Nil(suite.T(), clientInfo) @@ -257,7 +275,8 @@ func (suite *ClientAuthTestSuite) TestAuthenticate_EmptyClientIDAndSecretInBasic clientInfo, authErr := authenticate( req.Context(), req, - suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, testIssuer) + suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, suite.mockJtiStore, + testIssuer, testLeeway) assert.NotNil(suite.T(), authErr) assert.Nil(suite.T(), clientInfo) @@ -280,7 +299,8 @@ func (suite *ClientAuthTestSuite) TestAuthenticate_MissingClientSecret() { clientInfo, authErr := authenticate( req.Context(), req, - suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, testIssuer) + suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, suite.mockJtiStore, + testIssuer, testLeeway) assert.NotNil(suite.T(), authErr) assert.Nil(suite.T(), clientInfo) @@ -293,7 +313,8 @@ func (suite *ClientAuthTestSuite) TestAuthenticate_InvalidBasicAuth() { clientInfo, authErr := authenticate( req.Context(), req, - suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, testIssuer) + suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, suite.mockJtiStore, + testIssuer, testLeeway) assert.NotNil(suite.T(), authErr) assert.Nil(suite.T(), clientInfo) @@ -306,7 +327,8 @@ func (suite *ClientAuthTestSuite) TestAuthenticate_InvalidAuthorizationHeader() clientInfo, authErr := authenticate( req.Context(), req, - suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, testIssuer) + suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, suite.mockJtiStore, + testIssuer, testLeeway) assert.NotNil(suite.T(), authErr) assert.Nil(suite.T(), clientInfo) @@ -325,7 +347,8 @@ func (suite *ClientAuthTestSuite) TestAuthenticate_BothHeaderAndBody() { clientInfo, authErr := authenticate( req.Context(), req, - suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, testIssuer) + suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, suite.mockJtiStore, + testIssuer, testLeeway) assert.NotNil(suite.T(), authErr) assert.Nil(suite.T(), clientInfo) @@ -347,7 +370,8 @@ func (suite *ClientAuthTestSuite) TestAuthenticate_ClientNotFound() { clientInfo, authErr := authenticate( req.Context(), req, - suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, testIssuer) + suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, suite.mockJtiStore, + testIssuer, testLeeway) assert.NotNil(suite.T(), authErr) assert.Nil(suite.T(), clientInfo) @@ -387,7 +411,8 @@ func (suite *ClientAuthTestSuite) TestAuthenticate_InvalidClientSecret() { clientInfo, authErr := authenticate( req.Context(), req, - suite.actorProvider(), failAuthnProvider, suite.mockJwtService, testIssuer) + suite.actorProvider(), failAuthnProvider, suite.mockJwtService, suite.mockJtiStore, + testIssuer, testLeeway) assert.NotNil(suite.T(), authErr) assert.Nil(suite.T(), clientInfo) @@ -411,7 +436,8 @@ func (suite *ClientAuthTestSuite) TestAuthenticate_WrongAuthMethod() { clientInfo, authErr := authenticate( req.Context(), req, - suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, testIssuer) + suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, suite.mockJtiStore, + testIssuer, testLeeway) assert.NotNil(suite.T(), authErr) assert.Nil(suite.T(), clientInfo) @@ -441,7 +467,8 @@ func (suite *ClientAuthTestSuite) TestAuthenticate_PublicClientWithSecret() { // Try to use client_secret_post with public client clientInfo, authErr := authenticate( req.Context(), req, - suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, testIssuer) + suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, suite.mockJtiStore, + testIssuer, testLeeway) assert.NotNil(suite.T(), authErr) assert.Nil(suite.T(), clientInfo) @@ -469,7 +496,8 @@ func (suite *ClientAuthTestSuite) TestAuthenticate_PublicClientMissingSecret() { // Public client with authMethod = none should succeed clientInfo, authErr := authenticate( req.Context(), req, - suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, testIssuer) + suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, suite.mockJtiStore, + testIssuer, testLeeway) assert.Nil(suite.T(), authErr) assert.NotNil(suite.T(), clientInfo) @@ -496,7 +524,8 @@ func (suite *ClientAuthTestSuite) TestAuthenticate_ClientIDMismatch_HeaderVsBody clientInfo, authErr := authenticate( req.Context(), req, - suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, testIssuer) + suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, suite.mockJtiStore, + testIssuer, testLeeway) assert.NotNil(suite.T(), authErr) assert.Nil(suite.T(), clientInfo) @@ -517,7 +546,8 @@ func (suite *ClientAuthTestSuite) TestAuthenticate_ServiceError() { clientInfo, authErr := authenticate( req.Context(), req, - suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, testIssuer) + suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, suite.mockJtiStore, + testIssuer, testLeeway) assert.NotNil(suite.T(), authErr) assert.Nil(suite.T(), clientInfo) @@ -556,7 +586,7 @@ func buildTestRSAJWKS(kid string) string { func buildFakeJWTWithSub(subject string) string { return buildTestJWT( map[string]any{"alg": "RS256", "kid": "test-kid", "typ": "JWT"}, - map[string]any{"sub": subject, "aud": testIssuer}, + map[string]any{"sub": subject, "aud": testIssuer, "jti": "test-jti", "exp": 9999999999}, ) } @@ -599,7 +629,8 @@ func (suite *ClientAuthTestSuite) TestAuthenticate_Success_PrivateKeyJWT() { clientInfo, authErr := authenticate( req.Context(), req, - suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, testIssuer) + suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, suite.mockJtiStore, + testIssuer, testLeeway) assert.Nil(suite.T(), authErr) assert.NotNil(suite.T(), clientInfo) @@ -642,7 +673,8 @@ func (suite *ClientAuthTestSuite) TestAuthenticate_Success_PrivateKeyJWT_WithCli clientInfo, authErr := authenticate( req.Context(), req, - suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, testIssuer) + suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, suite.mockJtiStore, + testIssuer, testLeeway) assert.Nil(suite.T(), authErr) assert.NotNil(suite.T(), clientInfo) @@ -662,7 +694,8 @@ func (suite *ClientAuthTestSuite) TestAuthenticate_PrivateKeyJWT_UnsupportedAsse clientInfo, authErr := authenticate( req.Context(), req, - suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, testIssuer) + suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, suite.mockJtiStore, + testIssuer, testLeeway) assert.NotNil(suite.T(), authErr) assert.Nil(suite.T(), clientInfo) @@ -681,7 +714,8 @@ func (suite *ClientAuthTestSuite) TestAuthenticate_PrivateKeyJWT_OnlyAssertionTy clientInfo, authErr := authenticate( req.Context(), req, - suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, testIssuer) + suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, suite.mockJtiStore, + testIssuer, testLeeway) assert.NotNil(suite.T(), authErr) assert.Nil(suite.T(), clientInfo) @@ -705,7 +739,8 @@ func (suite *ClientAuthTestSuite) TestAuthenticate_PrivateKeyJWT_OnlyAssertionPr // Then it checks assertion_type != SupportedClientAssertionType, which fails. clientInfo, authErr := authenticate( req.Context(), req, - suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, testIssuer) + suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, suite.mockJtiStore, + testIssuer, testLeeway) assert.NotNil(suite.T(), authErr) assert.Nil(suite.T(), clientInfo) @@ -736,7 +771,8 @@ func (suite *ClientAuthTestSuite) TestAuthenticate_PrivateKeyJWT_InvalidAssertio clientInfo, authErr := authenticate( req.Context(), req, - suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, testIssuer) + suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, suite.mockJtiStore, + testIssuer, testLeeway) assert.NotNil(suite.T(), authErr) assert.Nil(suite.T(), clientInfo) @@ -763,7 +799,8 @@ func (suite *ClientAuthTestSuite) TestAuthenticate_PrivateKeyJWT_ClientNotFound( clientInfo, authErr := authenticate( req.Context(), req, - suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, testIssuer) + suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, suite.mockJtiStore, + testIssuer, testLeeway) assert.NotNil(suite.T(), authErr) assert.Nil(suite.T(), clientInfo) @@ -792,7 +829,8 @@ func (suite *ClientAuthTestSuite) TestAuthenticate_PrivateKeyJWT_AuthMethodNotAl clientInfo, authErr := authenticate( req.Context(), req, - suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, testIssuer) + suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, suite.mockJtiStore, + testIssuer, testLeeway) assert.NotNil(suite.T(), authErr) assert.Nil(suite.T(), clientInfo) @@ -822,7 +860,8 @@ func (suite *ClientAuthTestSuite) TestAuthenticate_PrivateKeyJWT_AssertionValida clientInfo, authErr := authenticate( req.Context(), req, - suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, testIssuer) + suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, suite.mockJtiStore, + testIssuer, testLeeway) assert.NotNil(suite.T(), authErr) assert.Nil(suite.T(), clientInfo) @@ -845,7 +884,8 @@ func (suite *ClientAuthTestSuite) TestAuthenticate_PrivateKeyJWT_ClientIDMismatc clientInfo, authErr := authenticate( req.Context(), req, - suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, testIssuer) + suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, suite.mockJtiStore, + testIssuer, testLeeway) assert.NotNil(suite.T(), authErr) assert.Nil(suite.T(), clientInfo) @@ -867,7 +907,8 @@ func (suite *ClientAuthTestSuite) TestAuthenticate_PrivateKeyJWT_WithBasicAuth_M clientInfo, authErr := authenticate( req.Context(), req, - suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, testIssuer) + suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, suite.mockJtiStore, + testIssuer, testLeeway) assert.NotNil(suite.T(), authErr) assert.Nil(suite.T(), clientInfo) @@ -890,7 +931,8 @@ func (suite *ClientAuthTestSuite) TestAuthenticate_PrivateKeyJWT_WithClientSecre clientInfo, authErr := authenticate( req.Context(), req, - suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, testIssuer) + suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, suite.mockJtiStore, + testIssuer, testLeeway) assert.NotNil(suite.T(), authErr) assert.Nil(suite.T(), clientInfo) @@ -914,7 +956,8 @@ func (suite *ClientAuthTestSuite) TestAuthenticate_PrivateKeyJWT_ServiceError() clientInfo, authErr := authenticate( req.Context(), req, - suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, testIssuer) + suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, suite.mockJtiStore, + testIssuer, testLeeway) assert.NotNil(suite.T(), authErr) assert.Nil(suite.T(), clientInfo) @@ -937,7 +980,8 @@ func (suite *ClientAuthTestSuite) TestAuthenticate_PrivateKeyJWT_InvalidBase64Pa clientInfo, authErr := authenticate( req.Context(), req, - suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, testIssuer) + suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, suite.mockJtiStore, + testIssuer, testLeeway) assert.NotNil(suite.T(), authErr) assert.Nil(suite.T(), clientInfo) @@ -963,7 +1007,8 @@ func (suite *ClientAuthTestSuite) TestAuthenticate_PrivateKeyJWT_InvalidJSONPayl clientInfo, authErr := authenticate( req.Context(), req, - suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, testIssuer) + suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, suite.mockJtiStore, + testIssuer, testLeeway) assert.NotNil(suite.T(), authErr) assert.Nil(suite.T(), clientInfo) @@ -991,7 +1036,8 @@ func (suite *ClientAuthTestSuite) TestAuthenticate_PrivateKeyJWT_MissingClientAu clientInfo, authErr := authenticate( req.Context(), req, - suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, testIssuer) + suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, suite.mockJtiStore, + testIssuer, testLeeway) assert.NotNil(suite.T(), authErr) assert.Nil(suite.T(), clientInfo) @@ -1008,8 +1054,8 @@ func (suite *ClientAuthTestSuite) TestValidateClientAssertion_NilCertificate() { } err := validateClientAssertion(context.Background(), - oauthApp, suite.mockJwtService, testIssuer, "test-client", - "some.jwt.token") + oauthApp, suite.mockJwtService, suite.mockJtiStore, testIssuer, "test-client", + "some.jwt.token", testLeeway) assert.NotNil(suite.T(), err) assert.Contains(suite.T(), err.Error(), "no certificate configured") } @@ -1031,7 +1077,7 @@ func (suite *ClientAuthTestSuite) TestValidateClientAssertion_JWKSURI_Success() Return(nil) err := validateClientAssertion(context.Background(), - oauthApp, suite.mockJwtService, testIssuer, "test-client", assertion) + oauthApp, suite.mockJwtService, suite.mockJtiStore, testIssuer, "test-client", assertion, testLeeway) assert.Nil(suite.T(), err) } @@ -1052,7 +1098,7 @@ func (suite *ClientAuthTestSuite) TestValidateClientAssertion_JWKSURI_Verificati Return(&tidcommon.ServiceError{Error: tidcommon.I18nMessage{DefaultValue: "verification failed"}}) err := validateClientAssertion(context.Background(), - oauthApp, suite.mockJwtService, testIssuer, "test-client", assertion) + oauthApp, suite.mockJwtService, suite.mockJtiStore, testIssuer, "test-client", assertion, testLeeway) assert.NotNil(suite.T(), err) assert.Contains(suite.T(), err.Error(), "client assertion verification with JWKS URI failed") } @@ -1067,10 +1113,10 @@ func (suite *ClientAuthTestSuite) TestValidateClientAssertion_InvalidJWKSJSON() } fakeJWT := buildTestJWT(map[string]any{"alg": "RS256", "kid": "test-kid", "typ": "JWT"}, - map[string]any{"sub": "test-client", "aud": testIssuer}) + map[string]any{"sub": "test-client", "aud": testIssuer, "jti": "test-jti", "exp": 9999999999}) - err := validateClientAssertion(context.Background(), oauthApp, suite.mockJwtService, testIssuer, - "test-client", fakeJWT) + err := validateClientAssertion(context.Background(), oauthApp, suite.mockJwtService, suite.mockJtiStore, testIssuer, + "test-client", fakeJWT, testLeeway) assert.NotNil(suite.T(), err) assert.Contains(suite.T(), err.Error(), "invalid JWKS certificate format") } @@ -1091,8 +1137,8 @@ func (suite *ClientAuthTestSuite) TestValidateClientAssertion_InvalidJWTFormat() []byte(`{"sub":"test-client","aud":"` + testIssuer + `"}`)) fakeJWT := "!!!." + payloadB64 + ".fake-signature" - err := validateClientAssertion(context.Background(), oauthApp, suite.mockJwtService, testIssuer, - "test-client", fakeJWT) + err := validateClientAssertion(context.Background(), oauthApp, suite.mockJwtService, suite.mockJtiStore, testIssuer, + "test-client", fakeJWT, testLeeway) assert.NotNil(suite.T(), err) assert.Contains(suite.T(), err.Error(), "failed to decode header") } @@ -1106,8 +1152,8 @@ func (suite *ClientAuthTestSuite) TestValidateClientAssertion_UndecodablePayload }, } - err := validateClientAssertion(context.Background(), oauthApp, suite.mockJwtService, testIssuer, - "test-client", "not-a-decodable-jwt") + err := validateClientAssertion(context.Background(), oauthApp, suite.mockJwtService, suite.mockJtiStore, testIssuer, + "test-client", "not-a-decodable-jwt", testLeeway) assert.NotNil(suite.T(), err) assert.Contains(suite.T(), err.Error(), "failed to decode client assertion payload") } @@ -1123,10 +1169,10 @@ func (suite *ClientAuthTestSuite) TestValidateClientAssertion_MissingKidInHeader } fakeJWT := buildTestJWT(map[string]any{"alg": "RS256", "typ": "JWT"}, - map[string]any{"sub": "test-client", "aud": testIssuer}) + map[string]any{"sub": "test-client", "aud": testIssuer, "jti": "test-jti", "exp": 9999999999}) err := validateClientAssertion(context.Background(), - oauthApp, suite.mockJwtService, testIssuer, "test-client", fakeJWT) + oauthApp, suite.mockJwtService, suite.mockJtiStore, testIssuer, "test-client", fakeJWT, testLeeway) assert.NotNil(suite.T(), err) assert.Contains(suite.T(), err.Error(), "JWT header missing 'kid' claim") } @@ -1142,10 +1188,10 @@ func (suite *ClientAuthTestSuite) TestValidateClientAssertion_EmptyKidInHeader() } fakeJWT := buildTestJWT(map[string]any{"alg": "RS256", "kid": "", "typ": "JWT"}, - map[string]any{"sub": "test-client", "aud": testIssuer}) + map[string]any{"sub": "test-client", "aud": testIssuer, "jti": "test-jti", "exp": 9999999999}) err := validateClientAssertion(context.Background(), - oauthApp, suite.mockJwtService, testIssuer, "test-client", fakeJWT) + oauthApp, suite.mockJwtService, suite.mockJtiStore, testIssuer, "test-client", fakeJWT, testLeeway) assert.NotNil(suite.T(), err) assert.Contains(suite.T(), err.Error(), "JWT header missing 'kid' claim") } @@ -1161,10 +1207,10 @@ func (suite *ClientAuthTestSuite) TestValidateClientAssertion_KidNotAString() { } fakeJWT := buildTestJWT(map[string]any{"alg": "RS256", "kid": 12345, "typ": "JWT"}, - map[string]any{"sub": "test-client", "aud": testIssuer}) + map[string]any{"sub": "test-client", "aud": testIssuer, "jti": "test-jti", "exp": 9999999999}) err := validateClientAssertion(context.Background(), - oauthApp, suite.mockJwtService, testIssuer, "test-client", fakeJWT) + oauthApp, suite.mockJwtService, suite.mockJtiStore, testIssuer, "test-client", fakeJWT, testLeeway) assert.NotNil(suite.T(), err) assert.Contains(suite.T(), err.Error(), "JWT header missing 'kid' claim") } @@ -1180,10 +1226,10 @@ func (suite *ClientAuthTestSuite) TestValidateClientAssertion_NoMatchingKidInJWK } fakeJWT := buildTestJWT(map[string]any{"alg": "RS256", "kid": "test-kid", "typ": "JWT"}, - map[string]any{"sub": "test-client", "aud": testIssuer}) + map[string]any{"sub": "test-client", "aud": testIssuer, "jti": "test-jti", "exp": 9999999999}) err := validateClientAssertion(context.Background(), - oauthApp, suite.mockJwtService, testIssuer, "test-client", fakeJWT) + oauthApp, suite.mockJwtService, suite.mockJtiStore, testIssuer, "test-client", fakeJWT, testLeeway) assert.NotNil(suite.T(), err) assert.Contains(suite.T(), err.Error(), "no matching key found in JWKS") } @@ -1205,7 +1251,7 @@ func (suite *ClientAuthTestSuite) TestValidateClientAssertion_InvalidJWKCannotCo } fakeJWT := buildTestJWT(map[string]any{"alg": "RS256", "kid": "test-kid", "typ": "JWT"}, - map[string]any{"sub": "test-client", "aud": testIssuer}) + map[string]any{"sub": "test-client", "aud": testIssuer, "jti": "test-jti", "exp": 9999999999}) // JWK-to-public-key conversion now happens inside the JWT service's crypto provider // rather than locally, so the invalid JWK surfaces as a verification failure. @@ -1223,7 +1269,7 @@ func (suite *ClientAuthTestSuite) TestValidateClientAssertion_InvalidJWKCannotCo }) err := validateClientAssertion(context.Background(), - oauthApp, suite.mockJwtService, testIssuer, "test-client", fakeJWT) + oauthApp, suite.mockJwtService, suite.mockJtiStore, testIssuer, "test-client", fakeJWT, testLeeway) assert.NotNil(suite.T(), err) assert.Contains(suite.T(), err.Error(), "client assertion verification failed") } @@ -1239,7 +1285,7 @@ func (suite *ClientAuthTestSuite) TestValidateClientAssertion_VerificationFails( } fakeJWT := buildTestJWT(map[string]any{"alg": "RS256", "kid": "test-kid", "typ": "JWT"}, - map[string]any{"sub": "test-client", "aud": testIssuer}) + map[string]any{"sub": "test-client", "aud": testIssuer, "jti": "test-jti", "exp": 9999999999}) suite.mockJwtService.EXPECT(). VerifyJWTWithPublicKey( @@ -1255,7 +1301,7 @@ func (suite *ClientAuthTestSuite) TestValidateClientAssertion_VerificationFails( }) err := validateClientAssertion(context.Background(), - oauthApp, suite.mockJwtService, testIssuer, "test-client", fakeJWT) + oauthApp, suite.mockJwtService, suite.mockJtiStore, testIssuer, "test-client", fakeJWT, testLeeway) assert.NotNil(suite.T(), err) assert.Contains(suite.T(), err.Error(), "client assertion verification failed") } @@ -1271,7 +1317,7 @@ func (suite *ClientAuthTestSuite) TestValidateClientAssertion_Success() { } fakeJWT := buildTestJWT(map[string]any{"alg": "RS256", "kid": "test-kid", "typ": "JWT"}, - map[string]any{"sub": "test-client", "aud": testIssuer}) + map[string]any{"sub": "test-client", "aud": testIssuer, "jti": "test-jti", "exp": 9999999999}) suite.mockJwtService.EXPECT(). VerifyJWTWithPublicKey( @@ -1283,7 +1329,7 @@ func (suite *ClientAuthTestSuite) TestValidateClientAssertion_Success() { Return(nil) err := validateClientAssertion(context.Background(), - oauthApp, suite.mockJwtService, testIssuer, "test-client", fakeJWT) + oauthApp, suite.mockJwtService, suite.mockJtiStore, testIssuer, "test-client", fakeJWT, testLeeway) assert.Nil(suite.T(), err) } @@ -1300,10 +1346,10 @@ func (suite *ClientAuthTestSuite) TestValidateClientAssertion_EmptyJWKSKeys() { } fakeJWT := buildTestJWT(map[string]any{"alg": "RS256", "kid": "test-kid", "typ": "JWT"}, - map[string]any{"sub": "test-client", "aud": testIssuer}) + map[string]any{"sub": "test-client", "aud": testIssuer, "jti": "test-jti", "exp": 9999999999}) err := validateClientAssertion(context.Background(), - oauthApp, suite.mockJwtService, testIssuer, "test-client", fakeJWT) + oauthApp, suite.mockJwtService, suite.mockJtiStore, testIssuer, "test-client", fakeJWT, testLeeway) assert.NotNil(suite.T(), err) assert.Contains(suite.T(), err.Error(), "no matching key found in JWKS") } @@ -1332,7 +1378,7 @@ func (suite *ClientAuthTestSuite) TestValidateClientAssertion_MultipleKeysMatche } fakeJWT := buildTestJWT(map[string]any{"alg": "RS256", "kid": "kid-2", "typ": "JWT"}, - map[string]any{"sub": "test-client", "aud": testIssuer}) + map[string]any{"sub": "test-client", "aud": testIssuer, "jti": "test-jti", "exp": 9999999999}) suite.mockJwtService.EXPECT(). VerifyJWTWithPublicKey( @@ -1344,7 +1390,7 @@ func (suite *ClientAuthTestSuite) TestValidateClientAssertion_MultipleKeysMatche Return(nil) err := validateClientAssertion(context.Background(), - oauthApp, suite.mockJwtService, testIssuer, "test-client", fakeJWT) + oauthApp, suite.mockJwtService, suite.mockJtiStore, testIssuer, "test-client", fakeJWT, testLeeway) assert.Nil(suite.T(), err) } @@ -1363,7 +1409,7 @@ func (suite *ClientAuthTestSuite) TestValidateClientAssertion_ArrayAudSingleElem map[string]any{"sub": "test-client", "aud": []string{testIssuer}}) err := validateClientAssertion(context.Background(), - oauthApp, suite.mockJwtService, testIssuer, "test-client", fakeJWT) + oauthApp, suite.mockJwtService, suite.mockJtiStore, testIssuer, "test-client", fakeJWT, testLeeway) assert.NotNil(suite.T(), err) assert.Contains(suite.T(), err.Error(), "'aud' claim must be a single string") } @@ -1382,7 +1428,7 @@ func (suite *ClientAuthTestSuite) TestValidateClientAssertion_ArrayAudMultiEleme map[string]any{"sub": "test-client", "aud": []string{testIssuer, "https://other"}}) err := validateClientAssertion(context.Background(), - oauthApp, suite.mockJwtService, testIssuer, "test-client", fakeJWT) + oauthApp, suite.mockJwtService, suite.mockJtiStore, testIssuer, "test-client", fakeJWT, testLeeway) assert.NotNil(suite.T(), err) assert.Contains(suite.T(), err.Error(), "'aud' claim must be a single string") } @@ -1401,7 +1447,7 @@ func (suite *ClientAuthTestSuite) TestValidateClientAssertion_StringAudMismatch_ map[string]any{"sub": "test-client", "aud": "https://wrong-issuer"}) err := validateClientAssertion(context.Background(), - oauthApp, suite.mockJwtService, testIssuer, "test-client", fakeJWT) + oauthApp, suite.mockJwtService, suite.mockJtiStore, testIssuer, "test-client", fakeJWT, testLeeway) assert.NotNil(suite.T(), err) assert.Contains(suite.T(), err.Error(), "does not match the issuer") } @@ -1412,7 +1458,7 @@ func (suite *ClientAuthTestSuite) TestAuthenticate_Success_PrivateKeyJWT_IssuerA jwksJSON := buildTestRSAJWKS("test-kid") assertion := buildTestJWT( map[string]any{"alg": "RS256", "kid": "test-kid", "typ": "JWT"}, - map[string]any{"sub": testClientID, "aud": testIssuer}) + map[string]any{"sub": testClientID, "aud": testIssuer, "jti": "test-jti", "exp": 9999999999}) mockApp := &providers.OAuthClient{ ClientID: testClientID, TokenEndpointAuthMethod: providers.TokenEndpointAuthMethodPrivateKeyJWT, @@ -1436,7 +1482,8 @@ func (suite *ClientAuthTestSuite) TestAuthenticate_Success_PrivateKeyJWT_IssuerA clientInfo, authErr := authenticate( req.Context(), req, - suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, testIssuer) + suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, suite.mockJtiStore, + testIssuer, testLeeway) assert.Nil(suite.T(), authErr) assert.NotNil(suite.T(), clientInfo) @@ -1445,6 +1492,111 @@ func (suite *ClientAuthTestSuite) TestAuthenticate_Success_PrivateKeyJWT_IssuerA } } +// TestAuthenticate_PrivateKeyJWT_ReplayRejected mirrors the CWE-294 PoC: a captured, still-valid +// client assertion authenticates once, then a byte-for-byte replay of the same assertion is rejected +// because its jti has already been recorded. +func (suite *ClientAuthTestSuite) TestAuthenticate_PrivateKeyJWT_ReplayRejected() { + jwksJSON := buildTestRSAJWKS("test-kid") + assertion := buildFakeJWTWithSub(testClientID) + mockApp := &providers.OAuthClient{ + ClientID: testClientID, + TokenEndpointAuthMethod: providers.TokenEndpointAuthMethodPrivateKeyJWT, + GrantTypes: []providers.GrantType{providers.GrantTypeAuthorizationCode}, + Certificate: &inboundmodel.Certificate{Value: jwksJSON}, + } + + suite.mockInboundClient.On("GetOAuthClientByClientID", mock.Anything, testClientID). + Return(mockApp, nil).Twice() + suite.mockJwtService.EXPECT(). + VerifyJWTWithPublicKey(mock.Anything, assertion, mock.Anything, testIssuer, testClientID). + Return(nil).Twice() + + // A dedicated store: the jti is fresh on first use, then reported as already recorded (replay). + replayStore := jtimock.NewJTIStoreInterfaceMock(suite.T()) + replayStore.EXPECT(). + RecordJTI(mock.Anything, jtiNamespace, "test-jti", mock.Anything). + Return(true, nil).Once() + replayStore.EXPECT(). + RecordJTI(mock.Anything, jtiNamespace, "test-jti", mock.Anything). + Return(false, nil).Once() + + buildReq := func() *http.Request { + formData := url.Values{} + formData.Set("client_assertion_type", constants.SupportedClientAssertionType) + formData.Set("client_assertion", assertion) + req, _ := http.NewRequest("POST", "/test", strings.NewReader(formData.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + _ = req.ParseForm() + return req + } + + // First use of the assertion succeeds. + req1 := buildReq() + clientInfo, authErr := authenticate(req1.Context(), req1, + suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, replayStore, testIssuer, testLeeway) + assert.Nil(suite.T(), authErr) + assert.NotNil(suite.T(), clientInfo) + + // Replaying the identical assertion is rejected. + req2 := buildReq() + clientInfo, authErr = authenticate(req2.Context(), req2, + suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, replayStore, testIssuer, testLeeway) + assert.NotNil(suite.T(), authErr) + assert.Nil(suite.T(), clientInfo) + assert.Equal(suite.T(), errInvalidClientAssertion, authErr) +} + +// TestValidateClientAssertion_ReplayRejected verifies that a verified assertion whose jti the store +// reports as already recorded is rejected as a replay. +func (suite *ClientAuthTestSuite) TestValidateClientAssertion_ReplayRejected() { + oauthApp := &providers.OAuthClient{ + ClientID: "test-client", + Certificate: &providers.Certificate{ + Type: "jwks", + Value: buildTestRSAJWKS("test-kid"), + }, + } + fakeJWT := buildTestJWT(map[string]any{"alg": "RS256", "kid": "test-kid", "typ": "JWT"}, + map[string]any{"sub": "test-client", "aud": testIssuer, "jti": "replayed-jti", "exp": 9999999999}) + + suite.mockJwtService.EXPECT(). + VerifyJWTWithPublicKey(mock.Anything, fakeJWT, mock.Anything, testIssuer, "test-client"). + Return(nil) + + replayStore := jtimock.NewJTIStoreInterfaceMock(suite.T()) + replayStore.EXPECT(). + RecordJTI(mock.Anything, jtiNamespace, "replayed-jti", mock.Anything). + Return(false, nil).Once() + + err := validateClientAssertion(context.Background(), oauthApp, suite.mockJwtService, replayStore, + testIssuer, "test-client", fakeJWT, testLeeway) + assert.NotNil(suite.T(), err) + assert.Contains(suite.T(), err.Error(), "replay detected") +} + +// TestValidateClientAssertion_MissingJTI rejects an otherwise valid assertion that carries no jti, +// since one-time-use cannot be enforced without it (RFC 7523 requires jti for this profile). +func (suite *ClientAuthTestSuite) TestValidateClientAssertion_MissingJTI() { + oauthApp := &providers.OAuthClient{ + ClientID: "test-client", + Certificate: &providers.Certificate{ + Type: "jwks", + Value: buildTestRSAJWKS("test-kid"), + }, + } + fakeJWT := buildTestJWT(map[string]any{"alg": "RS256", "kid": "test-kid", "typ": "JWT"}, + map[string]any{"sub": "test-client", "aud": testIssuer, "exp": 9999999999}) + + suite.mockJwtService.EXPECT(). + VerifyJWTWithPublicKey(mock.Anything, fakeJWT, mock.Anything, testIssuer, "test-client"). + Return(nil) + + err := validateClientAssertion(context.Background(), oauthApp, suite.mockJwtService, suite.mockJtiStore, + testIssuer, "test-client", fakeJWT, testLeeway) + assert.NotNil(suite.T(), err) + assert.Contains(suite.T(), err.Error(), "missing 'jti'") +} + // noopAuthnMgr returns an authentication-provider mock with no expectations, for tests that // build a real actor provider but never exercise actor authentication. func noopAuthnMgr() *managermock.AuthnProviderManagerMock { diff --git a/backend/internal/oauth/oauth2/clientauth/middleware.go b/backend/internal/oauth/oauth2/clientauth/middleware.go index 4ddf052cdf..36d3c09fea 100644 --- a/backend/internal/oauth/oauth2/clientauth/middleware.go +++ b/backend/internal/oauth/oauth2/clientauth/middleware.go @@ -6,6 +6,7 @@ package clientauth import ( "net/http" + "github.com/thunder-id/thunderid/internal/oauth/oauth2/jti" serverconst "github.com/thunder-id/thunderid/internal/system/constants" "github.com/thunder-id/thunderid/internal/system/jose/jwt" "github.com/thunder-id/thunderid/internal/system/utils" @@ -18,12 +19,15 @@ import ( func ClientAuthMiddleware(actorProvider providers.ActorProvider, authnProvider providers.AuthnProviderManager, jwtService jwt.JWTServiceInterface, - issuer string) func(http.Handler) http.Handler { + jtiStore jti.JTIStoreInterface, + issuer string, + leeway int64) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() // Authenticate client - clientInfo, authErr := authenticate(ctx, r, actorProvider, authnProvider, jwtService, issuer) + clientInfo, authErr := authenticate(ctx, r, actorProvider, authnProvider, jwtService, + jtiStore, issuer, leeway) if authErr != nil { // If the client attempted to authenticate via the Authorization // header, include WWW-Authenticate in 401 responses. diff --git a/backend/internal/oauth/oauth2/clientauth/middleware_test.go b/backend/internal/oauth/oauth2/clientauth/middleware_test.go index a25dd272c7..3d154268dd 100644 --- a/backend/internal/oauth/oauth2/clientauth/middleware_test.go +++ b/backend/internal/oauth/oauth2/clientauth/middleware_test.go @@ -70,7 +70,7 @@ func (suite *ClientAuthMiddlewareTestSuite) TestClientAuthMiddleware_Success_Cli // Create middleware (authn success mock from SetupTest applies via Maybe()) middleware := ClientAuthMiddleware( - suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, testIssuer) + suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, nil, testIssuer, testLeeway) // Create test handler that checks context var clientInfo *OAuthClientInfo @@ -115,7 +115,7 @@ func (suite *ClientAuthMiddlewareTestSuite) TestClientAuthMiddleware_Success_Cli // Create middleware middleware := ClientAuthMiddleware( - suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, testIssuer) + suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, nil, testIssuer, testLeeway) // Create test handler var clientInfo *OAuthClientInfo @@ -143,7 +143,7 @@ func (suite *ClientAuthMiddlewareTestSuite) TestClientAuthMiddleware_Success_Cli func (suite *ClientAuthMiddlewareTestSuite) TestClientAuthMiddleware_MissingClientID() { // Create middleware middleware := ClientAuthMiddleware( - suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, testIssuer) + suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, nil, testIssuer, testLeeway) handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) @@ -172,7 +172,7 @@ func (suite *ClientAuthMiddlewareTestSuite) TestClientAuthMiddleware_InvalidClie // Create middleware middleware := ClientAuthMiddleware( - suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, testIssuer) + suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, nil, testIssuer, testLeeway) handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) @@ -224,7 +224,7 @@ func (suite *ClientAuthMiddlewareTestSuite) TestClientAuthMiddleware_InvalidClie // Create middleware with failing authn provider middleware := ClientAuthMiddleware( - suite.actorProvider(), failAuthnProvider, suite.mockJwtService, testIssuer) + suite.actorProvider(), failAuthnProvider, suite.mockJwtService, nil, testIssuer, testLeeway) handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) @@ -258,7 +258,7 @@ func (suite *ClientAuthMiddlewareTestSuite) TestClientAuthMiddleware_HandlerNotC // Create middleware middleware := ClientAuthMiddleware( - suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, testIssuer) + suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, nil, testIssuer, testLeeway) // Track if handler was called handlerCalled := false @@ -298,7 +298,7 @@ func (suite *ClientAuthMiddlewareTestSuite) TestClientAuthMiddleware_ContextProp // Create middleware middleware := ClientAuthMiddleware( - suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, testIssuer) + suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, nil, testIssuer, testLeeway) // Create nested handler that also checks context var clientInfo *OAuthClientInfo @@ -337,7 +337,7 @@ func (suite *ClientAuthMiddlewareTestSuite) TestClientAuthMiddleware_BasicAuth_4 Return(nil, nil).Once() middleware := ClientAuthMiddleware( - suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, testIssuer) + suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, nil, testIssuer, testLeeway) handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) @@ -375,7 +375,7 @@ func (suite *ClientAuthMiddlewareTestSuite) TestClientAuthMiddleware_BasicAuth_I }).Maybe() middleware := ClientAuthMiddleware( - suite.actorProvider(), failAuthnProvider, suite.mockJwtService, testIssuer) + suite.actorProvider(), failAuthnProvider, suite.mockJwtService, nil, testIssuer, testLeeway) handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) @@ -396,7 +396,7 @@ func (suite *ClientAuthMiddlewareTestSuite) TestClientAuthMiddleware_PostAuth_40 Return(nil, nil).Once() middleware := ClientAuthMiddleware( - suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, testIssuer) + suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, nil, testIssuer, testLeeway) handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) @@ -418,7 +418,7 @@ func (suite *ClientAuthMiddlewareTestSuite) TestClientAuthMiddleware_PostAuth_40 func (suite *ClientAuthMiddlewareTestSuite) TestClientAuthMiddleware_InvalidBasicAuth_IncludesWWWAuthenticate() { // Invalid Basic auth header format should include WWW-Authenticate: Basic middleware := ClientAuthMiddleware( - suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, testIssuer) + suite.actorProvider(), suite.mockAuthnProvider, suite.mockJwtService, nil, testIssuer, testLeeway) handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) diff --git a/backend/internal/oauth/oauth2/introspect/init.go b/backend/internal/oauth/oauth2/introspect/init.go index 453c377845..cebfb3e649 100644 --- a/backend/internal/oauth/oauth2/introspect/init.go +++ b/backend/internal/oauth/oauth2/introspect/init.go @@ -9,6 +9,7 @@ import ( "github.com/thunder-id/thunderid/internal/oauth/oauth2/clientauth" "github.com/thunder-id/thunderid/internal/oauth/oauth2/discovery" + "github.com/thunder-id/thunderid/internal/oauth/oauth2/jti" "github.com/thunder-id/thunderid/internal/oauth/oauth2/tokenservice" "github.com/thunder-id/thunderid/internal/system/jose/jwt" "github.com/thunder-id/thunderid/internal/system/middleware" @@ -23,10 +24,13 @@ func Initialize( authnProvider providers.AuthnProviderManager, discoveryService discovery.DiscoveryServiceInterface, tokenValidator tokenservice.TokenValidatorInterface, + jtiStore jti.JTIStoreInterface, + leeway int64, ) TokenIntrospectionServiceInterface { introspectionService := newTokenIntrospectionService(tokenValidator) introspectHandler := newTokenIntrospectionHandler(introspectionService) - registerRoutes(mux, introspectHandler, actorProvider, authnProvider, jwtService, discoveryService) + registerRoutes(mux, introspectHandler, actorProvider, authnProvider, jwtService, discoveryService, + jtiStore, leeway) return introspectionService } @@ -38,6 +42,8 @@ func registerRoutes( authnProvider providers.AuthnProviderManager, jwtService jwt.JWTServiceInterface, discoveryService discovery.DiscoveryServiceInterface, + jtiStore jti.JTIStoreInterface, + leeway int64, ) { opts := middleware.CORSOptions{ AllowedMethods: []string{"POST", "OPTIONS"}, @@ -47,7 +53,8 @@ func registerRoutes( } issuer := discoveryService.GetOAuth2AuthorizationServerMetadata(context.Background()).Issuer - clientAuthMiddleware := clientauth.ClientAuthMiddleware(actorProvider, authnProvider, jwtService, issuer) + clientAuthMiddleware := clientauth.ClientAuthMiddleware(actorProvider, authnProvider, jwtService, + jtiStore, issuer, leeway) handler := clientAuthMiddleware(http.HandlerFunc(introspectHandler.HandleIntrospect)) pattern, wrappedHandler := middleware.WithCORS( diff --git a/backend/internal/oauth/oauth2/introspect/init_test.go b/backend/internal/oauth/oauth2/introspect/init_test.go index 2491fa09b4..f9bd7b9b3e 100644 --- a/backend/internal/oauth/oauth2/introspect/init_test.go +++ b/backend/internal/oauth/oauth2/introspect/init_test.go @@ -43,7 +43,7 @@ func (suite *InitTestSuite) TestInitialize() { mux := http.NewServeMux() service := Initialize(mux, suite.mockJWTService, nil, nil, suite.mockDiscoveryService, - suite.mockTokenValidator) + suite.mockTokenValidator, nil, 0) assert.NotNil(suite.T(), service) assert.Implements(suite.T(), (*TokenIntrospectionServiceInterface)(nil), service) @@ -53,7 +53,7 @@ func (suite *InitTestSuite) TestInitialize_RegistersRoutes() { mux := http.NewServeMux() Initialize(mux, suite.mockJWTService, nil, nil, suite.mockDiscoveryService, - suite.mockTokenValidator) + suite.mockTokenValidator, nil, 0) // Verify that the routes are registered by attempting to get a handler for them. // The pattern includes the method because of CORS middleware wrapping. diff --git a/backend/internal/oauth/oauth2/par/init.go b/backend/internal/oauth/oauth2/par/init.go index cb1d3800aa..30f69dff85 100644 --- a/backend/internal/oauth/oauth2/par/init.go +++ b/backend/internal/oauth/oauth2/par/init.go @@ -11,6 +11,7 @@ import ( "github.com/thunder-id/thunderid/internal/oauth/oauth2/clientauth" "github.com/thunder-id/thunderid/internal/oauth/oauth2/discovery" "github.com/thunder-id/thunderid/internal/oauth/oauth2/dpop" + "github.com/thunder-id/thunderid/internal/oauth/oauth2/jti" "github.com/thunder-id/thunderid/internal/system/jose/jwt" "github.com/thunder-id/thunderid/internal/system/middleware" "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" @@ -28,13 +29,15 @@ func Initialize( dpopVerifier dpop.VerifierInterface, cfg oauthconfig.Config, storeProvider providers.RuntimeStoreProvider, + jtiStore jti.JTIStoreInterface, ) PARServiceInterface { store := newPARRequestStore(storeProvider) parSvc := newPARService(store, resourceService, cfg) parEndpoint := discoveryService.GetOAuth2AuthorizationServerMetadata( context.Background()).PushedAuthorizationRequestEndpoint handler := newPARHandler(parSvc, dpopVerifier, parEndpoint) - registerRoutes(mux, handler, actorProvider, authnProvider, jwtService, discoveryService) + registerRoutes(mux, handler, actorProvider, authnProvider, jwtService, discoveryService, + jtiStore, cfg.JWT.Leeway) return parSvc } @@ -46,6 +49,8 @@ func registerRoutes( authnProvider providers.AuthnProviderManager, jwtService jwt.JWTServiceInterface, discoveryService discovery.DiscoveryServiceInterface, + jtiStore jti.JTIStoreInterface, + leeway int64, ) { corsOpts := middleware.CORSOptions{ AllowedMethods: []string{"POST"}, @@ -55,7 +60,8 @@ func registerRoutes( } issuer := discoveryService.GetOAuth2AuthorizationServerMetadata(context.Background()).Issuer - clientAuthMiddleware := clientauth.ClientAuthMiddleware(actorProvider, authnProvider, jwtService, issuer) + clientAuthMiddleware := clientauth.ClientAuthMiddleware(actorProvider, authnProvider, jwtService, + jtiStore, issuer, leeway) wrappedHandler := clientAuthMiddleware(http.HandlerFunc(handler.HandlePARRequest)) pattern, corsHandler := middleware.WithCORS( diff --git a/backend/internal/oauth/oauth2/revocation/init.go b/backend/internal/oauth/oauth2/revocation/init.go index d38ebc19a8..f8959add63 100644 --- a/backend/internal/oauth/oauth2/revocation/init.go +++ b/backend/internal/oauth/oauth2/revocation/init.go @@ -14,6 +14,7 @@ import ( "github.com/thunder-id/thunderid/internal/oauth/oauth2/clientauth" "github.com/thunder-id/thunderid/internal/oauth/oauth2/discovery" + "github.com/thunder-id/thunderid/internal/oauth/oauth2/jti" "github.com/thunder-id/thunderid/internal/system/jose/jwt" "github.com/thunder-id/thunderid/internal/system/middleware" "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" @@ -39,9 +40,12 @@ func RegisterRoutes( authnProvider providers.AuthnProviderManager, discoveryService discovery.DiscoveryServiceInterface, revocationService RevocationServiceInterface, + jtiStore jti.JTIStoreInterface, + leeway int64, ) { revocationHandler := newRevocationHandler(revocationService) - registerRoutes(mux, revocationHandler, actorProvider, authnProvider, jwtService, discoveryService) + registerRoutes(mux, revocationHandler, actorProvider, authnProvider, jwtService, discoveryService, + jtiStore, leeway) } // registerRoutes registers the routes for the token revocation endpoint. @@ -52,6 +56,8 @@ func registerRoutes( authnProvider providers.AuthnProviderManager, jwtService jwt.JWTServiceInterface, discoveryService discovery.DiscoveryServiceInterface, + jtiStore jti.JTIStoreInterface, + leeway int64, ) { opts := middleware.CORSOptions{ AllowedMethods: []string{"POST", "OPTIONS"}, @@ -61,7 +67,8 @@ func registerRoutes( } issuer := discoveryService.GetOAuth2AuthorizationServerMetadata(context.Background()).Issuer - clientAuthMiddleware := clientauth.ClientAuthMiddleware(actorProvider, authnProvider, jwtService, issuer) + clientAuthMiddleware := clientauth.ClientAuthMiddleware(actorProvider, authnProvider, jwtService, + jtiStore, issuer, leeway) handler := clientAuthMiddleware(http.HandlerFunc(revocationHandler.HandleRevoke)) pattern, wrappedHandler := middleware.WithCORS( diff --git a/backend/internal/oauth/oauth2/revocation/init_test.go b/backend/internal/oauth/oauth2/revocation/init_test.go index 14cc1ee7fa..7853f21b45 100644 --- a/backend/internal/oauth/oauth2/revocation/init_test.go +++ b/backend/internal/oauth/oauth2/revocation/init_test.go @@ -68,7 +68,7 @@ func (suite *InitTestSuite) TestInitialize_RegistersRoutes() { mux := http.NewServeMux() _, revocationService := Initialize(suite.mockJWTService, nil, time.Hour, true) - RegisterRoutes(mux, suite.mockJWTService, nil, nil, suite.mockDiscoveryService, revocationService) + RegisterRoutes(mux, suite.mockJWTService, nil, nil, suite.mockDiscoveryService, revocationService, nil, 0) // The pattern includes the method because of CORS middleware wrapping. _, pattern := mux.Handler(&http.Request{Method: "POST", URL: &url.URL{Path: "/oauth2/revoke"}}) diff --git a/backend/internal/oauth/oauth2/token/init.go b/backend/internal/oauth/oauth2/token/init.go index 9e0bbedcbd..ff6f70ee5d 100644 --- a/backend/internal/oauth/oauth2/token/init.go +++ b/backend/internal/oauth/oauth2/token/init.go @@ -12,6 +12,7 @@ import ( "github.com/thunder-id/thunderid/internal/oauth/oauth2/discovery" "github.com/thunder-id/thunderid/internal/oauth/oauth2/dpop" "github.com/thunder-id/thunderid/internal/oauth/oauth2/granthandlers" + "github.com/thunder-id/thunderid/internal/oauth/oauth2/jti" "github.com/thunder-id/thunderid/internal/oauth/scope" "github.com/thunder-id/thunderid/internal/system/jose/jwt" "github.com/thunder-id/thunderid/internal/system/middleware" @@ -29,6 +30,7 @@ func Initialize( observabilitySvc providers.ObservabilityProvider, discoveryService discovery.DiscoveryServiceInterface, dpopVerifier dpop.VerifierInterface, + jtiStore jti.JTIStoreInterface, cfg oauthconfig.Config, ) TokenHandlerInterface { tokenEndpoint := discoveryService.GetOAuth2AuthorizationServerMetadata(context.Background()).TokenEndpoint @@ -36,7 +38,8 @@ func Initialize( tokenSvc := newTokenService(grantHandlerProvider, scopeValidator, observabilitySvc, dpopVerifier, tokenEndpoint, dpopRequired) tokenHandler := newTokenHandler(tokenSvc, observabilitySvc) - registerRoutes(mux, tokenHandler, actorProvider, authnProvider, jwtService, discoveryService) + registerRoutes(mux, tokenHandler, actorProvider, authnProvider, jwtService, discoveryService, + jtiStore, cfg.JWT.Leeway) return tokenHandler } @@ -48,6 +51,8 @@ func registerRoutes( authnProvider providers.AuthnProviderManager, jwtService jwt.JWTServiceInterface, discoveryService discovery.DiscoveryServiceInterface, + jtiStore jti.JTIStoreInterface, + leeway int64, ) { corsOpts := middleware.CORSOptions{ AllowedMethods: []string{"POST"}, @@ -57,7 +62,8 @@ func registerRoutes( } issuer := discoveryService.GetOAuth2AuthorizationServerMetadata(context.Background()).Issuer - clientAuthMiddleware := clientauth.ClientAuthMiddleware(actorProvider, authnProvider, jwtService, issuer) + clientAuthMiddleware := clientauth.ClientAuthMiddleware(actorProvider, authnProvider, jwtService, + jtiStore, issuer, leeway) handler := clientAuthMiddleware(http.HandlerFunc(tokenHandler.HandleTokenRequest)) pattern, wrappedHandler := middleware.WithCORS(