diff --git a/packages/browser/deno.json b/packages/browser/deno.json index 88d1beb0c..b2a3dc7dd 100644 --- a/packages/browser/deno.json +++ b/packages/browser/deno.json @@ -44,5 +44,12 @@ "./src/**/__jest__", "./npm" ] + }, + "lint": { + "rules": { + "exclude": [ + "require-await" + ] + } } } diff --git a/packages/browser/src/helpers/__jest__/generateCustomError.ts b/packages/browser/src/helpers/__jest__/generateCustomError.ts index 25609fadd..734e681ce 100644 --- a/packages/browser/src/helpers/__jest__/generateCustomError.ts +++ b/packages/browser/src/helpers/__jest__/generateCustomError.ts @@ -8,6 +8,7 @@ type WebAuthnErrorName = | 'NotAllowedError' | 'NotSupportedError' | 'SecurityError' + | 'TypeError' | 'UnknownError'; export function generateCustomError( diff --git a/packages/browser/src/helpers/identifySignalError.ts b/packages/browser/src/helpers/identifySignalError.ts new file mode 100644 index 000000000..ad027510d --- /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 { + SendSignalAllAcceptedCredentialsOpts, + SendSignalCurrentUserDetailsOpts, + SendSignalUnknownCredentialOpts, +} 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: + | SendSignalUnknownCredentialOpts + | SendSignalAllAcceptedCredentialsOpts + | SendSignalCurrentUserDetailsOpts; +}): 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 domain "${globalThis.location.hostname}"`, + code: 'ERROR_INVALID_RP_ID', + cause: error, + }); + } + + /** + * Signal-specific error conditions + */ + if (options.signalName === 'unknownCredential') { + 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 === 'allAcceptedCredentials') { + 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 === 'currentUserDetails') { + 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 2b0efa71b..1c20d7449 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.test.ts b/packages/browser/src/index.test.ts index 19e52be90..93fc04bde 100644 --- a/packages/browser/src/index.test.ts +++ b/packages/browser/src/index.test.ts @@ -42,3 +42,7 @@ Deno.test('should export method `getBrowserCapabilities`', () => { Deno.test('should export method `browserSupportsPasskeys`', () => { assert(index.browserSupportsPasskeys); }); + +Deno.test('should export method `sendSignal`', () => { + assert(index.sendSignal); +}); diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index 354157a57..99e6dbece 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.test.ts b/packages/browser/src/methods/sendSignal.test.ts new file mode 100644 index 000000000..c0db969a6 --- /dev/null +++ b/packages/browser/src/methods/sendSignal.test.ts @@ -0,0 +1,278 @@ +import { assertEquals, assertRejects } from '@std/assert'; +import { assertSpyCall, assertSpyCalls, type Spy, spy } from '@std/testing/mock'; +import { beforeEach, describe, it } from '@std/testing/bdd'; + +import { generateCustomError } from '../helpers/__jest__/generateCustomError.ts'; +import type { Base64URLString } from '../types/index.ts'; +import { WebAuthnError } from '../helpers/webAuthnError.ts'; +import { + sendSignal, + type SendSignalAllAcceptedCredentialsOpts, + type SendSignalCurrentUserDetailsOpts, + type SendSignalUnknownCredentialOpts, +} from './sendSignal.ts'; + +const credentialID: Base64URLString = 'NYtMO7dYULX2NcXrpzp5ig'; +const rpID = 'simplewebauthn.dev'; +const userID = 'uHqO6EqtKu7UIG074emo5w'; +const userName = 'SimpleWebAuthn'; +const userDisplayName = 'SimpleWebAuthn (Browser)'; + +describe('Method: sendSignal()', () => { + describe('Signal: unknownCredential', () => { + let signalUnknownCredentialSpy: Spy; + const signalName: SendSignalUnknownCredentialOpts['signalName'] = 'unknownCredential'; + + beforeEach(() => { + signalUnknownCredentialSpy = spy(); + + // @ts-ignore: Set up PublicKeyCredential + globalThis.PublicKeyCredential = () => {}; + // @ts-ignore: Set up signalUnknownCredential + globalThis.PublicKeyCredential.signalUnknownCredential = signalUnknownCredentialSpy; + }); + + it('should call PublicKeyCredential.signalUnknownCredential', async () => { + const returned = await sendSignal({ signalName, rpID, credentialID }); + + assertSpyCalls(signalUnknownCredentialSpy, 1); + assertSpyCall(signalUnknownCredentialSpy, 0, { + args: [{ rpId: rpID, credentialId: credentialID }], + }); + + assertEquals(returned, undefined); + }); + + it('should reject when signal is unsupported', async () => { + // @ts-ignore: Intentionally deleting this + delete globalThis.PublicKeyCredential.signalUnknownCredential; + + await assertRejects(() => sendSignal({ signalName, rpID, credentialID })); + }); + + it('should identify incorrectly Base64URL-encoded credential ID', async () => { + const TypeError = generateCustomError('TypeError'); + signalUnknownCredentialSpy = spy(async () => { + throw TypeError; + }); + + // @ts-ignore: Set up PublicKeyCredential + globalThis.PublicKeyCredential = () => {}; + // @ts-ignore: Set up signalUnknownCredential + globalThis.PublicKeyCredential.signalUnknownCredential = signalUnknownCredentialSpy; + + const rejected = await assertRejects( + () => sendSignal({ signalName, rpID, credentialID }), + WebAuthnError, + 'invalid base64url string', + ); + + assertEquals(rejected.name, 'TypeError'); + assertEquals(rejected.code, 'ERROR_SIGNAL_INVALID_ARGUMENT'); + assertEquals(rejected.cause, TypeError); + }); + }); + + describe('Signal: allAcceptedCredentials', () => { + let signalAllAcceptedCredentialsSpy: Spy; + const signalName: SendSignalAllAcceptedCredentialsOpts['signalName'] = 'allAcceptedCredentials'; + + beforeEach(() => { + signalAllAcceptedCredentialsSpy = spy(); + + // @ts-ignore: Set up PublicKeyCredential + globalThis.PublicKeyCredential = () => {}; + // @ts-ignore: Set up signalAllAcceptedCredentials + globalThis.PublicKeyCredential.signalAllAcceptedCredentials = signalAllAcceptedCredentialsSpy; + }); + + it('should call PublicKeyCredential.signalAllAcceptedCredentials', async () => { + const returned = await sendSignal({ + signalName, + rpID, + userID, + allAcceptedCredentialIDs: [credentialID], + }); + + assertSpyCalls(signalAllAcceptedCredentialsSpy, 1); + assertSpyCall(signalAllAcceptedCredentialsSpy, 0, { + args: [{ rpId: rpID, userId: userID, allAcceptedCredentialIds: [credentialID] }], + }); + + assertEquals(returned, undefined); + }); + + it('should reject when signal is unsupported', async () => { + // @ts-ignore: Intentionally deleting this + delete globalThis.PublicKeyCredential.signalAllAcceptedCredentials; + + await assertRejects(() => + sendSignal({ signalName, rpID, userID, allAcceptedCredentialIDs: [credentialID] }) + ); + }); + + it('should identify incorrectly Base64URL-encoded userID or credential ID', async () => { + const TypeError = generateCustomError('TypeError'); + signalAllAcceptedCredentialsSpy = spy(async () => { + throw TypeError; + }); + + // @ts-ignore: Set up signalAllAcceptedCredentials + globalThis.PublicKeyCredential.signalAllAcceptedCredentials = signalAllAcceptedCredentialsSpy; + + const rejected = await assertRejects( + () => sendSignal({ signalName, rpID, userID, allAcceptedCredentialIDs: [credentialID] }), + WebAuthnError, + 'invalid base64url string', + ); + + assertEquals(rejected.name, 'TypeError'); + assertEquals(rejected.code, 'ERROR_SIGNAL_INVALID_ARGUMENT'); + assertEquals(rejected.cause, TypeError); + }); + }); + + describe('Signal: currentUserDetails', () => { + let signalCurrentUserDetailsSpy: Spy; + const signalName: SendSignalCurrentUserDetailsOpts['signalName'] = 'currentUserDetails'; + + beforeEach(() => { + signalCurrentUserDetailsSpy = spy(); + + // @ts-ignore: Set up PublicKeyCredential + globalThis.PublicKeyCredential = () => {}; + // @ts-ignore: Set up signalCurrentUserDetails + globalThis.PublicKeyCredential.signalCurrentUserDetails = signalCurrentUserDetailsSpy; + }); + + it('should call PublicKeyCredential.signalCurrentUserDetails', async () => { + const returned = await sendSignal({ signalName, rpID, userID, userName, userDisplayName }); + + assertSpyCalls(signalCurrentUserDetailsSpy, 1); + assertSpyCall(signalCurrentUserDetailsSpy, 0, { + args: [{ rpId: rpID, userId: userID, name: userName, displayName: userDisplayName }], + }); + + assertEquals(returned, undefined); + }); + + it('should reject when signal is unsupported', async () => { + // @ts-ignore: Intentionally deleting this + delete globalThis.PublicKeyCredential.signalCurrentUserDetails; + + await assertRejects(() => + sendSignal({ signalName, rpID, userID, userName, userDisplayName }) + ); + }); + + it('should default to empty displayName when omitted', async () => { + const returned = await sendSignal({ signalName, rpID, userID, userName }); + + assertSpyCalls(signalCurrentUserDetailsSpy, 1); + assertSpyCall(signalCurrentUserDetailsSpy, 0, { + args: [{ rpId: rpID, userId: userID, name: userName, displayName: '' }], + }); + + assertEquals(returned, undefined); + }); + + it('should identify incorrectly Base64URL-encoded userID', async () => { + const TypeError = generateCustomError('TypeError'); + signalCurrentUserDetailsSpy = spy(async () => { + throw TypeError; + }); + + // @ts-ignore: Set up signalCurrentUserDetails + globalThis.PublicKeyCredential.signalCurrentUserDetails = signalCurrentUserDetailsSpy; + + const rejected = await assertRejects( + () => sendSignal({ signalName, rpID, userID, userName }), + WebAuthnError, + 'invalid base64url string', + ); + + assertEquals(rejected.name, 'TypeError'); + assertEquals(rejected.code, 'ERROR_SIGNAL_INVALID_ARGUMENT'); + assertEquals(rejected.cause, TypeError); + }); + }); + + it('should identify invalid RP ID for domain when sending any signal', async () => { + /** + * I'm just testing one of the signals for now, this error is not specific to any of them + */ + const SecurityError = generateCustomError('SecurityError'); + const signalUnknownCredentialSpy = spy(async () => { + throw SecurityError; + }); + + // @ts-ignore: Setting up globalThis.location.hostname to be an invalid domain + globalThis.location = { hostname: 'localhost2' } as unknown; + // @ts-ignore: Set up PublicKeyCredential + globalThis.PublicKeyCredential = () => {}; + // @ts-ignore: Set up signalUnknownCredential + globalThis.PublicKeyCredential.signalUnknownCredential = signalUnknownCredentialSpy; + + const rejected = await assertRejects( + () => sendSignal({ signalName: 'unknownCredential', rpID, credentialID }), + WebAuthnError, + 'invalid domain', + ); + + assertEquals(rejected.name, 'SecurityError'); + assertEquals(rejected.code, 'ERROR_INVALID_DOMAIN'); + assertEquals(rejected.cause, SecurityError); + }); + + it('should identify missing Related Origins support when sending any signal', async () => { + /** + * I'm just testing one of the signals for now, this error is not specific to any of them + */ + const SecurityError = generateCustomError('SecurityError'); + const signalUnknownCredentialSpy = spy(async () => { + throw SecurityError; + }); + + // @ts-ignore: Setting up globalThis.location.hostname to be a valid domain + globalThis.location = { hostname: 'localhost' } as unknown; + // @ts-ignore: Set up PublicKeyCredential + globalThis.PublicKeyCredential = () => {}; + // @ts-ignore: Set up signalUnknownCredential + globalThis.PublicKeyCredential.signalUnknownCredential = signalUnknownCredentialSpy; + + const rejected = await assertRejects( + () => sendSignal({ signalName: 'unknownCredential', rpID, credentialID }), + WebAuthnError, + 'does not support Related Origins', + ); + + assertEquals(rejected.name, 'SecurityError'); + assertEquals(rejected.code, 'ERROR_INVALID_RP_ID'); + assertEquals(rejected.cause, SecurityError); + }); + + it('should default to passing through original error when sending any signal', async () => { + /** + * I'm just testing one of the signals for now, this error is not specific to any of them + */ + // This error isn't one expected to be raised by a signal + const ConstraintError = generateCustomError('ConstraintError'); + const signalUnknownCredentialSpy = spy(async () => { + throw ConstraintError; + }); + + // @ts-ignore: Set up PublicKeyCredential + globalThis.PublicKeyCredential = () => {}; + // @ts-ignore: Set up signalUnknownCredential + globalThis.PublicKeyCredential.signalUnknownCredential = signalUnknownCredentialSpy; + + const rejected = await assertRejects( + () => sendSignal({ signalName: 'unknownCredential', rpID, credentialID }), + WebAuthnError, + ); + + assertEquals(rejected.name, 'ConstraintError'); + assertEquals(rejected.code, 'ERROR_PASSTHROUGH_SEE_CAUSE_PROPERTY'); + assertEquals(rejected.cause, ConstraintError); + }); +}); diff --git a/packages/browser/src/methods/sendSignal.ts b/packages/browser/src/methods/sendSignal.ts new file mode 100644 index 000000000..1b24aaa19 --- /dev/null +++ b/packages/browser/src/methods/sendSignal.ts @@ -0,0 +1,121 @@ +import type { + PublicKeyCredentialFuture, + SendSignalAllAcceptedCredentialsOpts, + SendSignalCurrentUserDetailsOpts, + SendSignalUnknownCredentialOpts, +} from '../types/index.ts'; +import { identifySignalError } from '../helpers/identifySignalError.ts'; + +export type { + SendSignalAllAcceptedCredentialsOpts, + SendSignalCurrentUserDetailsOpts, + SendSignalUnknownCredentialOpts, +} from '../types/index.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 async function sendSignal( + opts: + | SendSignalUnknownCredentialOpts + | SendSignalAllAcceptedCredentialsOpts + | SendSignalCurrentUserDetailsOpts, +): Promise { + const { signalName } = opts; + + if (signalName === 'unknownCredential') { + return _callSignalUnknownCredential(opts); + } else if (signalName === 'allAcceptedCredentials') { + return _callSignalAllAcceptedCredentials(opts); + } else if (signalName === 'currentUserDetails') { + return _callSignalCurrentUserDetails(opts); + } + + // @ts-ignore: this should never happen, but just in case + throw new Error(`Received unrecognized signalName "${opts.signalName}"`); +} + +/** + * Wrapper for PublicKeyCredential.signalUnknownCredential() + */ +async function _callSignalUnknownCredential(opts: SendSignalUnknownCredentialOpts) { + const globalPublicKeyCredential = globalThis + .PublicKeyCredential as unknown as PublicKeyCredentialFuture; + + if (typeof globalPublicKeyCredential.signalUnknownCredential !== 'function') { + throw new Error('This browser does not support PublicKeyCredential.signalUnknownCredential()'); + } + + try { + await globalPublicKeyCredential.signalUnknownCredential({ + rpId: opts.rpID, + credentialId: opts.credentialID, + }); + } catch (err) { + throw identifySignalError({ error: err as Error, options: opts }); + } + + return undefined; +} + +/** + * Wrapper for PublicKeyCredential.signalAllAcceptedCredentials() + */ +async function _callSignalAllAcceptedCredentials(opts: SendSignalAllAcceptedCredentialsOpts) { + const globalPublicKeyCredential = globalThis + .PublicKeyCredential as unknown as PublicKeyCredentialFuture; + + if (typeof globalPublicKeyCredential.signalAllAcceptedCredentials !== 'function') { + throw new Error( + 'This browser does not support PublicKeyCredential.signalAllAcceptedCredentials()', + ); + } + + try { + await globalPublicKeyCredential.signalAllAcceptedCredentials({ + rpId: opts.rpID, + userId: opts.userID, + allAcceptedCredentialIds: opts.allAcceptedCredentialIDs, + }); + } catch (err) { + throw identifySignalError({ error: err as Error, options: opts }); + } + + return undefined; +} + +/** + * Wrapper for PublicKeyCredential.signalAllAcceptedCredentials() + */ +async function _callSignalCurrentUserDetails(opts: SendSignalCurrentUserDetailsOpts) { + const globalPublicKeyCredential = globalThis + .PublicKeyCredential as unknown as PublicKeyCredentialFuture; + + if (typeof globalPublicKeyCredential.signalCurrentUserDetails !== 'function') { + throw new Error( + 'This browser does not support PublicKeyCredential.signalCurrentUserDetails()', + ); + } + + try { + await globalPublicKeyCredential.signalCurrentUserDetails({ + rpId: opts.rpID, + userId: opts.userID, + name: opts.userName, + displayName: opts.userDisplayName ?? '', + }); + } catch (err) { + throw identifySignalError({ error: err as Error, options: opts }); + } + + return undefined; +} diff --git a/packages/browser/src/types/index.ts b/packages/browser/src/types/index.ts index e565d1c35..2600c4385 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,130 @@ 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; +}; + +/** + * Below are types for @simplewebauthn/browser's `sendSignal()` method. Shared out of here so that an RP + * might use these same types in @simplewebauthn/server to type an API return value that can be + * passed into `sendSignal()` + */ + +/** + * 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 SendSignalUnknownCredentialOpts = { + signalName: 'unknownCredential'; + /** 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 SendSignalAllAcceptedCredentialsOpts = { + signalName: 'allAcceptedCredentials'; + /** 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 SendSignalCurrentUserDetailsOpts = { + signalName: 'currentUserDetails'; + /** 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/server/src/types/index.ts b/packages/server/src/types/index.ts index e565d1c35..2600c4385 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,130 @@ 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; +}; + +/** + * Below are types for @simplewebauthn/browser's `sendSignal()` method. Shared out of here so that an RP + * might use these same types in @simplewebauthn/server to type an API return value that can be + * passed into `sendSignal()` + */ + +/** + * 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 SendSignalUnknownCredentialOpts = { + signalName: 'unknownCredential'; + /** 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 SendSignalAllAcceptedCredentialsOpts = { + signalName: 'allAcceptedCredentials'; + /** 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 SendSignalCurrentUserDetailsOpts = { + signalName: 'currentUserDetails'; + /** 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/types/src/index.ts b/packages/types/src/index.ts index 12a5857d7..e2402855c 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,130 @@ 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; +}; + +/** + * Below are types for @simplewebauthn/browser's `sendSignal()` method. Shared out of here so that an RP + * might use these same types in @simplewebauthn/server to type an API return value that can be + * passed into `sendSignal()` + */ + +/** + * 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 SendSignalUnknownCredentialOpts = { + signalName: 'unknownCredential'; + /** 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 SendSignalAllAcceptedCredentialsOpts = { + signalName: 'allAcceptedCredentials'; + /** 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 SendSignalCurrentUserDetailsOpts = { + signalName: 'currentUserDetails'; + /** 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; +};