Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

User handle support #31

Merged
merged 13 commits into from
Nov 10, 2023
73 changes: 68 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,9 @@ const result = await fetch(request)

3) Parse and verify the response and, if successful, associate with the user.

> [!NOTE]
> The `publicKey.user.id` field can be looked up and used later on during authentication.

```php
<?php

Expand Down Expand Up @@ -305,8 +308,12 @@ $data = json_decode($json, true);

$parser = new ResponseParser();
$getResponse = $parser->parseGetResponse($data);
$userHandle = $getResponse->getUserHandle();

$credentialContainer = getCredentialsForUserId($pdo, $_SESSION['authenticating_user_id']);
if ($userHandle !== null && $userHandle !== $_SESSION['authenticating_user_id']) {
throw new Exception('User handle does not match authentcating user');
}

try {
// $challengeManager and $rp are the values from the setup step
Expand All @@ -330,6 +337,15 @@ header('HTTP/1.1 200 OK');
// Send back whatever your webapp needs to finish authentication
```

> [!NOTE]
> The `$userHandle` value provides flexibility for different authentication flows.
> If null, the authenticator does not support user handles, and you MUST use a user-provided value to look up who is authenticating.
> If a value is present, it will match a previously-registered `publicKey.user.id` value.
> The userHandle SHOULD be used to cross-reference a user-provided id if set, and MAY be used to look up the authenticating user.
> In either case, the previously-registered credentials in `$credentialContainer` MUST be fetched based on the user name or id.
>
> See [Autofill-assited requests](#autofill-assisted-requests) and [WebAuthn §7.2 Step 6](https://www.w3.org/TR/2021/REC-webauthn-2-20210408/#sctn-verifying-assertion) for more details.

## Additional details

### Relying Parties
Expand Down Expand Up @@ -392,16 +408,63 @@ const createOptions = {
Examples: `example.com`, `www.example.com`, `localhost`.


### Autofill-assisted requests

The simplest implementation of WebAuthn still starts with a traditional username field.
To make a more streamlined authentication experience, you may use Conditional Medation and Autofill-assisted requests.

#### During registration

Cleanup Tasks
Ensure that the `user.id` field is set appropriately.
This SHOULD be an immutable value, such as (but not limited to) a primary key in a database.

#### During authentication

* Split apart the process of generating a challenge from looking up and providing previously-registered credential IDs.
This is genreally useful for all flows, but required to support Conditional Mediation since you don't know the user ahead of time.

### Mediation/PassKeys
* Add a check for Conditional Mediation support. If supported, use it.

- replace step 1 with just generating challenge (still put in session)
- step 2 removes allowCredentials, adds mediation:conditional
- step 3 replaces user from session with a user lookup from GetResponse.userHandle
```js
const isCMA = await PublicKeyCredential.isConditionalMediationAvailable()
if (!isCMA) {
// Autofill-assisted requests are not supported. Fall back to username flow.
return
}
const challenge = await getChallenge() // existing API call
const getOptions = {
publicKey: {
challenge,
// Set other options as appropriate
},
mediation: 'conditional', // Add this
}
const credential = await navigator.credentials.get(getOptions)
// proceed as usual
```

* Adjust verification API to use the userHandle from the credential.
This can be done either/or to have a single authentication endpoint.

```php
// ...
$getResponse = $parser->parseGetResponse($data);
$userHandle = $getResponse->getUserHandle();
$userId = $_POST['username'] ?? null; // match your existing form/API formats
if ($userHandle === null) {
assert($userId !== null);
$user = findUserById($userId); // ORM lookup, etc
} else {
$user = findUserById($userHandle);
assert($userId === $user->id || $userId === null);
}
$credentialContainer = getCredentialsForUser($user);
// ...
```



Cleanup Tasks

- [x] Pull across PublicKeyInterface
- [x] Pull across ECPublicKey
Expand Down
6 changes: 6 additions & 0 deletions src/GetResponse.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,16 @@ public function __construct(
private BinaryString $rawAuthenticatorData,
private BinaryString $clientDataJson,
private BinaryString $signature,
private ?BinaryString $userHandle,
) {
$this->authData = AuthenticatorData::parse($this->rawAuthenticatorData);
}

public function getUserHandle(): ?string
{
return $this->userHandle?->unwrap();
}

/**
* @internal
*/
Expand Down
13 changes: 9 additions & 4 deletions src/ResponseParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ public function parseCreateResponse(array $response): Responses\AttestationInter
* authenticatorData: int[],
* clientDataJSON: int[],
* signature: int[],
* userHandle: int[],
* }
*
* $response is left untyped since it performs additional checking from
Expand All @@ -117,17 +118,21 @@ public function parseGetResponse(array $response): Responses\AssertionInterface
if (!array_key_exists('signature', $response) || !is_array($response['signature'])) {
throw new Errors\ParseError('7.2.2', 'response.signature');
}
if (!array_key_exists('userHandle', $response) || !is_array($response['userHandle'])) {
throw new Errors\ParseError('7.2.2', 'response.userHandle');
}

// userHandle provides the user.id from registration
// var_dump(BinaryString::fromBytes($response['userHandle'])->unwrap());
// if userHandle is provided, feed to the response to be read by app
// and have key handles looked up for verify??
// userHandle provides the user.id from registration. Not necessarily
// binary-safe, but will be in the common-case. The recommended API
// format will send `[]` if the PublicKeyCredential.response.userHandle
// is null, so the value is special-cased below.

return new GetResponse(
credentialId: BinaryString::fromBytes($response['rawId']),
rawAuthenticatorData: BinaryString::fromBytes($response['authenticatorData']),
clientDataJson: BinaryString::fromBytes($response['clientDataJSON']),
signature: BinaryString::fromBytes($response['signature']),
userHandle: $response['userHandle'] === [] ? null : BinaryString::fromBytes($response['userHandle']),
);
}
}
25 changes: 23 additions & 2 deletions src/Responses/AssertionInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,31 @@
interface AssertionInterface
{
/**
*@internal
*/
* This is used to find the used credential in the credential container. It
* is not used to determine what user is authenticating, and MUST NOT be
* used in an attempt to do so.
*
* @internal
*/
public function getUsedCredentialId(): BinaryString;

/**
* Returns the userHandle associated with the credential. This will be the
* value set during credential creation in the
* `PublicKeyCredentialCreationOptions.user.id` field. While applications
* can put any value they want here, it is RECOMMENDED to store a user id
* or equivalent.
*
* The value may not be binary-safe depending on how your client code set
* up the value.
*
* This will be null if the authenticator doesn't support user handles. U2F
* authenticators, at least, do not support user handles.
*
* @api
*/
public function getUserHandle(): ?string;

/**
* @api
*/
Expand Down
38 changes: 38 additions & 0 deletions tests/GetResponseTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ public function testCDJTypeMismatchIsError(): void
rawAuthenticatorData: $this->rawAuthenticatorData,
clientDataJson: $newCdj,
signature: $this->signature,
userHandle: null,
);

$this->expectVerificationError('7.2.11');
Expand All @@ -115,6 +116,7 @@ public function testUsedChallengeIsError(): void
rawAuthenticatorData: $this->rawAuthenticatorData,
clientDataJson: $this->clientDataJson,
signature: $this->signature,
userHandle: null,
);

$credential = $response->verify($this->cm, $this->rp, $container);
Expand All @@ -136,6 +138,7 @@ public function testCDJChallengeMismatchIsError(): void
rawAuthenticatorData: $this->rawAuthenticatorData,
clientDataJson: $newCdj,
signature: $this->signature,
userHandle: null,
);

// Simulate replay. ChallengeManager no longer recognizes this one.
Expand All @@ -156,6 +159,7 @@ public function testCDJOriginMismatchIsError(): void
rawAuthenticatorData: $this->rawAuthenticatorData,
clientDataJson: $newCdj,
signature: $this->signature,
userHandle: null,
);

$this->expectVerificationError('7.2.13');
Expand All @@ -172,6 +176,7 @@ public function testRelyingPartyIdMismatchIsError(): void
rawAuthenticatorData: $this->rawAuthenticatorData,
clientDataJson: $this->clientDataJson,
signature: $this->signature,
userHandle: null,
);

$this->expectVerificationError('7.2.15');
Expand All @@ -194,6 +199,7 @@ public function testUserVerifiedNotPresentWhenRequiredIsError(): void
rawAuthenticatorData: $this->rawAuthenticatorData,
clientDataJson: $this->clientDataJson,
signature: $this->signature,
userHandle: null,
);

$this->expectVerificationError('7.2.17');
Expand All @@ -213,6 +219,7 @@ public function testIncorrectSignatureIsError(): void
rawAuthenticatorData: $this->rawAuthenticatorData,
clientDataJson: $this->clientDataJson,
signature: new BinaryString('incorrect'),
userHandle: null,
);

$this->expectVerificationError('7.2.20');
Expand All @@ -230,6 +237,7 @@ public function testVerifyReturnsCredentialWithUpdatedCounter(): void
rawAuthenticatorData: $this->rawAuthenticatorData,
clientDataJson: $this->clientDataJson,
signature: $this->signature,
userHandle: null,
);

$updatedCredential = $response->verify($this->cm, $this->rp, $this->credential);
Expand Down Expand Up @@ -265,6 +273,7 @@ public function testCredentialContainerWorks(): void
rawAuthenticatorData: $this->rawAuthenticatorData,
clientDataJson: $this->clientDataJson,
signature: $this->signature,
userHandle: null,
);

$credential = $response->verify($this->cm, $this->rp, $container);
Expand All @@ -280,6 +289,7 @@ public function testEmptyCredentialContainerFails(): void
rawAuthenticatorData: $this->rawAuthenticatorData,
clientDataJson: $this->clientDataJson,
signature: $this->signature,
userHandle: null,
);

$this->expectVerificationError('7.2.7');
Expand All @@ -297,12 +307,40 @@ public function testCredentialContainerMissingUsedCredentialFails(): void
rawAuthenticatorData: $this->rawAuthenticatorData,
clientDataJson: $this->clientDataJson,
signature: $this->signature,
userHandle: null,
);

$this->expectVerificationError('7.2.7');
$response->verify($this->cm, $this->rp, $container);
}

public function testNullUserHandle(): void
{
$response = new GetResponse(
credentialId: $this->id,
rawAuthenticatorData: $this->rawAuthenticatorData,
clientDataJson: $this->clientDataJson,
signature: $this->signature,
userHandle: null,
);

self::assertNull($response->getUserHandle());
}

public function testUserHandleWithValue(): void
{
$handle = bin2hex(random_bytes(10));
$response = new GetResponse(
credentialId: $this->id,
rawAuthenticatorData: $this->rawAuthenticatorData,
clientDataJson: $this->clientDataJson,
signature: $this->signature,
userHandle: new BinaryString($handle),
);

self::assertSame($handle, $response->getUserHandle());
}

private function expectVerificationError(string $section): void
{
$this->expectException(Errors\VerificationError::class);
Expand Down
19 changes: 19 additions & 0 deletions tests/ResponseParserTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,24 @@ public function testParseGetResponseInputValidation(array $response): void
$parser->parseGetResponse($response);
}

public function testParseGetResponseHandlesEmptyUserHandle(): void
{
$parser = new ResponseParser();
$response = $this->safeReadJsonFile(__DIR__ . '/fixtures/fido-u2f/login.json');
$assertion = $parser->parseGetResponse($response);

self::assertNull($assertion->getUserHandle());
}

public function testParseGetResponseHandlesProvidedUserHandle(): void
{
$parser = new ResponseParser();
$response = $this->safeReadJsonFile(__DIR__ . '/fixtures/touchid/login.json');
$assertion = $parser->parseGetResponse($response);

self::assertSame('443945aa-8acc-4b84-f05f-ec8ef86e7c5d', $assertion->getUserHandle());
}

/**
* @return array<mixed>[]
*/
Expand Down Expand Up @@ -104,6 +122,7 @@ public function badGetResponses(): array
// invalid clientDataJSON
'no signature' => $makeVector(['signature' => null]),
'invalid signature' => $makeVector(['signature' => 'sig']),
'no userHandle' => $makeVector(['userHandle' => null]),
];
}

Expand Down
Loading