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