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
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ 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:
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: [],
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' }],
},
],
};
39 changes: 39 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,41 @@ describe('Test FRWebAuthn class with 7.0 "Usernameless"', () => {
expect(stepType).toBe(WebAuthnStepType.Authentication);
});
});

describe('Test FRWebAuthn class with Conditional UI', () => {
it('should detect if conditional UI is supported', async () => {
const isSupported = await FRWebAuthn.isConditionalUISupported();
expect(typeof isSupported).toBe('boolean');
});

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();
});
});
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
108 changes: 99 additions & 9 deletions packages/javascript-sdk/src/fr-webauthn/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,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" />
* }
* ```
*/
abstract class FRWebAuthn {
/**
Expand Down Expand Up @@ -94,8 +112,27 @@ 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
if (typeof PublicKeyCredential.isConditionalMediationAvailable === 'function') {
return await PublicKeyCredential.isConditionalMediationAvailable();
}

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 +145,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 +345,34 @@ 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,
): 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;
}
}

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

Expand Down Expand Up @@ -433,22 +494,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 should be an empty array or omitted
// Only add if there are actual credentials AND 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
6 changes: 6 additions & 0 deletions packages/javascript-sdk/src/fr-webauthn/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,12 +77,18 @@ interface WebAuthnRegistrationMetadata {
}

interface WebAuthnAuthenticationMetadata {
_action?: string;
acceptableCredentials?: string;
allowCredentials?: string;
_allowCredentials?: PublicKeyCredentialDescriptor[];
challenge: string;
relyingPartyId: string;
_relyingPartyId?: string;
timeout: number;
userVerification: UserVerificationType;
conditionalWebAuthn?: boolean;
extensions?: Record<string, unknown>;
_type?: string;
supportsJsonResponse?: boolean;
}

Expand Down
Loading