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/ diff --git a/__test__/mfaMethods.test.ts b/__test__/mfaMethods.test.ts new file mode 100644 index 0000000..708edb6 --- /dev/null +++ b/__test__/mfaMethods.test.ts @@ -0,0 +1,161 @@ +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'); + }); + + 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/__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/__test__/querySync.test.ts b/__test__/querySync.test.ts new file mode 100644 index 0000000..471d88d --- /dev/null +++ b/__test__/querySync.test.ts @@ -0,0 +1,92 @@ +// 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]; + + // 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(queryFields).toContain(field); + } + for (const queryField of queryFields) { + expect(fields).toContain(queryField); + } + }); + + 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); + } + }); + + 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); + } + }); +}); 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", diff --git a/src/index.ts b/src/index.ts index 457bcae..77c826d 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); @@ -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, @@ -244,7 +250,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', }, @@ -1083,6 +1089,137 @@ 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]); + } + }; + + // 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 ( diff --git a/src/mfaRedirect.ts b/src/mfaRedirect.ts new file mode 100644 index 0000000..190197c --- /dev/null +++ b/src/mfaRedirect.ts @@ -0,0 +1,40 @@ +// 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[]; +} + +/** + * @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 { + 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 }; +} 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