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
135 changes: 135 additions & 0 deletions __test__/webauthnMethods.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
// Fast unit tests (no Docker/testcontainers, no real browser): assert that
// each WebAuthn SDK method sends the right GraphQL operation and unwraps the
// response correctly. The actual browser ceremony glue (src/webauthn.ts) is
// exercised live in a real browser, not here - PublicKeyCredential doesn't
// exist in this jsdom-less Node test environment.
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('WebAuthn SDK methods', () => {
const authorizerRef = new Authorizer({
authorizerURL: 'http://localhost:8080',
redirectURL: 'http://localhost:8080/app',
});

afterEach(() => mockFetch.mockReset());

it('webauthnRegistrationOptions sends the right operation and unwraps options', async () => {
mockFetch.mockReturnValueOnce(
jsonResponse({
data: { webauthn_registration_options: { options: '{"challenge":"abc"}' } },
}),
);
const res = await authorizerRef.webauthnRegistrationOptions();
expect(res.errors).toHaveLength(0);
expect(res.data?.options).toBe('{"challenge":"abc"}');
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.operationName).toBe('webauthn_registration_options');
});

it('webauthnRegistrationVerify sends the credential and returns the message', async () => {
mockFetch.mockReturnValueOnce(
jsonResponse({
data: { webauthn_registration_verify: { message: 'Passkey registered successfully.' } },
}),
);
const res = await authorizerRef.webauthnRegistrationVerify({
name: 'MacBook',
credential: '{"id":"cred-id"}',
});
expect(res.errors).toHaveLength(0);
expect(res.data?.message).toBe('Passkey registered successfully.');
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.variables.data.credential).toBe('{"id":"cred-id"}');
});

it('webauthnLoginOptions supports the usernameless (no email) call', async () => {
mockFetch.mockReturnValueOnce(
jsonResponse({
data: { webauthn_login_options: { options: '{"challenge":"xyz"}' } },
}),
);
const res = await authorizerRef.webauthnLoginOptions();
expect(res.errors).toHaveLength(0);
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.variables.email).toBeUndefined();
});

it('webauthnLoginVerify returns the full auth token shape on success', async () => {
mockFetch.mockReturnValueOnce(
jsonResponse({
data: {
webauthn_login_verify: {
message: 'Logged in successfully with passkey.',
access_token: 'token-abc',
user: { id: 'user-1', email: 'a@b.com' },
},
},
}),
);
const res = await authorizerRef.webauthnLoginVerify({
credential: '{"id":"cred-id"}',
});
expect(res.errors).toHaveLength(0);
expect(res.data?.access_token).toBe('token-abc');
});

it('webauthnLoginVerify surfaces the email-verification gate error with its code', async () => {
mockFetch.mockReturnValueOnce(
jsonResponse({
errors: [
{
message:
'email is not verified. please verify your email before signing in with a passkey',
extensions: { code: 'FAILED_PRECONDITION' },
},
],
data: null,
}),
);
const res = await authorizerRef.webauthnLoginVerify({
credential: '{"id":"cred-id"}',
});
expect(res.errors).toHaveLength(1);
expect(res.errors[0].code).toBe('FAILED_PRECONDITION');
});

it('webauthnCredentials lists the caller\'s own passkeys', async () => {
mockFetch.mockReturnValueOnce(
jsonResponse({
data: {
webauthn_credentials: [
{ id: 'cred-1', name: 'MacBook', transports: ['internal'] },
],
},
}),
);
const res = await authorizerRef.webauthnCredentials();
expect(res.errors).toHaveLength(0);
expect(res.data).toHaveLength(1);
expect(res.data?.[0].name).toBe('MacBook');
});

it('webauthnDeleteCredential sends the id and returns the message', async () => {
mockFetch.mockReturnValueOnce(
jsonResponse({
data: { webauthn_delete_credential: { message: 'Passkey deleted successfully.' } },
}),
);
const res = await authorizerRef.webauthnDeleteCredential('cred-1');
expect(res.errors).toHaveLength(0);
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.variables.id).toBe('cred-1');
});
});
147 changes: 147 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
trimURL,
} from './utils';
import { toSDKError } from './errors';
import * as webauthn from './webauthn';

// re-usable gql response fragment
const userFragment =
Expand Down Expand Up @@ -51,6 +52,7 @@ function toErrorList(errors: unknown): Types.AuthorizerSDKError[] {

export * from './types';
export { AuthorizerAdmin } from './admin';
export { isWebauthnSupported } from './webauthn';
export {
CLIENT_ASSERTION_TYPE_JWT_BEARER,
GRANT_TYPE_AUTHORIZATION_CODE,
Expand Down Expand Up @@ -611,6 +613,151 @@ export class Authorizer {
}
};

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

webauthnRegistrationOptions = async (
email?: 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 },
operationName: 'webauthn_registration_options',
});
return res?.errors?.length
? this.errorResponse(res.errors)
: this.okResponse(res.data?.webauthn_registration_options);
} catch (err) {
return this.errorResponse([err]);
}
};

webauthnRegistrationVerify = async (
data: Types.WebauthnRegistrationVerifyRequest,
): Promise<Types.ApiResponse<Types.GenericResponse>> => {
try {
const res = await this.graphqlQuery({
query:
'mutation webauthn_registration_verify($data: WebauthnRegistrationVerifyRequest!) { webauthn_registration_verify(params: $data) { message } }',
variables: { data },
operationName: 'webauthn_registration_verify',
});
return res?.errors?.length
? this.errorResponse(res.errors)
: this.okResponse(res.data?.webauthn_registration_verify);
} catch (err) {
return this.errorResponse([err]);
}
};

webauthnLoginOptions = async (
email?: string,
): Promise<Types.ApiResponse<Types.WebauthnLoginOptionsResponse>> => {
try {
const res = await this.graphqlQuery({
query:
'mutation webauthn_login_options($email: String) { webauthn_login_options(email: $email) { options } }',
variables: { email },
operationName: 'webauthn_login_options',
});
return res?.errors?.length
? this.errorResponse(res.errors)
: this.okResponse(res.data?.webauthn_login_options);
} catch (err) {
return this.errorResponse([err]);
}
};

webauthnLoginVerify = async (
data: Types.WebauthnLoginVerifyRequest,
): Promise<Types.ApiResponse<Types.AuthToken>> => {
try {
const res = await this.graphqlQuery({
query: `mutation webauthn_login_verify($data: WebauthnLoginVerifyRequest!) { webauthn_login_verify(params: $data) { ${authTokenFragment} } }`,
variables: { data },
operationName: 'webauthn_login_verify',
});
return res?.errors?.length
? this.errorResponse(res.errors)
: this.okResponse(res.data?.webauthn_login_verify);
} catch (err) {
return this.errorResponse([err]);
}
};

webauthnCredentials = async (): Promise<
Types.ApiResponse<Types.WebauthnCredentialInfo[]>
> => {
try {
const res = await this.graphqlQuery({
query:
'query webauthn_credentials { webauthn_credentials { id name transports created_at updated_at last_used_at } }',
operationName: 'webauthn_credentials',
});
return res?.errors?.length
? this.errorResponse(res.errors)
: this.okResponse(res.data?.webauthn_credentials);
} catch (err) {
return this.errorResponse([err]);
}
};

webauthnDeleteCredential = async (
id: string,
): Promise<Types.ApiResponse<Types.GenericResponse>> => {
try {
const res = await this.graphqlQuery({
query:
'mutation webauthn_delete_credential($id: ID!) { webauthn_delete_credential(id: $id) { message } }',
variables: { id },
operationName: 'webauthn_delete_credential',
});
return res?.errors?.length
? this.errorResponse(res.errors)
: this.okResponse(res.data?.webauthn_delete_credential);
} catch (err) {
return this.errorResponse([err]);
}
};

// 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).
registerPasskey = async (
name?: string,
): Promise<Types.ApiResponse<Types.GenericResponse>> => {
try {
const optRes = await this.webauthnRegistrationOptions();
if (optRes.errors.length) return this.errorResponse(optRes.errors);
const credential = await webauthn.registerPasskey(
optRes.data!.options,
);
return this.webauthnRegistrationVerify({ name, credential });
} catch (err) {
return this.errorResponse([err]);
}
};

// loginWithPasskey drives the full login ceremony end to end. Omit `email`
// for a usernameless (discoverable-credential) login; pass it to scope the
// ceremony to one account's own passkeys (the MFA-alternative flow).
loginWithPasskey = async (
email?: string,
): Promise<Types.ApiResponse<Types.AuthToken>> => {
try {
const optRes = await this.webauthnLoginOptions(email);
if (optRes.errors.length) return this.errorResponse(optRes.errors);
const credential = await webauthn.loginWithPasskey(optRes.data!.options);
return this.webauthnLoginVerify({ credential });
} catch (err) {
return this.errorResponse([err]);
}
};

resetPassword = async (
data: Types.ResetPasswordRequest,
): Promise<Types.ApiResponse<Types.GenericResponse>> => {
Expand Down
32 changes: 32 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,38 @@ export interface VerifyOTPRequest {
// Keep VerifyOtpRequest as alias for backward compatibility
export type VerifyOtpRequest = VerifyOTPRequest;

// WebAuthn / passkey types. `options`/`credential` are opaque JSON strings -
// the server's PublicKeyCredentialCreationOptionsJSON / RequestOptionsJSON on
// the way out, and the browser's RegistrationResponseJSON /
// AuthenticationResponseJSON (from PublicKeyCredential.toJSON()) on the way
// back in. See src/webauthn.ts for the browser ceremony glue.
export interface WebauthnRegistrationOptionsResponse {
options: string;
}

export interface WebauthnRegistrationVerifyRequest {
name?: string | null;
credential: string;
}

export interface WebauthnLoginOptionsResponse {
options: string;
}

export interface WebauthnLoginVerifyRequest {
state?: string | null;
credential: string;
}

export interface WebauthnCredentialInfo {
id: string;
name: string;
transports?: string[] | null;
created_at?: number | null;
updated_at?: number | null;
last_used_at?: number | null;
}

// ResendOTPRequest
export interface ResendOTPRequest {
email?: string | null;
Expand Down
Loading
Loading