12k
All articles

Comment ajouter des passkeys à une application iOS et Android

Ajoutez des clés d’accès aux apps iOS et Android avec Swift et Kotlin. Configurez les associations de domaine, l’inscription, la connexion et la vérification côté serveur.

OpenReplay Team
OpenReplay Team
Comment ajouter des passkeys à une application iOS et Android

Une passkey est une paire de clés publique/privée rattachée à un seul domaine. Votre application la crée via l’API d’identifiants de la plateforme, le serveur ne conserve que la clé publique, et Face ID ou l’empreinte digitale ne sert qu’à autoriser l’appareil à utiliser la clé privée.

Un scénario d’échec courant : le bouton est branché, le backend fonctionne sur le Web, et sur un vrai téléphone rien ne s’affiche. Aucune feuille système, aucune ligne de log, et souvent une simple erreur d’annulation générique.

La cause est presque toujours l’une de ces trois choses : l’association entre l’application et le domaine, une incohérence entre l’identifiant de relying party (RP ID) du serveur et ce domaine, ou une logique de vérification que le serveur n’a jamais terminée. Ce guide traite chacun de ces points sur iOS (Swift) et Android (Kotlin), avec le travail côté serveur pour chaque étape. Il part du principe que vous avez déjà opté pour les passkeys (le compromis entre mots de passe et passkeys est abordé ailleurs).

Points clés

  • Une application ne peut créer ou utiliser une passkey pour un domaine que si elle déclare ce domaine et que le domaine publie un fichier qui désigne l’application : apple-app-site-association avec webcredentials sur iOS, et assetlinks.json avec delegate_permission/common.get_login_creds sur Android.
  • Le RP ID envoyé par le serveur doit correspondre au domaine qui héberge les deux fichiers d’association, et le modifier ultérieurement rend inutilisables toutes les passkeys existantes.
  • Les applications distribuées avec la signature d’application Play (Play App Signing) doivent indiquer l’empreinte de la clé de signature d’application de Google dans assetlinks.json. Si vous n’indiquez que la clé d’importation, les passkeys fonctionnent dans les builds locaux mais échouent pour les installations depuis le store.
  • C’est le serveur qui décide si une passkey est valide. Il vérifie un challenge à usage unique, l’origine (y compris l’origine android:apk-key-hash: d’Android), le hash du rpId et la signature avant de créer une session.
  • Les passkeys synchronisées suivent le compte Apple ou Google de l’utilisateur : la sécurité de vos comptes dépend donc en partie de la leur. Conservez un parcours de récupération qui ne repose pas sur la passkey.

Une passkey, est-ce la même chose qu’un déverrouillage biométrique ?

Une passkey n’est pas un déverrouillage par empreinte digitale. Face ID ou l’empreinte digitale ne fait qu’autoriser l’appareil à utiliser une clé privée créée pour un domaine donné, qui ne quitte jamais l’appareil ni le trousseau synchronisé de l’utilisateur. Le serveur détient la clé publique correspondante et vérifie une signature. Il ne voit jamais aucune donnée biométrique.

Le contrôle biométrique local est un mécanisme différent. Dans ce cas, l’application déverrouille un jeton qu’elle a stocké auparavant, et le serveur ne dispose d’aucune preuve cryptographique de quoi que ce soit. Si c’est ce dont vous avez besoin, l’article sur l’authentification biométrique dans React Native en décrit la mise en place. Ce guide ne la répète pas.

Association avec la plateforme : l’étape qui échoue en silence

L’identifiant de relying party (rpId) envoyé par votre serveur doit être le domaine même qui héberge les deux fichiers d’association. Le système d’exploitation vérifie les deux côtés indépendamment. L’application liste le domaine, et le domaine publie un fichier qui liste l’application. Si l’un des deux côtés est incorrect, la feuille de passkey n’apparaît jamais, et aucune des deux plateformes n’indique pourquoi à l’application.

iOS : entitlement webcredentials et apple-app-site-association

Ajoutez le domaine à l’entitlement Associated Domains avec le service webcredentials :

<key>com.apple.developer.associated-domains</key>
<array>
    <string>webcredentials:example.com</string>
</array>

Servez ce fichier, sans extension, à l’adresse https://example.com/.well-known/apple-app-site-association. L’identifiant d’application est composé de votre Team ID et de votre bundle ID séparés par un point :

{
  "webcredentials": {
    "apps": ["ABCDE12345.com.example.shop"]
  }
}

Le guide d’Apple sur la prise en charge des domaines associés précise que le fichier doit être servi en HTTPS avec un certificat de confiance, et que la requête ne doit pas être redirigée. Comme le fichier n’a pas d’extension, de nombreux hébergeurs statiques et services de stockage objet le servent avec un type de contenu binaire générique : définissez donc le type explicitement :

location = /.well-known/apple-app-site-association {
    default_type application/json;
}
curl -sI https://example.com/.well-known/apple-app-site-association | grep -i content-type
# expect: content-type: application/json

Les appareils ne récupèrent pas ce fichier depuis votre serveur. Ils lisent une copie que le CDN d’Apple récupère et met en cache. Selon la documentation d’Apple, le CDN demande le fichier dans un délai d’environ 24 heures, et les appareils où l’application est installée vérifient la présence d’une copie à jour environ une fois par semaine. Un fichier corrigé met donc du temps à atteindre les applications installées : le CDN doit d’abord le récupérer à nouveau, puis chaque appareil le prend en compte à l’installation, à la mise à jour ou lors de sa prochaine vérification hebdomadaire. Si le fichier était défectueux au moment où le CDN l’a récupéré, le CDN met aussi en cache ce résultat défectueux. Pour le développement, utilisez webcredentials:example.com?mode=developer et activez Associated Domains Development dans Réglages › Développeur sur l’appareil. L’appareil récupère alors le fichier directement depuis votre domaine. Retirez ce suffixe avant la mise en production, car les builds de release s’appuient sur la copie du CDN.

Android : assetlinks.json avec get_login_creds

Sur Android, les passkeys de Credential Manager nécessitent Digital Asset Links sur le domaine du rpId, à l’adresse https://example.com/.well-known/assetlinks.json :

[
  {
    "relation": ["delegate_permission/common.get_login_creds"],
    "target": {
      "namespace": "android_app",
      "package_name": "com.example.shop",
      "sha256_cert_fingerprints": [
        "<PLAY_APP_SIGNING_KEY_SHA256>",
        "<UPLOAD_KEY_SHA256>"
      ]
    }
  }
]

Indiquez l’empreinte de chaque keystore avec lequel vous signez :

keytool -list -v -keystore release.keystore -alias release | grep SHA256

Si l’application est distribuée avec la signature d’application Play, Google la signe à nouveau avant de la distribuer. L’empreinte qui compte pour les installations depuis le store est celle de la clé de signature d’application affichée sur la page Signature d’application de la Play Console. N’indiquer que votre clé d’importation locale explique pourquoi les passkeys fonctionnent dans les builds de debug et échouent pour les utilisateurs qui ont installé l’application depuis le store. Les applications inscrites à la signature hybride « quantum-ready » de Play possèdent plusieurs clés de signature d’application : ajoutez donc chaque empreinte SHA-256 affichée sur cette page. Lister des empreintes supplémentaires ne pose aucun problème.

Vérifications préalables

VérificationComment vérifierSymptôme en cas d’erreur
Content-Typecurl -sI sur l’URL de l’AASAAucune feuille sur iOS
Aucune redirectioncurl -sI renvoie 200, et non 301/302Aucune feuille sur les deux plateformes
Correspondance du sous-domaineFichiers servis exactement sur l’hôte du rpIdFonctionne sur le Web, échoue dans l’application
Clé de signature PlayComparer avec la page Signature d’application de la Play ConsoleFonctionne en debug, échoue depuis le store
Copie du CDN d’AppleInterroger l’URL du CDN ci-dessousAncien fichier ou fichier défectueux toujours utilisé

Les sous-domaines n’héritent pas de l’association. La spécification Digital Asset Links identifie un site Web par son schéma, son hôte exact et son port : des fichiers hébergés sur www.example.com n’ont donc aucun effet pour un rpId example.com. La règle d’Apple est la même : chaque sous-domaine a besoin de son propre fichier apple-app-site-association.

Les deux fichiers peuvent être vérifiés sans installer de build. La première URL renvoie ce que le CDN d’Apple détient pour votre domaine (les ingénieurs d’Apple l’utilisent pour le diagnostic ; il ne s’agit pas d’une API documentée). La seconde appelle l’endpoint statements.list de Google :

curl https://app-site-association.cdn-apple.com/a/v1/example.com

curl "https://digitalassetlinks.googleapis.com/v1/statements:list?source.web.site=https://example.com&relation=delegate_permission/common.get_login_creds"

Une association défectueuse ne lève aucune exception que votre outil de crash reporting pourrait intercepter. Le session replay rend le schéma visible : un appui sur « Se connecter avec une passkey », aucune feuille système, puis l’utilisateur qui se rabat sur le champ de mot de passe. Vous pouvez ensuite filtrer ces sessions par build et par variante de signature.

Comment implémenter les passkeys : l’enregistrement

L’enregistrement d’une passkey dans une application native se déroule en trois temps. Le serveur génère les options avec un nouveau challenge et rp.id défini sur le domaine associé. L’API native crée l’identifiant. Le serveur vérifie le résultat et stocke la clé publique ainsi que l’identifiant de credential. Les options du serveur ressemblent à ceci :

{
  "challenge": "<base64url, random, single-use>",
  "rp": { "id": "example.com", "name": "Example Shop" },
  "user": { "id": "<base64url user handle>", "name": "ada@example.com", "displayName": "Ada" },
  "pubKeyCredParams": [{ "type": "public-key", "alg": -7 }],
  "authenticatorSelection": { "residentKey": "required", "userVerification": "preferred" },
  "attestation": "none",
  "excludeCredentials": []
}

Sur iOS, ASAuthorizationPlatformPublicKeyCredentialProvider construit la requête :

let provider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: "example.com")
let request = provider.createCredentialRegistrationRequest(
    challenge: challenge, name: "ada@example.com", userID: userHandle)
let controller = ASAuthorizationController(authorizationRequests: [request])
controller.delegate = self
controller.presentationContextProvider = self
controller.performRequests()

// In the delegate:
if let reg = authorization.credential as? ASAuthorizationPlatformPublicKeyCredentialRegistration {
    api.finishRegistration(id: reg.credentialID, clientData: reg.rawClientDataJSON,
                           attestation: reg.rawAttestationObject)
}

Sur Android, ajoutez androidx.credentials:credentials et androidx.credentials:credentials-play-services-auth. La seconde dépendance est obligatoire sur Android 13 et versions antérieures, comme l’explique le guide de migration FIDO2. Récupérez la version actuelle dans les notes de version. Transmettez ensuite le JSON du serveur tel quel à CreatePublicKeyCredentialRequest :

val credentialManager = CredentialManager.create(context)
val result = credentialManager.createCredential(
    context = activity,
    request = CreatePublicKeyCredentialRequest(requestJson = optionsJson)
) as CreatePublicKeyCredentialResponse
api.finishRegistration(result.registrationResponseJson)

Le serveur suit ensuite la cérémonie d’enregistrement WebAuthn. Il vérifie le challenge, l’origine et le hash du rpId, puis stocke l’identifiant de credential, la clé publique, le compteur de signatures et le user handle. Les applications Android envoient une origine de la forme android:apk-key-hash:<encoded SHA 256 fingerprint>, et votre serveur doit accepter exactement cette valeur parmi ses origines autorisées. Ajoutez une origine par certificat de signature.

Connexion : assertion et vérification côté serveur

La connexion par passkey suit le même schéma que l’enregistrement. Le serveur émet un challenge, l’appareil le signe avec la clé privée, et le serveur vérifie la signature à l’aide de la clé publique stockée. Ce n’est jamais l’application qui décide si une passkey est valide.

let request = provider.createCredentialAssertionRequest(challenge: challenge)
// Delegate receives ASAuthorizationPlatformPublicKeyCredentialAssertion:
// credentialID, rawClientDataJSON, rawAuthenticatorData, signature, userID
val request = GetCredentialRequest(listOf(GetPublicKeyCredentialOption(requestJson = optionsJson)))
val result = credentialManager.getCredential(context = activity, request = request)
api.finishSignIn((result.credential as PublicKeyCredential).authenticationResponseJson)

Conformément aux étapes de vérification d’assertion, le serveur :

  1. Confirme que le challenge correspond à l’un de ceux qu’il a émis et qu’il n’a pas déjà été utilisé.
  2. Vérifie l’origine par rapport à la liste d’autorisation, y compris chaque origine Android apk-key-hash.
  3. Vérifie que rpIdHash est égal au hash SHA-256 de votre rpId.
  4. Exige le flag user-present, ainsi que user-verified si votre politique l’impose.
  5. Vérifie la signature avec la clé publique stockée.
  6. Applique la règle du compteur de signatures. La spécification ne compare les compteurs que lorsque la valeur stockée ou reçue est non nulle : ne rejetez donc pas un compteur égal à zéro.

La synchronisation fait des passkeys une question de récupération de compte

Les passkeys synchronisées apparaissent sur des appareils qui ne se sont jamais connectés à votre application. Les passkeys enregistrées dans le Gestionnaire de mots de passe de Google suivent le compte Google de l’utilisateur sur ses autres appareils Android connectés, et le trousseau iCloud les synchronise sur l’ensemble des appareils Apple de l’utilisateur. Un utilisateur peut acheter un nouveau téléphone, ouvrir votre application et se connecter immédiatement.

La synchronisation des passkeys a trois conséquences pour la récupération de compte :

  • Téléphone perdu ne veut pas dire compte perdu. En cas de perte d’un appareil, la passkey reste disponible sur les autres appareils de l’utilisateur.
  • Le compte de la plateforme fait désormais partie de votre périmètre de sécurité. La sécurité de la passkey associée à votre domaine dépend du compte Apple ou Google de l’utilisateur, y compris de ses contrôles d’authentification à deux facteurs et de récupération.
  • Tout le monde ne synchronise pas. Les utilisateurs qui ont désactivé la synchronisation du trousseau, qui utilisent un gestionnaire tiers ou qui n’ont pas de compte Google sur l’appareil peuvent toujours perdre leur unique identifiant.

Autorisez plusieurs passkeys par compte, ajoutez un écran où les utilisateurs peuvent les consulter et les révoquer, et conservez une voie de récupération par e-mail ou OTP. Sur un nouvel appareil, une connexion qui se rabat sur un mot de passe malgré la présence d’une passkey synchronisée révèle généralement un bug de rpId ou de liste d’autorisation, plutôt qu’un refus de l’utilisateur.

Solution de repli pour les anciens appareils et les réfractaires

Conservez la connexion par mot de passe ou OTP en parallèle des passkeys au lancement. Les passkeys natives sur Android nécessitent Android 9 (niveau d’API 28) ou une version ultérieure, et l’utilisation d’un fournisseur de passkeys tiers nécessite Android 14. Certains utilisateurs refuseront l’invite, quel que soit leur appareil. Gérez NoCredentialException et ASAuthorizationError.canceled en affichant le formulaire de connexion existant, et non une erreur.

Proposez la création d’une passkey après une connexion réussie par mot de passe plutôt que de l’imposer à l’inscription. Considérez l’ancien parcours comme permanent jusqu’à ce que vos données montrent qu’il peut être relégué au second plan. L’article Comment fonctionne la connexion sans mot de passe explique pourquoi les passkeys résistent mieux que les liens magiques et les OTP. Les applications React Native et Flutter suivent les mêmes règles : quelle que soit la bibliothèque de bridge choisie, elle appelle ces mêmes API natives, de sorte que les fichiers d’association et les vérifications côté serveur restent identiques.

Conclusion

La plupart des bugs liés aux passkeys sont des bugs de configuration, pas des bugs de code. Avant d’écrire la moindre ligne de code natif, choisissez le rpId et hébergez les deux fichiers d’association exactement sur ce domaine. Vérifiez-les avec les deux validateurs curl, puis ajoutez côté serveur l’empreinte de la signature d’application Play et l’origine Android. Dès que les deux validateurs renvoient votre application, les appels Swift et Kotlin ci-dessus constituent tout le code client dont vous avez besoin.

FAQ

Quelle version minimale d'iOS les passkeys nécessitent-elles dans une application native ?

Les passkeys synchronisées nécessitent iOS 16 ou une version ultérieure, la version dans laquelle Apple a introduit les passkeys stockées dans le trousseau iCloud. Les identifiants créés avec l'authentificateur intégré de la plateforme sous iOS 15 ou une version antérieure ne deviennent jamais des passkeys et restent sur l'appareil qui les a créés. Pour en mettre un à niveau, enregistrez un nouvel identifiant avec le même user handle : iOS remplace alors l'ancien identifiant par une passkey synchronisée. Sur Android, la version minimale est Android 9 (niveau d'API 28).

Les utilisateurs peuvent-ils se connecter à l'application avec une passkey créée sur mon site Web ?

Oui, à condition que le site Web et l'application utilisent le même rpId. Une passkey est rattachée au rpId, et non au client : un même identifiant fonctionne donc dans le navigateur et dans les deux applications natives. Les applications iOS natives déclarent une origine composée de https:// suivi du rpId, soit la même valeur que celle envoyée par le site Web. Les applications Android déclarent une origine android:apk-key-hash, que le serveur doit ajouter séparément à sa liste d'autorisation.

Puis-je tester les passkeys sur l'émulateur Android et sur le simulateur iOS ?

Sur Android, oui. Utilisez une image d'émulateur avec Google Play (et non AOSP de base) au niveau d'API 28 ou supérieur, connectez-vous avec un compte Google et définissez un verrouillage d'écran, car c'est le Gestionnaire de mots de passe de Google qui stocke la passkey. Sans cela, la création de l'identifiant échoue avec une exception qui ressemble à un bug de l'application. Le simulateur iOS peut se comporter différemment du matériel réel : effectuez donc les tests finaux des passkeys sur un iPhone physique.

Quelles erreurs signalent une association défectueuse plutôt qu'un bug de code ?

Sur Android, une CreatePublicKeyCredentialDomException lors de l'enregistrement signifie généralement que le fichier assetlinks.json est absent ou inaccessible, qu'il indique un nom de package ou une empreinte incorrects, ou qu'il est hébergé sur un hôte qui ne correspond pas au rpId. Sur iOS, une association défectueuse se manifeste souvent par une ASAuthorizationError de code 1001, le même code que celui produit par une annulation de l'utilisateur. Sur Android, GetCredentialCancellationException signifie que l'utilisateur a fermé la feuille et ne nécessite aucun message d'erreur.

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.