Skip to content
Draft
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
81 changes: 81 additions & 0 deletions packages/browser/src/helpers/identifySignalError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { isValidDomain } from './isValidDomain.ts';
import { WebAuthnError } from './webAuthnError.ts';
import type {
SignalAllAcceptedCredentialsOpts,
SignalCurrentUserDetailsOpts,
SignalUnknownCredentialOpts,
} from '../methods/sendSignal.ts';

/**
* Attempt to intuit _why_ an error was raised after calling one of the WebAuthn Signal APIs
*/
export function identifySignalError({ error, options }: {
error: Error;
options:
| SignalUnknownCredentialOpts
| SignalAllAcceptedCredentialsOpts
| SignalCurrentUserDetailsOpts;
}): WebAuthnError {
/**
* General Signal API error conditions
*/
if (error.name === 'SecurityError') {
const effectiveDomain = globalThis.location.hostname;
if (!isValidDomain(effectiveDomain)) {
// https://w3c.github.io/webauthn/#sctn-signal-methods-async-rp-id-validation (Step 1)
return new WebAuthnError({
message: `${globalThis.location.hostname} is an invalid domain`,
code: 'ERROR_INVALID_DOMAIN',
cause: error,
});
}

// https://w3c.github.io/webauthn/#sctn-signal-methods-async-rp-id-validation (Step 3)
return new WebAuthnError({
message:
`The browser does not support Related Origins to enable signals for RP ID ${options.rpID} on this domain`,
code: 'ERROR_INVALID_RP_ID',
cause: error,
});
}

/**
* Signal-specific error conditions
*/
if (options.signalName === 'signalUnknownCredential') {
if (error.name === 'TypeError') {
// https://w3c.github.io/webauthn/#sctn-signalUnknownCredential (Step 1)
return new WebAuthnError({
message: 'credentialID is an invalid base64url string',
code: 'ERROR_SIGNAL_INVALID_ARGUMENT',
cause: error,
});
}
} else if (options.signalName === 'signalAllAcceptedCredentials') {
if (error.name === 'TypeError') {
// https://w3c.github.io/webauthn/#sctn-signalAllAcceptedCredentials (Step 1)
// https://w3c.github.io/webauthn/#sctn-signalAllAcceptedCredentials (Step 2)
return new WebAuthnError({
message: 'userID, or an entry in allAcceptedCredentialIDs, is an invalid base64url string',
code: 'ERROR_SIGNAL_INVALID_ARGUMENT',
cause: error,
});
}
} else if (options.signalName === 'signalCurrentUserDetails') {
if (error.name === 'TypeError') {
// https://w3c.github.io/webauthn/#sctn-signalCurrentUserDetails
return new WebAuthnError({
message: 'userID is an invalid base64url string',
code: 'ERROR_SIGNAL_INVALID_ARGUMENT',
cause: error,
});
}
}

// Consistently return a WebAuthnError, but point to the original error for more info
return new WebAuthnError({
message: error.message,
code: 'ERROR_PASSTHROUGH_SEE_CAUSE_PROPERTY',
cause: error,
});
}
1 change: 1 addition & 0 deletions packages/browser/src/helpers/webAuthnError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,5 @@ export type WebAuthnErrorCode =
| 'ERROR_AUTHENTICATOR_PREVIOUSLY_REGISTERED'
| 'ERROR_AUTHENTICATOR_NO_SUPPORTED_PUBKEYCREDPARAMS_ALG'
| 'ERROR_AUTO_REGISTER_USER_VERIFICATION_FAILURE'
| 'ERROR_SIGNAL_INVALID_ARGUMENT'
| 'ERROR_PASSTHROUGH_SEE_CAUSE_PROPERTY';
1 change: 1 addition & 0 deletions packages/browser/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from './methods/startRegistration.ts';
export * from './methods/startAuthentication.ts';
export * from './methods/sendSignal.ts';
export * from './helpers/browserSupportsWebAuthn.ts';
export * from './helpers/browserSupportsPasskeys.ts';
export * from './helpers/platformAuthenticatorIsAvailable.ts';
Expand Down
162 changes: 162 additions & 0 deletions packages/browser/src/methods/sendSignal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import type { Base64URLString, PublicKeyCredentialFuture } from '../types/index.ts';
import { identifySignalError } from '../helpers/identifySignalError.ts';

/**
* Broadcast a passkey state change on the server to the browser to enlist the browser's help
* in propagating that change to the corresponding authenticator. This can help prevent phantom
* credentials from being offered for use, and enable new usernames to be displayed after a
* passkey's creation.
*
* Sending a signal **does not** guarantee that the signal will be received by the authenticator.
* Signals are a "fire and forget" type of broadcast that will have browsers making a best effort
* to propagate the signal to the relevant authenticator. See the descriptions of the various
* signal option types for guidance on how often a signal may need to be resent for maximum
* efficacy.
*/
export function sendSignal(
opts:
| SignalAllAcceptedCredentialsOpts
| SignalCurrentUserDetailsOpts
| SignalUnknownCredentialOpts,
): Promise<undefined> {
const { signalName } = opts;

try {
if (signalName === 'signalUnknownCredential') {
return _callSignalUnknownCredential(opts);
} else if (signalName === 'signalAllAcceptedCredentials') {
return _callSignalAllAcceptedCredentials(opts);
} else if (signalName === 'signalCurrentUserDetails') {
return _callSignalCurrentUserDetails(opts);
}
} catch (err) {
throw identifySignalError({ error: err as Error, options: opts });
}

// @ts-ignore: this should never happen, but just in case
throw new Error(`Received unrecognized signalName "${opts.signalName}"`);
}

/**
* Wrapper for PublicKeyCredential.signalUnknownCredential()
*/
function _callSignalUnknownCredential(opts: SignalUnknownCredentialOpts) {
const globalPublicKeyCredential = globalThis
.PublicKeyCredential as unknown as PublicKeyCredentialFuture;

if (typeof globalPublicKeyCredential.signalUnknownCredential !== 'function') {
throw new Error('This browser does not support PublicKeyCredential.signalUnknownCredential()');
}

return globalPublicKeyCredential.signalUnknownCredential({
rpId: opts.rpID,
credentialId: opts.credentialID,
});
}

/**
* Wrapper for PublicKeyCredential.signalAllAcceptedCredentials()
*/
function _callSignalAllAcceptedCredentials(opts: SignalAllAcceptedCredentialsOpts) {
const globalPublicKeyCredential = globalThis
.PublicKeyCredential as unknown as PublicKeyCredentialFuture;

if (typeof globalPublicKeyCredential.signalAllAcceptedCredentials !== 'function') {
throw new Error(
'This browser does not support PublicKeyCredential.signalAllAcceptedCredentials()',
);
}

return globalPublicKeyCredential.signalAllAcceptedCredentials({
rpId: opts.rpID,
userId: opts.userID,
allAcceptedCredentialIds: opts.allAcceptedCredentialIDs,
});
}

/**
* Wrapper for PublicKeyCredential.signalAllAcceptedCredentials()
*/
function _callSignalCurrentUserDetails(opts: SignalCurrentUserDetailsOpts) {
const globalPublicKeyCredential = globalThis
.PublicKeyCredential as unknown as PublicKeyCredentialFuture;

if (typeof globalPublicKeyCredential.signalCurrentUserDetails !== 'function') {
throw new Error(
'This browser does not support PublicKeyCredential.signalCurrentUserDetails()',
);
}

return globalPublicKeyCredential.signalCurrentUserDetails({
rpId: opts.rpID,
userId: opts.userID,
name: opts.userName,
displayName: opts.userDisplayName ?? '',
});
}

/**
* A signal that communicates that the credential that the user just tried to register, or to
* authenticate with, was not one that the Relying Party recognizes. The authenticator responsible
* for the credential can hide or delete the credential so that the user does not see it in the
* future as an option to sign in with.
*
* It is a good idea for a Relying Party to send this signal immediately after the use of an
* unrecognized credential. For example, after rejecting the output from `startRegistration()` due
* to unsatisfied RP-specific authenticator registration policy; or after rejecting the output from
* `startAuthentication()` because the user deleted the passkey from their RP-specific user
* settings.
*
* See https://w3c.github.io/webauthn/#sctn-signalUnknownCredential for more info.
*/
export type SignalUnknownCredentialOpts = {
signalName: 'signalUnknownCredential';
/** The same value used for `rpID` when calling \@simplewebauthn/server's `generateRegistrationOptions()` */
rpID: string;
/** The credential ID that the Relying Party didn't recognize for use */
credentialID: Base64URLString;
};

/**
* A signal that communicates the current list of passkeys the Relying Party will recognize for use
* by the **authenticated** user on the next login. Authenticators that have a passkey for
* (rpId + userId), but the passkey ID is not found in allAcceptedCredentialIds, may choose to hide
* or delete the passkey because it will not be accepted for use by the Relying Party.
*
* It is a good idea for a Relying Party to periodically send this signal, for example after every
* successful authentication.
*
* See https://w3c.github.io/webauthn/#sctn-signalAllAcceptedCredentials for more info.
*/
export type SignalAllAcceptedCredentialsOpts = {
signalName: 'signalAllAcceptedCredentials';
/** The same value used for `rpID` when calling \@simplewebauthn/server's `generateRegistrationOptions()` */
rpID: string;
/** The base64url-encoded value used for `userID` when calling \@simplewebauthn/server's `generateRegistrationOptions()` */
userID: Base64URLString;
/** An array of base64url-encoded credential IDs for all credentials the user may use to authenticate */
allAcceptedCredentialIDs: Base64URLString[];
};

/**
* A signal that communicates a change in the **authenticated** user's name and/or display name.
* This can help browsers and platforms display the most up-to-date information about the user
* during a passkey authentication instead of always showing whatever value was set at the time of
* registration.
*
* It is a good idea for a Relying Party to periodically send this signal, for example after every
* successful authentication and immediately after the user name and/or display name is changed.
*
* See https://w3c.github.io/webauthn/#sctn-signalCurrentUserDetails for more info.
*/
export type SignalCurrentUserDetailsOpts = {
signalName: 'signalCurrentUserDetails';
/** The same value used for `rpID` when calling \@simplewebauthn/server's `generateRegistrationOptions()` */
rpID: string;
/** The base64url-encoded value used for `userID` when calling \@simplewebauthn/server's `generateRegistrationOptions()` */
userID: Base64URLString;
/** The primary account name, like an email address, username, etc... */
userName: string;
/** An optional, longer user identifier, like a full name, account differentiator, etc... Defaults to `""` */
userDisplayName?: string;
};
61 changes: 61 additions & 0 deletions packages/browser/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,12 @@ export interface PublicKeyCredentialFuture extends PublicKeyCredential {
toJSON(): PublicKeyCredentialJSON;
// See https://w3c.github.io/webauthn/#sctn-getClientCapabilities
getClientCapabilities?(): Promise<PublicKeyCredentialClientCapabilities>;
// See https://w3c.github.io/webauthn/#sctn-signalUnknownCredential
signalUnknownCredential(options: UnknownCredentialOptions): Promise<undefined>;
// See https://w3c.github.io/webauthn/#sctn-signalAllAcceptedCredentials
signalAllAcceptedCredentials(options: AllAcceptedCredentialsOptions): Promise<undefined>;
// See https://w3c.github.io/webauthn/#sctn-signalCurrentUserDetails
signalCurrentUserDetails(options: CurrentUserDetailsOptions): Promise<undefined>;
}

/**
Expand Down Expand Up @@ -287,3 +293,58 @@ export type PublicKeyCredentialClientCapabilities = {
* https://github.com/denoland/std/blob/b5a5fe4f96b91c1fe8dba5cc0270092dd11d3287/bytes/_types.ts#L11
*/
export type Uint8Array_ = ReturnType<Uint8Array['slice']>;

/**
* Options for `PublicKeyCredential.signalUnknownCredential()`. This signal communicates that the
* credential that the user just tried to register, or to authenticate with, was not one that the
* Relying Party recognizes. The authenticator responsible for the credential can hide or delete
* the credential so that the user does not see it in the future as an option to sign in with.
*
* It is a good idea for a Relying Party to send this signal immediately after the use of an
* unrecognized credential. For example, after rejecting the output from `startRegistration()` due
* to unsatisfied RP-specific authenticator registration policy; or after rejecting the output from
* `startAuthentication()` because the user deleted the passkey from their RP-specific user
* settings.
*
* See https://w3c.github.io/webauthn/#sctn-signalUnknownCredential for more info.
*/
type UnknownCredentialOptions = {
rpId: string;
credentialId: Base64URLString;
};

/**
* Options for `PublicKeyCredential.signalAllAcceptedCredentials()`. This signal communicates the
* current list of passkeys the Relying Party will recognize for use by the **authenticated** user
* on the next login. Authenticators that have a passkey for (rpId + userId), but the passkey ID is
* not found in allAcceptedCredentialIds, may choose to hide or delete the passkey because it will
* not be accepted for use by the Relying Party.
*
* It is a good idea for a Relying Party to periodically send this signal, for example after every
* successful authentication.
*
* See https://w3c.github.io/webauthn/#sctn-signalAllAcceptedCredentials for more info.
*/
type AllAcceptedCredentialsOptions = {
rpId: string;
userId: Base64URLString;
allAcceptedCredentialIds: Base64URLString[];
};

/**
* Options for `PublicKeyCredential.signalCurrentUserDetails()`. This signal that communicates a
* change in the **authenticated** user's name and/or display name. This can help browsers and
* platforms display the most up-to-date information about the user during a passkey authentication
* instead of always showing whatever value was set at the time of registration.
*
* It is a good idea for a Relying Party to periodically send this signal, for example after every
* successful authentication and immediately after the user name and/or display name is changed.
*
* See https://w3c.github.io/webauthn/#sctn-signalCurrentUserDetails for more info.
*/
type CurrentUserDetailsOptions = {
rpId: string;
userId: Base64URLString;
name: string;
displayName: string;
};
Loading