1+ import { Response } from 'express' ;
2+ import { DemoInMemoryAuthProvider , DemoInMemoryClientsStore } from './demoInMemoryOAuthProvider.js' ;
3+ import { AuthorizationParams } from '../../server/auth/provider.js' ;
4+ import { OAuthClientInformationFull } from '../../shared/auth.js' ;
5+ import { InvalidRequestError } from '../../server/auth/errors.js' ;
6+
7+ describe ( 'DemoInMemoryAuthProvider' , ( ) => {
8+ let provider : DemoInMemoryAuthProvider ;
9+ let mockResponse : Response & { getRedirectUrl : ( ) => string } ;
10+
11+ const createMockResponse = ( ) : Response & { getRedirectUrl : ( ) => string } => {
12+ let capturedRedirectUrl : string | undefined ;
13+
14+ const mockRedirect = jest . fn ( ) . mockImplementation ( ( url : string | number , status ?: number ) => {
15+ if ( typeof url === 'string' ) {
16+ capturedRedirectUrl = url ;
17+ } else if ( typeof status === 'string' ) {
18+ capturedRedirectUrl = status ;
19+ }
20+ return mockResponse ;
21+ } ) ;
22+
23+ const mockResponse = {
24+ redirect : mockRedirect ,
25+ status : jest . fn ( ) . mockReturnThis ( ) ,
26+ json : jest . fn ( ) . mockReturnThis ( ) ,
27+ send : jest . fn ( ) . mockReturnThis ( ) ,
28+ getRedirectUrl : ( ) => {
29+ if ( capturedRedirectUrl === undefined ) {
30+ throw new Error ( 'No redirect URL was captured. Ensure redirect() was called first.' ) ;
31+ }
32+ return capturedRedirectUrl ;
33+ } ,
34+ } as unknown as Response & { getRedirectUrl : ( ) => string } ;
35+
36+ return mockResponse ;
37+ } ;
38+
39+ beforeEach ( ( ) => {
40+ provider = new DemoInMemoryAuthProvider ( ) ;
41+ mockResponse = createMockResponse ( ) ;
42+ } ) ;
43+
44+ describe ( 'authorize' , ( ) => {
45+ const validClient : OAuthClientInformationFull = {
46+ client_id : 'test-client' ,
47+ client_secret : 'test-secret' ,
48+ redirect_uris : [
49+ 'https://example.com/callback' ,
50+ 'https://example.com/callback2'
51+ ] ,
52+ scope : 'test-scope'
53+ } ;
54+
55+ it ( 'should redirect to the requested redirect_uri when valid' , async ( ) => {
56+ const params : AuthorizationParams = {
57+ redirectUri : 'https://example.com/callback' ,
58+ state : 'test-state' ,
59+ codeChallenge : 'test-challenge' ,
60+ scopes : [ 'test-scope' ]
61+ } ;
62+
63+ await provider . authorize ( validClient , params , mockResponse ) ;
64+
65+ expect ( mockResponse . redirect ) . toHaveBeenCalled ( ) ;
66+ expect ( mockResponse . getRedirectUrl ( ) ) . toBeDefined ( ) ;
67+
68+ const url = new URL ( mockResponse . getRedirectUrl ( ) ) ;
69+ expect ( url . origin + url . pathname ) . toBe ( 'https://example.com/callback' ) ;
70+ expect ( url . searchParams . get ( 'state' ) ) . toBe ( 'test-state' ) ;
71+ expect ( url . searchParams . has ( 'code' ) ) . toBe ( true ) ;
72+ } ) ;
73+
74+ it ( 'should throw InvalidRequestError for unregistered redirect_uri' , async ( ) => {
75+ const params : AuthorizationParams = {
76+ redirectUri : 'https://evil.com/callback' ,
77+ state : 'test-state' ,
78+ codeChallenge : 'test-challenge' ,
79+ scopes : [ 'test-scope' ]
80+ } ;
81+
82+ await expect (
83+ provider . authorize ( validClient , params , mockResponse )
84+ ) . rejects . toThrow ( InvalidRequestError ) ;
85+
86+ await expect (
87+ provider . authorize ( validClient , params , mockResponse )
88+ ) . rejects . toThrow ( 'Unregistered redirect_uri' ) ;
89+
90+ expect ( mockResponse . redirect ) . not . toHaveBeenCalled ( ) ;
91+ } ) ;
92+
93+ it ( 'should generate unique authorization codes for multiple requests' , async ( ) => {
94+ const params1 : AuthorizationParams = {
95+ redirectUri : 'https://example.com/callback' ,
96+ state : 'state-1' ,
97+ codeChallenge : 'challenge-1' ,
98+ scopes : [ 'test-scope' ]
99+ } ;
100+
101+ const params2 : AuthorizationParams = {
102+ redirectUri : 'https://example.com/callback' ,
103+ state : 'state-2' ,
104+ codeChallenge : 'challenge-2' ,
105+ scopes : [ 'test-scope' ]
106+ } ;
107+
108+ await provider . authorize ( validClient , params1 , mockResponse ) ;
109+ const firstRedirectUrl = mockResponse . getRedirectUrl ( ) ;
110+ const firstCode = new URL ( firstRedirectUrl ) . searchParams . get ( 'code' ) ;
111+
112+ // Reset the mock for the second call
113+ mockResponse = createMockResponse ( ) ;
114+ await provider . authorize ( validClient , params2 , mockResponse ) ;
115+ const secondRedirectUrl = mockResponse . getRedirectUrl ( ) ;
116+ const secondCode = new URL ( secondRedirectUrl ) . searchParams . get ( 'code' ) ;
117+
118+ expect ( firstCode ) . toBeDefined ( ) ;
119+ expect ( secondCode ) . toBeDefined ( ) ;
120+ expect ( firstCode ) . not . toBe ( secondCode ) ;
121+ } ) ;
122+
123+ it ( 'should handle params without state' , async ( ) => {
124+ const params : AuthorizationParams = {
125+ redirectUri : 'https://example.com/callback' ,
126+ codeChallenge : 'test-challenge' ,
127+ scopes : [ 'test-scope' ]
128+ } ;
129+
130+ await provider . authorize ( validClient , params , mockResponse ) ;
131+
132+ expect ( mockResponse . redirect ) . toHaveBeenCalled ( ) ;
133+ expect ( mockResponse . getRedirectUrl ( ) ) . toBeDefined ( ) ;
134+
135+ const url = new URL ( mockResponse . getRedirectUrl ( ) ) ;
136+ expect ( url . searchParams . has ( 'state' ) ) . toBe ( false ) ;
137+ expect ( url . searchParams . has ( 'code' ) ) . toBe ( true ) ;
138+ } ) ;
139+ } ) ;
140+
141+ describe ( 'challengeForAuthorizationCode' , ( ) => {
142+ const validClient : OAuthClientInformationFull = {
143+ client_id : 'test-client' ,
144+ client_secret : 'test-secret' ,
145+ redirect_uris : [ 'https://example.com/callback' ] ,
146+ scope : 'test-scope'
147+ } ;
148+
149+ it ( 'should return the code challenge for a valid authorization code' , async ( ) => {
150+ const params : AuthorizationParams = {
151+ redirectUri : 'https://example.com/callback' ,
152+ state : 'test-state' ,
153+ codeChallenge : 'test-challenge-value' ,
154+ scopes : [ 'test-scope' ]
155+ } ;
156+
157+ await provider . authorize ( validClient , params , mockResponse ) ;
158+ const code = new URL ( mockResponse . getRedirectUrl ( ) ) . searchParams . get ( 'code' ) ! ;
159+
160+ const challenge = await provider . challengeForAuthorizationCode ( validClient , code ) ;
161+ expect ( challenge ) . toBe ( 'test-challenge-value' ) ;
162+ } ) ;
163+
164+ it ( 'should throw error for invalid authorization code' , async ( ) => {
165+ await expect (
166+ provider . challengeForAuthorizationCode ( validClient , 'invalid-code' )
167+ ) . rejects . toThrow ( 'Invalid authorization code' ) ;
168+ } ) ;
169+ } ) ;
170+
171+ describe ( 'exchangeAuthorizationCode' , ( ) => {
172+ const validClient : OAuthClientInformationFull = {
173+ client_id : 'test-client' ,
174+ client_secret : 'test-secret' ,
175+ redirect_uris : [ 'https://example.com/callback' ] ,
176+ scope : 'test-scope'
177+ } ;
178+
179+ it ( 'should exchange valid authorization code for tokens' , async ( ) => {
180+ const params : AuthorizationParams = {
181+ redirectUri : 'https://example.com/callback' ,
182+ state : 'test-state' ,
183+ codeChallenge : 'test-challenge' ,
184+ scopes : [ 'test-scope' , 'other-scope' ]
185+ } ;
186+
187+ await provider . authorize ( validClient , params , mockResponse ) ;
188+ const code = new URL ( mockResponse . getRedirectUrl ( ) ) . searchParams . get ( 'code' ) ! ;
189+
190+ const tokens = await provider . exchangeAuthorizationCode ( validClient , code ) ;
191+
192+ expect ( tokens ) . toEqual ( {
193+ access_token : expect . any ( String ) ,
194+ token_type : 'bearer' ,
195+ expires_in : 3600 ,
196+ scope : 'test-scope other-scope'
197+ } ) ;
198+ } ) ;
199+
200+ it ( 'should throw error for invalid authorization code' , async ( ) => {
201+ await expect (
202+ provider . exchangeAuthorizationCode ( validClient , 'invalid-code' )
203+ ) . rejects . toThrow ( 'Invalid authorization code' ) ;
204+ } ) ;
205+
206+ it ( 'should throw error when client_id does not match' , async ( ) => {
207+ const params : AuthorizationParams = {
208+ redirectUri : 'https://example.com/callback' ,
209+ state : 'test-state' ,
210+ codeChallenge : 'test-challenge' ,
211+ scopes : [ 'test-scope' ]
212+ } ;
213+
214+ await provider . authorize ( validClient , params , mockResponse ) ;
215+ const code = new URL ( mockResponse . getRedirectUrl ( ) ) . searchParams . get ( 'code' ) ! ;
216+
217+ const differentClient : OAuthClientInformationFull = {
218+ client_id : 'different-client' ,
219+ client_secret : 'different-secret' ,
220+ redirect_uris : [ 'https://example.com/callback' ] ,
221+ scope : 'test-scope'
222+ } ;
223+
224+ await expect (
225+ provider . exchangeAuthorizationCode ( differentClient , code )
226+ ) . rejects . toThrow ( 'Authorization code was not issued to this client' ) ;
227+ } ) ;
228+
229+ it ( 'should delete authorization code after successful exchange' , async ( ) => {
230+ const params : AuthorizationParams = {
231+ redirectUri : 'https://example.com/callback' ,
232+ state : 'test-state' ,
233+ codeChallenge : 'test-challenge' ,
234+ scopes : [ 'test-scope' ]
235+ } ;
236+
237+ await provider . authorize ( validClient , params , mockResponse ) ;
238+ const code = new URL ( mockResponse . getRedirectUrl ( ) ) . searchParams . get ( 'code' ) ! ;
239+
240+ // First exchange should succeed
241+ await provider . exchangeAuthorizationCode ( validClient , code ) ;
242+
243+ // Second exchange should fail
244+ await expect (
245+ provider . exchangeAuthorizationCode ( validClient , code )
246+ ) . rejects . toThrow ( 'Invalid authorization code' ) ;
247+ } ) ;
248+
249+ it ( 'should validate resource when validateResource is provided' , async ( ) => {
250+ const validateResource = jest . fn ( ) . mockReturnValue ( false ) ;
251+ const strictProvider = new DemoInMemoryAuthProvider ( validateResource ) ;
252+
253+ const params : AuthorizationParams = {
254+ redirectUri : 'https://example.com/callback' ,
255+ state : 'test-state' ,
256+ codeChallenge : 'test-challenge' ,
257+ scopes : [ 'test-scope' ] ,
258+ resource : new URL ( 'https://invalid-resource.com' )
259+ } ;
260+
261+ await strictProvider . authorize ( validClient , params , mockResponse ) ;
262+ const code = new URL ( mockResponse . getRedirectUrl ( ) ) . searchParams . get ( 'code' ) ! ;
263+
264+ await expect (
265+ strictProvider . exchangeAuthorizationCode ( validClient , code )
266+ ) . rejects . toThrow ( 'Invalid resource: https://invalid-resource.com/' ) ;
267+
268+ expect ( validateResource ) . toHaveBeenCalledWith ( params . resource ) ;
269+ } ) ;
270+ } ) ;
271+
272+ describe ( 'DemoInMemoryClientsStore' , ( ) => {
273+ let store : DemoInMemoryClientsStore ;
274+
275+ beforeEach ( ( ) => {
276+ store = new DemoInMemoryClientsStore ( ) ;
277+ } ) ;
278+
279+ it ( 'should register and retrieve client' , async ( ) => {
280+ const client : OAuthClientInformationFull = {
281+ client_id : 'test-client' ,
282+ client_secret : 'test-secret' ,
283+ redirect_uris : [ 'https://example.com/callback' ] ,
284+ scope : 'test-scope'
285+ } ;
286+
287+ await store . registerClient ( client ) ;
288+ const retrieved = await store . getClient ( 'test-client' ) ;
289+
290+ expect ( retrieved ) . toEqual ( client ) ;
291+ } ) ;
292+
293+ it ( 'should return undefined for non-existent client' , async ( ) => {
294+ const retrieved = await store . getClient ( 'non-existent' ) ;
295+ expect ( retrieved ) . toBeUndefined ( ) ;
296+ } ) ;
297+ } ) ;
298+ } ) ;
0 commit comments