From 188102b45644448dac7d22faf3df20296c12b529 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Wed, 15 Jul 2026 18:14:44 +0530 Subject: [PATCH 1/8] feat(mfa): add MFA behavior redesign fields + query-sync guard --- __test__/querySync.test.ts | 64 ++++++++++++++++++++++++++++++++++++++ src/index.ts | 6 ++-- src/types.ts | 27 ++++++++++++++++ 3 files changed, 94 insertions(+), 3 deletions(-) create mode 100644 __test__/querySync.test.ts diff --git a/__test__/querySync.test.ts b/__test__/querySync.test.ts new file mode 100644 index 0000000..029cfd6 --- /dev/null +++ b/__test__/querySync.test.ts @@ -0,0 +1,64 @@ +// Regression guard: every field on AuthResponse/Meta must have a matching +// token in its corresponding hand-written GraphQL query/fragment string in +// src/index.ts, and vice versa. TypeScript type-checking does NOT catch this +// class of bug — the interface and the query string are independent, and +// this project has shipped the omission twice (should_offer_webauthn_mfa_verify +// and is_mfa_enforced both missing from their query strings, in an earlier, +// unmerged version of this same MFA work). +import * as fs from 'fs'; +import * as path from 'path'; + +const indexSource = fs.readFileSync( + path.join(__dirname, '../src/index.ts'), + 'utf-8', +); +const typesSource = fs.readFileSync( + path.join(__dirname, '../src/types.ts'), + 'utf-8', +); + +function fieldsOfInterface(source: string, interfaceName: string): string[] { + const match = source.match( + new RegExp(`export interface ${interfaceName} \\{([\\s\\S]*?)\\n\\}`), + ); + if (!match) throw new Error(`interface ${interfaceName} not found`); + const body = match[1]; + const fieldRegex = /^\s*([a-zA-Z_][a-zA-Z0-9_]*)\??:/gm; + const fields: string[] = []; + let m: RegExpExecArray | null; + while ((m = fieldRegex.exec(body))) { + fields.push(m[1]); + } + return fields; +} + +describe('query/type sync', () => { + it('every AuthResponse field appears in authTokenFragment, and vice versa', () => { + const fields = fieldsOfInterface(typesSource, 'AuthResponse').filter( + (f) => f !== 'user', // nested fragment, not a flat token + ); + const fragmentMatch = indexSource.match( + /const authTokenFragment = `([\s\S]*?)`;/, + ); + expect(fragmentMatch).not.toBeNull(); + const fragment = fragmentMatch![1]; + + for (const field of fields) { + expect(fragment).toContain(field); + } + }); + + it('every Meta field appears in the meta query string, and vice versa', () => { + const fields = fieldsOfInterface(typesSource, 'Meta'); + const queryMatch = indexSource.match(/query meta \{ meta \{ ([\s\S]*?) \} \}/); + expect(queryMatch).not.toBeNull(); + const queryFields = queryMatch![1].trim().split(/\s+/); + + for (const field of fields) { + expect(queryFields).toContain(field); + } + for (const queryField of queryFields) { + expect(fields).toContain(queryField); + } + }); +}); diff --git a/src/index.ts b/src/index.ts index 457bcae..3948733 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,8 +21,8 @@ import * as webauthn from './webauthn'; // re-usable gql response fragment const userFragment = - 'id email email_verified given_name family_name middle_name nickname preferred_username picture signup_methods gender birthdate phone_number phone_number_verified roles created_at updated_at revoked_timestamp is_multi_factor_auth_enabled app_data'; -const authTokenFragment = `message access_token expires_in refresh_token id_token should_show_email_otp_screen should_show_mobile_otp_screen should_show_totp_screen authenticator_scanner_image authenticator_secret authenticator_recovery_codes user { ${userFragment} }`; + 'id email email_verified given_name family_name middle_name nickname preferred_username picture signup_methods gender birthdate phone_number phone_number_verified roles created_at updated_at revoked_timestamp is_multi_factor_auth_enabled has_skipped_mfa_setup_at mfa_locked_at app_data'; +const authTokenFragment = `message access_token expires_in refresh_token id_token should_show_email_otp_screen should_show_mobile_otp_screen should_show_totp_screen should_offer_webauthn_mfa_verify should_offer_webauthn_mfa_setup should_offer_email_otp_mfa_setup should_offer_sms_otp_mfa_setup authenticator_scanner_image authenticator_secret authenticator_recovery_codes user { ${userFragment} }`; // set fetch based on window object. Cross fetch have issues with umd build const getFetcher = () => (hasWindow() ? window.fetch : crossFetch); @@ -244,7 +244,7 @@ export class Authorizer { ['graphql', 'rest'], { query: - 'query meta { meta { version client_id is_google_login_enabled is_facebook_login_enabled is_github_login_enabled is_linkedin_login_enabled is_apple_login_enabled is_discord_login_enabled is_twitter_login_enabled is_microsoft_login_enabled is_twitch_login_enabled is_roblox_login_enabled is_email_verification_enabled is_basic_authentication_enabled is_magic_link_login_enabled is_sign_up_enabled is_strong_password_enabled is_multi_factor_auth_enabled is_mobile_basic_authentication_enabled is_phone_verification_enabled is_totp_mfa_enabled is_email_otp_mfa_enabled is_sms_otp_mfa_enabled is_webauthn_enabled } }', + 'query meta { meta { version client_id is_google_login_enabled is_facebook_login_enabled is_github_login_enabled is_linkedin_login_enabled is_apple_login_enabled is_discord_login_enabled is_twitter_login_enabled is_microsoft_login_enabled is_twitch_login_enabled is_roblox_login_enabled is_email_verification_enabled is_basic_authentication_enabled is_magic_link_login_enabled is_sign_up_enabled is_strong_password_enabled is_multi_factor_auth_enabled is_mobile_basic_authentication_enabled is_phone_verification_enabled is_totp_mfa_enabled is_email_otp_mfa_enabled is_sms_otp_mfa_enabled is_webauthn_enabled is_mfa_enforced } }', operationName: 'meta', op: 'meta', }, diff --git a/src/types.ts b/src/types.ts index 946b1d6..aca0bfa 100644 --- a/src/types.ts +++ b/src/types.ts @@ -74,6 +74,7 @@ export interface Meta { is_email_otp_mfa_enabled: boolean; is_sms_otp_mfa_enabled: boolean; is_webauthn_enabled: boolean; + is_mfa_enforced: boolean; } // User @@ -97,6 +98,8 @@ export interface User { updated_at: number | null; revoked_timestamp: number | null; is_multi_factor_auth_enabled: boolean | null; + has_skipped_mfa_setup_at: number | null; + mfa_locked_at: number | null; app_data: Record | null; } @@ -137,6 +140,10 @@ export interface AuthResponse { should_show_email_otp_screen: boolean | null; should_show_mobile_otp_screen: boolean | null; should_show_totp_screen: boolean | null; + should_offer_webauthn_mfa_verify: boolean | null; + should_offer_webauthn_mfa_setup: boolean | null; + should_offer_email_otp_mfa_setup: boolean | null; + should_offer_sms_otp_mfa_setup: boolean | null; access_token: string | null; id_token: string | null; refresh_token: string | null; @@ -238,6 +245,25 @@ export interface VerifyOTPRequest { // Keep VerifyOtpRequest as alias for backward compatibility export type VerifyOtpRequest = VerifyOTPRequest; +// SkipMfaSetupRequest +export interface SkipMfaSetupRequest { + email?: string; + phone_number?: string; + state?: string; +} + +// LockMfaRequest +export interface LockMfaRequest { + email?: string; + phone_number?: string; +} + +// OtpMfaSetupRequest +export interface OtpMfaSetupRequest { + email?: string; + phone_number?: string; +} + // WebAuthn / passkey types. `options`/`credential` are opaque JSON strings - // the server's PublicKeyCredentialCreationOptionsJSON / RequestOptionsJSON on // the way out, and the browser's RegistrationResponseJSON / @@ -315,6 +341,7 @@ export interface UpdateUserRequest { roles?: string[] | null; is_multi_factor_auth_enabled?: boolean | null; app_data?: Record | null; + reset_mfa?: boolean; } // ForgotPasswordRequest From 3767f4e36bb84432699be7a532e20f81e0908107 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Wed, 15 Jul 2026 18:24:32 +0530 Subject: [PATCH 2/8] fix(test): add bidirectional checks to query/fragment sync test The AuthResponse test claimed to check 'vice versa' but only verified that interface fields appeared in the fragment. Fixed by adding reverse check that each fragment token maps to an interface field, correctly extracting only top-level tokens from authTokenFragment to handle the nested user { userFragment } sub-selection. Added new User <-> userFragment test with full bidirectional coverage, matching the existing Meta test pattern. Both reverse-direction checks now catch drift where fragments contain fields not in the interface. --- __test__/querySync.test.ts | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/__test__/querySync.test.ts b/__test__/querySync.test.ts index 029cfd6..6953c47 100644 --- a/__test__/querySync.test.ts +++ b/__test__/querySync.test.ts @@ -43,9 +43,21 @@ describe('query/type sync', () => { expect(fragmentMatch).not.toBeNull(); const fragment = fragmentMatch![1]; + // Extract only top-level tokens, excluding nested content inside user { ... } + const userStart = fragment.indexOf('user {'); + const topLevelFragment = userStart !== -1 + ? fragment.substring(0, userStart) + 'user' + : fragment; + const queryFields = topLevelFragment + .split(/\s+/) + .filter((f) => f && f !== 'user'); // exclude nested user fragment + for (const field of fields) { expect(fragment).toContain(field); } + for (const queryField of queryFields) { + expect(fields).toContain(queryField); + } }); it('every Meta field appears in the meta query string, and vice versa', () => { @@ -61,4 +73,20 @@ describe('query/type sync', () => { expect(fields).toContain(queryField); } }); + + it('every User field appears in userFragment, and vice versa', () => { + const fields = fieldsOfInterface(typesSource, 'User'); + const fragmentMatch = indexSource.match( + /const userFragment =\s*'([\s\S]*?)';/, + ); + expect(fragmentMatch).not.toBeNull(); + const fragmentFields = fragmentMatch![1].split(/\s+/).filter(f => f); + + for (const field of fields) { + expect(fragmentFields).toContain(field); + } + for (const fragmentField of fragmentFields) { + expect(fields).toContain(fragmentField); + } + }); }); From 742829d17b44793ba445255a4f1bb3fbd813bf10 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Wed, 15 Jul 2026 18:28:55 +0530 Subject: [PATCH 3/8] feat(mfa): add skipMfaSetup, lockMfa, emailOtpMfaSetup, smsOtpMfaSetup --- __test__/mfaMethods.test.ts | 117 ++++++++++++++++++++++++++++++++++++ src/index.ts | 99 ++++++++++++++++++++++++++++++ 2 files changed, 216 insertions(+) create mode 100644 __test__/mfaMethods.test.ts diff --git a/__test__/mfaMethods.test.ts b/__test__/mfaMethods.test.ts new file mode 100644 index 0000000..c7381e7 --- /dev/null +++ b/__test__/mfaMethods.test.ts @@ -0,0 +1,117 @@ +jest.mock('cross-fetch', () => jest.fn()); + +import crossFetch from 'cross-fetch'; +import { Authorizer } from '../lib'; + +const mockFetch = crossFetch as unknown as jest.Mock; + +const jsonResponse = (body: unknown) => + Promise.resolve({ + ok: true, + status: 200, + text: () => Promise.resolve(JSON.stringify(body)), + }); + +describe('MFA setup/skip/lock SDK methods', () => { + const authorizerRef = new Authorizer({ + authorizerURL: 'http://localhost:8080', + redirectURL: 'http://localhost:8080/app', + }); + + afterEach(() => mockFetch.mockReset()); + + it('skipMfaSetup sends email/phone_number/state and returns the issued token', async () => { + mockFetch.mockReturnValueOnce( + jsonResponse({ + data: { + skip_mfa_setup: { + message: 'MFA setup skipped', + access_token: 'tok-123', + user: { id: 'u1' }, + }, + }, + }), + ); + const res = await authorizerRef.skipMfaSetup({ + email: 'user@example.com', + state: 'oidc-state', + }); + expect(res.errors).toHaveLength(0); + expect(res.data?.access_token).toBe('tok-123'); + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.operationName).toBe('skip_mfa_setup'); + expect(body.variables.data.email).toBe('user@example.com'); + expect(body.variables.data.state).toBe('oidc-state'); + }); + + it('lockMfa sends email/phone_number and returns the message', async () => { + mockFetch.mockReturnValueOnce( + jsonResponse({ + data: { + lock_mfa: { + message: + 'Your account is locked. Contact your administrator to regain access.', + }, + }, + }), + ); + const res = await authorizerRef.lockMfa({ email: 'user@example.com' }); + expect(res.errors).toHaveLength(0); + expect(res.data?.message).toMatch(/locked/i); + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.operationName).toBe('lock_mfa'); + expect(body.variables.data.email).toBe('user@example.com'); + }); + + it('emailOtpMfaSetup works with no params (bearer-token mode)', async () => { + mockFetch.mockReturnValueOnce( + jsonResponse({ + data: { + email_otp_mfa_setup: { + message: 'Check your email for the verification code', + }, + }, + }), + ); + const res = await authorizerRef.emailOtpMfaSetup(); + expect(res.errors).toHaveLength(0); + expect(res.data?.message).toMatch(/email/i); + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.operationName).toBe('email_otp_mfa_setup'); + }); + + it('emailOtpMfaSetup sends email when provided (cookie mode)', async () => { + mockFetch.mockReturnValueOnce( + jsonResponse({ + data: { + email_otp_mfa_setup: { + message: 'Check your email for the verification code', + }, + }, + }), + ); + await authorizerRef.emailOtpMfaSetup({ email: 'user@example.com' }); + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.variables.data.email).toBe('user@example.com'); + }); + + it('smsOtpMfaSetup sends phone_number and returns the message', async () => { + mockFetch.mockReturnValueOnce( + jsonResponse({ + data: { + sms_otp_mfa_setup: { + message: 'Check your phone for the verification code', + }, + }, + }), + ); + const res = await authorizerRef.smsOtpMfaSetup({ + phone_number: '+15551234567', + }); + expect(res.errors).toHaveLength(0); + expect(res.data?.message).toMatch(/phone/i); + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.operationName).toBe('sms_otp_mfa_setup'); + expect(body.variables.data.phone_number).toBe('+15551234567'); + }); +}); diff --git a/src/index.ts b/src/index.ts index 3948733..863caa1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1083,6 +1083,105 @@ export class Authorizer { } }; + skipMfaSetup = async ( + data: Types.SkipMfaSetupRequest, + ): Promise> => { + try { + const res = await this.dispatch( + 'skipMfaSetup', + ['graphql', 'rest'], + { + query: `mutation skip_mfa_setup($data: SkipMfaSetupRequest!) { skip_mfa_setup(params: $data) { ${authTokenFragment}}}`, + operationName: 'skip_mfa_setup', + op: 'skip_mfa_setup', + }, + { method: 'POST', path: '/v1/skip_mfa_setup', body: data }, + { data }, + ); + + return res?.errors?.length + ? this.errorResponse(res.errors) + : this.okResponse(res.data); + } catch (err) { + return this.errorResponse([err]); + } + }; + + lockMfa = async ( + data: Types.LockMfaRequest, + ): Promise> => { + try { + const res = await this.dispatch( + 'lockMfa', + ['graphql', 'rest'], + { + query: + 'mutation lock_mfa($data: LockMfaRequest!) { lock_mfa(params: $data) { message } }', + operationName: 'lock_mfa', + op: 'lock_mfa', + }, + { method: 'POST', path: '/v1/lock_mfa', body: data }, + { data }, + ); + + return res?.errors?.length + ? this.errorResponse(res.errors) + : this.okResponse(res.data); + } catch (err) { + return this.errorResponse([err]); + } + }; + + emailOtpMfaSetup = async ( + data?: Types.OtpMfaSetupRequest, + ): Promise> => { + try { + const res = await this.dispatch( + 'emailOtpMfaSetup', + ['graphql', 'rest'], + { + query: + 'mutation email_otp_mfa_setup($data: OtpMfaSetupRequest) { email_otp_mfa_setup(params: $data) { message } }', + operationName: 'email_otp_mfa_setup', + op: 'email_otp_mfa_setup', + }, + { method: 'POST', path: '/v1/email_otp_mfa_setup', body: data }, + { data }, + ); + + return res?.errors?.length + ? this.errorResponse(res.errors) + : this.okResponse(res.data); + } catch (err) { + return this.errorResponse([err]); + } + }; + + smsOtpMfaSetup = async ( + data?: Types.OtpMfaSetupRequest, + ): Promise> => { + try { + const res = await this.dispatch( + 'smsOtpMfaSetup', + ['graphql', 'rest'], + { + query: + 'mutation sms_otp_mfa_setup($data: OtpMfaSetupRequest) { sms_otp_mfa_setup(params: $data) { message } }', + operationName: 'sms_otp_mfa_setup', + op: 'sms_otp_mfa_setup', + }, + { method: 'POST', path: '/v1/sms_otp_mfa_setup', body: data }, + { data }, + ); + + return res?.errors?.length + ? this.errorResponse(res.errors) + : this.okResponse(res.data); + } catch (err) { + return this.errorResponse([err]); + } + }; + // helper to execute graphql queries // takes in any query or mutation string as value graphqlQuery = async ( From 6466d339ebd16e117f5b81b45ef8b71905567a07 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Wed, 15 Jul 2026 18:32:55 +0530 Subject: [PATCH 4/8] feat(mfa): add parseMfaRedirectParams OAuth-redirect helper --- __test__/mfaRedirect.test.ts | 46 ++++++++++++++++++++++++++++++++++++ src/index.ts | 6 +++++ src/mfaRedirect.ts | 34 ++++++++++++++++++++++++++ 3 files changed, 86 insertions(+) create mode 100644 __test__/mfaRedirect.test.ts create mode 100644 src/mfaRedirect.ts diff --git a/__test__/mfaRedirect.test.ts b/__test__/mfaRedirect.test.ts new file mode 100644 index 0000000..3e86cc2 --- /dev/null +++ b/__test__/mfaRedirect.test.ts @@ -0,0 +1,46 @@ +import { parseMfaRedirectParams, MFA_REQUIRED_PARAM, MFA_METHODS_PARAM } from '../lib'; + +describe('parseMfaRedirectParams', () => { + it('returns null when mfa_required is absent', () => { + const result = parseMfaRedirectParams( + 'http://localhost:3000/app?state=abc&code=xyz', + ); + expect(result).toBeNull(); + }); + + it('returns null when mfa_required is present but not "1"', () => { + const result = parseMfaRedirectParams( + 'http://localhost:3000/app?mfa_required=0&state=abc', + ); + expect(result).toBeNull(); + }); + + it('parses mfa_required=1 with a comma-separated method list', () => { + const result = parseMfaRedirectParams( + 'http://localhost:3000/app?mfa_required=1&mfa_methods=totp%2Cwebauthn', + ); + expect(result).toEqual({ + mfaRequired: true, + mfaMethods: ['totp', 'webauthn'], + }); + }); + + it('handles a missing mfa_methods param as an empty list', () => { + const result = parseMfaRedirectParams( + 'http://localhost:3000/app?mfa_required=1', + ); + expect(result).toEqual({ mfaRequired: true, mfaMethods: [] }); + }); + + it('accepts a URL instance, not just a string', () => { + const result = parseMfaRedirectParams( + new URL('http://localhost:3000/app?mfa_required=1&mfa_methods=totp'), + ); + expect(result).toEqual({ mfaRequired: true, mfaMethods: ['totp'] }); + }); + + it('exports the exact param-name constants the backend uses', () => { + expect(MFA_REQUIRED_PARAM).toBe('mfa_required'); + expect(MFA_METHODS_PARAM).toBe('mfa_methods'); + }); +}); diff --git a/src/index.ts b/src/index.ts index 863caa1..246c1ca 100644 --- a/src/index.ts +++ b/src/index.ts @@ -53,6 +53,12 @@ function toErrorList(errors: unknown): Types.AuthorizerSDKError[] { export * from './types'; export { AuthorizerAdmin } from './admin'; export { isWebauthnSupported } from './webauthn'; +export { + parseMfaRedirectParams, + MFA_REQUIRED_PARAM, + MFA_METHODS_PARAM, +} from './mfaRedirect'; +export type { MfaRedirectParams } from './mfaRedirect'; export { CLIENT_ASSERTION_TYPE_JWT_BEARER, GRANT_TYPE_AUTHORIZATION_CODE, diff --git a/src/mfaRedirect.ts b/src/mfaRedirect.ts new file mode 100644 index 0000000..8cdb978 --- /dev/null +++ b/src/mfaRedirect.ts @@ -0,0 +1,34 @@ +// Parses the mfa_required/mfa_methods query params the backend's OAuth +// callback (oauth_callback.go) appends to the redirect URL instead of the +// normal state/code params when a first-time MFA offer or verification is +// needed before a token can be issued -- see authorizer's +// internal/service/oauth_mfa_gate.go. Standalone (no Authorizer instance +// needed) since the redirect page is the entry point, before any SDK client +// is necessarily constructed. + +export const MFA_REQUIRED_PARAM = 'mfa_required'; +export const MFA_METHODS_PARAM = 'mfa_methods'; + +export interface MfaRedirectParams { + mfaRequired: true; + // Raw method-name strings from the backend (e.g. 'totp', 'webauthn', + // 'email_otp', 'sms_otp') -- intentionally not enum-typed, since the + // backend's method list is expected to grow over time and a strict union + // here would need updating in lockstep for no benefit to the caller, who + // only needs to know which setup/verify screens to route to. + mfaMethods: string[]; +} + +export function parseMfaRedirectParams( + url: string | URL, +): MfaRedirectParams | null { + const parsed = typeof url === 'string' ? new URL(url) : url; + if (parsed.searchParams.get(MFA_REQUIRED_PARAM) !== '1') { + return null; + } + const methodsParam = parsed.searchParams.get(MFA_METHODS_PARAM); + const mfaMethods = methodsParam + ? methodsParam.split(',').filter((m) => m.length > 0) + : []; + return { mfaRequired: true, mfaMethods }; +} From e7a4211ae44ac98581b270bd38f0ae894c160bea Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Wed, 15 Jul 2026 18:39:05 +0530 Subject: [PATCH 5/8] docs(mfa): note absolute-URL requirement, tighten querySync exactness JSDoc on parseMfaRedirectParams noting it needs an absolute URL (window.location.href), not .search/.pathname. querySync's AuthResponse check now uses exact top-level token membership on both sides, matching the Meta/User checks instead of a looser substring check on the forward direction. --- __test__/querySync.test.ts | 2 +- src/mfaRedirect.ts | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/__test__/querySync.test.ts b/__test__/querySync.test.ts index 6953c47..471d88d 100644 --- a/__test__/querySync.test.ts +++ b/__test__/querySync.test.ts @@ -53,7 +53,7 @@ describe('query/type sync', () => { .filter((f) => f && f !== 'user'); // exclude nested user fragment for (const field of fields) { - expect(fragment).toContain(field); + expect(queryFields).toContain(field); } for (const queryField of queryFields) { expect(fields).toContain(queryField); diff --git a/src/mfaRedirect.ts b/src/mfaRedirect.ts index 8cdb978..190197c 100644 --- a/src/mfaRedirect.ts +++ b/src/mfaRedirect.ts @@ -19,6 +19,12 @@ export interface MfaRedirectParams { mfaMethods: string[]; } +/** + * @param url The full redirect URL (e.g. `window.location.href`), or a + * `URL` instance. Must be absolute -- passing `window.location.search` or + * `.pathname` will throw, since those aren't parseable as a `URL` on their + * own. + */ export function parseMfaRedirectParams( url: string | URL, ): MfaRedirectParams | null { From 073b3744312f2d61c0159658bb9d6ee0616a6097 Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Wed, 15 Jul 2026 18:43:29 +0530 Subject: [PATCH 6/8] chore: ignore .worktrees/ (local git-worktree scratch dir) --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 9d17e88..371b7c4 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,5 @@ package-lock.json .pnpm-store # Local planning artifacts (not for publication) docs/superpowers/ +# Local git-worktree scratch dir +.worktrees/ From fd561eddc1efcc550d398d769dd5e0c238afe34f Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Thu, 16 Jul 2026 12:28:39 +0530 Subject: [PATCH 7/8] chore: bump version to 3.3.0-rc.2 3.3.0-rc.1 is already published to npm without skipMfaSetup, emailOtpMfaSetup, smsOtpMfaSetup, or parseMfaRedirectParams - reusing that version string left every clean npm install of authorizer-react silently resolving a stale SDK. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 09c8ea3..8d0dfce 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@authorizerdev/authorizer-js", - "version": "3.3.0-rc.1", + "version": "3.3.0-rc.2", "packageManager": "pnpm@8.15.6", "author": "Lakhan Samani", "license": "MIT", From 0d37a22185b04433d42e398791ae59adea76e21a Mon Sep 17 00:00:00 2001 From: Lakhan Samani Date: Thu, 16 Jul 2026 18:11:32 +0530 Subject: [PATCH 8/8] feat(mfa): add totpMfaSetup SDK method email/smsOtpMfaSetup's TOTP twin - settings-mode TOTP enrollment for an already-authenticated caller, backed by the backend's new totp_mfa_setup mutation. Returns the enrollment payload (authenticator_scanner_image/secret/recovery_codes) directly rather than just a message, since nothing is sent anywhere for TOTP - the caller scans/enters it, then completes enrollment via verifyOtp({ is_totp: true }). --- __test__/mfaMethods.test.ts | 44 +++++++++++++++++++++++++++++++++++++ src/index.ts | 32 +++++++++++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/__test__/mfaMethods.test.ts b/__test__/mfaMethods.test.ts index c7381e7..708edb6 100644 --- a/__test__/mfaMethods.test.ts +++ b/__test__/mfaMethods.test.ts @@ -114,4 +114,48 @@ describe('MFA setup/skip/lock SDK methods', () => { expect(body.operationName).toBe('sms_otp_mfa_setup'); expect(body.variables.data.phone_number).toBe('+15551234567'); }); + + it('totpMfaSetup works with no params (bearer-token mode) and returns the enrollment payload', async () => { + mockFetch.mockReturnValueOnce( + jsonResponse({ + data: { + totp_mfa_setup: { + message: 'Proceed to totp verification screen', + should_show_totp_screen: true, + authenticator_scanner_image: 'base64-image-data', + authenticator_secret: 'JBSWY3DPEHPK3PXP', + authenticator_recovery_codes: ['code-1', 'code-2'], + }, + }, + }), + ); + const res = await authorizerRef.totpMfaSetup(); + expect(res.errors).toHaveLength(0); + expect(res.data?.authenticator_secret).toBe('JBSWY3DPEHPK3PXP'); + expect(res.data?.authenticator_recovery_codes).toEqual([ + 'code-1', + 'code-2', + ]); + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.operationName).toBe('totp_mfa_setup'); + }); + + it('totpMfaSetup sends email when provided (cookie mode)', async () => { + mockFetch.mockReturnValueOnce( + jsonResponse({ + data: { + totp_mfa_setup: { + message: 'Proceed to totp verification screen', + should_show_totp_screen: true, + authenticator_scanner_image: 'base64-image-data', + authenticator_secret: 'JBSWY3DPEHPK3PXP', + authenticator_recovery_codes: ['code-1'], + }, + }, + }), + ); + await authorizerRef.totpMfaSetup({ email: 'user@example.com' }); + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.variables.data.email).toBe('user@example.com'); + }); }); diff --git a/src/index.ts b/src/index.ts index 246c1ca..77c826d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1188,6 +1188,38 @@ export class Authorizer { } }; + // totpMfaSetup generates a fresh TOTP secret/QR/recovery-codes for the + // caller to enroll as an MFA method - email/smsOtpMfaSetup's TOTP twin. + // Unlike those, nothing is sent anywhere: the enrollment payload comes + // back directly (same shape login/signup/verifyOtp already return via + // authenticator_scanner_image/authenticator_secret/authenticator_recovery_codes), + // so the caller scans/enters it, then completes enrollment via + // verifyOtp({ is_totp: true }). + totpMfaSetup = async ( + data?: Types.OtpMfaSetupRequest, + ): Promise> => { + try { + const res = await this.dispatch( + 'totpMfaSetup', + ['graphql', 'rest'], + { + query: + 'mutation totp_mfa_setup($data: OtpMfaSetupRequest) { totp_mfa_setup(params: $data) { message should_show_totp_screen authenticator_scanner_image authenticator_secret authenticator_recovery_codes } }', + operationName: 'totp_mfa_setup', + op: 'totp_mfa_setup', + }, + { method: 'POST', path: '/v1/totp_mfa_setup', body: data }, + { data }, + ); + + return res?.errors?.length + ? this.errorResponse(res.errors) + : this.okResponse(res.data); + } catch (err) { + return this.errorResponse([err]); + } + }; + // helper to execute graphql queries // takes in any query or mutation string as value graphqlQuery = async (