diff --git a/docs/kratos/emails-sms/05_custom-email-templates.mdx b/docs/kratos/emails-sms/05_custom-email-templates.mdx index 2c542899dd..06fa7ff3c9 100644 --- a/docs/kratos/emails-sms/05_custom-email-templates.mdx +++ b/docs/kratos/emails-sms/05_custom-email-templates.mdx @@ -63,7 +63,7 @@ To learn more about the verification flow, read When you enable the one-time code method the login flow will need to send out an email to users signing in through the one-time code method. The system will then use the `login_code.valid` template to send the login code to the user. -To learn more about login via a one-time code, read the [one-time code](../passwordless/06_code.mdx) documentation. +To learn more about login via a one-time code, read the [one-time code](../passwordless/07_code.mdx) documentation. ```mdx-code-block @@ -74,7 +74,7 @@ To learn more about login via a one-time code, read the [one-time code](../passw When you enable the one-time code method the registration flow will need to send out an email to users signing up using the one-time code method. The system will then use the `registration_code.valid` template to send the registration code to the user. -To learn more about registration via a one-time code, read the [one-time code](../passwordless/06_code.mdx) documentation. +To learn more about registration via a one-time code, read the [one-time code](../passwordless/07_code.mdx) documentation. ```mdx-code-block diff --git a/docs/kratos/passwordless/05_passkeys.mdx b/docs/kratos/passwordless/05_passkeys.mdx index 79c3c3674c..6d2dcad5c1 100644 --- a/docs/kratos/passwordless/05_passkeys.mdx +++ b/docs/kratos/passwordless/05_passkeys.mdx @@ -148,6 +148,18 @@ is defined, the passkey strategy will not work. } ``` +### Implementing passkeys in your application + +After configuring passkeys, you need to integrate them into your application. The implementation differs depending on your +platform: + +- Web applications can use Ory's `webauthn.js` helper script or manually integrate the WebAuthn API. +- Mobile applications (iOS and Android) require platform-specific credential management APIs and direct integration with Ory's + JSON API endpoints. + +For detailed implementation instructions, code examples, and best practices for both web and mobile platforms, see +[Implementing passkeys for web and mobile](./06_passkeys-mobile.mdx). + ## Passkeys with the WebAuthN strategy ### Configuration diff --git a/docs/kratos/passwordless/06_passkeys-mobile.mdx b/docs/kratos/passwordless/06_passkeys-mobile.mdx new file mode 100644 index 0000000000..c12242d881 --- /dev/null +++ b/docs/kratos/passwordless/06_passkeys-mobile.mdx @@ -0,0 +1,940 @@ +--- +id: passkeys-mobile +title: Implement passkey authentication in web and mobile applications +sidebar_label: Passkeys for mobile +slug: passkeys-mobile-web-implementation +--- + +import Tabs from "@theme/Tabs" +import TabItem from "@theme/TabItem" + +This guide covers how to implement passkey authentication in your applications using Ory Kratos or Ory Network. Passkeys provide a +passwordless authentication experience using WebAuthn across web browsers and mobile platforms. + +This page assumes you have already configured the passkey method in your Ory configuration. See the +[Passkeys overview](./05_passkeys.mdx) for initial setup instructions. + +:::note + +Code examples in this guide are illustrative and likely need adjustments based on your specific configuration, identity schema, +and application requirements. + +::: + +## Overview + +Passkey implementation differs between platforms: + +- Web applications use browser-native WebAuthn APIs with JavaScript. +- Mobile applications use platform-specific credential management APIs (iOS AuthenticationServices, Android CredentialManager) + with Ory's JSON API endpoints. + +This guide focuses on the integration patterns for each platform. + +## Web implementation + +For web applications, you can use the browser's native WebAuthn API to create and authenticate with passkeys. + +### Using Ory's webauthn.js + +Ory provides a `webauthn.js` helper script that simplifies WebAuthn integration in browser flows. When you initialize a +registration or login flow through the browser, Ory automatically injects the necessary JavaScript to handle passkey operations. + +```html + + +``` + +The script automatically: + +- Detects passkey-related form fields. +- Calls `navigator.credentials.create()` for registration. +- Calls `navigator.credentials.get()` for authentication. +- Submits the WebAuthn response back to Ory. + +See [Custom UI Advanced Integration](../bring-your-own-ui/custom-ui-advanced-integration#passwordless-authentication.mdx) for +details on using `webauthn.js` in custom UIs. + +### Manual WebAuthn integration + +For more control, you can manually integrate the [W3C WebAuthn API](https://www.w3.org/TR/webauthn-2/): + +1. Initialize a registration or login flow via Ory's API. +2. Parse the WebAuthn challenge from the flow response. +3. Call `navigator.credentials.create()` or `navigator.credentials.get()`. +4. Submit the credential response back to Ory. + +The WebAuthn API is well-documented by the W3C and MDN: + +- [W3C WebAuthn Specification](https://www.w3.org/TR/webauthn-2/) +- [MDN Web Authentication API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Authentication_API) + +## Mobile implementation + +Mobile passkey implementation requires using platform-specific APIs and Ory's JSON API endpoints. Unlike browser flows, mobile +apps don't receive the `webauthn.js` script and must handle credential operations manually. + +### Platform requirements + +Both iOS and Android require configuration to associate your app with your authentication domain. + +### iOS Associated Domains + +iOS requires an `apple-app-site-association` (AASA) file to create a bidirectional link between your app and your domain. This +proves that your app and domain are controlled by the same entity. + +#### How iOS association works + +1. You add an Associated Domains entitlement to your app: `webcredentials:example.com` +2. When your app is installed/updated, iOS downloads the AASA file from that domain (via Apple's CDN) +3. When creating a passkey, iOS verifies the RP ID matches a domain in your entitlements AND the AASA file lists your app + +#### Add the Associated Domains entitlement + +Add the entitlement to your Xcode project. The domain should match your RP ID: + +```xml + + + + + + com.apple.developer.associated-domains + + + webcredentials:example.com + + + +``` + +**For subdomain support**, iOS allows wildcard entitlements: + +```xml +webcredentials:*.example.com +``` + +This allows your app to work with any subdomain. However, the AASA file must still be served from your RP ID domain. + +#### Serve the apple-app-site-association file + +The AASA file must be hosted at `https://{your-rp-id}/.well-known/apple-app-site-association`. If your RP ID is `example.com`, +host it at: + +``` +https://example.com/.well-known/apple-app-site-association +``` + +**File structure:** + +```json +{ + "webcredentials": { + "apps": ["ABCDE12345.com.example.yourapp"] + } +} +``` + +The value format is `{Team_ID}.{Bundle_Identifier}`: + +- **Team ID**: Find in [Apple Developer portal](https://developer.apple.com/account) under Membership +- **Bundle Identifier**: Your app's bundle ID from Xcode (e.g., `com.example.yourapp`) + +**File requirements:** + +- Must be served over HTTPS with a valid TLS certificate +- Must return `Content-Type: application/json` +- Must be accessible without authentication +- Apple's CDN caches the file for up to 24 hours + +:::warning + +Ory Network doesn't host `apple-app-site-association` files automatically. You must host this file on your own domain at the RP ID +you configured. + +::: + +#### Apple documentation + +- [Supporting Associated Domains](https://developer.apple.com/documentation/xcode/supporting-associated-domains) +- [About the apple-app-site-association file](https://developer.apple.com/documentation/bundleresources/applinks/details/supporting_associated_domains) + +### Android Digital Asset Links + +Android uses Digital Asset Links to establish trust between your app and your domain. This verification allows credential sharing +between your website and app. + +#### How Android association works + +When your app requests passkey operations, Android: + +1. Checks the domain associated with the RP ID +2. Downloads the `assetlinks.json` file from that domain +3. Verifies your app's package name and signing certificate match the file +4. Grants permission for credential operations if validated + +**Important:** Android does **not** support wildcard domains. The `assetlinks.json` file must be hosted at the exact RP ID domain. + +#### Serve the assetlinks.json file + +The file must be hosted at `https://{your-rp-id}/.well-known/assetlinks.json`. If your RP ID is `example.com`, host it at: + +``` +https://example.com/.well-known/assetlinks.json +``` + +**File structure:** + +```json +[ + { + "relation": ["delegate_permission/common.handle_all_urls", "delegate_permission/common.get_login_creds"], + "target": { + "namespace": "android_app", + "package_name": "com.example.yourapp", + "sha256_cert_fingerprints": [ + "14:6D:E9:83:C5:73:06:50:D8:EE:B9:95:2F:34:FC:64:16:A0:83:42:E6:1D:BE:A8:8A:04:96:B2:3F:CF:44:E5" + ] + } + } +] +``` + +**Key relation for passkeys:** `delegate_permission/common.get_login_creds` enables credential sharing between your website and +app. + +#### Generate SHA-256 certificate fingerprint + +For debug builds: + +```shell +keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android -keypass android +``` + +For release builds: + +```shell +keytool -list -v -keystore /path/to/your-release-key.keystore +``` + +Copy the SHA-256 fingerprint from the output. + +**File requirements:** + +- Must be served over HTTPS with a valid TLS certificate +- Must return `Content-Type: application/json` +- Must be accessible without authentication +- Package name must match your app's `applicationId` in `build.gradle` +- SHA-256 fingerprint must match your app's signing key + +:::warning + +Ory Network doesn't host `assetlinks.json` files automatically. You must host this file on your own domain at the RP ID you +configured. + +::: + +#### Subdomain handling + +Unlike iOS, Android does **not** support wildcard associations. If you need passkeys on multiple subdomains: + +1. Use your root domain as the RP ID (recommended) +2. Host the `assetlinks.json` at the root domain +3. All subdomains will share the same passkey credentials + +**Example:** With RP ID `example.com`, passkeys work on: + +- `https://example.com` +- `https://login.example.com` +- `https://app.example.com` + +All using the single `assetlinks.json` file at `https://example.com/.well-known/assetlinks.json`. + +#### Validation tools + +Test your `assetlinks.json` configuration: + +- [Statement List Generator and Tester](https://developers.google.com/digital-asset-links/tools/generator) +- Command line: `curl https://example.com/.well-known/assetlinks.json` + +#### Android documentation + +- [Android Credential Manager](https://developer.android.com/training/sign-in/passkeys) +- [Digital Asset Links](https://developers.google.com/digital-asset-links/v1/getting-started) +- [Verify Android App Links](https://developer.android.com/training/app-links/verify-android-applinks) + +### iOS implementation + +iOS passkey support uses the [AuthenticationServices framework](https://developer.apple.com/documentation/authenticationservices). +Here's how to integrate with Ory's API. + +#### Registration flow + +Initialize the registration flow: + +```swift +func initializeRegistrationFlow() async throws -> FlowResponse { + var request = URLRequest(url: URL(string: "\(oryBaseURL)/self-service/registration/api")!) + request.httpMethod = "GET" + request.setValue("application/json", forHTTPHeaderField: "Accept") + + let (data, response) = try await URLSession.shared.data(for: request) + // Handle response... + return try decodeFlowResponse(from: data) +} +``` + +Parse the WebAuthn challenge by extracting the challenge from the `passkey_create_data` node: + +```swift +func extractRegistrationChallenge(_ flow: FlowResponse) -> String? { + guard let ui = flow.raw["ui"] as? [String: Any], + let nodes = ui["nodes"] as? [[String: Any]] else { + return nil + } + + // Find the passkey_create_data node + for node in nodes { + guard let attributes = node["attributes"] as? [String: Any], + let name = attributes["name"] as? String, + name == "passkey_create_data" else { + continue + } + + // Parse the nested JSON value + if let valueStr = attributes["value"] as? String, + let valueData = valueStr.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: valueData) as? [String: Any], + let credentialOptions = json["credentialOptions"] as? [String: Any], + let publicKey = credentialOptions["publicKey"] as? [String: Any], + let challenge = publicKey["challenge"] as? String { + return challenge + } + } + return nil +} +``` + +The `passkey_create_data` node contains a JSON string with this structure: + +```json +{ + "credentialOptions": { + "publicKey": { + "challenge": "base64url-encoded-challenge", + "rp": { "name": "Your App", "id": "ory.your-custom-domain.com" }, + "user": { "id": "base64url-user-id", "name": "", "displayName": "" }, + "pubKeyCredParams": [ + { "type": "public-key", "alg": -7 }, + { "type": "public-key", "alg": -257 } + ], + "authenticatorSelection": { + "userVerification": "required", + "residentKey": "required" + } + } + }, + "displayNameFieldName": "traits.email" +} +``` + +Create the passkey: + +```swift +func signUpWith(userName: String, challenge: Data, domain: String) { + let publicKeyCredentialProvider = ASAuthorizationPlatformPublicKeyCredentialProvider( + relyingPartyIdentifier: domain + ) + + let userID = Data(UUID().uuidString.utf8) + let registrationRequest = publicKeyCredentialProvider.createCredentialRegistrationRequest( + challenge: challenge, + name: userName, + userID: userID + ) + + let authController = ASAuthorizationController(authorizationRequests: [registrationRequest]) + authController.delegate = self + authController.presentationContextProvider = self + authController.performRequests() +} +``` + +When the user completes registration, format and submit the credential: + +```swift +func submitRegistration(credential: ASAuthorizationPlatformPublicKeyCredentialRegistration) async throws { + let credentialDict: [String: Any] = [ + "id": credential.credentialID.base64URLEncodedString(), + "rawId": credential.credentialID.base64URLEncodedString(), + "type": "public-key", + "response": [ + "clientDataJSON": credential.rawClientDataJSON.base64URLEncodedString(), + "attestationObject": credential.rawAttestationObject?.base64URLEncodedString() ?? "" + ] + ] + + let credentialJSON = try JSONSerialization.data(withJSONObject: credentialDict) + let credentialString = String(data: credentialJSON, encoding: .utf8)! + + var request = URLRequest(url: URL(string: "\(oryBaseURL)/self-service/registration?flow=\(flowId)")!) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let payload: [String: Any] = [ + "method": "passkey", + "passkey_register": credentialString, + "traits": [ + "email": userName // Or other identity traits + ] + ] + + request.httpBody = try JSONSerialization.data(withJSONObject: payload) + let (data, response) = try await URLSession.shared.data(for: request) + // Handle response... +} +``` + +#### Login flow + +Initialize the login flow: + +```swift +func initializeLoginFlow() async throws -> FlowResponse { + var request = URLRequest(url: URL(string: "\(oryBaseURL)/self-service/login/api")!) + request.httpMethod = "GET" + request.setValue("application/json", forHTTPHeaderField: "Accept") + + let (data, response) = try await URLSession.shared.data(for: request) + return try decodeFlowResponse(from: data) +} +``` + +Extract the challenge from the `passkey_challenge` node: + +```swift +func extractLoginChallenge(_ flow: FlowResponse) -> String? { + guard let ui = flow.raw["ui"] as? [String: Any], + let nodes = ui["nodes"] as? [[String: Any]] else { + return nil + } + + // Find the passkey_challenge node + for node in nodes { + guard let attributes = node["attributes"] as? [String: Any], + let name = attributes["name"] as? String, + name == "passkey_challenge" else { + continue + } + + // Parse the JSON value + if let valueStr = attributes["value"] as? String, + let valueData = valueStr.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: valueData) as? [String: Any], + let publicKey = json["publicKey"] as? [String: Any], + let challenge = publicKey["challenge"] as? String { + return challenge + } + } + return nil +} +``` + +The `passkey_challenge` node contains a JSON string with this structure: + +```json +{ + "publicKey": { + "challenge": "base64url-encoded-challenge", + "rpId": "ory.your-custom-domain.com", + "allowCredentials": [], + "userVerification": "required" + } +} +``` + +Authenticate with passkey: + +```swift +func signInWith(challenge: Data, domain: String) { + let publicKeyCredentialProvider = ASAuthorizationPlatformPublicKeyCredentialProvider( + relyingPartyIdentifier: domain + ) + + let assertionRequest = publicKeyCredentialProvider.createCredentialAssertionRequest( + challenge: challenge + ) + + let authController = ASAuthorizationController(authorizationRequests: [assertionRequest]) + authController.delegate = self + authController.presentationContextProvider = self + authController.performRequests() +} +``` + +Submit the assertion: + +```swift +func submitLogin(credential: ASAuthorizationPlatformPublicKeyCredentialAssertion) async throws { + let credentialDict: [String: Any] = [ + "id": credential.credentialID.base64URLEncodedString(), + "rawId": credential.credentialID.base64URLEncodedString(), + "type": "public-key", + "response": [ + "clientDataJSON": credential.rawClientDataJSON.base64URLEncodedString(), + "authenticatorData": credential.rawAuthenticatorData.base64URLEncodedString(), + "signature": credential.signature.base64URLEncodedString(), + "userHandle": credential.userID.base64URLEncodedString() + ] + ] + + let credentialJSON = try JSONSerialization.data(withJSONObject: credentialDict) + let credentialString = String(data: credentialJSON, encoding: .utf8)! + + var request = URLRequest(url: URL(string: "\(oryBaseURL)/self-service/login?flow=\(flowId)")!) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let payload: [String: Any] = [ + "method": "passkey", + "passkey_login": credentialString + ] + + request.httpBody = try JSONSerialization.data(withJSONObject: payload) + let (data, response) = try await URLSession.shared.data(for: request) + // Handle response... +} +``` + +#### Base64URL encoding + +iOS requires Base64URL encoding (not standard Base64) for WebAuthn: + +```swift +extension Data { + func base64URLEncodedString() -> String { + return self.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + + init?(base64URLEncoded string: String) { + var base64 = string + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + let remainder = base64.count % 4 + if remainder > 0 { + base64 += String(repeating: "=", count: 4 - remainder) + } + self.init(base64Encoded: base64) + } +} +``` + +### Android implementation + +Android passkey support uses the [Credential Manager API](https://developer.android.com/training/sign-in/passkeys). The +integration pattern is similar to iOS. + +#### Dependencies + +Add the Credential Manager dependency to your `build.gradle`: + +```gradle +dependencies { + implementation "androidx.credentials:credentials:1.2.0" + implementation "androidx.credentials:credentials-play-services-auth:1.2.0" +} +``` + +#### Registration flow + +Initialize the registration flow: + +```kotlin +suspend fun initializeRegistrationFlow(): FlowResponse { + val url = URL("$oryBaseURL/self-service/registration/api") + val connection = url.openConnection() as HttpURLConnection + connection.requestMethod = "GET" + connection.setRequestProperty("Accept", "application/json") + + val response = connection.inputStream.bufferedReader().readText() + return parseFlowResponse(response) +} +``` + +Parse the WebAuthn challenge: + +```kotlin +fun extractRegistrationChallenge(flow: FlowResponse): String? { + val ui = flow.raw["ui"] as? Map<*, *> ?: return null + val nodes = ui["nodes"] as? List<*> ?: return null + + for (node in nodes) { + val nodeMap = node as? Map<*, *> ?: continue + val attributes = nodeMap["attributes"] as? Map<*, *> ?: continue + val name = attributes["name"] as? String ?: continue + + if (name == "passkey_create_data") { + val valueStr = attributes["value"] as? String ?: continue + val json = JSONObject(valueStr) + val credentialOptions = json.getJSONObject("credentialOptions") + val publicKey = credentialOptions.getJSONObject("publicKey") + return publicKey.getString("challenge") + } + } + return null +} +``` + +Create the passkey: + +```kotlin +suspend fun signUpWith(userName: String, requestJson: String, context: Context) { + val credentialManager = CredentialManager.create(context) + + val request = CreatePublicKeyCredentialRequest(requestJson) + + try { + val result = credentialManager.createCredential( + request = request, + context = context as Activity + ) as CreatePublicKeyCredentialResponse + + submitRegistration(result.registrationResponseJson) + } catch (e: CreateCredentialException) { + // Handle error + } +} +``` + +Submit the credential: + +```kotlin +suspend fun submitRegistration(credentialJson: String) { + val url = URL("$oryBaseURL/self-service/registration?flow=$flowId") + val connection = url.openConnection() as HttpURLConnection + connection.requestMethod = "POST" + connection.setRequestProperty("Content-Type", "application/json") + connection.doOutput = true + + val payload = JSONObject().apply { + put("method", "passkey") + put("passkey_register", credentialJson) + put("traits", JSONObject().apply { + put("email", userName) + }) + } + + connection.outputStream.write(payload.toString().toByteArray()) + val response = connection.inputStream.bufferedReader().readText() + // Handle response... +} +``` + +#### Login flow + +Initialize the login flow: + +```kotlin +suspend fun initializeLoginFlow(): FlowResponse { + val url = URL("$oryBaseURL/self-service/login/api") + val connection = url.openConnection() as HttpURLConnection + connection.requestMethod = "GET" + connection.setRequestProperty("Accept", "application/json") + + val response = connection.inputStream.bufferedReader().readText() + return parseFlowResponse(response) +} +``` + +Parse the WebAuthn challenge: + +```kotlin +fun extractLoginChallenge(flow: FlowResponse): String? { + val ui = flow.raw["ui"] as? Map<*, *> ?: return null + val nodes = ui["nodes"] as? List<*> ?: return null + + for (node in nodes) { + val nodeMap = node as? Map<*, *> ?: continue + val attributes = nodeMap["attributes"] as? Map<*, *> ?: continue + val name = attributes["name"] as? String ?: continue + + if (name == "passkey_challenge") { + val valueStr = attributes["value"] as? String ?: continue + val json = JSONObject(valueStr) + val publicKey = json.getJSONObject("publicKey") + return publicKey.getString("challenge") + } + } + return null +} +``` + +Authenticate with passkey: + +```kotlin +suspend fun signInWith(requestJson: String, context: Context) { + val credentialManager = CredentialManager.create(context) + + val request = GetPublicKeyCredentialOption(requestJson) + val getCredRequest = GetCredentialRequest(listOf(request)) + + try { + val result = credentialManager.getCredential( + request = getCredRequest, + context = context as Activity + ) + + val credential = result.credential as PublicKeyCredential + submitLogin(credential.authenticationResponseJson) + } catch (e: GetCredentialException) { + // Handle error + } +} +``` + +Submit the assertion: + +```kotlin +suspend fun submitLogin(credentialJson: String) { + val url = URL("$oryBaseURL/self-service/login?flow=$flowId") + val connection = url.openConnection() as HttpURLConnection + connection.requestMethod = "POST" + connection.setRequestProperty("Content-Type", "application/json") + connection.doOutput = true + + val payload = JSONObject().apply { + put("method", "passkey") + put("passkey_login", credentialJson) + } + + connection.outputStream.write(payload.toString().toByteArray()) + val response = connection.inputStream.bufferedReader().readText() + // Handle response... +} +``` + +## API response handling + +### Flow response structure + +All registration and login flows return a similar structure: + +```json +{ + "id": "flow-id-uuid", + "type": "api", + "expires_at": "2025-11-23T12:00:00Z", + "issued_at": "2025-11-23T11:00:00Z", + "request_url": "https://example.com/self-service/registration/api", + "ui": { + "action": "https://example.com/self-service/registration?flow=flow-id-uuid", + "method": "POST", + "nodes": [...] + } +} +``` + +### Node types to parse + +Registration flows contain these key nodes: + +- `csrf_token` (group: default): CSRF protection token +- `traits.email` (group: default): User identity traits +- `passkey_create_data` (group: passkey): WebAuthn creation options +- `passkey_register` (group: passkey): Where to submit the credential + +Login flows contain: + +- `csrf_token` (group: default): CSRF protection token +- `identifier` (group: default): Optional username field +- `passkey_challenge` (group: passkey): WebAuthn assertion options +- `passkey_login` (group: passkey): Where to submit the assertion + +### Success response + +On successful authentication, Ory returns a session: + +```json +{ + "session": { + "id": "session-id-uuid", + "active": true, + "expires_at": "2025-11-23T13:00:00Z", + "authenticated_at": "2025-11-23T11:00:00Z", + "authenticator_assurance_level": "aal1", + "authentication_methods": [ + { + "method": "passkey", + "aal": "aal1", + "completed_at": "2025-11-23T11:00:00Z" + } + ], + "identity": { + "id": "identity-id-uuid", + "schema_id": "default", + "traits": { + "email": "user@example.com" + } + } + } +} +``` + +Store the session token for authenticated requests. On mobile, use secure storage (iOS Keychain, Android Keystore). + +## Error handling + +### Common errors + +#### Invalid WebAuthn response + +```json +{ + "error": { + "id": "browser_location_change_required", + "code": 422, + "status": "Unprocessable Entity", + "reason": "Unable to parse WebAuthn response: Parse error for Registration" + } +} +``` + +This error occurs when there is incorrect Base64URL encoding (using standard Base64 instead), missing required fields in the +credential response, or malformed JSON in `passkey_register` or `passkey_login` fields. + +To resolve this, verify your Base64URL encoding and ensure all required WebAuthn response fields are included. + +#### Domain mismatch + +```json +{ + "error": { + "id": "security_identity_mismatch", + "code": 400, + "status": "Bad Request", + "reason": "The request was malformed or contained invalid parameters" + } +} +``` + +This error occurs when the Associated Domains or `assetlinks.json` domain doesn't match Kratos `rp.id`, the AASA or +`assetlinks.json` file isn't properly served, or the application uses HTTP instead of HTTPS. + +To resolve this, verify your Associated Domains entitlement matches your Kratos configuration. Test AASA file accessibility using +`curl https://ory.your-custom-domain.com/.well-known/apple-app-site-association`. Test `assetlinks.json` using +`curl https://ory.your-custom-domain.com/.well-known/assetlinks.json`. Ensure HTTPS with valid certificate. + +#### Flow expired + +```json +{ + "error": { + "id": "self_service_flow_expired", + "code": 410, + "status": "Gone", + "reason": "The self-service flow has expired" + } +} +``` + +This error occurs when the user took too long to complete the flow (default: 1 hour). + +To resolve this, initialize a new flow and retry the operation. + +#### User canceled + +On mobile platforms, users can cancel the passkey prompt. Handle this gracefully. + +```mdx-code-block + + +``` + +```swift +func authorizationController(controller: ASAuthorizationController, + didCompleteWithError error: Error) { + if let authError = error as? ASAuthorizationError, + authError.code == .canceled { + // User canceled - show alternative login options + } +} +``` + +```mdx-code-block + + +``` + +```kotlin +catch (e: GetCredentialException) { + when (e) { + is GetCredentialCancellationException -> { + // User canceled - show alternative login options + } + } +} +``` + +```mdx-code-block + + +``` + +### Mobile-specific issues + +#### AASA file not found on iOS + +The passkey prompt doesn't appear or the user sees "No credentials available" errors. + +To troubleshoot this issue: + +1. Verify the AASA file is accessible via browser. +2. Check the file has correct `Content-Type: application/json`. +3. Verify the Team ID and Bundle ID are correct. +4. Try uninstalling and reinstalling the app. +5. Check the device isn't using a VPN that blocks the domain. + +#### assetlinks.json validation failed on Android + +The user sees "No credentials found" errors or the passkey dialog doesn't show. + +To troubleshoot this issue: + +1. Verify the `assetlinks.json` file is accessible via browser. +2. Use the [Statement List Generator and Tester](https://developers.google.com/digital-asset-links/tools/generator) to validate. +3. Verify the SHA-256 fingerprint matches your signing key. +4. Ensure the package name matches exactly. +5. Clear app data and retry. + +#### Domain not HTTPS + +Both iOS and Android require HTTPS for passkeys. HTTP domains fail silently or with cryptic errors. + +To resolve this, use HTTPS with a valid TLS certificate. For local development, use a tool like [ngrok](https://ngrok.com/) to +create an HTTPS tunnel. + +## Best practices + +### Session management + +Store session tokens securely using iOS Keychain or Android Keystore. Handle session expiration by checking session validity +before making authenticated requests. Implement token refresh using Ory's `/sessions/whoami` endpoint to verify sessions. + +### User experience + +Provide fallback options to allow users to sign in with other methods if passkeys fail. Handle errors gracefully by showing +user-friendly error messages. Support AutoFill on iOS 17+ and Android 14+ to enable AutoFill-assisted passkey sign-in for better +user experience. + +### Testing + +Test on physical devices as passkeys don't work reliably in simulators or emulators. Test with multiple accounts to verify +credential isolation. Test cross-platform to ensure passkeys created on one platform work on others via cloud sync. + +## Next steps + +Review the [Passkeys overview](./05_passkeys.mdx) for configuration options. See [Self-Service Flows](../self-service.mdx) for +more flow details. Check out the [API documentation](../reference/api.mdx) for complete endpoint reference. diff --git a/docs/kratos/passwordless/06_code.mdx b/docs/kratos/passwordless/07_code.mdx similarity index 100% rename from docs/kratos/passwordless/06_code.mdx rename to docs/kratos/passwordless/07_code.mdx diff --git a/docs/kratos/self-service/flows/user-login.mdx b/docs/kratos/self-service/flows/user-login.mdx index 44331fbd5f..e819c21882 100644 --- a/docs/kratos/self-service/flows/user-login.mdx +++ b/docs/kratos/self-service/flows/user-login.mdx @@ -59,7 +59,7 @@ Supported login methods are - `oidc` for signing in using a social sign in provider such as Google or Facebook. Visit the [Social Sign In guide](../../social-signin/overview). - `passkey` for signing in with a Passkey. Visit the [Passkey guide](../../passwordless/05_passkeys.mdx). -- `code` for signing in with a code via Email or SMS. Visit the [Code via Email / SMS guide](../../passwordless/06_code.mdx). +- `code` for signing in with a code via Email or SMS. Visit the [Code via Email / SMS guide](../../passwordless/07_code.mdx). - `webauthn` (legacy) for signing in with Webauthn. This method is supported for backwards compatibility, use [Passkey](../../passwordless/05_passkeys.mdx) instead. diff --git a/package-lock.json b/package-lock.json index 3c1ff65f91..98234cb1ff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4354,9 +4354,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", - "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz", + "integrity": "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==", "dev": true, "license": "MIT", "optional": true, diff --git a/src/sidebar.ts b/src/sidebar.ts index d934da6619..cf2294cfbd 100644 --- a/src/sidebar.ts +++ b/src/sidebar.ts @@ -359,6 +359,7 @@ const kratos: SidebarItemsConfig = [ "kratos/passwordless/passwordless", "kratos/passwordless/one-time-code", "kratos/passwordless/passkeys", + "kratos/passwordless/passkeys-mobile", "kratos/organizations/organizations", "kratos/emails-sms/custom-email-templates", ],