Skip to content
Open
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,5 @@ package-lock.json
.pnpm-store
# Local planning artifacts (not for publication)
docs/superpowers/
# Local git-worktree scratch dir
.worktrees/
161 changes: 161 additions & 0 deletions __test__/mfaMethods.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
46 changes: 46 additions & 0 deletions __test__/mfaRedirect.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
92 changes: 92 additions & 0 deletions __test__/querySync.test.ts
Original file line number Diff line number Diff line change
@@ -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);
}
});
});
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Loading
Loading