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.

Choose a reason for hiding this comment

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

Commend addressed

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
116 changes: 107 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

Copy link
Contributor

Choose a reason for hiding this comment

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

Yes, we throw in cases where the continuation is not possible, but in this case, we can consider this progressive enhancement, yeah? In other words, "conditional UI" isn't necessary for the basic function of WebAuthn, so we don't need to throw.

Copy link
Contributor

Choose a reason for hiding this comment

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

@cerebrl it's possible that we haven't really "started" webauthn though. This flow can be activated before a username or password has entered, so the fallback to "webauthn" is unlikely because usually you need to progress to that point.

Right?

* }
* ```
*/
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');
Copy link
Contributor

Choose a reason for hiding this comment

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

Should we throw here, or just return false? If this feature isn't supported, we can just fall back to "normal" behavior, yeah?

Choose a reason for hiding this comment

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

Definitely feels like there is no "right" answer. I think we (Liskov) will discuss this as a team and then go forward with that, if that sounds good to oyu.

}

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,36 @@ 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();

Choose a reason for hiding this comment

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

This won't hang as the timeout was added above.

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.');
}
}

const credential = await navigator.credentials.get(credentialRequestOptions);

Choose a reason for hiding this comment

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

This is for under discussion about hanging

return credential as PublicKeyCredential;
}

Expand Down Expand Up @@ -433,22 +502,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
Loading