diff --git a/.eslintrc.js b/.eslintrc.js index ac92bc9..2a02313 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,4 +1,5 @@ module.exports = { + 'root': true, 'env': { 'browser': true, 'es2021': true diff --git a/.gitignore b/.gitignore index 9d17e88..0ec62d6 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,5 @@ package-lock.json .pnpm-store # Local planning artifacts (not for publication) docs/superpowers/ + +.worktrees/ diff --git a/__test__/querySync.test.ts b/__test__/querySync.test.ts new file mode 100644 index 0000000..08b0a3a --- /dev/null +++ b/__test__/querySync.test.ts @@ -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: [], + }); + }); +}); diff --git a/package.json b/package.json index 09c8ea3..ff4de71 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/index.ts b/src/index.ts index 457bcae..0a95e83 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 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); @@ -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', }, @@ -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> => { + 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. diff --git a/src/types.ts b/src/types.ts index 946b1d6..060843d 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,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 | null; } @@ -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;