1
+ import { Response } from "express" ;
2
+ import { ProxyOAuthServerProvider , ProxyOptions } from "./proxyProvider.js" ;
3
+ import { AuthInfo } from "./types.js" ;
4
+ import { OAuthClientInformationFull , OAuthTokens } from "../../shared/auth.js" ;
5
+ import { ServerError } from "./errors.js" ;
6
+
7
+ describe ( "Proxy OAuth Server Provider" , ( ) => {
8
+ // Mock client data
9
+ const validClient : OAuthClientInformationFull = {
10
+ client_id : "test-client" ,
11
+ client_secret : "test-secret" ,
12
+ redirect_uris : [ "https://example.com/callback" ] ,
13
+ } ;
14
+
15
+ // Mock response object
16
+ const mockResponse = {
17
+ redirect : jest . fn ( ) ,
18
+ } as unknown as Response ;
19
+
20
+ // Base provider options
21
+ const baseOptions : ProxyOptions = {
22
+ endpoints : {
23
+ authorizationUrl : "https://auth.example.com/authorize" ,
24
+ tokenUrl : "https://auth.example.com/token" ,
25
+ revocationUrl : "https://auth.example.com/revoke" ,
26
+ registrationUrl : "https://auth.example.com/register" ,
27
+ } ,
28
+ verifyToken : jest . fn ( ) . mockImplementation ( async ( token : string ) => {
29
+ if ( token === "valid-token" ) {
30
+ return {
31
+ token,
32
+ clientId : "test-client" ,
33
+ scopes : [ "read" , "write" ] ,
34
+ expiresAt : Date . now ( ) / 1000 + 3600 ,
35
+ } as AuthInfo ;
36
+ }
37
+ throw new Error ( "Invalid token" ) ;
38
+ } ) ,
39
+ getClient : jest . fn ( ) . mockImplementation ( async ( clientId : string ) => {
40
+ if ( clientId === "test-client" ) {
41
+ return validClient ;
42
+ }
43
+ return undefined ;
44
+ } ) ,
45
+ } ;
46
+
47
+ let provider : ProxyOAuthServerProvider ;
48
+ let originalFetch : typeof global . fetch ;
49
+
50
+ beforeEach ( ( ) => {
51
+ provider = new ProxyOAuthServerProvider ( baseOptions ) ;
52
+ originalFetch = global . fetch ;
53
+ global . fetch = jest . fn ( ) ;
54
+ } ) ;
55
+
56
+ afterEach ( ( ) => {
57
+ global . fetch = originalFetch ;
58
+ jest . clearAllMocks ( ) ;
59
+ } ) ;
60
+
61
+ describe ( "authorization" , ( ) => {
62
+ it ( "redirects to authorization endpoint with correct parameters" , async ( ) => {
63
+ await provider . authorize (
64
+ validClient ,
65
+ {
66
+ redirectUri : "https://example.com/callback" ,
67
+ codeChallenge : "test-challenge" ,
68
+ state : "test-state" ,
69
+ scopes : [ "read" , "write" ] ,
70
+ } ,
71
+ mockResponse
72
+ ) ;
73
+
74
+ const expectedUrl = new URL ( "https://auth.example.com/authorize" ) ;
75
+ expectedUrl . searchParams . set ( "client_id" , "test-client" ) ;
76
+ expectedUrl . searchParams . set ( "response_type" , "code" ) ;
77
+ expectedUrl . searchParams . set ( "redirect_uri" , "https://example.com/callback" ) ;
78
+ expectedUrl . searchParams . set ( "code_challenge" , "test-challenge" ) ;
79
+ expectedUrl . searchParams . set ( "code_challenge_method" , "S256" ) ;
80
+ expectedUrl . searchParams . set ( "state" , "test-state" ) ;
81
+ expectedUrl . searchParams . set ( "scope" , "read write" ) ;
82
+
83
+ expect ( mockResponse . redirect ) . toHaveBeenCalledWith ( expectedUrl . toString ( ) ) ;
84
+ } ) ;
85
+
86
+ it ( "throws error when authorization endpoint is not configured" , async ( ) => {
87
+ const providerWithoutAuth = new ProxyOAuthServerProvider ( {
88
+ ...baseOptions ,
89
+ endpoints : { ...baseOptions . endpoints , authorizationUrl : undefined } ,
90
+ } ) ;
91
+
92
+ await expect (
93
+ providerWithoutAuth . authorize ( validClient , {
94
+ redirectUri : "https://example.com/callback" ,
95
+ codeChallenge : "test-challenge" ,
96
+ } , mockResponse )
97
+ ) . rejects . toThrow ( "No authorization endpoint configured" ) ;
98
+ } ) ;
99
+ } ) ;
100
+
101
+ describe ( "token exchange" , ( ) => {
102
+ const mockTokenResponse : OAuthTokens = {
103
+ access_token : "new-access-token" ,
104
+ token_type : "Bearer" ,
105
+ expires_in : 3600 ,
106
+ refresh_token : "new-refresh-token" ,
107
+ } ;
108
+
109
+ beforeEach ( ( ) => {
110
+ ( global . fetch as jest . Mock ) . mockImplementation ( ( ) =>
111
+ Promise . resolve ( {
112
+ ok : true ,
113
+ json : ( ) => Promise . resolve ( mockTokenResponse ) ,
114
+ } )
115
+ ) ;
116
+ } ) ;
117
+
118
+ it ( "exchanges authorization code for tokens" , async ( ) => {
119
+ const tokens = await provider . exchangeAuthorizationCode (
120
+ validClient ,
121
+ "test-code" ,
122
+ "test-verifier"
123
+ ) ;
124
+
125
+ expect ( global . fetch ) . toHaveBeenCalledWith (
126
+ "https://auth.example.com/token" ,
127
+ expect . objectContaining ( {
128
+ method : "POST" ,
129
+ headers : {
130
+ "Content-Type" : "application/x-www-form-urlencoded" ,
131
+ } ,
132
+ body : expect . stringContaining ( "grant_type=authorization_code" )
133
+ } )
134
+ ) ;
135
+ expect ( tokens ) . toEqual ( mockTokenResponse ) ;
136
+ } ) ;
137
+
138
+ it ( "exchanges refresh token for new tokens" , async ( ) => {
139
+ const tokens = await provider . exchangeRefreshToken (
140
+ validClient ,
141
+ "test-refresh-token" ,
142
+ [ "read" , "write" ]
143
+ ) ;
144
+
145
+ expect ( global . fetch ) . toHaveBeenCalledWith (
146
+ "https://auth.example.com/token" ,
147
+ expect . objectContaining ( {
148
+ method : "POST" ,
149
+ headers : {
150
+ "Content-Type" : "application/x-www-form-urlencoded" ,
151
+ } ,
152
+ body : expect . stringContaining ( "grant_type=refresh_token" )
153
+ } )
154
+ ) ;
155
+ expect ( tokens ) . toEqual ( mockTokenResponse ) ;
156
+ } ) ;
157
+
158
+ it ( "throws error when token endpoint is not configured" , async ( ) => {
159
+ const providerWithoutToken = new ProxyOAuthServerProvider ( {
160
+ ...baseOptions ,
161
+ endpoints : { ...baseOptions . endpoints , tokenUrl : undefined } ,
162
+ } ) ;
163
+
164
+ await expect (
165
+ providerWithoutToken . exchangeAuthorizationCode ( validClient , "test-code" )
166
+ ) . rejects . toThrow ( "No token endpoint configured" ) ;
167
+ } ) ;
168
+
169
+ it ( "handles token exchange failure" , async ( ) => {
170
+ ( global . fetch as jest . Mock ) . mockImplementation ( ( ) =>
171
+ Promise . resolve ( {
172
+ ok : false ,
173
+ status : 400 ,
174
+ } )
175
+ ) ;
176
+
177
+ await expect (
178
+ provider . exchangeAuthorizationCode ( validClient , "invalid-code" )
179
+ ) . rejects . toThrow ( ServerError ) ;
180
+ } ) ;
181
+ } ) ;
182
+
183
+ describe ( "client registration" , ( ) => {
184
+ it ( "registers new client" , async ( ) => {
185
+ const newClient : OAuthClientInformationFull = {
186
+ client_id : "new-client" ,
187
+ redirect_uris : [ "https://new-client.com/callback" ] ,
188
+ } ;
189
+
190
+ ( global . fetch as jest . Mock ) . mockImplementation ( ( ) =>
191
+ Promise . resolve ( {
192
+ ok : true ,
193
+ json : ( ) => Promise . resolve ( newClient ) ,
194
+ } )
195
+ ) ;
196
+
197
+ const result = await provider . clientsStore . registerClient ! ( newClient ) ;
198
+
199
+ expect ( global . fetch ) . toHaveBeenCalledWith (
200
+ "https://auth.example.com/register" ,
201
+ expect . objectContaining ( {
202
+ method : "POST" ,
203
+ headers : {
204
+ "Content-Type" : "application/json" ,
205
+ } ,
206
+ body : JSON . stringify ( newClient ) ,
207
+ } )
208
+ ) ;
209
+ expect ( result ) . toEqual ( newClient ) ;
210
+ } ) ;
211
+
212
+ it ( "handles registration failure" , async ( ) => {
213
+ ( global . fetch as jest . Mock ) . mockImplementation ( ( ) =>
214
+ Promise . resolve ( {
215
+ ok : false ,
216
+ status : 400 ,
217
+ } )
218
+ ) ;
219
+
220
+ const newClient : OAuthClientInformationFull = {
221
+ client_id : "new-client" ,
222
+ redirect_uris : [ "https://new-client.com/callback" ] ,
223
+ } ;
224
+
225
+ await expect (
226
+ provider . clientsStore . registerClient ! ( newClient )
227
+ ) . rejects . toThrow ( ServerError ) ;
228
+ } ) ;
229
+ } ) ;
230
+
231
+ describe ( "token revocation" , ( ) => {
232
+ it ( "revokes token" , async ( ) => {
233
+ ( global . fetch as jest . Mock ) . mockImplementation ( ( ) =>
234
+ Promise . resolve ( {
235
+ ok : true ,
236
+ } )
237
+ ) ;
238
+
239
+ await provider . revokeToken ! ( validClient , {
240
+ token : "token-to-revoke" ,
241
+ token_type_hint : "access_token" ,
242
+ } ) ;
243
+
244
+ expect ( global . fetch ) . toHaveBeenCalledWith (
245
+ "https://auth.example.com/revoke" ,
246
+ expect . objectContaining ( {
247
+ method : "POST" ,
248
+ headers : {
249
+ "Content-Type" : "application/x-www-form-urlencoded" ,
250
+ } ,
251
+ body : expect . stringContaining ( "token=token-to-revoke" ) ,
252
+ } )
253
+ ) ;
254
+ } ) ;
255
+
256
+ it ( "handles revocation failure" , async ( ) => {
257
+ ( global . fetch as jest . Mock ) . mockImplementation ( ( ) =>
258
+ Promise . resolve ( {
259
+ ok : false ,
260
+ status : 400 ,
261
+ } )
262
+ ) ;
263
+
264
+ await expect (
265
+ provider . revokeToken ! ( validClient , {
266
+ token : "invalid-token" ,
267
+ } )
268
+ ) . rejects . toThrow ( ServerError ) ;
269
+ } ) ;
270
+ } ) ;
271
+
272
+ describe ( "token verification" , ( ) => {
273
+ it ( "verifies valid token" , async ( ) => {
274
+ const authInfo = await provider . verifyAccessToken ( "valid-token" ) ;
275
+ expect ( authInfo . token ) . toBe ( "valid-token" ) ;
276
+ expect ( baseOptions . verifyToken ) . toHaveBeenCalledWith ( "valid-token" ) ;
277
+ } ) ;
278
+
279
+ it ( "rejects invalid token" , async ( ) => {
280
+ await expect (
281
+ provider . verifyAccessToken ( "invalid-token" )
282
+ ) . rejects . toThrow ( "Invalid token" ) ;
283
+ } ) ;
284
+ } ) ;
285
+ } ) ;
0 commit comments