@@ -21,6 +21,7 @@ import (
2121 "github.com/stretchr/testify/suite"
2222
2323 "github.com/thunder-id/thunderid/internal/system/config"
24+ "github.com/thunder-id/thunderid/internal/system/jose/jwt"
2425 "github.com/thunder-id/thunderid/tests/mocks/jose/jwtmock"
2526)
2627
@@ -97,10 +98,18 @@ func (suite *JWTAuthenticatorTestSuite) TestCanHandle() {
9798}
9899
99100func (suite * JWTAuthenticatorTestSuite ) TestAuthenticate () {
100- // Valid JWT token with attributes (simplified representation)
101- // Payload: {"sub":"user123","scope":"system users:read","ouId":"ou1","app_id":"app1"}
102- //nolint:gosec,lll // Test data, not a real credential
103- validToken := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyMTIzIiwic2NvcGUiOiJzeXN0ZW0gdXNlcnM6cmVhZCIsIm91SWQiOiJvdTEiLCJhcHBfaWQiOiJhcHAxIn0.signature"
101+ // A self-issued token only authenticates as an access token, so every fixture that is meant to
102+ // reach signature verification carries the RFC 9068 typ header.
103+ validToken := buildFakeJWT (
104+ accessTokenHeader (),
105+ map [string ]interface {}{
106+ "sub" : "user123" , "scope" : "system users:read" , "ouId" : "ou1" , "app_id" : "app1" ,
107+ },
108+ )
109+ badSignatureToken := buildFakeJWT (accessTokenHeader (), map [string ]interface {}{"sub" : "user123" })
110+ expiredToken := buildFakeJWT (accessTokenHeader (), map [string ]interface {}{"sub" : "expired-user" })
111+ malformedBase64PayloadToken := accessTokenHeaderB64 () + ".invalid!base64!payload.signature"
112+ malformedJSONPayloadToken := accessTokenHeaderB64 () + ".bm90X3ZhbGlkX2pzb24.signature"
104113
105114 tests := []struct {
106115 name string
@@ -142,9 +151,9 @@ func (suite *JWTAuthenticatorTestSuite) TestAuthenticate() {
142151 },
143152 {
144153 name : "Invalid JWT signature" ,
145- authHeader : "Bearer invalid.jwt.token" ,
154+ authHeader : "Bearer " + badSignatureToken ,
146155 setupMock : func (m * jwtmock.JWTServiceInterfaceMock ) {
147- m .On ("VerifyJWT" , mock .Anything , "invalid.jwt.token" , "" , "" ).Return (& tidcommon.ServiceError {
156+ m .On ("VerifyJWT" , mock .Anything , badSignatureToken , "" , "" ).Return (& tidcommon.ServiceError {
148157 Type : tidcommon .ServerErrorType ,
149158 Code : "INVALID_SIGNATURE" ,
150159 Error : tidcommon.I18nMessage {DefaultValue : "Invalid signature" },
@@ -155,9 +164,9 @@ func (suite *JWTAuthenticatorTestSuite) TestAuthenticate() {
155164 },
156165 {
157166 name : "Expired JWT token" ,
158- authHeader : "Bearer expired.jwt.token" ,
167+ authHeader : "Bearer " + expiredToken ,
159168 setupMock : func (m * jwtmock.JWTServiceInterfaceMock ) {
160- m .On ("VerifyJWT" , mock .Anything , "expired.jwt.token" , "" , "" ).Return (& tidcommon.ServiceError {
169+ m .On ("VerifyJWT" , mock .Anything , expiredToken , "" , "" ).Return (& tidcommon.ServiceError {
161170 Type : tidcommon .ClientErrorType ,
162171 Code : "JWT-60005" ,
163172 Error : tidcommon.I18nMessage {DefaultValue : "Token has expired" },
@@ -167,28 +176,26 @@ func (suite *JWTAuthenticatorTestSuite) TestAuthenticate() {
167176 expectedError : errInvalidToken ,
168177 },
169178 {
170- name : "Invalid JWT format - decoding error" ,
171- authHeader : "Bearer invalidjwtformat" , // Not 3 parts separated by dots
172- setupMock : func ( m * jwtmock. JWTServiceInterfaceMock ) {
173- m . On ( "VerifyJWT" , mock . Anything , "invalidjwtformat" , "" , "" ). Return ( nil )
174- },
179+ // Not 3 parts separated by dots, so the header cannot be decoded and the token is
180+ // rejected as not an access token, before any verifier is consulted.
181+ name : "Invalid JWT format - decoding error" ,
182+ authHeader : "Bearer invalidjwtformat" ,
183+ setupMock : func ( m * jwtmock. JWTServiceInterfaceMock ) { },
175184 expectedError : errInvalidToken ,
176185 },
177186 {
178187 name : "Invalid JWT payload - malformed base64" ,
179- authHeader : "Bearer eyJhbGciOiJIUzI1NiJ9.invalid!base64!payload.signature" ,
188+ authHeader : "Bearer " + malformedBase64PayloadToken ,
180189 setupMock : func (m * jwtmock.JWTServiceInterfaceMock ) {
181- const tok = "eyJhbGciOiJIUzI1NiJ9.invalid!base64!payload.signature"
182- m .On ("VerifyJWT" , mock .Anything , tok , "" , "" ).Return (nil )
190+ m .On ("VerifyJWT" , mock .Anything , malformedBase64PayloadToken , "" , "" ).Return (nil )
183191 },
184192 expectedError : errInvalidToken ,
185193 },
186194 {
187- name : "Invalid JWT payload - malformed JSON" ,
188- authHeader : "Bearer eyJhbGciOiJIUzI1NiJ9.bm90X3ZhbGlkX2pzb24.signature" , // "not_valid_json" base64 encoded
195+ name : "Invalid JWT payload - malformed JSON" , // "not_valid_json" base64 encoded
196+ authHeader : "Bearer " + malformedJSONPayloadToken ,
189197 setupMock : func (m * jwtmock.JWTServiceInterfaceMock ) {
190- const tok = "eyJhbGciOiJIUzI1NiJ9.bm90X3ZhbGlkX2pzb24.signature"
191- m .On ("VerifyJWT" , mock .Anything , tok , "" , "" ).Return (nil )
198+ m .On ("VerifyJWT" , mock .Anything , malformedJSONPayloadToken , "" , "" ).Return (nil )
192199 },
193200 expectedError : errInvalidToken ,
194201 },
@@ -233,7 +240,7 @@ func (suite *JWTAuthenticatorTestSuite) TestAuthenticate() {
233240// resource-indicator audience check; the REST gate's own jwtAuthenticator always passes "" here.
234241func (suite * JWTAuthenticatorTestSuite ) TestAuthenticate_DoesNotValidateAudience () {
235242 token := buildFakeJWT (
236- map [ string ] interface {}{ "alg" : "RS256" , "kid" : "test-kid" } ,
243+ accessTokenHeader () ,
237244 map [string ]interface {}{"sub" : "user123" , "aud" : "https://some-other-resource/mcp" },
238245 )
239246
@@ -299,11 +306,13 @@ func (suite *JWTAuthenticatorTestSuite) TestExtractPermissionsFromJWTClaims() {
299306 expectedPermissions : []string {"users:read" },
300307 },
301308 {
302- name : "ThunderID assertion authorized_permissions attribute" ,
309+ // An assertion's authorized_permissions never becomes a caller's permissions. Only an
310+ // access token authenticates, and its scopes are carried in scope.
311+ name : "Assertion authorized_permissions attribute is ignored" ,
303312 attributes : map [string ]interface {}{
304313 "authorized_permissions" : "perm1 perm2 perm3" ,
305314 },
306- expectedPermissions : []string {"perm1" , "perm2" , "perm3" },
315+ expectedPermissions : []string {},
307316 },
308317 }
309318
@@ -480,6 +489,19 @@ const (
480489)
481490
482491// buildFakeJWT creates a fake JWT string with the given header and payload claims.
492+ // accessTokenHeader returns the header of a self-issued access token, the only self-issued token the
493+ // gate authenticates.
494+ func accessTokenHeader () map [string ]interface {} {
495+ return map [string ]interface {}{"alg" : "RS256" , "kid" : "test-kid" , "typ" : jwt .TokenTypeAccessToken }
496+ }
497+
498+ // accessTokenHeaderB64 returns that header already encoded, for building tokens whose payload is
499+ // deliberately malformed and so cannot go through buildFakeJWT.
500+ func accessTokenHeaderB64 () string {
501+ headerJSON , _ := json .Marshal (accessTokenHeader ())
502+ return base64 .RawURLEncoding .EncodeToString (headerJSON )
503+ }
504+
483505func buildFakeJWT (header , payload map [string ]interface {}) string {
484506 headerJSON , _ := json .Marshal (header )
485507 payloadJSON , _ := json .Marshal (payload )
@@ -892,7 +914,7 @@ func (suite *JWTAuthenticatorTestSuite) TestAuthenticate_SelfIssuedTokenUnderFed
892914 _ = config .InitializeServerRuntime ("" , federatedConfigWithLocalIssuer ())
893915
894916 token := buildFakeJWT (
895- map [string ]interface {}{"alg" : "RS256" , "kid" : "local-kid" },
917+ map [string ]interface {}{"alg" : "RS256" , "kid" : "local-kid" , "typ" : jwt . TokenTypeAccessToken },
896918 map [string ]interface {}{
897919 "sub" : "service-app" ,
898920 "access_token_sub" : "user-123" ,
@@ -929,7 +951,7 @@ func (suite *JWTAuthenticatorTestSuite) TestAuthenticate_SelfIssuedTokenInvalidU
929951 _ = config .InitializeServerRuntime ("" , federatedConfigWithLocalIssuer ())
930952
931953 token := buildFakeJWT (
932- map [string ]interface {}{"alg" : "RS256" , "kid" : "local-kid" },
954+ map [string ]interface {}{"alg" : "RS256" , "kid" : "local-kid" , "typ" : jwt . TokenTypeAccessToken },
933955 map [string ]interface {}{"sub" : "service-app" , "iss" : testLocalIssuer },
934956 )
935957
@@ -976,3 +998,107 @@ func (suite *JWTAuthenticatorTestSuite) TestAuthenticate_UnknownIssuerUnderFeder
976998 mockJWT .AssertNotCalled (suite .T (), "VerifyJWT" )
977999 mockJWT .AssertNotCalled (suite .T (), "VerifyJWTWithJWKS" )
9781000}
1001+
1002+ // TestAuthenticate_RequiresAccessTokenTypeForSelfIssuedToken asserts the gate accepts only an access
1003+ // token (RFC 9068 typ) on the self-issued branch, so no other JWT this server signs with the same key
1004+ // — an auth assertion, ID token, magic link, OTP, consent or flow token — passes as an API credential.
1005+ func (suite * JWTAuthenticatorTestSuite ) TestAuthenticate_RequiresAccessTokenTypeForSelfIssuedToken () {
1006+ tests := []struct {
1007+ name string
1008+ typ interface {}
1009+ accepted bool
1010+ }{
1011+ {name : "at+jwt is accepted" , typ : jwt .TokenTypeAccessToken , accepted : true },
1012+ {name : "media type form is accepted" , typ : jwt .TokenTypeAccessTokenWithPrefix , accepted : true },
1013+ {name : "typ is compared case insensitively" , typ : "AT+JWT" , accepted : true },
1014+ {name : "plain JWT is rejected" , typ : jwt .TokenTypeJWT , accepted : false },
1015+ {name : "ID-JAG is rejected" , typ : jwt .TokenTypeIDJAG , accepted : false },
1016+ {name : "missing typ is rejected" , typ : nil , accepted : false },
1017+ }
1018+
1019+ for _ , tt := range tests {
1020+ suite .Run (tt .name , func () {
1021+ header := map [string ]interface {}{"alg" : "RS256" , "kid" : "test-kid" }
1022+ if tt .typ != nil {
1023+ header ["typ" ] = tt .typ
1024+ }
1025+ token := buildFakeJWT (header , map [string ]interface {}{"sub" : "user123" })
1026+
1027+ mockJWT := jwtmock .NewJWTServiceInterfaceMock (suite .T ())
1028+ if tt .accepted {
1029+ mockJWT .On ("VerifyJWT" , mock .Anything , token , "" , "" ).Return (nil )
1030+ }
1031+ auth := newJWTAuthenticator (mockJWT )
1032+
1033+ req := httptest .NewRequest (http .MethodGet , "/users" , nil )
1034+ req .Header .Set ("Authorization" , "Bearer " + token )
1035+
1036+ authCtx , err := auth .Authenticate (req )
1037+
1038+ if tt .accepted {
1039+ assert .NoError (suite .T (), err )
1040+ assert .NotNil (suite .T (), authCtx )
1041+ } else {
1042+ assert .ErrorIs (suite .T (), err , errInvalidToken )
1043+ assert .Nil (suite .T (), authCtx )
1044+ // The type check must short-circuit before signature verification.
1045+ mockJWT .AssertNotCalled (suite .T (), "VerifyJWT" )
1046+ }
1047+ mockJWT .AssertExpectations (suite .T ())
1048+ })
1049+ }
1050+ }
1051+
1052+ // TestAuthenticate_RejectsFlowAuthAssertion covers the concrete confusion the type check closes. The
1053+ // assertion the sign-in flow returns is self-issued and carries authorized_permissions, which the
1054+ // gate read as the caller's permissions; it is meant only for exchange at the token endpoint.
1055+ func (suite * JWTAuthenticatorTestSuite ) TestAuthenticate_RejectsFlowAuthAssertion () {
1056+ assertion := buildFakeJWT (
1057+ map [string ]interface {}{"alg" : "RS256" , "kid" : "test-kid" , "typ" : jwt .TokenTypeJWT },
1058+ map [string ]interface {}{
1059+ "sub" : "user123" ,
1060+ "aud" : "app1" ,
1061+ "assurance" : map [string ]interface {}{"aal" : "AAL1" , "ial" : "IAL1" },
1062+ "authorized_permissions" : "users:read users:write" ,
1063+ },
1064+ )
1065+
1066+ mockJWT := jwtmock .NewJWTServiceInterfaceMock (suite .T ())
1067+ auth := newJWTAuthenticator (mockJWT )
1068+
1069+ req := httptest .NewRequest (http .MethodGet , "/users" , nil )
1070+ req .Header .Set ("Authorization" , "Bearer " + assertion )
1071+
1072+ authCtx , err := auth .Authenticate (req )
1073+
1074+ assert .ErrorIs (suite .T (), err , errInvalidToken )
1075+ assert .Nil (suite .T (), authCtx )
1076+ }
1077+
1078+ // TestAuthenticate_FederatedTokenTypeNotRestricted pins the access-token type check to self-issued
1079+ // tokens only. A trusted issuer may be a generic OIDC provider that stamps typ JWT on its access
1080+ // tokens, and those must keep authenticating.
1081+ func (suite * JWTAuthenticatorTestSuite ) TestAuthenticate_FederatedTokenTypeNotRestricted () {
1082+ config .ResetServerRuntime ()
1083+ defer config .ResetServerRuntime ()
1084+ _ = config .InitializeServerRuntime ("" , federatedConfigWithLocalIssuer ())
1085+
1086+ token := buildFakeJWT (
1087+ map [string ]interface {}{"alg" : "RS256" , "kid" : "test-kid" , "typ" : jwt .TokenTypeJWT },
1088+ map [string ]interface {}{"sub" : "federated-user" , "iss" : testFederatedIssuer },
1089+ )
1090+
1091+ mockJWT := jwtmock .NewJWTServiceInterfaceMock (suite .T ())
1092+ mockJWT .On ("VerifyJWTWithJWKS" , mock .Anything , token ,
1093+ testFederatedJWKSURL , testFederatedAudience , testFederatedIssuer ).Return (nil )
1094+ auth := newJWTAuthenticator (mockJWT )
1095+
1096+ req := httptest .NewRequest (http .MethodGet , "/users" , nil )
1097+ req .Header .Set ("Authorization" , "Bearer " + token )
1098+
1099+ authCtx , err := auth .Authenticate (req )
1100+
1101+ assert .NoError (suite .T (), err )
1102+ assert .NotNil (suite .T (), authCtx )
1103+ mockJWT .AssertExpectations (suite .T ())
1104+ }
0 commit comments