@@ -27,6 +27,11 @@ const authTokenFragment = `message access_token expires_in refresh_token id_toke
2727// set fetch based on window object. Cross fetch have issues with umd build
2828const getFetcher = ( ) => ( hasWindow ( ) ? window . fetch : crossFetch ) ;
2929
30+ // Name prefix of the MFA-gate cookies the server sets on a token-withheld
31+ // signup/login (`mfa_session` and its domain-scoped twin `mfa_session_domain`
32+ // — see the backend's internal/cookie/mfa_session.go).
33+ const MFA_COOKIE_PREFIX = 'mfa_session' ;
34+
3035function toErrorList ( errors : unknown ) : Types . AuthorizerSDKError [ ] {
3136 if ( Array . isArray ( errors ) ) {
3237 return errors . map ( toSDKError ) ;
@@ -82,6 +87,23 @@ export class Authorizer {
8287 // it can be aborted before a modal ceremony starts - the browser allows only
8388 // one outstanding navigator.credentials.get() at a time.
8489 private conditionalPasskeyAbort ?: AbortController ;
90+ // MFA-session cookie store for non-browser runtimes. `credentials:
91+ // 'include'` is a browser-only mechanism: node's fetch has no cookie store,
92+ // so every Set-Cookie the server returns is dropped. That store is
93+ // REQUIRED, not an optimisation - since server 2.4.0 MFA is on by default,
94+ // so signup/login withhold the access token ("Proceed to mfa setup") and
95+ // identify the pending user by an `mfa_session` cookie. skipMfaSetup /
96+ // verifyOtp / the webauthn MFA-setup path resolve it only if that cookie
97+ // comes back, so without a store they always fail with "invalid session"
98+ // and the whole MFA surface is unreachable from node.
99+ //
100+ // Deliberately scoped to the MFA-gate cookies (MFA_COOKIE_PREFIX) rather
101+ // than being a general cookie jar: the server resolves a request's identity
102+ // from the `cookie` session BEFORE the Authorization header, so replaying a
103+ // login session cookie would silently override the bearer token a caller
104+ // passed explicitly. The MFA session is bound to one user id and consumed
105+ // on use, so it cannot be traded for another user's token.
106+ private mfaSessionCookies = new Map < string , string > ( ) ;
85107
86108 // constructor
87109 constructor ( config : Types . ConfigType ) {
@@ -1308,15 +1330,14 @@ export class Authorizer {
13081330 graphqlQuery = async (
13091331 data : Types . GraphqlQueryRequest ,
13101332 ) : Promise < Types . GrapQlResponseType > => {
1311- const fetcher = getFetcher ( ) ;
13121333 const body : Record < string , unknown > = {
13131334 query : data . query ,
13141335 variables : data . variables || { } ,
13151336 } ;
13161337 if ( data . operationName ) {
13171338 body . operationName = data . operationName ;
13181339 }
1319- const res = await fetcher ( `${ this . config . authorizerURL } /graphql` , {
1340+ const res = await this . fetchWithCookies ( `${ this . config . authorizerURL } /graphql` , {
13201341 method : 'POST' ,
13211342 body : JSON . stringify ( body ) ,
13221343 headers : {
@@ -1426,8 +1447,7 @@ export class Authorizer {
14261447 body ?: Record < string , unknown > ,
14271448 headers ?: Types . Headers ,
14281449 ) : Promise < Types . GrapQlResponseType > => {
1429- const fetcher = getFetcher ( ) ;
1430- const res = await fetcher ( `${ this . config . authorizerURL } ${ path } ` , {
1450+ const res = await this . fetchWithCookies ( `${ this . config . authorizerURL } ${ path } ` , {
14311451 method,
14321452 ...( method === 'POST' ? { body : JSON . stringify ( body || { } ) } : { } ) ,
14331453 headers : {
@@ -1472,6 +1492,68 @@ export class Authorizer {
14721492 return { data : coerceInt64Fields ( json ) , errors : [ ] } ;
14731493 } ;
14741494
1495+ // fetchWithCookies is the single choke point every graphql/rest call goes
1496+ // through. In a browser it is a plain fetch (the browser owns the cookies
1497+ // and `Cookie` is a forbidden request header anyway); elsewhere it replays
1498+ // the stored MFA session and records the one the response sets.
1499+ private fetchWithCookies = async (
1500+ url : string ,
1501+ init : Record < string , any > ,
1502+ ) : Promise < any > => {
1503+ const fetcher = getFetcher ( ) ;
1504+ if ( hasWindow ( ) ) return fetcher ( url , init as any ) ;
1505+
1506+ const cookie = [ ...this . mfaSessionCookies ]
1507+ . map ( ( [ k , v ] ) => `${ k } =${ v } ` )
1508+ . join ( '; ' ) ;
1509+ const res = await fetcher ( url , {
1510+ ...init ,
1511+ headers : {
1512+ // Caller-supplied headers still win, so an explicit Cookie header
1513+ // overrides the stored one.
1514+ ...( cookie ? { Cookie : cookie } : { } ) ,
1515+ ...init . headers ,
1516+ } ,
1517+ } as any ) ;
1518+ this . storeMfaSessionCookies ( res ) ;
1519+ return res ;
1520+ } ;
1521+
1522+ // storeMfaSessionCookies records the MFA-gate cookies from a response.
1523+ // ponytail: name=value only - no domain/path/Secure matching, because every
1524+ // request from this instance goes to the one origin in config.authorizerURL.
1525+ private storeMfaSessionCookies = ( res : any ) : void => {
1526+ const h = res ?. headers ;
1527+ // undici/whatwg expose getSetCookie(); cross-fetch on node (node-fetch v2)
1528+ // exposes raw(). `get('set-cookie')` is the last-resort single-value read.
1529+ const raw : string [ ] =
1530+ typeof h ?. getSetCookie === 'function'
1531+ ? h . getSetCookie ( )
1532+ : typeof h ?. raw === 'function'
1533+ ? h . raw ( ) [ 'set-cookie' ] || [ ]
1534+ : h ?. get ?.( 'set-cookie' )
1535+ ? [ h . get ( 'set-cookie' ) ]
1536+ : [ ] ;
1537+
1538+ for ( const entry of raw ) {
1539+ const [ pair , ...attrs ] = entry . split ( ';' ) ;
1540+ const eq = pair . indexOf ( '=' ) ;
1541+ if ( eq < 1 ) continue ;
1542+ const name = pair . slice ( 0 , eq ) . trim ( ) ;
1543+ if ( ! name . startsWith ( MFA_COOKIE_PREFIX ) ) continue ;
1544+ const value = pair . slice ( eq + 1 ) . trim ( ) ;
1545+ // The server expires a cookie by resending it empty with Max-Age<=0
1546+ // (consuming or abandoning the mfa session); drop it rather than
1547+ // replaying a dead session id.
1548+ const expired = attrs . some ( ( a ) => {
1549+ const [ k , v ] = a . split ( '=' ) ;
1550+ return k . trim ( ) . toLowerCase ( ) === 'max-age' && Number ( v ) <= 0 ;
1551+ } ) ;
1552+ if ( ! value || expired ) this . mfaSessionCookies . delete ( name ) ;
1553+ else this . mfaSessionCookies . set ( name , value ) ;
1554+ }
1555+ } ;
1556+
14751557 errorResponse = ( errors : unknown ) : Types . ApiResponse < any > => {
14761558 return {
14771559 data : undefined ,
0 commit comments