@@ -2,7 +2,7 @@ import { generateSecureRandomString } from "@stackframe/stack-shared/dist/utils/
2
2
import { StackAssertionError , throwErr } from "@stackframe/stack-shared/dist/utils/errors" ;
3
3
import { filterUndefined } from "@stackframe/stack-shared/dist/utils/objects" ;
4
4
import { expect } from "vitest" ;
5
- import { Context , Mailbox , NiceRequestInit , NiceResponse , STACK_BACKEND_BASE_URL , STACK_INTERNAL_PROJECT_ADMIN_KEY , STACK_INTERNAL_PROJECT_CLIENT_KEY , STACK_INTERNAL_PROJECT_ID , STACK_INTERNAL_PROJECT_SERVER_KEY , createMailbox , niceFetch } from "../helpers" ;
5
+ import { Context , Mailbox , NiceRequestInit , NiceResponse , STACK_BACKEND_BASE_URL , STACK_INTERNAL_PROJECT_ADMIN_KEY , STACK_INTERNAL_PROJECT_CLIENT_KEY , STACK_INTERNAL_PROJECT_ID , STACK_INTERNAL_PROJECT_SERVER_KEY , createMailbox , localRedirectUrl , niceFetch , updateCookiesFromResponse } from "../helpers" ;
6
6
7
7
type BackendContext = {
8
8
readonly projectKeys : ProjectKeys ,
@@ -60,7 +60,7 @@ function expectSnakeCase(obj: unknown, path: string): void {
60
60
}
61
61
}
62
62
63
- export async function niceBackendFetch ( url : string , options ?: Omit < NiceRequestInit , "body" | "headers" > & {
63
+ export async function niceBackendFetch ( url : string | URL , options ?: Omit < NiceRequestInit , "body" | "headers" > & {
64
64
accessType ?: null | "client" | "server" | "admin" ,
65
65
body ?: unknown ,
66
66
headers ?: Record < string , string | undefined > ,
@@ -70,7 +70,10 @@ export async function niceBackendFetch(url: string, options?: Omit<NiceRequestIn
70
70
expectSnakeCase ( body , "req.body" ) ;
71
71
}
72
72
const { projectKeys, userAuth } = backendContext . value ;
73
- const res = await niceFetch ( new URL ( url , STACK_BACKEND_BASE_URL ) , {
73
+ const fullUrl = new URL ( url , STACK_BACKEND_BASE_URL ) ;
74
+ if ( fullUrl . origin !== new URL ( STACK_BACKEND_BASE_URL ) . origin ) throw new StackAssertionError ( `Invalid niceBackendFetch origin: ${ fullUrl . origin } ` ) ;
75
+ if ( fullUrl . protocol !== new URL ( STACK_BACKEND_BASE_URL ) . protocol ) throw new StackAssertionError ( `Invalid niceBackendFetch protocol: ${ fullUrl . protocol } ` ) ;
76
+ const res = await niceFetch ( fullUrl , {
74
77
...otherOptions ,
75
78
...body !== undefined ? { body : JSON . stringify ( body ) } : { } ,
76
79
headers : filterUndefined ( {
@@ -288,6 +291,153 @@ export namespace Auth {
288
291
} ;
289
292
}
290
293
}
294
+
295
+ export namespace OAuth {
296
+ export async function getAuthorizeQuery ( ) {
297
+ const projectKeys = backendContext . value . projectKeys ;
298
+ if ( projectKeys === "no-project" ) throw new Error ( "No project keys found in the backend context" ) ;
299
+
300
+ return {
301
+ client_id : projectKeys . projectId ,
302
+ client_secret : projectKeys . publishableClientKey ?? throwErr ( "No publishable client key found in the backend context" ) ,
303
+ redirect_uri : localRedirectUrl ,
304
+ scope : "legacy" ,
305
+ response_type : "code" ,
306
+ state : "this-is-some-state" ,
307
+ grant_type : "authorization_code" ,
308
+ code_challenge : "some-code-challenge" ,
309
+ code_challenge_method : "plain" ,
310
+ } ;
311
+ }
312
+
313
+ export async function authorize ( options ?: { redirectUrl : string } ) {
314
+ const response = await niceBackendFetch ( "/api/v1/auth/oauth/authorize/facebook" , {
315
+ redirect : "manual" ,
316
+ query : {
317
+ ...await Auth . OAuth . getAuthorizeQuery ( ) ,
318
+ ...filterUndefined ( {
319
+ redirect_uri : options ?. redirectUrl ?? undefined ,
320
+ } ) ,
321
+ } ,
322
+ } ) ;
323
+ expect ( response . status ) . toBe ( 307 ) ;
324
+ expect ( response . headers . get ( "location" ) ) . toMatch ( / ^ h t t p : \/ \/ l o c a l h o s t : 8 1 0 7 \/ a u t h \? .* $ / ) ;
325
+ expect ( response . headers . get ( "set-cookie" ) ) . toMatch ( / ^ s t a c k - o a u t h - i n n e r - [ ^ ; ] + = [ ^ ; ] + ; P a t h = \/ ; E x p i r e s = [ ^ ; ] + ; M a x - A g e = \d + ; ( S e c u r e ; ) ? H t t p O n l y $ / ) ;
326
+ return {
327
+ authorizeResponse : response ,
328
+ } ;
329
+ }
330
+
331
+ export async function getInnerCallbackUrl ( options ?: { authorizeResponse : NiceResponse } ) {
332
+ options ??= await Auth . OAuth . authorize ( ) ;
333
+ const providerPassword = generateSecureRandomString ( ) ;
334
+ const authLocation = new URL ( options . authorizeResponse . headers . get ( "location" ) ! ) ;
335
+ const redirectResponse1 = await niceFetch ( authLocation , {
336
+ redirect : "manual" ,
337
+ } ) ;
338
+ expect ( redirectResponse1 ) . toEqual ( {
339
+ status : 303 ,
340
+ headers : expect . any ( Headers ) ,
341
+ body : expect . any ( String ) ,
342
+ } ) ;
343
+ const signInInteractionLocation = new URL ( redirectResponse1 . headers . get ( "location" ) ?? throwErr ( "missing redirect location" , { redirectResponse1 } ) , authLocation ) ;
344
+ const signInInteractionCookies = updateCookiesFromResponse ( "" , redirectResponse1 ) ;
345
+ const response1 = await niceFetch ( signInInteractionLocation , {
346
+ method : "POST" ,
347
+ redirect : "manual" ,
348
+ body : new URLSearchParams ( {
349
+ prompt : "login" ,
350
+ login : backendContext . value . mailbox . emailAddress ,
351
+ password : providerPassword ,
352
+ } ) ,
353
+ headers : {
354
+ "content-type" : "application/x-www-form-urlencoded" ,
355
+ cookie : signInInteractionCookies ,
356
+ } ,
357
+ } ) ;
358
+ expect ( response1 ) . toEqual ( {
359
+ status : 303 ,
360
+ headers : expect . any ( Headers ) ,
361
+ body : expect . any ( ArrayBuffer ) ,
362
+ } ) ;
363
+ const redirectResponse2 = await niceFetch ( new URL ( response1 . headers . get ( "location" ) ?? throwErr ( "missing redirect location" , { response1 } ) , signInInteractionLocation ) , {
364
+ redirect : "manual" ,
365
+ headers : {
366
+ cookie : updateCookiesFromResponse ( signInInteractionCookies , response1 ) ,
367
+ } ,
368
+ } ) ;
369
+ expect ( redirectResponse2 ) . toEqual ( {
370
+ status : 303 ,
371
+ headers : expect . any ( Headers ) ,
372
+ body : expect . any ( String ) ,
373
+ } ) ;
374
+ const authorizeInteractionLocation = new URL ( redirectResponse2 . headers . get ( "location" ) ?? throwErr ( "missing redirect location" , { redirectResponse2 } ) , authLocation ) ;
375
+ const authorizeInteractionCookies = updateCookiesFromResponse ( signInInteractionCookies , redirectResponse2 ) ;
376
+ const response2 = await niceFetch ( authorizeInteractionLocation , {
377
+ method : "POST" ,
378
+ redirect : "manual" ,
379
+ body : new URLSearchParams ( {
380
+ prompt : "consent" ,
381
+ } ) ,
382
+ headers : {
383
+ "content-type" : "application/x-www-form-urlencoded" ,
384
+ cookie : authorizeInteractionCookies ,
385
+ } ,
386
+ } ) ;
387
+ expect ( response2 ) . toEqual ( {
388
+ status : 303 ,
389
+ headers : expect . any ( Headers ) ,
390
+ body : expect . any ( ArrayBuffer ) ,
391
+ } ) ;
392
+ const redirectResponse3 = await niceFetch ( new URL ( response2 . headers . get ( "location" ) ?? throwErr ( "missing redirect location" , { response2 } ) , authLocation ) , {
393
+ redirect : "manual" ,
394
+ headers : {
395
+ cookie : updateCookiesFromResponse ( authorizeInteractionCookies , response2 ) ,
396
+ } ,
397
+ } ) ;
398
+ expect ( redirectResponse3 ) . toEqual ( {
399
+ status : 303 ,
400
+ headers : expect . any ( Headers ) ,
401
+ body : expect . any ( String ) ,
402
+ } ) ;
403
+ const innerCallbackUrl = new URL ( redirectResponse3 . headers . get ( "location" ) ?? throwErr ( "missing redirect location" , { redirectResponse3 } ) ) ;
404
+ expect ( innerCallbackUrl . origin ) . toBe ( "http://localhost:8102" ) ;
405
+ expect ( innerCallbackUrl . pathname ) . toBe ( "/api/v1/auth/oauth/callback/facebook" ) ;
406
+ return {
407
+ ...options ,
408
+ innerCallbackUrl,
409
+ } ;
410
+ }
411
+
412
+ export async function getAuthorizationCode ( options ?: { innerCallbackUrl : URL , authorizeResponse : NiceResponse } ) {
413
+ options ??= await Auth . OAuth . getInnerCallbackUrl ( ) ;
414
+ const cookie = updateCookiesFromResponse ( "" , options . authorizeResponse ) ;
415
+ const response = await niceBackendFetch ( options . innerCallbackUrl . toString ( ) , {
416
+ redirect : "manual" ,
417
+ headers : {
418
+ cookie,
419
+ } ,
420
+ } ) ;
421
+ expect ( response ) . toEqual ( {
422
+ status : 302 ,
423
+ headers : expect . any ( Headers ) ,
424
+ body : { } ,
425
+ } ) ;
426
+ const outerCallbackUrl = new URL ( response . headers . get ( "location" ) ?? throwErr ( "missing redirect location" , { response } ) ) ;
427
+ expect ( outerCallbackUrl . origin ) . toBe ( new URL ( localRedirectUrl ) . origin ) ;
428
+ expect ( outerCallbackUrl . pathname ) . toBe ( new URL ( localRedirectUrl ) . pathname ) ;
429
+ expect ( Object . fromEntries ( outerCallbackUrl . searchParams . entries ( ) ) ) . toEqual ( {
430
+ code : expect . any ( String ) ,
431
+ state : "this-is-some-state" ,
432
+ } ) ;
433
+
434
+ return {
435
+ callbackResponse : response ,
436
+ outerCallbackUrl,
437
+ authorizationCode : outerCallbackUrl . searchParams . get ( "code" ) ! ,
438
+ } ;
439
+ }
440
+ }
291
441
}
292
442
293
443
export namespace ContactChannels {
0 commit comments