diff --git a/backend/internal/oauth/oauth2/granthandlers/ciba.go b/backend/internal/oauth/oauth2/granthandlers/ciba.go index 188258ad57..a4c9f9da4b 100644 --- a/backend/internal/oauth/oauth2/granthandlers/ciba.go +++ b/backend/internal/oauth/oauth2/granthandlers/ciba.go @@ -12,6 +12,7 @@ import ( "github.com/thunder-id/thunderid/internal/attributecache" "github.com/thunder-id/thunderid/internal/oauth/oauth2/ciba" "github.com/thunder-id/thunderid/internal/oauth/oauth2/constants" + "github.com/thunder-id/thunderid/internal/oauth/oauth2/dpop" "github.com/thunder-id/thunderid/internal/oauth/oauth2/model" "github.com/thunder-id/thunderid/internal/oauth/oauth2/resourceindicators" "github.com/thunder-id/thunderid/internal/oauth/oauth2/tokenservice" @@ -230,6 +231,7 @@ func (h *cibaGrantHandler) issueTokens(ctx context.Context, record *ciba.CIBAAut GrantType: string(providers.GrantTypeCIBA), OAuthApp: oauthApp, ValidityPeriod: userSubConfig.ValidityPeriodOrZero(), + DPoPJkt: dpop.GetJkt(ctx), }) if err != nil { h.logger.Error(ctx, "Failed to generate access token", log.Error(err)) diff --git a/backend/internal/oauth/oauth2/granthandlers/ciba_test.go b/backend/internal/oauth/oauth2/granthandlers/ciba_test.go index 00b4e38818..e6e580219b 100644 --- a/backend/internal/oauth/oauth2/granthandlers/ciba_test.go +++ b/backend/internal/oauth/oauth2/granthandlers/ciba_test.go @@ -18,6 +18,7 @@ import ( "github.com/thunder-id/thunderid/internal/attributecache" "github.com/thunder-id/thunderid/internal/oauth/oauth2/ciba" "github.com/thunder-id/thunderid/internal/oauth/oauth2/constants" + "github.com/thunder-id/thunderid/internal/oauth/oauth2/dpop" "github.com/thunder-id/thunderid/internal/oauth/oauth2/model" "github.com/thunder-id/thunderid/internal/oauth/oauth2/tokenservice" "github.com/thunder-id/thunderid/tests/mocks/attributecachemock" @@ -252,6 +253,42 @@ func (suite *CIBAGrantHandlerTestSuite) TestHandleGrant_Authenticated_IssuesToke suite.Equal("id-token", resp.IDToken.Token) } +// A CIBA access token is sender-constrained to the key the client proved possession of when polling +// the token endpoint, so a stolen token cannot be replayed without the matching DPoP proof. +func (suite *CIBAGrantHandlerTestSuite) TestHandleGrant_Authenticated_BindsDPoPJkt() { + record := suite.boundAuthenticatedRecord(testScopeRead) + suite.mockCIBAService.EXPECT().GetByAuthReqID(mock.Anything, "auth-req-1").Return(record, nil) + suite.expectResourceServer() + suite.mockTokenBuilder.EXPECT().BuildAccessToken(mock.Anything, mock.MatchedBy( + func(ctx *tokenservice.AccessTokenBuildContext) bool { + return ctx.DPoPJkt == "test-jkt" + })).Return(&model.TokenDTO{Token: "access-token", TokenType: "DPoP"}, nil) + suite.mockCIBAService.EXPECT().MarkConsumed(mock.Anything, "auth-req-1").Return(true, nil) + + ctx := dpop.WithJkt(context.Background(), "test-jkt") + resp, errResp := suite.handler.HandleGrant(ctx, suite.tokenReq, suite.oauthApp) + suite.Nil(errResp) + suite.NotNil(resp) + suite.Equal("access-token", resp.AccessToken.Token) +} + +// Without a verified DPoP proof the access token stays unbound, so non-DPoP clients are unaffected. +func (suite *CIBAGrantHandlerTestSuite) TestHandleGrant_Authenticated_NoProofLeavesTokenUnbound() { + record := suite.boundAuthenticatedRecord(testScopeRead) + suite.mockCIBAService.EXPECT().GetByAuthReqID(mock.Anything, "auth-req-1").Return(record, nil) + suite.expectResourceServer() + suite.mockTokenBuilder.EXPECT().BuildAccessToken(mock.Anything, mock.MatchedBy( + func(ctx *tokenservice.AccessTokenBuildContext) bool { + return ctx.DPoPJkt == "" + })).Return(&model.TokenDTO{Token: "access-token", TokenType: "Bearer"}, nil) + suite.mockCIBAService.EXPECT().MarkConsumed(mock.Anything, "auth-req-1").Return(true, nil) + + resp, errResp := suite.handler.HandleGrant(context.Background(), suite.tokenReq, suite.oauthApp) + suite.Nil(errResp) + suite.NotNil(resp) + suite.Equal("access-token", resp.AccessToken.Token) +} + func (suite *CIBAGrantHandlerTestSuite) TestHandleGrant_Authenticated_NoOpenIDSkipsIDToken() { record := suite.boundAuthenticatedRecord(testScopeRead) suite.mockCIBAService.EXPECT().GetByAuthReqID(mock.Anything, "auth-req-1").Return(record, nil)