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