Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
module.exports = {
'root': true,
'env': {
'browser': true,
'es2021': true
Expand Down
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/

.worktrees/
121 changes: 121 additions & 0 deletions __test__/querySync.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
// Static-analysis guard: the `Meta`/`AuthResponse` TypeScript interfaces and
// their hard-coded GraphQL selection strings in src/index.ts are two
// independent, unconnected pieces of code - adding a field to one without
// the other type-checks fine (tsc has no idea a string literal is supposed
// to mirror an interface). That's bitten this repo twice in one session
// (should_offer_webauthn_mfa_verify on AuthResponse, then is_mfa_enforced on
// Meta). This test reads the raw source of both files and asserts the field
// sets match, so a future drift fails a fast unit test instead of shipping
// silently.
import { readFileSync } from 'node:fs';
import { join } from 'node:path';

const typesSource = readFileSync(
join(__dirname, '../src/types.ts'),
'utf-8',
);
const indexSource = readFileSync(join(__dirname, '../src/index.ts'), 'utf-8');

function interfaceFields(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 fields: string[] = [];
for (const line of match[1].split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('//')) continue;
const fieldMatch = trimmed.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\??:/);
if (fieldMatch) fields.push(fieldMatch[1]);
}
return fields;
}

// Parses a GraphQL selection-set snippet (no surrounding braces) into its
// top-level field names, skipping over nested object selections (`user {
// ... }`) and template interpolations (`${userFragment}`) rather than
// descending into them.
function topLevelSelectionFields(selection: string): string[] {
const fields: string[] = [];
let depth = 0;
let i = 0;
while (i < selection.length) {
if (selection.startsWith('${', i)) {
const end = selection.indexOf('}', i + 2);
i = end === -1 ? selection.length : end + 1;
continue;
}
const ch = selection[i];
if (ch === '{') {
depth++;
i++;
continue;
}
if (ch === '}') {
depth--;
i++;
continue;
}
if (/[a-zA-Z_]/.test(ch)) {
let j = i;
while (j < selection.length && /[a-zA-Z0-9_]/.test(selection[j])) j++;
if (depth === 0) fields.push(selection.slice(i, j));
i = j;
continue;
}
i++;
}
return fields;
}

describe('Meta interface stays in sync with the meta query', () => {
it('every Meta field is selected, and every selected field is on Meta', () => {
const interfaceFieldSet = new Set(interfaceFields(typesSource, 'Meta'));

const queryMatch = indexSource.match(
/query meta\s*\{\s*meta\s*\{([^}]*)\}\s*\}/,
);
if (!queryMatch) throw new Error('meta query not found in src/index.ts');
const queryFieldSet = new Set(topLevelSelectionFields(queryMatch[1]));

const missingFromQuery = [...interfaceFieldSet].filter(
(f) => !queryFieldSet.has(f),
);
const missingFromInterface = [...queryFieldSet].filter(
(f) => !interfaceFieldSet.has(f),
);

expect({ missingFromQuery, missingFromInterface }).toEqual({
missingFromQuery: [],
missingFromInterface: [],
});
});
});

describe('AuthResponse interface stays in sync with authTokenFragment', () => {
it('every AuthResponse field is selected, and every selected field is on AuthResponse', () => {
const interfaceFieldSet = new Set(
interfaceFields(typesSource, 'AuthResponse'),
);

const fragmentMatch = indexSource.match(
/const authTokenFragment = `([^`]*)`;/,
);
if (!fragmentMatch) throw new Error('authTokenFragment not found');
const fragmentFieldSet = new Set(
topLevelSelectionFields(fragmentMatch[1]),
);

const missingFromFragment = [...interfaceFieldSet].filter(
(f) => !fragmentFieldSet.has(f),
);
const missingFromInterface = [...fragmentFieldSet].filter(
(f) => !interfaceFieldSet.has(f),
);

expect({ missingFromFragment, missingFromInterface }).toEqual({
missingFromFragment: [],
missingFromInterface: [],
});
});
});
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.6.0-rc.0",
"packageManager": "pnpm@8.15.6",
"author": "Lakhan Samani",
"license": "MIT",
Expand Down
34 changes: 31 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 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_mfa_setup should_offer_webauthn_mfa_verify 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);
Expand Down Expand Up @@ -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',
},
Expand Down Expand Up @@ -617,6 +617,34 @@ export class Authorizer {
}
};

// Authenticated (cookie/bearer) call — mirrors deactivateAccount's auth
// pattern (optional headers passthrough), not resendOtp's unauthenticated
// shape, since this dismisses the current user's own MFA setup prompt.
skipMfaSetup = async (
headers?: Types.Headers,
): Promise<Types.ApiResponse<Types.GenericResponse>> => {
try {
const res = await this.dispatch(
'skipMfaSetup',
['graphql'],
{
query: 'mutation skip_mfa_setup { skip_mfa_setup { message } }',
operationName: 'skip_mfa_setup',
op: 'skip_mfa_setup',
},
{ method: 'POST', path: '/v1/skip_mfa_setup', body: {} },
undefined,
headers,
);

return res?.errors?.length
? this.errorResponse(res.errors)
: this.okResponse(res.data);
} catch (err) {
return this.errorResponse([err]);
}
};

// WebAuthn / passkey ops are GraphQL-only (no REST route), so these call
// graphqlQuery directly rather than going through dispatch.

Expand Down
4 changes: 4 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -97,6 +98,7 @@ 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;
app_data: Record<string, any> | null;
}

Expand Down Expand Up @@ -137,6 +139,8 @@ 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_mfa_setup: boolean | null;
access_token: string | null;
id_token: string | null;
refresh_token: string | null;
Expand Down
Loading