passkeygoogle-chrome-android

Why does Chrome on Android not save my passkey?


I'm trying to implement passkey authentication but it looks like the (imo simple) solution I've created works fine on Chrome for Windows, but on Chrome on Android my registration prompts for biometrics, create credentials, sends them to the server (so all seems fine), but then when I use a navigator.credentials.get() call, it states there are no passkeys.

Here's what I do:

Step 1
I perform a call to the server to obtain registration options. The server responds with:

{
    "rp": {
        "id": "<my site url>",
        "name": "Team Manager"
    },
    "user": {
        "name": "<my email>",
        "id": {
            "0": 114,
            "1": 111,
            "2": 98,
            "3": 101,
            "4": 114,
            "5": 116,
            "6": 64,
            "7": 99,
            "8": 114,
            "9": 101,
            "10": 97,
            "11": 116,
            "12": 105,
            "13": 118,
            "14": 101,
            "15": 105,
            "16": 100,
            "17": 46,
            "18": 110,
            "19": 108
        },
        "displayName": "My Name"
    },
    "challenge": {
        "0": 106,
        "1": 79,
        "2": 70,
        "3": 250,
        "4": 51,
        "5": 146,
        "6": 189,
        "7": 164,
        "8": 248,
        "9": 47,
        "10": 43,
        "11": 99,
        "12": 89,
        "13": 253,
        "14": 68,
        "15": 237,
        "16": 212,
        "17": 78,
        "18": 38,
        "19": 97,
        "20": 64,
        "21": 241,
        "22": 131,
        "23": 127,
        "24": 6,
        "25": 96,
        "26": 186,
        "27": 210,
        "28": 225,
        "29": 192,
        "30": 29,
        "31": 37,
        "32": 79,
        "33": 164,
        "34": 115,
        "35": 132,
        "36": 225,
        "37": 245,
        "38": 229,
        "39": 48,
        "40": 37,
        "41": 233,
        "42": 144,
        "43": 140,
        "44": 145,
        "45": 160,
        "46": 162,
        "47": 147,
        "48": 78,
        "49": 36,
        "50": 63,
        "51": 64,
        "52": 253,
        "53": 164,
        "54": 214,
        "55": 7,
        "56": 56,
        "57": 195,
        "58": 64,
        "59": 133,
        "60": 57,
        "61": 73,
        "62": 200,
        "63": 106
    },
    "pubKeyCredParams": [{
            "type": "public-key",
            "alg": -7
        }, {
            "type": "public-key",
            "alg": -257
        }, {
            "type": "public-key",
            "alg": -37
        }, {
            "type": "public-key",
            "alg": -35
        }, {
            "type": "public-key",
            "alg": -258
        }, {
            "type": "public-key",
            "alg": -38
        }, {
            "type": "public-key",
            "alg": -36
        }, {
            "type": "public-key",
            "alg": -259
        }, {
            "type": "public-key",
            "alg": -39
        }, {
            "type": "public-key",
            "alg": -8
        }
    ],
    "timeout": 30000,
    "attestation": "none",
    "authenticatorSelection": {
        "requireResidentKey": false,
        "userVerification": "preferred"
    },
    "excludeCredentials": [],
    "status": "ok",
    "errorMessage": ""
}

Step 2
I use the options returned from the server (with the challenge and user Id changed into a buffer) to call the credentials.create function. The phone asks me for a fingerprint scan as expected:

this.credential = await navigator.credentials.create({ publicKey: this.options }) as PublicKeyCredential;
const response = this.credential.response as AuthenticatorAttestationResponse;
const attestationObject = this.bufferToBase64URL(response.attestationObject);
const clientDataJSON = this.bufferToBase64URL(response.clientDataJSON);

this.credentialsCopy = {
    type: this.credential.type,
    id: this.credential.id,
    rawId: this.credential.id,
    response: {
        attestationObject,
        clientDataJSON
    }
}

Step 3
Next, I post the result from the credential.create function to the server to create the account and save the public key:

await fetch('/api/finish-registration', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(this.credentialsCopy)
        });

Step 4
At this point, the account is created on the server, and it's public key is associated. However, when I try to login using navigator.credentials.get with the options returned from the server again:

{
    "challenge": "pTTJb6OEs9n-BwsLOeqamxNY0pz664ylaB6Bhn9jpcnriM6mKfxHkN76IbcO7EjqDAYiYE3R819JKLrIuaySeQ",
    "timeout": 30000,
    "rpId": "<my domain>",
    "allowCredentials": [],
    "userVerification": "preferred",
    "extensions": {},
    "status": "ok",
    "errorMessage": ""
}

I just get the error that there are no associated passkeys for <my site>. Also, when I look in the password manager, I don't see any passkeys for my domain.

As I said, the exact same code works on Windows. What am I doing wrong here?


Solution

  • In authenticatorSelection, ensure residentKey: required and requireResidentKey: true in order to create a passkey.

    (technically requireResidentKey is not required, but it doesn't hurt to include them both)