diff --git a/__test__/admin.test.ts b/__test__/admin.test.ts index 128f73d..b436aca 100644 --- a/__test__/admin.test.ts +++ b/__test__/admin.test.ts @@ -73,7 +73,7 @@ describe('Integration Tests - AuthorizerAdmin (graphql + rest)', () => { const { args } = buildAuthorizerCliArgs(); container = await new GenericContainer( - process.env.AUTHORIZER_IMAGE || 'quay.io/authorizer/authorizer:2.3.0', + process.env.AUTHORIZER_IMAGE || 'quay.io/authorizer/authorizer:2.4.0-rc.1', ) .withCommand(args) .withExposedPorts(8080) diff --git a/__test__/index.test.ts b/__test__/index.test.ts index 4913b81..d2b75fc 100644 --- a/__test__/index.test.ts +++ b/__test__/index.test.ts @@ -97,7 +97,7 @@ describe('Integration Tests - authorizer-js', () => { // Override with AUTHORIZER_IMAGE to test against a different server build // (e.g. a locally built image with newer GraphQL surface). container = await new GenericContainer( - process.env.AUTHORIZER_IMAGE || 'quay.io/authorizer/authorizer:2.3.0', + process.env.AUTHORIZER_IMAGE || 'quay.io/authorizer/authorizer:2.4.0-rc.1', ) .withCommand(args) .withExposedPorts(8080) diff --git a/__test__/mfaRedirect.test.ts b/__test__/mfaRedirect.test.ts index 3e86cc2..17d3ce6 100644 --- a/__test__/mfaRedirect.test.ts +++ b/__test__/mfaRedirect.test.ts @@ -1,4 +1,9 @@ -import { parseMfaRedirectParams, MFA_REQUIRED_PARAM, MFA_METHODS_PARAM } from '../lib'; +import { + parseMfaRedirectParams, + MFA_REQUIRED_PARAM, + MFA_METHODS_PARAM, + MFA_GATE_PARAM, +} from '../lib'; describe('parseMfaRedirectParams', () => { it('returns null when mfa_required is absent', () => { @@ -15,32 +20,52 @@ describe('parseMfaRedirectParams', () => { expect(result).toBeNull(); }); - it('parses mfa_required=1 with a comma-separated method list', () => { + it('parses mfa_required=1 with a comma-separated method list, defaulting mfaGate to offer', () => { const result = parseMfaRedirectParams( 'http://localhost:3000/app?mfa_required=1&mfa_methods=totp%2Cwebauthn', ); expect(result).toEqual({ mfaRequired: true, mfaMethods: ['totp', 'webauthn'], + mfaGate: 'offer', }); }); + it('parses mfa_gate=verify', () => { + const result = parseMfaRedirectParams( + 'http://localhost:3000/app?mfa_required=1&mfa_methods=totp&mfa_gate=verify', + ); + expect(result).toEqual({ + mfaRequired: true, + mfaMethods: ['totp'], + mfaGate: 'verify', + }); + }); + + it('treats an unrecognized mfa_gate value as offer, not a crash', () => { + const result = parseMfaRedirectParams( + 'http://localhost:3000/app?mfa_required=1&mfa_gate=something-new', + ); + expect(result?.mfaGate).toBe('offer'); + }); + 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: [] }); + expect(result).toEqual({ mfaRequired: true, mfaMethods: [], mfaGate: 'offer' }); }); 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'] }); + expect(result).toEqual({ mfaRequired: true, mfaMethods: ['totp'], mfaGate: 'offer' }); }); it('exports the exact param-name constants the backend uses', () => { expect(MFA_REQUIRED_PARAM).toBe('mfa_required'); expect(MFA_METHODS_PARAM).toBe('mfa_methods'); + expect(MFA_GATE_PARAM).toBe('mfa_gate'); }); }); diff --git a/__test__/webauthnMethods.test.ts b/__test__/webauthnMethods.test.ts index d143727..541a3d9 100644 --- a/__test__/webauthnMethods.test.ts +++ b/__test__/webauthnMethods.test.ts @@ -54,6 +54,38 @@ describe('WebAuthn SDK methods', () => { expect(body.variables.data.credential).toBe('{"id":"cred-id"}'); }); + it('webauthnRegistrationVerify returns access_token when the server completes an MFA-session offer', async () => { + mockFetch.mockReturnValueOnce( + jsonResponse({ + data: { + webauthn_registration_verify: { + message: 'Passkey registered and MFA setup complete.', + access_token: 'token-mfa-setup', + }, + }, + }), + ); + const res = await authorizerRef.webauthnRegistrationVerify({ + credential: '{"id":"cred-id"}', + email: 'a@b.com', + }); + expect(res.errors).toHaveLength(0); + expect(res.data?.access_token).toBe('token-mfa-setup'); + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.variables.data.email).toBe('a@b.com'); + }); + + it('webauthnRegistrationOptions sends phone_number when provided', async () => { + mockFetch.mockReturnValueOnce( + jsonResponse({ + data: { webauthn_registration_options: { options: '{"challenge":"abc"}' } }, + }), + ); + await authorizerRef.webauthnRegistrationOptions(undefined, '+15551234567'); + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body.variables.phone_number).toBe('+15551234567'); + }); + it('webauthnLoginOptions supports the usernameless (no email) call', async () => { mockFetch.mockReturnValueOnce( jsonResponse({ diff --git a/src/admin.ts b/src/admin.ts index 019761c..c9d89b8 100644 --- a/src/admin.ts +++ b/src/admin.ts @@ -9,7 +9,7 @@ const getFetcher = () => (hasWindow() ? window.fetch : crossFetch); // re-usable gql response fragments shared across admin ops. 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'; + '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 enrolled_mfa_methods app_data'; const paginationFragment = 'limit page offset total'; const webhookFragment = 'id event_name event_description endpoint enabled headers created_at updated_at'; @@ -31,6 +31,7 @@ const orgOIDCConnectionFragment = const orgSAMLConnectionFragment = 'id org_id name idp_entity_id idp_sso_url sp_entity_id acs_url attribute_mapping allow_idp_initiated is_active created_at updated_at'; const scimEndpointFragment = 'id org_id enabled created_at updated_at'; +const orgDomainFragment = 'domain org_id verified_at created_at updated_at'; function toErrorList(errors: unknown): Types.AuthorizerSDKError[] { if (Array.isArray(errors)) { @@ -1133,6 +1134,23 @@ export class AuthorizerAdmin { { params }, ); + // userOrganizations returns the organizations a user belongs to along with + // the roles held in each. + userOrganizations = ( + params: Types.UserOrganizationsRequest, + ): Promise> => + this.dispatch( + 'UserOrganizations', + ['graphql'], + { + query: `query _user_organizations($params: UserOrganizationsRequest!) { _user_organizations(params: $params) { pagination { ${paginationFragment} } user_organizations { organization { ${organizationFragment} } roles } } }`, + operationName: '_user_organizations', + op: '_user_organizations', + }, + null, + { params }, + ); + // ---- Org SSO connections (graphql-only: no proto/REST routes yet) ---- // createOrgOIDCConnection registers a per-org upstream OIDC IdP (Authorizer @@ -1343,6 +1361,94 @@ export class AuthorizerAdmin { { params }, ); + // ---- Org verified domains (graphql-only: no proto/REST routes yet) ---- + + // requestOrgDomain starts domain verification for an org, returning the DNS + // TXT challenge the tenant must publish. + requestOrgDomain = ( + params: Types.RequestOrgDomainRequest, + ): Promise> => + this.dispatch( + 'RequestOrgDomain', + ['graphql'], + { + query: + 'mutation _request_org_domain($params: RequestOrgDomainRequest!) { _request_org_domain(params: $params) { domain record_type record_name record_value } }', + operationName: '_request_org_domain', + op: '_request_org_domain', + }, + null, + { params }, + ); + + // verifyOrgDomain checks the published DNS challenge and, on success, records + // the verified domain. + verifyOrgDomain = ( + params: Types.VerifyOrgDomainRequest, + ): Promise> => + this.dispatch( + 'VerifyOrgDomain', + ['graphql'], + { + query: `mutation _verify_org_domain($params: VerifyOrgDomainRequest!) { _verify_org_domain(params: $params) { ${orgDomainFragment} } }`, + operationName: '_verify_org_domain', + op: '_verify_org_domain', + }, + null, + { params }, + ); + + // addVerifiedOrgDomain records a verified domain without a DNS challenge + // (super-admin only, trusted-assert). + addVerifiedOrgDomain = ( + params: Types.AddVerifiedOrgDomainRequest, + ): Promise> => + this.dispatch( + 'AddVerifiedOrgDomain', + ['graphql'], + { + query: `mutation _add_verified_org_domain($params: AddVerifiedOrgDomainRequest!) { _add_verified_org_domain(params: $params) { ${orgDomainFragment} } }`, + operationName: '_add_verified_org_domain', + op: '_add_verified_org_domain', + }, + null, + { params }, + ); + + // deleteOrgDomain removes a verified domain by domain. DESTRUCTIVE: home-realm + // discovery for that domain stops immediately. + deleteOrgDomain = ( + params: Types.DeleteOrgDomainRequest, + ): Promise> => + this.dispatch( + 'DeleteOrgDomain', + ['graphql'], + { + query: + 'mutation _delete_org_domain($params: DeleteOrgDomainRequest!) { _delete_org_domain(params: $params) { message } }', + operationName: '_delete_org_domain', + op: '_delete_org_domain', + }, + null, + { params }, + ); + + // orgDomains returns a paginated list of an organization's verified domains. + orgDomains = ( + params: Types.ListOrgDomainsRequest, + ): Promise> => + this.dispatch( + 'OrgDomains', + ['graphql'], + { + query: `query _org_domains($params: ListOrgDomainsRequest!) { _org_domains(params: $params) { pagination { ${paginationFragment} } org_domains { ${orgDomainFragment} } } }`, + operationName: '_org_domains', + op: '_org_domains', + }, + null, + { params }, + ); + // ---- gql-only extras (no proto / no rest) ---- // adminSignup sets the admin secret on a fresh deployment. (graphql-only.) diff --git a/src/index.ts b/src/index.ts index d9d2180..02eb8e7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,7 +21,7 @@ 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 has_skipped_mfa_setup_at mfa_locked_at app_data'; + '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 enrolled_mfa_methods 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 @@ -57,6 +57,7 @@ export { parseMfaRedirectParams, MFA_REQUIRED_PARAM, MFA_METHODS_PARAM, + MFA_GATE_PARAM, } from './mfaRedirect'; export type { MfaRedirectParams } from './mfaRedirect'; export { @@ -628,12 +629,13 @@ export class Authorizer { webauthnRegistrationOptions = async ( email?: string, + phoneNumber?: string, ): Promise> => { try { const res = await this.graphqlQuery({ query: - 'mutation webauthn_registration_options($email: String) { webauthn_registration_options(email: $email) { options } }', - variables: { email }, + 'mutation webauthn_registration_options($email: String, $phone_number: String) { webauthn_registration_options(email: $email, phone_number: $phone_number) { options } }', + variables: { email, phone_number: phoneNumber }, operationName: 'webauthn_registration_options', }); return res?.errors?.length @@ -644,13 +646,18 @@ export class Authorizer { } }; + // webauthnRegistrationVerify returns the full AuthResponse shape (not just + // a message): on the MFA-session-cookie path (registering a passkey mid + // login-time MFA offer) this also completes the gate, so access_token and + // friends are populated exactly like verify_otp/skip_mfa_setup. On the + // ordinary authenticated-settings-page path access_token is always null - + // the caller already has one. webauthnRegistrationVerify = async ( data: Types.WebauthnRegistrationVerifyRequest, - ): Promise> => { + ): Promise> => { try { const res = await this.graphqlQuery({ - query: - 'mutation webauthn_registration_verify($data: WebauthnRegistrationVerifyRequest!) { webauthn_registration_verify(params: $data) { message } }', + query: `mutation webauthn_registration_verify($data: WebauthnRegistrationVerifyRequest!) { webauthn_registration_verify(params: $data) { ${authTokenFragment} } }`, variables: { data }, operationName: 'webauthn_registration_verify', }); @@ -735,18 +742,30 @@ export class Authorizer { // registerPasskey drives the full registration ceremony end to end: fetch // options from the server, prompt the platform authenticator via the // browser's WebAuthn API, and send the resulting credential back for - // verification. Requires an authenticated session (a passkey is always - // added to the caller's own account). + // verification. Normally requires an authenticated session (a passkey is + // added to the caller's own account) - pass `mfaSetup` to instead + // authenticate via the MFA session cookie mid a login-time MFA offer, + // which also completes the gate and returns the previously-withheld token. registerPasskey = async ( name?: string, - ): Promise> => { + mfaSetup?: { email?: string; phoneNumber?: string; state?: string }, + ): Promise> => { try { - const optRes = await this.webauthnRegistrationOptions(); + const optRes = await this.webauthnRegistrationOptions( + mfaSetup?.email, + mfaSetup?.phoneNumber, + ); if (optRes.errors.length) return this.errorResponse(optRes.errors); const credential = await webauthn.registerPasskey( optRes.data!.options, ); - return this.webauthnRegistrationVerify({ name, credential }); + return this.webauthnRegistrationVerify({ + name, + credential, + email: mfaSetup?.email, + phone_number: mfaSetup?.phoneNumber, + state: mfaSetup?.state, + }); } catch (err) { return this.errorResponse([err]); } diff --git a/src/mfaRedirect.ts b/src/mfaRedirect.ts index 190197c..5f71a4f 100644 --- a/src/mfaRedirect.ts +++ b/src/mfaRedirect.ts @@ -8,6 +8,7 @@ export const MFA_REQUIRED_PARAM = 'mfa_required'; export const MFA_METHODS_PARAM = 'mfa_methods'; +export const MFA_GATE_PARAM = 'mfa_gate'; export interface MfaRedirectParams { mfaRequired: true; @@ -17,6 +18,12 @@ export interface MfaRedirectParams { // 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[]; + // 'verify': the user already has a completed second factor to challenge - + // route to the code/passkey-assertion screen. 'offer': first-time + // enrollment with a Skip option - route to the setup screen. Defaults to + // 'offer' when the param is absent (older server), matching the only + // screen callers rendered before this field existed. + mfaGate: 'offer' | 'verify'; } /** @@ -36,5 +43,7 @@ export function parseMfaRedirectParams( const mfaMethods = methodsParam ? methodsParam.split(',').filter((m) => m.length > 0) : []; - return { mfaRequired: true, mfaMethods }; + const mfaGate: 'offer' | 'verify' = + parsed.searchParams.get(MFA_GATE_PARAM) === 'verify' ? 'verify' : 'offer'; + return { mfaRequired: true, mfaMethods, mfaGate }; } diff --git a/src/types.ts b/src/types.ts index e2d354d..c84342c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -101,6 +101,7 @@ export interface User { is_multi_factor_auth_enabled: boolean | null; has_skipped_mfa_setup_at: number | null; mfa_locked_at: number | null; + enrolled_mfa_methods?: string[]; app_data: Record | null; } @@ -277,6 +278,12 @@ export interface WebauthnRegistrationOptionsResponse { export interface WebauthnRegistrationVerifyRequest { name?: string | null; credential: string; + // email/phone_number/state are only used on the MFA-session-cookie path + // (registering a passkey mid login-time MFA offer) — ignored for an + // ordinary authenticated-settings-page caller. + email?: string | null; + phone_number?: string | null; + state?: string | null; } export interface WebauthnLoginOptionsResponse { @@ -633,6 +640,7 @@ export interface AdminMeta { roles: string[]; default_roles: string[]; protected_roles: string[]; + is_multi_factor_auth_service_enabled: boolean; } // PaginatedRequest wraps the pagination input for list queries. @@ -1099,6 +1107,26 @@ export interface ListOrgMembersRequest { pagination?: PaginationRequest | null; } +// UserOrganization pairs an organization with the roles a specific user holds +// in it. Returned by the admin _user_organizations query. +export interface UserOrganization { + organization: Organization; + roles: string[]; +} + +// UserOrganizations is a paginated list of a user's organization memberships. +export interface UserOrganizations { + pagination: Pagination; + user_organizations: UserOrganization[]; +} + +// UserOrganizationsRequest lists the organizations a user belongs to, with the +// roles held per org. +export interface UserOrganizationsRequest { + user_id: string; + pagination?: PaginationRequest | null; +} + // OrgOIDCConnection is a per-organization upstream OIDC IdP that Authorizer // brokers as a Relying Party. The upstream client_secret is NEVER projected. export interface OrgOIDCConnection { @@ -1246,3 +1274,64 @@ export interface CreateScimEndpointRequest { export interface ScimEndpointRequest { org_id: string; } + +// OrgDomain is a VERIFIED mapping from a DNS domain to exactly one organization, +// used for home-realm discovery. A row exists ONLY once the domain is verified. +export interface OrgDomain { + domain: string; + org_id: string; + verified_at: number | null; + created_at: number | null; + updated_at: number | null; +} + +// OrgDomains is a paginated list of an organization's verified domains. +export interface OrgDomains { + pagination: Pagination; + org_domains: OrgDomain[]; +} + +// OrgDomainChallenge is the DNS TXT record a tenant must publish to prove +// control of a domain. Returned by _request_org_domain; no durable row exists +// until the domain is verified. +export interface OrgDomainChallenge { + domain: string; + // record_type is always "TXT". + record_type: string; + // record_name is the DNS name to create, e.g. _authorizer-challenge.acme.com + record_name: string; + // record_value is the exact TXT value to publish. + record_value: string; +} + +// RequestOrgDomainRequest starts domain verification for an org, returning the +// DNS challenge to publish. +export interface RequestOrgDomainRequest { + org_id: string; + domain: string; +} + +// VerifyOrgDomainRequest checks the published DNS challenge and, on success, +// records the verified domain. +export interface VerifyOrgDomainRequest { + org_id: string; + domain: string; +} + +// AddVerifiedOrgDomainRequest records a verified domain without a DNS challenge +// (super-admin only, trusted-assert). +export interface AddVerifiedOrgDomainRequest { + org_id: string; + domain: string; +} + +// ListOrgDomainsRequest is a paginated read of one organization's verified domains. +export interface ListOrgDomainsRequest { + org_id: string; + pagination?: PaginationRequest | null; +} + +// DeleteOrgDomainRequest removes a verified domain (keyed by domain alone). +export interface DeleteOrgDomainRequest { + domain: string; +}