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
12 changes: 2 additions & 10 deletions android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -58,16 +58,8 @@ android {
targetCompatibility JavaVersion.VERSION_17
}

def agpVersion = com.android.Version.ANDROID_GRADLE_PLUGIN_VERSION
if (agpVersion.tokenize('.')[0].toInteger() < 8) {
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}

kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.majorVersion
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.majorVersion
}

namespace "expo.modules.passkeys"
Expand Down
21 changes: 16 additions & 5 deletions ios/ReactNativePasskeysModule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,14 +41,25 @@ final public class ReactNativePasskeysModule: Module, PasskeyResultHandler {
throw InvalidChallengeException()
}

let crossPlatformKeyAssertionRequest = prepareCrossPlatformAssertionRequest(
challenge: challengeData, request: request)
let platformKeyAssertionRequest = try preparePlatformAssertionRequest(
challenge: challengeData, request: request)

let authController = ASAuthorizationController(authorizationRequests: [
platformKeyAssertionRequest, crossPlatformKeyAssertionRequest,
])
// When allowCredentials is specified, use only the platform provider so iOS
// auto-selects the credential without showing a picker. Including the
// cross-platform provider triggers the expanded "choose how to sign in" UI,
// which breaks targeted assertions with third-party providers (Chrome, 1Password).
let authController: ASAuthorizationController
if let allowCredentials = request.allowCredentials, !allowCredentials.isEmpty {
authController = ASAuthorizationController(authorizationRequests: [
platformKeyAssertionRequest,
])
} else {
let crossPlatformKeyAssertionRequest = prepareCrossPlatformAssertionRequest(
challenge: challengeData, request: request)
authController = ASAuthorizationController(authorizationRequests: [
platformKeyAssertionRequest, crossPlatformKeyAssertionRequest,
])
Comment on lines +52 to +61
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Apple ASAuthorizationPlatformPublicKeyCredentialAssertionRequest security key external credentials allowCredentials

💡 Result:

In Apple’s passkeys APIs, ASAuthorizationPlatformPublicKeyCredentialAssertionRequest.allowedCredentials is the native equivalent of WebAuthn allowCredentials: it lets you restrict an assertion to a specific set of credential IDs (non‑discoverable / “username-first” style sign-in). If you omit it or set it to an empty array, you enable the discoverable (passkey) experience where the system can show an account selector / pick from available passkeys without you pre-supplying credential IDs. [1], [2], [3]

Important distinction: platform vs external (security key) credentials

  • ASAuthorizationPlatformPublicKeyCredentialAssertionRequest is for the platform authenticator (passkeys in iCloud Keychain on Apple devices). [1]
  • For external / roaming authenticators (USB/NFC/BLE security keys), use ASAuthorizationSecurityKeyPublicKeyCredentialProvider and its ASAuthorizationSecurityKeyPublicKeyCredentialAssertionRequest, which also supports an allow-list via allowedCredentials (and some bindings expose additional related properties like SecurityAllowedCredentials). [4]

Typical patterns

1) Discoverable passkey sign-in (recommended UX): don’t set allowedCredentials

let provider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: rpId)
let req = provider.createCredentialAssertionRequest(challenge: challenge)
// req.allowedCredentials = nil  // or []

Omitting/empty allowCredentials is what enables discoverable credential flows in WebAuthn/passkeys. [2], [3]

2) Restrict to known credential IDs (allow-list)

req.allowedCredentials = credentialIDs.map {
  ASAuthorizationPlatformPublicKeyCredentialDescriptor(credentialID: $0)
}

This mirrors WebAuthn allowCredentials usage. [2], [3]

3) External security key assertion: use the security-key provider
Conceptually:

  • create an ASAuthorizationSecurityKeyPublicKeyCredentialAssertionRequest
  • set its allowedCredentials to the security-key credential descriptors you want to permit (optionally including transport hints), then run it via ASAuthorizationController. [4]

Sources: Apple “Supporting passkeys” overview for the platform flow [1], WebAuthn/passkeys behavior of allowCredentials (omit/empty for discoverable credentials) [2], [3], and the security-key assertion request API surface showing AllowedCredentials for security keys [4].


🌐 Web query:

Apple AuthenticationServices ASAuthorizationSecurityKeyPublicKeyCredentialAssertionRequest vs platform request

💡 Result:

In AuthenticationServices, the assertion request type mainly selects which kind of WebAuthn authenticator you’re targeting:

  • ASAuthorizationPlatformPublicKeyCredentialAssertionRequest (“platform request”)

    • Targets a platform authenticator: a passkey stored on/managed by the Apple platform (e.g., iCloud Keychain / device-bound passkeys).
    • Can also surface hybrid / “use a phone” flows (QR/Bluetooth) on supported OS versions via platform-only UI controls like shouldShowHybridTransport (seen used only on the platform assertion request). [2]
    • In practice, this is the request you use for “Sign in with passkey” on Apple devices. [1]
  • ASAuthorizationSecurityKeyPublicKeyCredentialAssertionRequest (“security key request”)

    • Targets a cross‑platform external authenticator (a “security key”): USB/NFC/BLE FIDO2 keys (e.g., YubiKey).
    • Uses security‑key descriptors (ASAuthorizationSecurityKeyPublicKeyCredentialDescriptor) rather than platform descriptors, and does not participate in the platform’s hybrid/passkey-on-phone UX toggles. [3][2]

What’s the practical difference in code?

  • You create them from different providers:
    • ASAuthorizationPlatformPublicKeyCredentialProvider(...).createCredentialAssertionRequest(challenge:) for platform/passkeys. [1][2]
    • ASAuthorizationSecurityKeyPublicKeyCredentialProvider(...).createCredentialAssertionRequest(challenge:) for external security keys. [2]

Common pattern (support both)

Many implementations create both a platform assertion request and a security-key assertion request (same RP ID + challenge), set allowedCredentials appropriately for each descriptor type, then pass both to ASAuthorizationController so the user can choose the authenticator. [2][3]

Sources: [1] [2] [3] [4]


🌐 Web query:

WebAuthn allowCredentials attribute effect on authenticator selection

💡 Result:

allowCredentials (in PublicKeyCredentialRequestOptions passed to navigator.credentials.get()) directly filters which authenticators are eligible by telling the user agent “only these credential IDs are acceptable for this assertion.”

  • If allowCredentials is non-empty: the browser will only let the user complete authentication using an authenticator that can produce an assertion for one of the listed credential IDs (and matching RP ID, etc.). If no available authenticator can satisfy any entry, the ceremony fails (commonly surfaced as NotAllowedError). [1], [2]
  • If allowCredentials is empty (or omitted): it means any credential is acceptable, which enables “username-less” / discoverable credential flows (the authenticator can choose an RP-matching discoverable credential and the browser may show an account picker). [1], [3]
  • transports inside each allowCredentials entry: are hints the platform can use to decide which authenticators could work (e.g., “internal” vs “usb/nfc/ble”), potentially avoiding presenting options that can’t satisfy the request. [2]

Practical effect on authenticator selection:

  • Populate allowCredentials when you already know the user (e.g., after username entry) to constrain authentication to that account’s registered credentials. [3]
  • Leave it empty/omitted only if you intend to support discoverable credentials and account selection on the authenticator/UI. [3]

Sources:
[1] MDN: PublicKeyCredentialRequestOptions.allowCredentials (restriction + empty array meaning “any credential”) (developer.mozilla.org)
[2] W3C WebAuthn Level 2: ineligible authenticators + transports hint and “no eligible credential” failure behavior (w3.org)
[3] W3C WebAuthn Level 3: definition/purpose of allowCredentials (“find authenticators eligible…”, recommended when account identified) (w3.org)

Citations:


Functional regression: non-empty allowCredentials prevents security-key authentication.

When allowCredentials is non-empty, the code now creates only a platform assertion request. If the allowCredentials list contains a credential ID registered to an external/security-key authenticator, that authenticator cannot fulfill the assertion—the platform request cannot produce a valid assertion for security-key credentials. This causes NotAllowedError.

Fix: Always provide both platform and security-key assertion requests when allowCredentials is set. Set allowedCredentials on each request type with appropriate descriptors, allowing either authenticator type to satisfy the credentials in the allow-list. Only use platform-only or security-key-only paths if there is explicit, verifiable information about which authenticator types the credentials belong to.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ios/ReactNativePasskeysModule.swift` around lines 52 - 61, The current branch
that checks request.allowCredentials only creates platformKeyAssertionRequest,
preventing external/security-key authenticators from matching; always include
both platformKeyAssertionRequest and the crossPlatformKeyAssertionRequest (from
prepareCrossPlatformAssertionRequest) in the ASAuthorizationController requests
when allowCredentials is non-empty, and set the allowedCredentials (credential
descriptors) on both request objects using request.allowCredentials so either
authenticator type can satisfy the assertion; only reduce to platform-only or
cross-platform-only when you have explicit verified metadata indicating the
credentials are exclusively one authenticator type.

}

passkeyDelegate.performAuthForController(controller: authController)
}.runOnQueue(.main)
Expand Down
Loading