Skip to content
8 changes: 8 additions & 0 deletions .changeset/expo-passkeys-prefer-immediately-available.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@clerk/expo-passkeys': minor
'@clerk/expo': minor
'@clerk/clerk-js': minor
'@clerk/shared': minor
---

Add a `preferImmediatelyAvailableCredentials` option to `signIn.authenticateWithPasskey()` and `signIn.passkey()`. With `@clerk/expo-passkeys` on iOS 16+ and Android, it limits sign-in to passkeys already on the device, and the request fails without showing the "use a passkey from another device" sheet when there are none. This lets an app prompt for a passkey automatically when its sign-in screen opens. Browsers ignore it.
1 change: 1 addition & 0 deletions packages/clerk-js/src/core/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,7 @@ export class Clerk implements ClerkInterface {
publicKeyOptions,
}: {
publicKeyOptions: PublicKeyCredentialRequestOptionsWithoutExtensions;
preferImmediatelyAvailableCredentials?: boolean;
}) => Promise<CredentialReturn<PublicKeyCredentialWithAuthenticatorAssertionResponse>>)
| undefined;

Expand Down
7 changes: 5 additions & 2 deletions packages/clerk-js/src/core/resources/SignIn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -665,7 +665,7 @@ export class SignIn extends BaseResource implements SignInResource {
* @returns The updated `SignIn` resource.
*/
public authenticateWithPasskey = async (params?: AuthenticateWithPasskeyParams): Promise<SignInResource> => {
const { flow } = params || {};
const { flow, preferImmediatelyAvailableCredentials } = params || {};

/**
* The UI should always prevent from this method being called if WebAuthn is not supported.
Expand Down Expand Up @@ -699,6 +699,7 @@ export class SignIn extends BaseResource implements SignInResource {
const { publicKeyCredential, error } = await webAuthnGetCredential({
publicKeyOptions: secondFactorPublicKeyOptions,
conditionalUI: false,
preferImmediatelyAvailableCredentials,
});
if (!publicKeyCredential) {
throw error;
Expand Down Expand Up @@ -748,6 +749,7 @@ export class SignIn extends BaseResource implements SignInResource {
const { publicKeyCredential, error } = await webAuthnGetCredential({
publicKeyOptions,
conditionalUI: canUseConditionalUI,
preferImmediatelyAvailableCredentials,
});

if (!publicKeyCredential) {
Expand Down Expand Up @@ -1578,7 +1580,7 @@ class SignInFuture implements SignInFutureResource {
}

async passkey(params?: SignInFuturePasskeyParams): Promise<{ error: ClerkError | null }> {
const { flow } = params || {};
const { flow, preferImmediatelyAvailableCredentials } = params || {};

/**
* The UI should always prevent from this method being called if WebAuthn is not supported.
Expand Down Expand Up @@ -1633,6 +1635,7 @@ class SignInFuture implements SignInFutureResource {
const { publicKeyCredential, error } = await webAuthnGetCredential({
publicKeyOptions,
conditionalUI: canUseConditionalUI,
preferImmediatelyAvailableCredentials,
});

if (!publicKeyCredential) {
Expand Down
139 changes: 139 additions & 0 deletions packages/clerk-js/src/core/resources/__tests__/SignIn.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1020,6 +1020,93 @@ describe('SignIn', () => {
}),
);
});

it('forwards preferImmediatelyAvailableCredentials to the credential request in the discoverable flow', async () => {
const mockIsWebAuthnSupported = vi.fn().mockReturnValue(true);
const mockWebAuthnGetCredential = vi.fn().mockResolvedValue({
publicKeyCredential: mockPublicKeyCredential,
error: null,
});

SignIn.clerk = {
__internal_isWebAuthnSupported: mockIsWebAuthnSupported,
__internal_getPublicCredentials: mockWebAuthnGetCredential,
} as any;

const mockFetch = vi
.fn()
.mockResolvedValueOnce({
client: null,
response: {
id: 'signin_456',
status: 'needs_first_factor',
first_factor_verification: {
nonce: JSON.stringify({ challenge: 'Y2hhbGxlbmdl' }),
},
},
})
.mockResolvedValueOnce({
client: null,
response: { id: 'signin_456', status: 'complete' },
});
BaseResource._fetch = mockFetch;

const signIn = new SignIn({ id: 'signin_123' } as any);
await signIn.authenticateWithPasskey({ flow: 'discoverable', preferImmediatelyAvailableCredentials: true });

expect(mockWebAuthnGetCredential).toHaveBeenCalledWith(
expect.objectContaining({ preferImmediatelyAvailableCredentials: true }),
);
});

it('forwards preferImmediatelyAvailableCredentials to the credential request for a passkey second factor', async () => {
const mockIsWebAuthnSupported = vi.fn().mockReturnValue(true);
const mockWebAuthnGetCredential = vi.fn().mockResolvedValue({
publicKeyCredential: mockPublicKeyCredential,
error: null,
});

SignIn.clerk = {
__internal_isWebAuthnSupported: mockIsWebAuthnSupported,
__internal_getPublicCredentials: mockWebAuthnGetCredential,
} as any;

const mockFetch = vi
.fn()
.mockResolvedValueOnce({
client: null,
response: {
id: 'signin_123',
status: 'needs_second_factor',
supported_second_factors: [{ strategy: 'passkey' }],
second_factor_verification: {
nonce: JSON.stringify({ challenge: 'Y2hhbGxlbmdl' }),
},
},
})
.mockResolvedValueOnce({
client: null,
response: { id: 'signin_123', status: 'complete' },
});
BaseResource._fetch = mockFetch;

const signIn = new SignIn({
id: 'signin_123',
status: 'needs_second_factor',
supported_second_factors: [{ strategy: 'passkey' }, { strategy: 'totp' }],
} as any);
await signIn.authenticateWithPasskey({ preferImmediatelyAvailableCredentials: true });

expect(mockFetch).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
path: '/client/sign_ins/signin_123/prepare_second_factor',
}),
);
expect(mockWebAuthnGetCredential).toHaveBeenCalledWith(
expect.objectContaining({ preferImmediatelyAvailableCredentials: true }),
);
});
});

describe('trusted_device strategy', () => {
Expand Down Expand Up @@ -2593,6 +2680,58 @@ describe('SignIn', () => {
expect(mockWebAuthnGetCredential).toHaveBeenCalled();
});

it('forwards preferImmediatelyAvailableCredentials to the credential request', async () => {
const mockIsWebAuthnSupported = vi.fn().mockReturnValue(true);
const mockWebAuthnGetCredential = vi.fn().mockResolvedValue({
publicKeyCredential: {
id: 'credential_123',
rawId: new ArrayBuffer(32),
response: {
authenticatorData: new ArrayBuffer(37),
clientDataJSON: new ArrayBuffer(121),
signature: new ArrayBuffer(64),
userHandle: null,
},
type: 'public-key',
},
error: null,
});

SignIn.clerk = {
__internal_isWebAuthnSupported: mockIsWebAuthnSupported,
__internal_getPublicCredentials: mockWebAuthnGetCredential,
__internal_environment: {
displayConfig: {
captchaOauthBypass: [],
},
},
} as any;

const mockFetch = vi
.fn()
.mockResolvedValueOnce({
client: null,
response: {
id: 'signin_123',
first_factor_verification: {
nonce: JSON.stringify({ challenge: 'Y2hhbGxlbmdl' }),
},
},
})
.mockResolvedValueOnce({
client: null,
response: { id: 'signin_123', status: 'complete' },
});
BaseResource._fetch = mockFetch;

const signIn = new SignIn();
await signIn.__internal_future.passkey({ flow: 'discoverable', preferImmediatelyAvailableCredentials: true });

expect(mockWebAuthnGetCredential).toHaveBeenCalledWith(
expect.objectContaining({ preferImmediatelyAvailableCredentials: true }),
);
});

it('throws error when WebAuthn is not supported', async () => {
const mockIsWebAuthnSupported = vi.fn().mockReturnValue(false);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,16 @@ import androidx.credentials.exceptions.GetCredentialException
import expo.modules.kotlin.Promise
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import expo.modules.kotlin.records.Field
import expo.modules.kotlin.records.Record
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch

class GetPasskeyOptionsArgument : Record {
@Field var preferImmediatelyAvailableCredentials: Boolean = false
}

class ClerkExpoPasskeysModule : Module() {
private val mCoroutine = CoroutineScope(Dispatchers.Default)

Expand All @@ -30,17 +36,24 @@ class ClerkExpoPasskeysModule : Module() {
}

AsyncFunction("get") { request: String, promise: Promise ->
launchGet(request, false, promise)
}

mCoroutine.launch {
try {
val response = getPasskey(request, appContext)
promise.resolve(response)
} catch (e: GetCredentialException) {
handleGetFailure(e, promise)
}
}
AsyncFunction("getWithOptions") { request: String, options: GetPasskeyOptionsArgument, promise: Promise ->
launchGet(request, options.preferImmediatelyAvailableCredentials, promise)
}


}

private fun launchGet(request: String, preferImmediatelyAvailableCredentials: Boolean, promise: Promise) {
mCoroutine.launch {
try {
val response = getPasskey(request, preferImmediatelyAvailableCredentials, appContext)
promise.resolve(response)
} catch (e: GetCredentialException) {
handleGetFailure(e, promise)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,16 @@ suspend fun createPasskey(

suspend fun getPasskey(
request: String,
preferImmediatelyAvailableCredentials: Boolean,
appContext: AppContext,
): String? {
val credentialManager =
CredentialManager.create(appContext.reactContext?.applicationContext!!)
val getCredentialRequest =
GetCredentialRequest(listOf(GetPublicKeyCredentialOption(request)))
GetCredentialRequest(
credentialOptions = listOf(GetPublicKeyCredentialOption(request)),
preferImmediatelyAvailableCredentials = preferImmediatelyAvailableCredentials,
)

val result = appContext.activityProvider?.currentActivity?.let {
credentialManager.getCredential(it, getCredentialRequest)
Expand Down
8 changes: 6 additions & 2 deletions packages/expo-passkeys/ios/AccountManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ class AccountManager: NSObject, ASAuthorizationControllerPresentationContextProv
}


func getPasskey(challengeBase64URL: String, rpId: String, promise: Promise) {
func getPasskey(challengeBase64URL: String, rpId: String, preferImmediatelyAvailableCredentials: Bool, promise: Promise) {
let publicKeyCredentialProvider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: rpId)

let challenge = dataFromBase64URL(base64urlString: challengeBase64URL)!
Expand All @@ -68,7 +68,11 @@ class AccountManager: NSObject, ASAuthorizationControllerPresentationContextProv
let authController = ASAuthorizationController(authorizationRequests: [ assertionRequest ] )
authController.delegate = self
authController.presentationContextProvider = self
authController.performRequests()
if preferImmediatelyAvailableCredentials, #available(iOS 16.0, *) {
authController.performRequests(options: .preferImmediatelyAvailableCredentials)
} else {
authController.performRequests()
}

self.authCallback = { result,error in
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
if (error != nil) {
Expand Down
10 changes: 9 additions & 1 deletion packages/expo-passkeys/ios/ClerkExpoPasskeysModule.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import ExpoModulesCore

struct GetPasskeyOptions: Record {
@Field var preferImmediatelyAvailableCredentials: Bool = false
}

@available(iOS 15.0, *)
public class ClerkExpoPasskeysModule: Module {
let credentialManager = AccountManager()
Expand All @@ -12,7 +16,11 @@ public class ClerkExpoPasskeysModule: Module {
}

AsyncFunction("get") { (challenge: String, rpId:String, promise: Promise) in
self.credentialManager.getPasskey(challengeBase64URL: challenge, rpId: rpId, promise: promise)
self.credentialManager.getPasskey(challengeBase64URL: challenge, rpId: rpId, preferImmediatelyAvailableCredentials: false, promise: promise)
}

AsyncFunction("getWithOptions") { (challenge: String, rpId: String, options: GetPasskeyOptions, promise: Promise) in
self.credentialManager.getPasskey(challengeBase64URL: challenge, rpId: rpId, preferImmediatelyAvailableCredentials: options.preferImmediatelyAvailableCredentials, promise: promise)
}

AsyncFunction("autofill") { (challenge: String, rpId:String, promise: Promise) in
Expand Down
11 changes: 9 additions & 2 deletions packages/expo-passkeys/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,8 +115,10 @@ const makeSerializedGetResponse = (

export async function get({
publicKeyOptions,
preferImmediatelyAvailableCredentials,
}: {
publicKeyOptions: PublicKeyCredentialRequestOptionsWithoutExtensions;
preferImmediatelyAvailableCredentials?: boolean;
}): Promise<CredentialReturn<PublicKeyCredentialWithAuthenticatorAssertionResponse>> {
if (!publicKeyOptions) {
throw new Error('publicKeyCredential has not been provided');
Expand All @@ -128,9 +130,14 @@ export async function get({
challenge: arrayBufferToBase64Url(publicKeyOptions.challenge),
};

const callGet = (...args: unknown[]) =>
preferImmediatelyAvailableCredentials && ClerkExpoPasskeys.getWithOptions
? ClerkExpoPasskeys.getWithOptions(...args, { preferImmediatelyAvailableCredentials: true })
: ClerkExpoPasskeys.get(...args);

const getPasskeyModule = Platform.select({
android: async () => ClerkExpoPasskeys.get(JSON.stringify(serializedPublicCredential)),
ios: async () => ClerkExpoPasskeys.get(serializedPublicCredential.challenge, serializedPublicCredential.rpId),
android: async () => callGet(JSON.stringify(serializedPublicCredential)),
ios: async () => callGet(serializedPublicCredential.challenge, serializedPublicCredential.rpId),
default: null,
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,34 @@ describe('createClerkInstance', () => {
expect(get).toHaveBeenNthCalledWith(2, { publicKeyOptions: sessionSecondFactorOptions });
});

test('forwards preferImmediatelyAvailableCredentials to the native passkey adapter', async () => {
const createClerkInstance = await loadCreateClerkInstance();
const getClerkInstance = createClerkInstance(MockClerk as unknown as typeof Clerk);
const get = vi.fn().mockResolvedValue({ publicKeyCredential: null, error: null });
const clerk = getClerkInstance({
publishableKey: 'pk_test_123',
__experimental_passkeys: {
get,
create: vi.fn(),
isSupported: vi.fn(() => true),
isAutoFillSupported: vi.fn(() => Promise.resolve(false)),
},
}) as unknown as MockClerk & {
__internal_getPublicCredentials: (params: {
publicKeyOptions: PublicKeyCredentialRequestOptionsWithoutExtensions;
preferImmediatelyAvailableCredentials?: boolean;
}) => Promise<unknown>;
};
const publicKeyOptions = {
challenge: new Uint8Array([1]).buffer,
rpId: 'example.com',
} as PublicKeyCredentialRequestOptionsWithoutExtensions;

await clerk.__internal_getPublicCredentials({ publicKeyOptions, preferImmediatelyAvailableCredentials: true });

expect(get).toHaveBeenCalledWith({ publicKeyOptions, preferImmediatelyAvailableCredentials: true });
});

test('recreates the singleton when proxyUrl changes', async () => {
const createClerkInstance = await loadCreateClerkInstance();
const getClerkInstance = createClerkInstance(MockClerk as unknown as typeof Clerk);
Expand Down
Loading
Loading