1
+ import {
2
+ discoverOAuthMetadata ,
3
+ startAuthorization ,
4
+ exchangeAuthorization ,
5
+ refreshAuthorization ,
6
+ registerClient ,
7
+ } from "./auth" ;
8
+
9
+ // Mock pkce-challenge
10
+ jest . mock ( "pkce-challenge" , ( ) => ( {
11
+ __esModule : true ,
12
+ default : ( ) => ( {
13
+ code_verifier : "test_verifier" ,
14
+ code_challenge : "test_challenge" ,
15
+ } ) ,
16
+ } ) ) ;
17
+
18
+ // Mock fetch globally
19
+ const mockFetch = jest . fn ( ) ;
20
+ global . fetch = mockFetch ;
21
+
22
+ describe ( "OAuth Authorization" , ( ) => {
23
+ beforeEach ( ( ) => {
24
+ mockFetch . mockReset ( ) ;
25
+ } ) ;
26
+
27
+ describe ( "discoverOAuthMetadata" , ( ) => {
28
+ const validMetadata = {
29
+ issuer : "https://auth.example.com" ,
30
+ authorization_endpoint : "https://auth.example.com/authorize" ,
31
+ token_endpoint : "https://auth.example.com/token" ,
32
+ registration_endpoint : "https://auth.example.com/register" ,
33
+ response_types_supported : [ "code" ] ,
34
+ code_challenge_methods_supported : [ "S256" ] ,
35
+ } ;
36
+
37
+ it ( "returns metadata when discovery succeeds" , async ( ) => {
38
+ mockFetch . mockResolvedValueOnce ( {
39
+ ok : true ,
40
+ status : 200 ,
41
+ json : async ( ) => validMetadata ,
42
+ } ) ;
43
+
44
+ const metadata = await discoverOAuthMetadata ( "https://auth.example.com" ) ;
45
+ expect ( metadata ) . toEqual ( validMetadata ) ;
46
+ expect ( mockFetch ) . toHaveBeenCalledWith (
47
+ expect . objectContaining ( {
48
+ href : "https://auth.example.com/.well-known/oauth-authorization-server" ,
49
+ } )
50
+ ) ;
51
+ } ) ;
52
+
53
+ it ( "returns undefined when discovery endpoint returns 404" , async ( ) => {
54
+ mockFetch . mockResolvedValueOnce ( {
55
+ ok : false ,
56
+ status : 404 ,
57
+ } ) ;
58
+
59
+ const metadata = await discoverOAuthMetadata ( "https://auth.example.com" ) ;
60
+ expect ( metadata ) . toBeUndefined ( ) ;
61
+ } ) ;
62
+
63
+ it ( "throws on non-404 errors" , async ( ) => {
64
+ mockFetch . mockResolvedValueOnce ( {
65
+ ok : false ,
66
+ status : 500 ,
67
+ } ) ;
68
+
69
+ await expect (
70
+ discoverOAuthMetadata ( "https://auth.example.com" )
71
+ ) . rejects . toThrow ( "HTTP 500" ) ;
72
+ } ) ;
73
+
74
+ it ( "validates metadata schema" , async ( ) => {
75
+ mockFetch . mockResolvedValueOnce ( {
76
+ ok : true ,
77
+ status : 200 ,
78
+ json : async ( ) => ( {
79
+ // Missing required fields
80
+ issuer : "https://auth.example.com" ,
81
+ } ) ,
82
+ } ) ;
83
+
84
+ await expect (
85
+ discoverOAuthMetadata ( "https://auth.example.com" )
86
+ ) . rejects . toThrow ( ) ;
87
+ } ) ;
88
+ } ) ;
89
+
90
+ describe ( "startAuthorization" , ( ) => {
91
+ const validMetadata = {
92
+ issuer : "https://auth.example.com" ,
93
+ authorization_endpoint : "https://auth.example.com/authorize" ,
94
+ token_endpoint : "https://auth.example.com/token" ,
95
+ response_types_supported : [ "code" ] ,
96
+ code_challenge_methods_supported : [ "S256" ] ,
97
+ } ;
98
+
99
+ it ( "generates authorization URL with PKCE challenge" , async ( ) => {
100
+ const { authorizationUrl, codeVerifier } = await startAuthorization (
101
+ "https://auth.example.com" ,
102
+ {
103
+ redirectUrl : "http://localhost:3000/callback" ,
104
+ }
105
+ ) ;
106
+
107
+ expect ( authorizationUrl . toString ( ) ) . toMatch (
108
+ / ^ h t t p s : \/ \/ a u t h \. e x a m p l e \. c o m \/ a u t h o r i z e \? /
109
+ ) ;
110
+ expect ( authorizationUrl . searchParams . get ( "response_type" ) ) . toBe ( "code" ) ;
111
+ expect ( authorizationUrl . searchParams . get ( "code_challenge" ) ) . toBe ( "test_challenge" ) ;
112
+ expect ( authorizationUrl . searchParams . get ( "code_challenge_method" ) ) . toBe (
113
+ "S256"
114
+ ) ;
115
+ expect ( authorizationUrl . searchParams . get ( "redirect_uri" ) ) . toBe (
116
+ "http://localhost:3000/callback"
117
+ ) ;
118
+ expect ( codeVerifier ) . toBe ( "test_verifier" ) ;
119
+ } ) ;
120
+
121
+ it ( "uses metadata authorization_endpoint when provided" , async ( ) => {
122
+ const { authorizationUrl } = await startAuthorization (
123
+ "https://auth.example.com" ,
124
+ {
125
+ metadata : validMetadata ,
126
+ redirectUrl : "http://localhost:3000/callback" ,
127
+ }
128
+ ) ;
129
+
130
+ expect ( authorizationUrl . toString ( ) ) . toMatch (
131
+ / ^ h t t p s : \/ \/ a u t h \. e x a m p l e \. c o m \/ a u t h o r i z e \? /
132
+ ) ;
133
+ } ) ;
134
+
135
+ it ( "validates response type support" , async ( ) => {
136
+ const metadata = {
137
+ ...validMetadata ,
138
+ response_types_supported : [ "token" ] , // Does not support 'code'
139
+ } ;
140
+
141
+ await expect (
142
+ startAuthorization ( "https://auth.example.com" , {
143
+ metadata,
144
+ redirectUrl : "http://localhost:3000/callback" ,
145
+ } )
146
+ ) . rejects . toThrow ( / d o e s n o t s u p p o r t r e s p o n s e t y p e / ) ;
147
+ } ) ;
148
+
149
+ it ( "validates PKCE support" , async ( ) => {
150
+ const metadata = {
151
+ ...validMetadata ,
152
+ response_types_supported : [ "code" ] ,
153
+ code_challenge_methods_supported : [ "plain" ] , // Does not support 'S256'
154
+ } ;
155
+
156
+ await expect (
157
+ startAuthorization ( "https://auth.example.com" , {
158
+ metadata,
159
+ redirectUrl : "http://localhost:3000/callback" ,
160
+ } )
161
+ ) . rejects . toThrow ( / d o e s n o t s u p p o r t c o d e c h a l l e n g e m e t h o d / ) ;
162
+ } ) ;
163
+ } ) ;
164
+
165
+ describe ( "exchangeAuthorization" , ( ) => {
166
+ const validTokens = {
167
+ access_token : "access123" ,
168
+ token_type : "Bearer" ,
169
+ expires_in : 3600 ,
170
+ refresh_token : "refresh123" ,
171
+ } ;
172
+
173
+ it ( "exchanges code for tokens" , async ( ) => {
174
+ mockFetch . mockResolvedValueOnce ( {
175
+ ok : true ,
176
+ status : 200 ,
177
+ json : async ( ) => validTokens ,
178
+ } ) ;
179
+
180
+ const tokens = await exchangeAuthorization ( "https://auth.example.com" , {
181
+ authorizationCode : "code123" ,
182
+ codeVerifier : "verifier123" ,
183
+ } ) ;
184
+
185
+ expect ( tokens ) . toEqual ( validTokens ) ;
186
+ expect ( mockFetch ) . toHaveBeenCalledWith (
187
+ expect . objectContaining ( {
188
+ href : "https://auth.example.com/token" ,
189
+ } ) ,
190
+ expect . objectContaining ( {
191
+ method : "POST" ,
192
+ headers : {
193
+ "Content-Type" : "application/x-www-form-urlencoded" ,
194
+ } ,
195
+ } )
196
+ ) ;
197
+
198
+ const body = mockFetch . mock . calls [ 0 ] [ 1 ] . body as URLSearchParams ;
199
+ expect ( body . get ( "grant_type" ) ) . toBe ( "authorization_code" ) ;
200
+ expect ( body . get ( "code" ) ) . toBe ( "code123" ) ;
201
+ expect ( body . get ( "code_verifier" ) ) . toBe ( "verifier123" ) ;
202
+ } ) ;
203
+
204
+ it ( "validates token response schema" , async ( ) => {
205
+ mockFetch . mockResolvedValueOnce ( {
206
+ ok : true ,
207
+ status : 200 ,
208
+ json : async ( ) => ( {
209
+ // Missing required fields
210
+ access_token : "access123" ,
211
+ } ) ,
212
+ } ) ;
213
+
214
+ await expect (
215
+ exchangeAuthorization ( "https://auth.example.com" , {
216
+ authorizationCode : "code123" ,
217
+ codeVerifier : "verifier123" ,
218
+ } )
219
+ ) . rejects . toThrow ( ) ;
220
+ } ) ;
221
+
222
+ it ( "throws on error response" , async ( ) => {
223
+ mockFetch . mockResolvedValueOnce ( {
224
+ ok : false ,
225
+ status : 400 ,
226
+ } ) ;
227
+
228
+ await expect (
229
+ exchangeAuthorization ( "https://auth.example.com" , {
230
+ authorizationCode : "code123" ,
231
+ codeVerifier : "verifier123" ,
232
+ } )
233
+ ) . rejects . toThrow ( "Token exchange failed" ) ;
234
+ } ) ;
235
+ } ) ;
236
+
237
+ describe ( "refreshAuthorization" , ( ) => {
238
+ const validTokens = {
239
+ access_token : "newaccess123" ,
240
+ token_type : "Bearer" ,
241
+ expires_in : 3600 ,
242
+ refresh_token : "newrefresh123" ,
243
+ } ;
244
+
245
+ it ( "exchanges refresh token for new tokens" , async ( ) => {
246
+ mockFetch . mockResolvedValueOnce ( {
247
+ ok : true ,
248
+ status : 200 ,
249
+ json : async ( ) => validTokens ,
250
+ } ) ;
251
+
252
+ const tokens = await refreshAuthorization ( "https://auth.example.com" , {
253
+ refreshToken : "refresh123" ,
254
+ } ) ;
255
+
256
+ expect ( tokens ) . toEqual ( validTokens ) ;
257
+ expect ( mockFetch ) . toHaveBeenCalledWith (
258
+ expect . objectContaining ( {
259
+ href : "https://auth.example.com/token" ,
260
+ } ) ,
261
+ expect . objectContaining ( {
262
+ method : "POST" ,
263
+ headers : {
264
+ "Content-Type" : "application/x-www-form-urlencoded" ,
265
+ } ,
266
+ } )
267
+ ) ;
268
+
269
+ const body = mockFetch . mock . calls [ 0 ] [ 1 ] . body as URLSearchParams ;
270
+ expect ( body . get ( "grant_type" ) ) . toBe ( "refresh_token" ) ;
271
+ expect ( body . get ( "refresh_token" ) ) . toBe ( "refresh123" ) ;
272
+ } ) ;
273
+
274
+ it ( "validates token response schema" , async ( ) => {
275
+ mockFetch . mockResolvedValueOnce ( {
276
+ ok : true ,
277
+ status : 200 ,
278
+ json : async ( ) => ( {
279
+ // Missing required fields
280
+ access_token : "newaccess123" ,
281
+ } ) ,
282
+ } ) ;
283
+
284
+ await expect (
285
+ refreshAuthorization ( "https://auth.example.com" , {
286
+ refreshToken : "refresh123" ,
287
+ } )
288
+ ) . rejects . toThrow ( ) ;
289
+ } ) ;
290
+
291
+ it ( "throws on error response" , async ( ) => {
292
+ mockFetch . mockResolvedValueOnce ( {
293
+ ok : false ,
294
+ status : 400 ,
295
+ } ) ;
296
+
297
+ await expect (
298
+ refreshAuthorization ( "https://auth.example.com" , {
299
+ refreshToken : "refresh123" ,
300
+ } )
301
+ ) . rejects . toThrow ( "Token exchange failed" ) ;
302
+ } ) ;
303
+ } ) ;
304
+
305
+ describe ( "registerClient" , ( ) => {
306
+ const validClientMetadata = {
307
+ redirect_uris : [ "http://localhost:3000/callback" ] ,
308
+ client_name : "Test Client" ,
309
+ } ;
310
+
311
+ const validClientInfo = {
312
+ client_id : "client123" ,
313
+ client_secret : "secret123" ,
314
+ client_id_issued_at : 1612137600 ,
315
+ client_secret_expires_at : 1612224000 ,
316
+ ...validClientMetadata ,
317
+ } ;
318
+
319
+ it ( "registers client and returns client information" , async ( ) => {
320
+ mockFetch . mockResolvedValueOnce ( {
321
+ ok : true ,
322
+ status : 200 ,
323
+ json : async ( ) => validClientInfo ,
324
+ } ) ;
325
+
326
+ const clientInfo = await registerClient ( "https://auth.example.com" , {
327
+ clientMetadata : validClientMetadata ,
328
+ } ) ;
329
+
330
+ expect ( clientInfo ) . toEqual ( validClientInfo ) ;
331
+ expect ( mockFetch ) . toHaveBeenCalledWith (
332
+ expect . objectContaining ( {
333
+ href : "https://auth.example.com/register" ,
334
+ } ) ,
335
+ expect . objectContaining ( {
336
+ method : "POST" ,
337
+ headers : {
338
+ "Content-Type" : "application/json" ,
339
+ } ,
340
+ body : JSON . stringify ( validClientMetadata ) ,
341
+ } )
342
+ ) ;
343
+ } ) ;
344
+
345
+ it ( "validates client information response schema" , async ( ) => {
346
+ mockFetch . mockResolvedValueOnce ( {
347
+ ok : true ,
348
+ status : 200 ,
349
+ json : async ( ) => ( {
350
+ // Missing required fields
351
+ client_secret : "secret123" ,
352
+ } ) ,
353
+ } ) ;
354
+
355
+ await expect (
356
+ registerClient ( "https://auth.example.com" , {
357
+ clientMetadata : validClientMetadata ,
358
+ } )
359
+ ) . rejects . toThrow ( ) ;
360
+ } ) ;
361
+
362
+ it ( "throws when registration endpoint not available in metadata" , async ( ) => {
363
+ const metadata = {
364
+ issuer : "https://auth.example.com" ,
365
+ authorization_endpoint : "https://auth.example.com/authorize" ,
366
+ token_endpoint : "https://auth.example.com/token" ,
367
+ response_types_supported : [ "code" ] ,
368
+ } ;
369
+
370
+ await expect (
371
+ registerClient ( "https://auth.example.com" , {
372
+ metadata,
373
+ clientMetadata : validClientMetadata ,
374
+ } )
375
+ ) . rejects . toThrow ( / d o e s n o t s u p p o r t d y n a m i c c l i e n t r e g i s t r a t i o n / ) ;
376
+ } ) ;
377
+
378
+ it ( "throws on error response" , async ( ) => {
379
+ mockFetch . mockResolvedValueOnce ( {
380
+ ok : false ,
381
+ status : 400 ,
382
+ } ) ;
383
+
384
+ await expect (
385
+ registerClient ( "https://auth.example.com" , {
386
+ clientMetadata : validClientMetadata ,
387
+ } )
388
+ ) . rejects . toThrow ( "Dynamic client registration failed" ) ;
389
+ } ) ;
390
+ } ) ;
391
+ } ) ;
0 commit comments