@@ -20,6 +20,10 @@ describe("OAuth Authorization", () => {
20
20
beforeEach ( ( ) => {
21
21
mockFetch . mockReset ( ) ;
22
22
} ) ;
23
+
24
+ afterEach ( ( ) => {
25
+ jest . restoreAllMocks ( ) ;
26
+ } ) ;
23
27
24
28
describe ( "extractResourceMetadataUrl" , ( ) => {
25
29
it ( "returns resource metadata url when present" , async ( ) => {
@@ -773,20 +777,6 @@ describe("OAuth Authorization", () => {
773
777
expect ( authorizationUrl . searchParams . has ( "nonce" ) ) . toBe ( false ) ;
774
778
} ) ;
775
779
776
- it ( "generates nonce when openid scope is included with other scopes" , async ( ) => {
777
- const { authorizationUrl, nonce } = await startAuthorization (
778
- "https://auth.example.com" ,
779
- {
780
- clientInformation : validClientInfo ,
781
- redirectUrl : "http://localhost:3000/callback" ,
782
- scope : "read openid write profile" ,
783
- }
784
- ) ;
785
-
786
- expect ( nonce ) . toBeDefined ( ) ;
787
- expect ( authorizationUrl . searchParams . get ( "nonce" ) ) . toBe ( nonce ) ;
788
- } ) ;
789
-
790
780
it ( "uses metadata authorization_endpoint when provided" , async ( ) => {
791
781
const { authorizationUrl } = await startAuthorization (
792
782
"https://auth.example.com" ,
@@ -975,6 +965,250 @@ describe("OAuth Authorization", () => {
975
965
} )
976
966
) . rejects . toThrow ( "Token exchange failed" ) ;
977
967
} ) ;
968
+
969
+ it ( "validates nonce in ID token when present" , async ( ) => {
970
+ const idToken = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJub25jZSI6InRlc3Qtbm9uY2UtMTIzIiwiYXVkIjoiY2xpZW50MTIzIiwic3ViIjoiMTIzNDU2Nzg5MCIsIm5hbWUiOiJKb2huIERvZSIsImlhdCI6MTUxNjIzOTAyMn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" ;
971
+ const tokensWithIdToken = {
972
+ ...validTokens ,
973
+ id_token : idToken ,
974
+ } ;
975
+
976
+ mockFetch . mockResolvedValueOnce ( {
977
+ ok : true ,
978
+ status : 200 ,
979
+ json : async ( ) => tokensWithIdToken ,
980
+ } ) ;
981
+
982
+ const tokens = await exchangeAuthorization ( "https://auth.example.com" , {
983
+ clientInformation : validClientInfo ,
984
+ authorizationCode : "code123" ,
985
+ codeVerifier : "verifier123" ,
986
+ redirectUri : "http://localhost:3000/callback" ,
987
+ nonce : "test-nonce-123" ,
988
+ } ) ;
989
+
990
+ expect ( tokens ) . toEqual ( tokensWithIdToken ) ;
991
+ } ) ;
992
+
993
+ it ( "throws error when nonce in ID token doesn't match" , async ( ) => {
994
+ // ID token with different nonce
995
+ const idToken = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJub25jZSI6ImRpZmZlcmVudC1ub25jZSIsImF1ZCI6ImNsaWVudDEyMyIsInN1YiI6IjEyMzQ1Njc4OTAiLCJuYW1lIjoiSm9obiBEb2UiLCJpYXQiOjE1MTYyMzkwMjJ9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" ;
996
+ const tokensWithIdToken = {
997
+ ...validTokens ,
998
+ id_token : idToken ,
999
+ } ;
1000
+
1001
+ mockFetch . mockResolvedValueOnce ( {
1002
+ ok : true ,
1003
+ status : 200 ,
1004
+ json : async ( ) => tokensWithIdToken ,
1005
+ } ) ;
1006
+
1007
+ await expect (
1008
+ exchangeAuthorization ( "https://auth.example.com" , {
1009
+ clientInformation : validClientInfo ,
1010
+ authorizationCode : "code123" ,
1011
+ codeVerifier : "verifier123" ,
1012
+ redirectUri : "http://localhost:3000/callback" ,
1013
+ nonce : "test-nonce-123" ,
1014
+ } )
1015
+ ) . rejects . toThrow ( "ID token nonce mismatch - possible replay attack" ) ;
1016
+ } ) ;
1017
+
1018
+ it ( "throws error when nonce is expected but missing in ID token" , async ( ) => {
1019
+ // ID token without nonce claim
1020
+ const idToken = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJjbGllbnQxMjMiLCJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" ;
1021
+ const tokensWithIdToken = {
1022
+ ...validTokens ,
1023
+ id_token : idToken ,
1024
+ } ;
1025
+
1026
+ mockFetch . mockResolvedValueOnce ( {
1027
+ ok : true ,
1028
+ status : 200 ,
1029
+ json : async ( ) => tokensWithIdToken ,
1030
+ } ) ;
1031
+
1032
+ await expect (
1033
+ exchangeAuthorization ( "https://auth.example.com" , {
1034
+ clientInformation : validClientInfo ,
1035
+ authorizationCode : "code123" ,
1036
+ codeVerifier : "verifier123" ,
1037
+ redirectUri : "http://localhost:3000/callback" ,
1038
+ nonce : "test-nonce-123" ,
1039
+ } )
1040
+ ) . rejects . toThrow ( "ID token nonce mismatch - possible replay attack" ) ;
1041
+ } ) ;
1042
+
1043
+ it ( "skips nonce validation when no nonce was provided" , async ( ) => {
1044
+ // ID token with nonce claim
1045
+ const idToken = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJub25jZSI6InRlc3Qtbm9uY2UtMTIzIiwiYXVkIjoiY2xpZW50MTIzIiwic3ViIjoiMTIzNDU2Nzg5MCIsIm5hbWUiOiJKb2huIERvZSIsImlhdCI6MTUxNjIzOTAyMn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" ;
1046
+ const tokensWithIdToken = {
1047
+ ...validTokens ,
1048
+ id_token : idToken ,
1049
+ } ;
1050
+
1051
+ mockFetch . mockResolvedValueOnce ( {
1052
+ ok : true ,
1053
+ status : 200 ,
1054
+ json : async ( ) => tokensWithIdToken ,
1055
+ } ) ;
1056
+
1057
+ // No nonce parameter provided
1058
+ const tokens = await exchangeAuthorization ( "https://auth.example.com" , {
1059
+ clientInformation : validClientInfo ,
1060
+ authorizationCode : "code123" ,
1061
+ codeVerifier : "verifier123" ,
1062
+ redirectUri : "http://localhost:3000/callback" ,
1063
+ } ) ;
1064
+
1065
+ expect ( tokens ) . toEqual ( tokensWithIdToken ) ;
1066
+ } ) ;
1067
+
1068
+ it ( "validates audience in ID token" , async ( ) => {
1069
+ // ID token with correct audience
1070
+ const idToken = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJjbGllbnQxMjMiLCJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" ;
1071
+ const tokensWithIdToken = {
1072
+ ...validTokens ,
1073
+ id_token : idToken ,
1074
+ } ;
1075
+
1076
+ mockFetch . mockResolvedValueOnce ( {
1077
+ ok : true ,
1078
+ status : 200 ,
1079
+ json : async ( ) => tokensWithIdToken ,
1080
+ } ) ;
1081
+
1082
+ const tokens = await exchangeAuthorization ( "https://auth.example.com" , {
1083
+ clientInformation : validClientInfo ,
1084
+ authorizationCode : "code123" ,
1085
+ codeVerifier : "verifier123" ,
1086
+ redirectUri : "http://localhost:3000/callback" ,
1087
+ } ) ;
1088
+
1089
+ expect ( tokens ) . toEqual ( tokensWithIdToken ) ;
1090
+ } ) ;
1091
+
1092
+ it ( "validates audience when ID token has array audience" , async ( ) => {
1093
+ // ID token with array audience containing our client_id
1094
+ // Payload: {"aud":["client123","other-client"],"sub":"1234567890"}
1095
+ const idToken = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsiY2xpZW50MTIzIiwib3RoZXItY2xpZW50Il0sInN1YiI6IjEyMzQ1Njc4OTAifQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" ;
1096
+ const tokensWithIdToken = {
1097
+ ...validTokens ,
1098
+ id_token : idToken ,
1099
+ } ;
1100
+
1101
+ mockFetch . mockResolvedValueOnce ( {
1102
+ ok : true ,
1103
+ status : 200 ,
1104
+ json : async ( ) => tokensWithIdToken ,
1105
+ } ) ;
1106
+
1107
+ const tokens = await exchangeAuthorization ( "https://auth.example.com" , {
1108
+ clientInformation : validClientInfo ,
1109
+ authorizationCode : "code123" ,
1110
+ codeVerifier : "verifier123" ,
1111
+ redirectUri : "http://localhost:3000/callback" ,
1112
+ } ) ;
1113
+
1114
+ expect ( tokens ) . toEqual ( tokensWithIdToken ) ;
1115
+ } ) ;
1116
+
1117
+ it ( "throws error when audience in ID token doesn't match" , async ( ) => {
1118
+ // ID token with wrong audience
1119
+ const idToken = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJ3cm9uZy1jbGllbnQiLCJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" ;
1120
+ const tokensWithIdToken = {
1121
+ ...validTokens ,
1122
+ id_token : idToken ,
1123
+ } ;
1124
+
1125
+ mockFetch . mockResolvedValueOnce ( {
1126
+ ok : true ,
1127
+ status : 200 ,
1128
+ json : async ( ) => tokensWithIdToken ,
1129
+ } ) ;
1130
+
1131
+ await expect (
1132
+ exchangeAuthorization ( "https://auth.example.com" , {
1133
+ clientInformation : validClientInfo ,
1134
+ authorizationCode : "code123" ,
1135
+ codeVerifier : "verifier123" ,
1136
+ redirectUri : "http://localhost:3000/callback" ,
1137
+ } )
1138
+ ) . rejects . toThrow ( "ID token audience mismatch" ) ;
1139
+ } ) ;
1140
+
1141
+ it ( "throws error when ID token is malformed (not 3 parts)" , async ( ) => {
1142
+ // Malformed ID token with only 2 parts
1143
+ const idToken = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJjbGllbnQxMjMiLCJzdWIiOiIxMjM0NTY3ODkwIn0" ;
1144
+ const tokensWithIdToken = {
1145
+ ...validTokens ,
1146
+ id_token : idToken ,
1147
+ } ;
1148
+
1149
+ mockFetch . mockResolvedValueOnce ( {
1150
+ ok : true ,
1151
+ status : 200 ,
1152
+ json : async ( ) => tokensWithIdToken ,
1153
+ } ) ;
1154
+
1155
+ await expect (
1156
+ exchangeAuthorization ( "https://auth.example.com" , {
1157
+ clientInformation : validClientInfo ,
1158
+ authorizationCode : "code123" ,
1159
+ codeVerifier : "verifier123" ,
1160
+ redirectUri : "http://localhost:3000/callback" ,
1161
+ } )
1162
+ ) . rejects . toThrow ( "Invalid JWT format" ) ;
1163
+ } ) ;
1164
+
1165
+ it ( "throws error when ID token has invalid base64 in payload" , async ( ) => {
1166
+ // ID token with invalid base64 characters in payload
1167
+ const idToken = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.!!!invalid-base64!!!.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" ;
1168
+ const tokensWithIdToken = {
1169
+ ...validTokens ,
1170
+ id_token : idToken ,
1171
+ } ;
1172
+
1173
+ mockFetch . mockResolvedValueOnce ( {
1174
+ ok : true ,
1175
+ status : 200 ,
1176
+ json : async ( ) => tokensWithIdToken ,
1177
+ } ) ;
1178
+
1179
+ await expect (
1180
+ exchangeAuthorization ( "https://auth.example.com" , {
1181
+ clientInformation : validClientInfo ,
1182
+ authorizationCode : "code123" ,
1183
+ codeVerifier : "verifier123" ,
1184
+ redirectUri : "http://localhost:3000/callback" ,
1185
+ } )
1186
+ ) . rejects . toThrow ( ) ;
1187
+ } ) ;
1188
+
1189
+ it ( "throws error when ID token payload is not valid JSON" , async ( ) => {
1190
+ // ID token with invalid JSON in payload (base64 of "not json")
1191
+ const idToken = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.bm90IGpzb24.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" ;
1192
+ const tokensWithIdToken = {
1193
+ ...validTokens ,
1194
+ id_token : idToken ,
1195
+ } ;
1196
+
1197
+ mockFetch . mockResolvedValueOnce ( {
1198
+ ok : true ,
1199
+ status : 200 ,
1200
+ json : async ( ) => tokensWithIdToken ,
1201
+ } ) ;
1202
+
1203
+ await expect (
1204
+ exchangeAuthorization ( "https://auth.example.com" , {
1205
+ clientInformation : validClientInfo ,
1206
+ authorizationCode : "code123" ,
1207
+ codeVerifier : "verifier123" ,
1208
+ redirectUri : "http://localhost:3000/callback" ,
1209
+ } )
1210
+ ) . rejects . toThrow ( ) ;
1211
+ } ) ;
978
1212
} ) ;
979
1213
980
1214
describe ( "refreshAuthorization" , ( ) => {
0 commit comments