Skip to content
Open
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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ jobs:
- run: pnpm exec nx-cloud record -- nx format:check --verbose
- run: pnpm exec nx affected -t build lint test docs e2e-ci

- name: Publish previews to Stackblitz on PR
run: pnpm pkg-pr-new publish './packages/*' --packageManager=pnpm

- uses: codecov/codecov-action@v5
with:
files: ./packages/**/coverage/*.xml
Expand Down
37 changes: 37 additions & 0 deletions packages/javascript-sdk/src/fr-webauthn/fr-webauthn.mock.data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -489,3 +489,40 @@ export const webAuthnRegMetaCallbackJsonResponse = {
},
],
};

export const webAuthnAuthConditionalMetaCallback = {
authId: 'test-auth-id-conditional',
callbacks: [
{
type: CallbackType.MetadataCallback,
output: [
{
name: 'data',
value: {
_action: 'webauthn_authentication',
challenge: 'JEisuqkVMhI490jM0/iEgrRz+j94OoGc7gdY4gYicSk=',
allowCredentials: '',
_allowCredentials: [],
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So AM now sends both of these values? we need to take precedence on the array I assume because it can list the possible passkeys?

Is this AM being backwards compatible with the older allowCredentials?

Copy link

@KMForgeRock KMForgeRock Jan 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They are effectively identical as AM supports both for as you mentioned backwards compatibility. If you feel like this answers your question, give me a should and I can resolve this thread.

timeout: 60000,
userVerification: 'preferred',
conditionalWebAuthn: true,
relyingPartyId: '',
_relyingPartyId: 'example.com',
extensions: {},
_type: 'WebAuthn',
supportsJsonResponse: true,
},
},
],
_id: 0,
},
{
type: CallbackType.HiddenValueCallback,
output: [
{ name: 'value', value: 'false' },
{ name: 'id', value: 'webAuthnOutcome' },
],
input: [{ name: 'IDToken1', value: 'webAuthnOutcome' }],
},
],
};
104 changes: 104 additions & 0 deletions packages/javascript-sdk/src/fr-webauthn/fr-webauthn.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
webAuthnAuthJSCallback70StoredUsername,
webAuthnRegMetaCallback70StoredUsername,
webAuthnAuthMetaCallback70StoredUsername,
webAuthnAuthConditionalMetaCallback,
} from './fr-webauthn.mock.data';
import FRStep from '../fr-auth/fr-step';

Expand Down Expand Up @@ -104,3 +105,106 @@ describe('Test FRWebAuthn class with 7.0 "Usernameless"', () => {
expect(stepType).toBe(WebAuthnStepType.Authentication);
});
});

describe('Test FRWebAuthn class with Conditional UI', () => {
beforeEach(() => {
// Mock navigator.credentials and window.PublicKeyCredential
Object.defineProperty(global.navigator, 'credentials', {
value: {
get: vi.fn().mockResolvedValue(null),
create: vi.fn(),
},
writable: true,
});
Object.defineProperty(window, 'PublicKeyCredential', {
value: {
isConditionalMediationAvailable: vi.fn(),
},
writable: true,
});
});

afterEach(() => {
vi.restoreAllMocks();
});

it('should detect if conditional UI is supported', async () => {
vi.spyOn(window.PublicKeyCredential, 'isConditionalMediationAvailable').mockResolvedValue(true);
const isSupported = await FRWebAuthn.isConditionalUISupported();
expect(isSupported).toBe(true);
});

it('should return Authentication type with conditional UI metadata callback', () => {
const step = new FRStep(webAuthnAuthConditionalMetaCallback as any);
const stepType = FRWebAuthn.getWebAuthnStepType(step);
expect(stepType).toBe(WebAuthnStepType.Authentication);
});

it('should create authentication public key with empty allowCredentials for conditional UI', () => {
const metadata: any = {
_action: 'webauthn_authentication',
challenge: 'JEisuqkVMhI490jM0/iEgrRz+j94OoGc7gdY4gYicSk=',
allowCredentials: '',
_allowCredentials: [],
timeout: 60000,
userVerification: 'preferred',
conditionalWebAuthn: true,
relyingPartyId: '',
_relyingPartyId: 'example.com',
extensions: {},
supportsJsonResponse: true,
};

const publicKey = FRWebAuthn.createAuthenticationPublicKey(metadata);

expect(publicKey.challenge).toBeDefined();
expect(publicKey.timeout).toBe(60000);
expect(publicKey.userVerification).toBe('preferred');
expect(publicKey.rpId).toBe('example.com');
// allowCredentials should not be present for conditional UI with empty credentials
expect(publicKey.allowCredentials).toBeUndefined();
});

it('should warn and fallback if conditional UI is requested but not supported', async () => {
// Mock browser support for conditional UI to be false
vi.spyOn(window.PublicKeyCredential, 'isConditionalMediationAvailable').mockResolvedValue(
false,
);
// FIX APPLIED HERE: Added block comment to empty function
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {
/* empty */
});
const getSpy = vi.spyOn(navigator.credentials, 'get');

// Attempt to authenticate with conditional UI requested
await FRWebAuthn.getAuthenticationCredential({}, true);

// Expect a warning to be logged
expect(consoleSpy).toHaveBeenCalledWith(
'Conditional UI was requested, but is not supported by this browser.',
);

// Expect the call to navigator.credentials.get to NOT have the mediation property
expect(getSpy).toHaveBeenCalledWith(
expect.not.objectContaining({
mediation: 'conditional',
}),
);
});

it('should set mediation to conditional if supported', async () => {
// Mock browser support for conditional UI to be true
vi.spyOn(window.PublicKeyCredential, 'isConditionalMediationAvailable').mockResolvedValue(true);
const getSpy = vi.spyOn(navigator.credentials, 'get');

// Attempt to authenticate with conditional UI requested
await FRWebAuthn.getAuthenticationCredential({}, true);

// Expect the call to navigator.credentials.get to have the mediation property
expect(getSpy).toHaveBeenCalledWith(
expect.objectContaining({
mediation: 'conditional',
}),
);
});
});
5 changes: 5 additions & 0 deletions packages/javascript-sdk/src/fr-webauthn/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ function getIndexOne(arr: RegExpMatchArray | null): string {

// TODO: Remove this once AM is providing fully-serialized JSON
function parseCredentials(value: string): ParsedCredential[] {
// Handle empty string or missing value
if (!value || value === '' || value === '[]') {
return [];
}

try {
const creds = value
.split('}')
Expand Down
117 changes: 108 additions & 9 deletions packages/javascript-sdk/src/fr-webauthn/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { CallbackType } from '../auth/enums';
import type HiddenValueCallback from '../fr-auth/callbacks/hidden-value-callback';
import type MetadataCallback from '../fr-auth/callbacks/metadata-callback';
import type FRStep from '../fr-auth/fr-step';
import { FRLogger } from '../util/logger';
import { WebAuthnOutcome, WebAuthnOutcomeType, WebAuthnStepType } from './enums';
import {
arrayBufferToString,
Expand All @@ -30,6 +31,7 @@ import type {
} from './interfaces';
import type TextOutputCallback from '../fr-auth/callbacks/text-output-callback';
import { parseWebAuthnAuthenticateText, parseWebAuthnRegisterText } from './script-parser';
import { withTimeout } from '../util/timeout';

// <clientdata>::<attestation>::<publickeyCredential>::<DeviceName>
type OutcomeWithName<
Expand All @@ -44,6 +46,8 @@ type OutcomeWithName<
type WebAuthnMetadata = WebAuthnAuthenticationMetadata | WebAuthnRegistrationMetadata;
// Script-based WebAuthn
type WebAuthnTextOutput = WebAuthnTextOutputRegistration;
const ONE_SECOND = 1000;

/**
* Utility for integrating a web browser's WebAuthn API.
*
Expand All @@ -60,6 +64,24 @@ type WebAuthnTextOutput = WebAuthnTextOutputRegistration;
* await FRWebAuthn.authenticate(step);
* }
* ```
*
* Conditional UI (Autofill) Support:
*
* ```js
* // Check if browser supports conditional UI
* const supportsConditionalUI = await FRWebAuthn.isConditionalUISupported();
*
* if (supportsConditionalUI) {
* // The authenticate() method automatically handles conditional UI
* // when the server indicates support via conditionalWebAuthn: true
* // in the metadata. No additional code changes needed.
* await FRWebAuthn.authenticate(step);
*
* // For conditional UI to work in the browser, add autocomplete="webauthn"
* // to your username input field:
* // <input type="text" name="username" autocomplete="webauthn" />
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens if this isn't provided and the promise is hanging?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've got a fix in based on code I've seen elsewhere in the code base. Feel free to correct me if I've mssed up.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kian note:
There is a current question in my fix if after timeout we shout throw an exception or just return false. Either way we should make it so the admin can specify a timeout time (have a default just incase).
Throwing errors seems to be what we do in the SDK

* }
* ```
*/
abstract class FRWebAuthn {
/**
Expand Down Expand Up @@ -94,8 +116,29 @@ abstract class FRWebAuthn {
}
}

/**
* Checks if the browser supports conditional UI (autofill) for WebAuthn.
*
* @return Promise<boolean> indicating if conditional mediation is available
*/
public static async isConditionalUISupported(): Promise<boolean> {
if (!window.PublicKeyCredential) {
return false;
}

// Check if the browser supports conditional mediation
try{
return withTimeout(PublicKeyCredential.isConditionalMediationAvailable(), ONE_SECOND)
} catch {
throw new Error('Error determining conditional mediation support');
}

return false;
}

/**
* Populates the step with the necessary authentication outcome.
* Automatically handles conditional UI if indicated by the server metadata.
*
* @param step The step that contains WebAuthn authentication data
* @return The populated step
Expand All @@ -108,19 +151,27 @@ abstract class FRWebAuthn {

try {
let publicKey: PublicKeyCredentialRequestOptions;
let useConditionalUI = false;

if (metadataCallback) {
const meta = metadataCallback.getOutputValue('data') as WebAuthnAuthenticationMetadata;

// Check if server indicates conditional UI should be used
useConditionalUI = meta.conditionalWebAuthn === true;

publicKey = this.createAuthenticationPublicKey(meta);

credential = await this.getAuthenticationCredential(
publicKey as PublicKeyCredentialRequestOptions,
useConditionalUI,
);
outcome = this.getAuthenticationOutcome(credential);
} else if (textOutputCallback) {
publicKey = parseWebAuthnAuthenticateText(textOutputCallback.getMessage());

credential = await this.getAuthenticationCredential(
publicKey as PublicKeyCredentialRequestOptions,
false, // Script-based callbacks don't support conditional UI
);
outcome = this.getAuthenticationOutcome(credential);
} else {
Expand Down Expand Up @@ -300,18 +351,37 @@ abstract class FRWebAuthn {
* Retrieves the credential from the browser Web Authentication API.
*
* @param options The public key options associated with the request
* @param useConditionalUI Whether to use conditional UI (autofill)
* @return The credential
*/
public static async getAuthenticationCredential(
options: PublicKeyCredentialRequestOptions,
useConditionalUI = false,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as per the hanging promise comment, we may need to use an abort signal here to allow developers to abort the promise if the passkey option isn't there?

I may be not following this correctly, so please correct me where I'm wrong, but if a developer is using autofill ui, but fails to add the html correctly, can't we end up in a hanging promise state?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also abort signal may be the right path, we would have to figure out how to do it.

): Promise<PublicKeyCredential | null> {
// Feature check before we attempt registering a device
// Feature check before we attempt authenticating
if (!window.PublicKeyCredential) {
const e = new Error('PublicKeyCredential not supported by this browser');
e.name = WebAuthnOutcomeType.NotSupportedError;
throw e;
}
const credential = await navigator.credentials.get({ publicKey: options });
// Build the credential request options
const credentialRequestOptions: CredentialRequestOptions = {
publicKey: options,
};

// Add conditional mediation if requested and supported
if (useConditionalUI) {
const isConditionalSupported = await this.isConditionalUISupported();
if (isConditionalSupported) {
credentialRequestOptions.mediation = 'conditional' as CredentialMediationRequirement;
} else {
// eslint-disable-next-line no-console
FRLogger.warn('Conditional UI was requested, but is not supported by this browser.');
console.warn('Conditional UI was requested, but is not supported by this browser.');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we need to use logger for this, in case a user does not want console log's to happen. the logger module will call console logs if enabled.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, shout if this is correct/incorrect please.

}
}

const credential = await navigator.credentials.get(credentialRequestOptions);
return credential as PublicKeyCredential;
}

Expand Down Expand Up @@ -433,22 +503,51 @@ abstract class FRWebAuthn {
const {
acceptableCredentials,
allowCredentials,
_allowCredentials,
challenge,
relyingPartyId,
_relyingPartyId,
timeout,
userVerification,
extensions,
} = metadata;
const rpId = parseRelyingPartyId(relyingPartyId);
const allowCredentialsValue = parseCredentials(allowCredentials || acceptableCredentials || '');

return {
// Use the structured _allowCredentials if available, otherwise parse the string format
let allowCredentialsValue: PublicKeyCredentialDescriptor[] | undefined;
if (_allowCredentials && Array.isArray(_allowCredentials)) {
allowCredentialsValue = _allowCredentials;
} else {
allowCredentialsValue = parseCredentials(allowCredentials || acceptableCredentials || '');
}

// Use _relyingPartyId if available, otherwise parse the old format
const rpId = _relyingPartyId || parseRelyingPartyId(relyingPartyId);

const options: PublicKeyCredentialRequestOptions = {
challenge: Uint8Array.from(atob(challenge), (c) => c.charCodeAt(0)).buffer,
timeout,
// only add key-value pair if proper value is provided
...(allowCredentialsValue && { allowCredentials: allowCredentialsValue }),
...(userVerification && { userVerification }),
...(rpId && { rpId }),
};
// For conditional UI, allowCredentials can be omitted.
// For standard WebAuthn, it may or may not be present.
// Only add the property if the array is not empty.
if (allowCredentialsValue && allowCredentialsValue.length > 0) {
options.allowCredentials = allowCredentialsValue;
}

// Add optional properties only if they have values
if (userVerification) {
options.userVerification = userVerification;
}

if (rpId) {
options.rpId = rpId;
}

if (extensions && Object.keys(extensions).length > 0) {
options.extensions = extensions;
}

return options;
}

/**
Expand Down
Loading