diff --git a/.changeset/pink-jars-check.md b/.changeset/pink-jars-check.md new file mode 100644 index 00000000000..a8b19070cb6 --- /dev/null +++ b/.changeset/pink-jars-check.md @@ -0,0 +1,6 @@ +--- +'@forgerock/journey-client': minor +'@forgerock/oidc-client': minor +--- + +Add AM server-side conditional mediation check to `WebAuthn.isConditionalMediationSupported` and export custom storage config types from oidc-client diff --git a/e2e/journey-app/components/webauthn-step.ts b/e2e/journey-app/components/webauthn-step.ts index fe136b202b8..78388b44298 100644 --- a/e2e/journey-app/components/webauthn-step.ts +++ b/e2e/journey-app/components/webauthn-step.ts @@ -65,16 +65,9 @@ export async function handleWebAuthnStep( ) as HTMLInputElement | null; conditionalInput?.focus(); - const isConditionalSupported = await WebAuthn.isConditionalMediationSupported(); + const isConditionalMediation = await WebAuthn.isConditionalMediationSupported(step); - const metadataCallback = WebAuthn.getMetadataCallback(step); - const meta = metadataCallback?.getData<{ - mediation?: CredentialMediationRequirement; - conditional?: boolean; - }>(); - const isConditionalMediation = meta?.mediation === 'conditional' || meta?.conditional === true; - - if (isConditionalSupported && conditionalInput && isConditionalMediation) { + if (isConditionalMediation && conditionalInput) { const controller = new AbortController(); void WebAuthn.authenticate(step, controller.signal) .then(() => submitForm()) diff --git a/interface_mapping.md b/interface_mapping.md index db7be28a768..e6469ef09da 100644 --- a/interface_mapping.md +++ b/interface_mapping.md @@ -859,24 +859,24 @@ const response = await fetch('https://api.example.com/resource', { ## 12. WebAuthn -| Legacy API | New API | Return Type Change | Behavioral Notes | -| ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | ------------------ | -------------------------------------------------------------------------- | -| `import { FRWebAuthn, WebAuthnStepType } from '@forgerock/javascript-sdk'` | `import { WebAuthn, WebAuthnStepType } from '@forgerock/journey-client/webauthn'` | — | Class renamed `FRWebAuthn` → `WebAuthn`. Import path changed to submodule | -| `FRWebAuthn.getWebAuthnStepType(step: FRStep): WebAuthnStepType` | `WebAuthn.getWebAuthnStepType(step: JourneyStep): WebAuthnStepType` | Same | Step type changed to `JourneyStep` | -| `FRWebAuthn.authenticate(step: FRStep, optionsTransformer?): Promise` | `WebAuthn.authenticate(step: JourneyStep): Promise` | `FRStep` → `void` | Mutates step in-place instead of returning it | -| `FRWebAuthn.register(step: FRStep, deviceName?): Promise` | `WebAuthn.register(step: JourneyStep): Promise` | `FRStep` → `void` | Mutates step in-place instead of returning it. Device name not passed here | -| `FRWebAuthn.isWebAuthnSupported(): boolean` | No equivalent exported | — | Check `window.PublicKeyCredential` directly | -| `FRWebAuthn.isConditionalMediationSupported(): Promise` | No equivalent exported | — | Check `PublicKeyCredential.isConditionalMediationAvailable()` directly | -| `FRWebAuthn.getCallbacks(step): WebAuthnCallbacks` | Not exported as public API | — | Internal to `WebAuthn.authenticate/register` | -| `FRWebAuthn.getMetadataCallback(step)` | Not exported as public API | — | Internal | -| `FRWebAuthn.getOutcomeCallback(step)` | Not exported as public API | — | Internal | -| `FRWebAuthn.getTextOutputCallback(step)` | Not exported as public API | — | Internal | -| `FRWebAuthn.getAuthenticationCredential(options)` | Not exported as public API | — | Internal | -| `FRWebAuthn.getAuthenticationOutcome(credential)` | Not exported as public API | — | Internal | -| `FRWebAuthn.getRegistrationCredential(options)` | Not exported as public API | — | Internal | -| `FRWebAuthn.getRegistrationOutcome(credential)` | Not exported as public API | — | Internal | -| `FRWebAuthn.createAuthenticationPublicKey(metadata)` | Not exported as public API | — | Internal | -| `FRWebAuthn.createRegistrationPublicKey(metadata)` | Not exported as public API | — | Internal | +| Legacy API | New API | Return Type Change | Behavioral Notes | +| ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| `import { FRWebAuthn, WebAuthnStepType } from '@forgerock/javascript-sdk'` | `import { WebAuthn, WebAuthnStepType } from '@forgerock/journey-client/webauthn'` | — | Class renamed `FRWebAuthn` → `WebAuthn`. Import path changed to submodule | +| `FRWebAuthn.getWebAuthnStepType(step: FRStep): WebAuthnStepType` | `WebAuthn.getWebAuthnStepType(step: JourneyStep): WebAuthnStepType` | Same | Step type changed to `JourneyStep` | +| `FRWebAuthn.authenticate(step: FRStep, optionsTransformer?): Promise` | `WebAuthn.authenticate(step: JourneyStep): Promise` | `FRStep` → `void` | Mutates step in-place instead of returning it | +| `FRWebAuthn.register(step: FRStep, deviceName?): Promise` | `WebAuthn.register(step: JourneyStep): Promise` | `FRStep` → `void` | Mutates step in-place instead of returning it. Device name not passed here | +| `FRWebAuthn.isWebAuthnSupported(): boolean` | No equivalent exported | — | Check `window.PublicKeyCredential` directly | +| `FRWebAuthn.isConditionalMediationSupported(): Promise` | `WebAuthn.isConditionalMediationSupported(step?): Promise` | Same | Pass a step to also check AM requested conditional mediation (`meta.mediation === 'conditional'`); without a step, only the browser check runs | +| `FRWebAuthn.getCallbacks(step): WebAuthnCallbacks` | Not exported as public API | — | Internal to `WebAuthn.authenticate/register` | +| `FRWebAuthn.getMetadataCallback(step)` | Not exported as public API | — | Internal | +| `FRWebAuthn.getOutcomeCallback(step)` | Not exported as public API | — | Internal | +| `FRWebAuthn.getTextOutputCallback(step)` | Not exported as public API | — | Internal | +| `FRWebAuthn.getAuthenticationCredential(options)` | Not exported as public API | — | Internal | +| `FRWebAuthn.getAuthenticationOutcome(credential)` | Not exported as public API | — | Internal | +| `FRWebAuthn.getRegistrationCredential(options)` | Not exported as public API | — | Internal | +| `FRWebAuthn.getRegistrationOutcome(credential)` | Not exported as public API | — | Internal | +| `FRWebAuthn.createAuthenticationPublicKey(metadata)` | Not exported as public API | — | Internal | +| `FRWebAuthn.createRegistrationPublicKey(metadata)` | Not exported as public API | — | Internal | ### WebAuthn Enums diff --git a/packages/journey-client/api-report/journey-client.webauthn.api.md b/packages/journey-client/api-report/journey-client.webauthn.api.md index 311a1028280..5b995dd7ad3 100644 --- a/packages/journey-client/api-report/journey-client.webauthn.api.md +++ b/packages/journey-client/api-report/journey-client.webauthn.api.md @@ -104,7 +104,7 @@ export abstract class WebAuthn { static getRegistrationOutcome(credential: PublicKeyCredential | null): OutcomeWithName; static getTextOutputCallback(step: JourneyStep): TextOutputCallback | undefined; static getWebAuthnStepType(step: JourneyStep): WebAuthnStepType; - static isConditionalMediationSupported(): Promise; + static isConditionalMediationSupported(step?: JourneyStep): Promise; static register(step: JourneyStep, deviceName?: T): Promise; } diff --git a/packages/journey-client/src/lib/webauthn/webauthn.mock.data.ts b/packages/journey-client/src/lib/webauthn/webauthn.mock.data.ts index dcb4809860d..898176ac442 100644 --- a/packages/journey-client/src/lib/webauthn/webauthn.mock.data.ts +++ b/packages/journey-client/src/lib/webauthn/webauthn.mock.data.ts @@ -3,7 +3,7 @@ * * fr-webauthn.mock.data.ts * - * Copyright (c) 2020 - 2025 Ping Identity Corporation. All rights reserved. + * Copyright (c) 2020 - 2026 Ping Identity Corporation. All rights reserved. * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. */ @@ -325,6 +325,37 @@ export const webAuthnAuthMetaCallback70StoredUsername = { ], }; +export const webAuthnAuthMetaCallback70Conditional = { + authId: 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9 ... ', + callbacks: [ + { + type: callbackType.MetadataCallback, + output: [ + { + name: 'data', + value: { + userVerification: 'preferred', + _type: 'WebAuthn', + challenge: 'OHmmFKfBhrUZKkuZJ84lf9N8TaRmQSjRdZyueeSIXAo=', + relyingPartyId: '', + allowCredentials: '', + timeout: '60000', + mediation: 'conditional', + }, + }, + ], + }, + { + type: callbackType.HiddenValueCallback, + output: [ + { name: 'value', value: 'false' }, + { name: 'id', value: 'webAuthnOutcome' }, + ], + input: [{ name: 'IDToken2', value: 'webAuthnOutcome' }], + }, + ], +}; + export const webAuthnAuthMetaCallbackJsonResponse = { authId: 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJhdXRoSW5kZXhWYWx1ZSI6IndlYmF1dGhuIiwib3RrIjoicXN1dTA0anNxZ2hmcGpubjFiM2IxdDh0NTQiLCJhdXRoSW5kZXhUeXBlIjoic2VydmljZSIsInJlYWxtIjoiLyIsInNlc3Npb25JZCI6IipBQUpUU1FBQ01ERUFCSFI1Y0dVQUNFcFhWRjlCVlZSSUFBSlRNUUFBKmV5SjBlWEFpT2lKS1YxUWlMQ0pqZEhraU9pSktWMVFpTENKaGJHY2lPaUpJVXpJMU5pSjkuWlhsS01HVllRV2xQYVVwTFZqRlJhVXhEU214aWJVMXBUMmxLUWsxVVNUUlJNRXBFVEZWb1ZFMXFWVEpKYVhkcFdWZDRia2xxYjJsYVIyeDVTVzR3TGk0M01qbDZSRXhsU0hwM1pXbFVPRll6VDB4cFgycEJMbGxpV1Y5WVl6WTJPVWxJU0MxT01VTk5Va05MZVROdFZFaHliVlJSVDJoV2VVSTBUVUUzYUVoRmFISjFTRU5SVWpWeWVsQnRZVzFpYlRNeFFqQlhSMlZxVEU5allUWllWMEUwTVROSlozRnVXV2gwV0hadlRHaE9VMkpDYUdkUlJFaGFSV1pLVmxaeWNsOTBTRUpJU1Y5elIyMVhNMEZYWm5wTk9WZElXblJMTm10ZmFITm9hemRNUTBkSVkwbzVhVGQ2Wnpob2FUbFROVm8wTVVkbk1rZFpXSFJJTnpoWlRGOVVaVTl5VWtOc1ozTlBkbDlWTWpGRFJ6STBReTFMWVRJNVMxRm9ibFYwTTNCVlozWldiamRCVUhWdWRFdEdaR2h6VERselJ6QlBTVnAwYzNoV1NqTXRPVmc1TmpSM2VrVldSV2w2Vnprek9XNTRlVm94VjNWVVgzWmpTRzFFV2t4eE0yWTJXamswVlVWTU5VNDJjMjVMTWs5U01XeDBOR3BrVld3eWVVMVRaVXR6YzJkb2VIRmlNMnhoZW1WQlVtMDBWM3BUVld4c1JFUTRVVVJuWDNoSFozSktlVmhQZWxCa1RWUnZYeTB0U1c0MFkxVjBTeloxTVdWSVkxOWhZbkZLTlhsRVpWcEpaM04yYkU5eE5qVTJkVU5KV0dzNU9GbElWMHBEWkhSR1MzcGtWV1k1ZG0xNlJIWk9iMmxXTlZnd2RXd3hiRzlTV0dOaFZtTkhVMDlaTlVGNFdITkJkSGd5UVhkVlVXUnViR0pmYTJodWN5MUhXblowZUVOM1lYRldlR2h1T1RsdVVWY3ljWFphUjNCTk1raFVPRzFMYUU5SVIyOVJOQzFWVkZrNVVWbDBNbGcwZGpaZlQzSk5kemxUZEdwSVl6RnRjMTkxVTI1VWVtVmpUR2RYUkVZdFVFNXNVM0J1Ymkxc1EyRlljRXREVmxsS1FVeDVUbWhoWDBJeGQwNTRSRzV0WW5vNVdYVjFXakZMYzFWTkxVZHJjVlJZYkY5c2JqUlBMVEpXVUhoTVFYSjFVblZOZW0xaVgxQndRMjlqY1d4T1Z6Sm1jWHBPV25seVlteE9RVEZXUkdaM04yYzJNMnhmTkhvd05UWkhlRXhOVjBOck5rOTZVQzFMY1RJMVlXTmxSa0ZQWWpGd1JtMXBkVGgzWW5kUGVITmtZa0ZLVW0xSWMxVlJWVzlQWm5aQlpURldORmcyUW5veGNFeG9SV3d0UzNGblkwMDBjMjluYTFab1YyRkhZWFpyVFUxSVFTNVlWRVpOV1d4R2F6bHFSV1V5VG5CamFIZDVZVzVuLmYyS2t1RlhnM05MUU1NbGNnMU1HU2Y2YTZQVmdJalhtUC1wcmJhQTNtTnciLCJleHAiOjE2MTM0OTc0OTksImlhdCI6MTYxMzQ5NzE5OX0.EuDmsY3C6I6vc_x7KlkW4rSQJY1FWevbGGmxkSu4HVU', diff --git a/packages/journey-client/src/lib/webauthn/webauthn.test.ts b/packages/journey-client/src/lib/webauthn/webauthn.test.ts index 60714f14346..fe87b2525ee 100644 --- a/packages/journey-client/src/lib/webauthn/webauthn.test.ts +++ b/packages/journey-client/src/lib/webauthn/webauthn.test.ts @@ -21,9 +21,64 @@ import { webAuthnAuthJSCallback70StoredUsername, webAuthnRegMetaCallback70StoredUsername, webAuthnAuthMetaCallback70StoredUsername, + webAuthnAuthMetaCallback70Conditional, } from './webauthn.mock.data.js'; import { createJourneyStep } from '../step.utils.js'; +describe('Test FRWebAuthn conditional mediation support', () => { + const originalPublicKeyCredential = globalThis.PublicKeyCredential; + + const withConditionalMediationAvailable = (available: boolean) => { + // jsdom has no PublicKeyCredential; install a minimal stub for these tests. + Object.defineProperty(globalThis, 'PublicKeyCredential', { + value: { + isConditionalMediationAvailable: vi.fn().mockResolvedValue(available), + }, + configurable: true, + writable: true, + }); + }; + + afterEach(() => { + Object.defineProperty(globalThis, 'PublicKeyCredential', { + value: originalPublicKeyCredential, + configurable: true, + writable: true, + }); + }); + + it('should return true when the browser supports conditional mediation and no step is given', async () => { + withConditionalMediationAvailable(true); + await expect(WebAuthn.isConditionalMediationSupported()).resolves.toBe(true); + }); + + it('should return false when the browser does not support conditional mediation and no step is given', async () => { + withConditionalMediationAvailable(false); + await expect(WebAuthn.isConditionalMediationSupported()).resolves.toBe(false); + }); + + it('should return false when the browser supports it but AM did not request conditional mediation', async () => { + // eslint-disable-next-line + const step = createJourneyStep(webAuthnAuthMetaCallback70 as any); + withConditionalMediationAvailable(true); + await expect(WebAuthn.isConditionalMediationSupported(step)).resolves.toBe(false); + }); + + it('should return true when the browser supports it and AM requested conditional mediation', async () => { + // eslint-disable-next-line + const step = createJourneyStep(webAuthnAuthMetaCallback70Conditional as any); + withConditionalMediationAvailable(true); + await expect(WebAuthn.isConditionalMediationSupported(step)).resolves.toBe(true); + }); + + it('should return false when AM requested conditional mediation but the browser does not support it', async () => { + // eslint-disable-next-line + const step = createJourneyStep(webAuthnAuthMetaCallback70Conditional as any); + withConditionalMediationAvailable(false); + await expect(WebAuthn.isConditionalMediationSupported(step)).resolves.toBe(false); + }); +}); + describe('Test FRWebAuthn class with 6.5.3 "Passwordless"', () => { it('should return Registration type with register text-output callbacks', () => { // eslint-disable-next-line diff --git a/packages/journey-client/src/lib/webauthn/webauthn.ts b/packages/journey-client/src/lib/webauthn/webauthn.ts index 0c2da99b7f8..74acb8625f2 100644 --- a/packages/journey-client/src/lib/webauthn/webauthn.ts +++ b/packages/journey-client/src/lib/webauthn/webauthn.ts @@ -66,8 +66,9 @@ type WebAuthnMetadata = WebAuthnAuthenticationMetadata | WebAuthnRegistrationMet * Conditional mediation is **server-driven** in this SDK via WebAuthn metadata (`meta.mediation`). * * ```js - * // Optional: feature-detect conditional UI before attempting - * const supportsConditionalUI = await WebAuthn.isConditionalMediationSupported(); + * // Optional: feature-detect conditional UI before attempting. + * // Pass the step to also confirm AM requested conditional mediation. + * const supportsConditionalUI = await WebAuthn.isConditionalMediationSupported(step); * * if (supportsConditionalUI) { * const controller = new AbortController(); @@ -126,14 +127,29 @@ export abstract class WebAuthn { /** * Determines if the browser supports conditional mediation. * - * @return Whether the browser supports conditional mediation + * When a step is provided, this also checks that the AM server requested + * conditional mediation via the WebAuthn metadata (`meta.mediation === 'conditional'`). + * + * @param step Optional step containing the WebAuthn metadata callback + * @return Whether conditional mediation is supported by the browser, + * and requested by AM when a step is provided */ - public static async isConditionalMediationSupported(): Promise { - return ( + public static async isConditionalMediationSupported(step?: JourneyStep): Promise { + const isBrowserSupported = typeof PublicKeyCredential !== 'undefined' && typeof PublicKeyCredential.isConditionalMediationAvailable === 'function' && - (await PublicKeyCredential.isConditionalMediationAvailable()) - ); + (await PublicKeyCredential.isConditionalMediationAvailable()); + + if (!step) { + return isBrowserSupported; + } + + const metadataCallback = this.getMetadataCallback(step); + const meta = metadataCallback?.getOutputValue('data') as + | WebAuthnAuthenticationMetadata + | undefined; + + return isBrowserSupported && meta?.mediation === 'conditional'; } /** diff --git a/packages/oidc-client/api-report/oidc-client.api.md b/packages/oidc-client/api-report/oidc-client.api.md index bcdce96e278..5c0ac2818a3 100644 --- a/packages/oidc-client/api-report/oidc-client.api.md +++ b/packages/oidc-client/api-report/oidc-client.api.md @@ -6,8 +6,11 @@ import { ActionTypes } from '@forgerock/sdk-request-middleware'; import { BaseQueryFn } from '@reduxjs/toolkit/query'; +import { BrowserStorageConfig } from '@forgerock/storage'; import { CombinedState } from '@reduxjs/toolkit/query'; import { CustomLogger } from '@forgerock/sdk-logger'; +import { CustomStorageConfig } from '@forgerock/storage'; +import { CustomStorageObject } from '@forgerock/sdk-types'; import { EnhancedStore } from '@reduxjs/toolkit'; import { FetchArgs } from '@reduxjs/toolkit/query'; import type { FetchBaseQueryError } from '@reduxjs/toolkit/query'; @@ -111,6 +114,8 @@ export interface AuthorizeSuccessResponse { status?: string; } +export { BrowserStorageConfig } + // @public (undocumented) export type BuildAuthorizationData = [string, GetAuthorizationUrlOptions]; @@ -230,6 +235,10 @@ debug: (...args: LogMessage[]) => void; export { CustomLogger } +export { CustomStorageConfig } + +export { CustomStorageObject } + export { GenericError } export { GetAuthorizationUrlOptions } diff --git a/packages/oidc-client/api-report/oidc-client.types.api.md b/packages/oidc-client/api-report/oidc-client.types.api.md index 02a90f352a5..7faf79cf13e 100644 --- a/packages/oidc-client/api-report/oidc-client.types.api.md +++ b/packages/oidc-client/api-report/oidc-client.types.api.md @@ -6,8 +6,11 @@ import { ActionTypes } from '@forgerock/sdk-request-middleware'; import { BaseQueryFn } from '@reduxjs/toolkit/query'; +import { BrowserStorageConfig } from '@forgerock/storage'; import { CombinedState } from '@reduxjs/toolkit/query'; import { CustomLogger } from '@forgerock/sdk-logger'; +import { CustomStorageConfig } from '@forgerock/storage'; +import { CustomStorageObject } from '@forgerock/sdk-types'; import { EnhancedStore } from '@reduxjs/toolkit'; import { FetchArgs } from '@reduxjs/toolkit/query'; import type { FetchBaseQueryError } from '@reduxjs/toolkit/query'; @@ -110,6 +113,8 @@ export interface AuthorizeSuccessResponse { status?: string; } +export { BrowserStorageConfig } + // @public (undocumented) export type BuildAuthorizationData = [string, GetAuthorizationUrlOptions]; @@ -229,6 +234,10 @@ debug: (...args: LogMessage[]) => void; export { CustomLogger } +export { CustomStorageConfig } + +export { CustomStorageObject } + export { GenericError } export { GetAuthorizationUrlOptions } diff --git a/packages/oidc-client/src/__tests__/types-reexport.typecheck.ts b/packages/oidc-client/src/__tests__/types-reexport.typecheck.ts index 7d1d6bdb3dc..9ef1f05b7ad 100644 --- a/packages/oidc-client/src/__tests__/types-reexport.typecheck.ts +++ b/packages/oidc-client/src/__tests__/types-reexport.typecheck.ts @@ -8,6 +8,9 @@ import type { GetAuthorizationUrlOptions, WellknownResponse, StorageConfig, + BrowserStorageConfig, + CustomStorageConfig, + CustomStorageObject, ActionTypes, RequestMiddleware, CustomLogger, @@ -22,6 +25,9 @@ type _Assert = [ GetAuthorizationUrlOptions, WellknownResponse, StorageConfig, + BrowserStorageConfig, + CustomStorageConfig, + CustomStorageObject, ActionTypes, RequestMiddleware, CustomLogger, diff --git a/packages/oidc-client/src/types.ts b/packages/oidc-client/src/types.ts index 6a17dcbfef9..9496a4ae6c4 100644 --- a/packages/oidc-client/src/types.ts +++ b/packages/oidc-client/src/types.ts @@ -1,4 +1,5 @@ -/* Copyright © 2025 - 2026 Ping Identity Corporation. All rights reserved. +/* + * Copyright (c) 2025 - 2026 Ping Identity Corporation. All rights reserved. * * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. @@ -18,7 +19,8 @@ export type { } from '@forgerock/sdk-types'; export type { ActionTypes, RequestMiddleware } from '@forgerock/sdk-request-middleware'; export type { CustomLogger, LogLevel } from '@forgerock/sdk-logger'; -export type { StorageConfig } from '@forgerock/storage'; +export type { BrowserStorageConfig, CustomStorageConfig, StorageConfig } from '@forgerock/storage'; +export type { CustomStorageObject } from '@forgerock/sdk-types'; // Re-export functions needed to resolve OidcClient and ClientStore type aliases export { oidc } from './lib/client.store.js';