Skip to content
Merged
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
2 changes: 1 addition & 1 deletion __test__/admin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion __test__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
33 changes: 29 additions & 4 deletions __test__/mfaRedirect.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand All @@ -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');
});
});
32 changes: 32 additions & 0 deletions __test__/webauthnMethods.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
108 changes: 107 additions & 1 deletion src/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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)) {
Expand Down Expand Up @@ -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<Types.ApiResponse<Types.UserOrganizations>> =>
this.dispatch<Types.UserOrganizations>(
'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
Expand Down Expand Up @@ -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<Types.ApiResponse<Types.OrgDomainChallenge>> =>
this.dispatch<Types.OrgDomainChallenge>(
'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<Types.ApiResponse<Types.OrgDomain>> =>
this.dispatch<Types.OrgDomain>(
'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<Types.ApiResponse<Types.OrgDomain>> =>
this.dispatch<Types.OrgDomain>(
'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<Types.ApiResponse<Types.Response>> =>
this.dispatch<Types.Response>(
'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<Types.ApiResponse<Types.OrgDomains>> =>
this.dispatch<Types.OrgDomains>(
'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.)
Expand Down
41 changes: 30 additions & 11 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -57,6 +57,7 @@ export {
parseMfaRedirectParams,
MFA_REQUIRED_PARAM,
MFA_METHODS_PARAM,
MFA_GATE_PARAM,
} from './mfaRedirect';
export type { MfaRedirectParams } from './mfaRedirect';
export {
Expand Down Expand Up @@ -628,12 +629,13 @@ export class Authorizer {

webauthnRegistrationOptions = async (
email?: string,
phoneNumber?: string,
): Promise<Types.ApiResponse<Types.WebauthnRegistrationOptionsResponse>> => {
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
Expand All @@ -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<Types.ApiResponse<Types.GenericResponse>> => {
): Promise<Types.ApiResponse<Types.AuthToken>> => {
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',
});
Expand Down Expand Up @@ -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<Types.ApiResponse<Types.GenericResponse>> => {
mfaSetup?: { email?: string; phoneNumber?: string; state?: string },
): Promise<Types.ApiResponse<Types.AuthToken>> => {
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]);
}
Expand Down
11 changes: 10 additions & 1 deletion src/mfaRedirect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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';
}

/**
Expand All @@ -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 };
}
Loading
Loading