diff --git a/packages/browser/src/helpers/identifySignalError.ts b/packages/browser/src/helpers/identifySignalError.ts new file mode 100644 index 00000000..f5745745 --- /dev/null +++ b/packages/browser/src/helpers/identifySignalError.ts @@ -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, + }); +} diff --git a/packages/browser/src/helpers/webAuthnError.ts b/packages/browser/src/helpers/webAuthnError.ts index 2b0efa71..1c20d744 100644 --- a/packages/browser/src/helpers/webAuthnError.ts +++ b/packages/browser/src/helpers/webAuthnError.ts @@ -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'; diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index 354157a5..99e6dbec 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -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'; diff --git a/packages/browser/src/methods/sendSignal.ts b/packages/browser/src/methods/sendSignal.ts new file mode 100644 index 00000000..02af4d65 --- /dev/null +++ b/packages/browser/src/methods/sendSignal.ts @@ -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 { + 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; +}; diff --git a/packages/browser/src/types/index.ts b/packages/browser/src/types/index.ts index e565d1c3..9c657a72 100644 --- a/packages/browser/src/types/index.ts +++ b/packages/browser/src/types/index.ts @@ -212,6 +212,12 @@ export interface PublicKeyCredentialFuture extends PublicKeyCredential { toJSON(): PublicKeyCredentialJSON; // See https://w3c.github.io/webauthn/#sctn-getClientCapabilities getClientCapabilities?(): Promise; + // See https://w3c.github.io/webauthn/#sctn-signalUnknownCredential + signalUnknownCredential(options: UnknownCredentialOptions): Promise; + // See https://w3c.github.io/webauthn/#sctn-signalAllAcceptedCredentials + signalAllAcceptedCredentials(options: AllAcceptedCredentialsOptions): Promise; + // See https://w3c.github.io/webauthn/#sctn-signalCurrentUserDetails + signalCurrentUserDetails(options: CurrentUserDetailsOptions): Promise; } /** @@ -287,3 +293,58 @@ export type PublicKeyCredentialClientCapabilities = { * https://github.com/denoland/std/blob/b5a5fe4f96b91c1fe8dba5cc0270092dd11d3287/bytes/_types.ts#L11 */ export type Uint8Array_ = ReturnType; + +/** + * 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; +}; diff --git a/packages/server/src/types/index.ts b/packages/server/src/types/index.ts index e565d1c3..9c657a72 100644 --- a/packages/server/src/types/index.ts +++ b/packages/server/src/types/index.ts @@ -212,6 +212,12 @@ export interface PublicKeyCredentialFuture extends PublicKeyCredential { toJSON(): PublicKeyCredentialJSON; // See https://w3c.github.io/webauthn/#sctn-getClientCapabilities getClientCapabilities?(): Promise; + // See https://w3c.github.io/webauthn/#sctn-signalUnknownCredential + signalUnknownCredential(options: UnknownCredentialOptions): Promise; + // See https://w3c.github.io/webauthn/#sctn-signalAllAcceptedCredentials + signalAllAcceptedCredentials(options: AllAcceptedCredentialsOptions): Promise; + // See https://w3c.github.io/webauthn/#sctn-signalCurrentUserDetails + signalCurrentUserDetails(options: CurrentUserDetailsOptions): Promise; } /** @@ -287,3 +293,58 @@ export type PublicKeyCredentialClientCapabilities = { * https://github.com/denoland/std/blob/b5a5fe4f96b91c1fe8dba5cc0270092dd11d3287/bytes/_types.ts#L11 */ export type Uint8Array_ = ReturnType; + +/** + * 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; +}; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 12a5857d..9f4a8f15 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -202,6 +202,12 @@ export interface PublicKeyCredentialFuture extends PublicKeyCredential { toJSON(): PublicKeyCredentialJSON; // See https://w3c.github.io/webauthn/#sctn-getClientCapabilities getClientCapabilities?(): Promise; + // See https://w3c.github.io/webauthn/#sctn-signalUnknownCredential + signalUnknownCredential(options: UnknownCredentialOptions): Promise; + // See https://w3c.github.io/webauthn/#sctn-signalAllAcceptedCredentials + signalAllAcceptedCredentials(options: AllAcceptedCredentialsOptions): Promise; + // See https://w3c.github.io/webauthn/#sctn-signalCurrentUserDetails + signalCurrentUserDetails(options: CurrentUserDetailsOptions): Promise; } /** @@ -277,3 +283,58 @@ export type PublicKeyCredentialClientCapabilities = { * https://github.com/denoland/std/blob/b5a5fe4f96b91c1fe8dba5cc0270092dd11d3287/bytes/_types.ts#L11 */ export type Uint8Array_ = ReturnType; + +/** + * 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; +};