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

[3.x] Feat flexible origins #91

Merged
merged 4 commits into from
Jul 27, 2024
Merged
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
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -661,6 +661,16 @@ class AppServiceProvider extends ServiceProvider
}
```

## Origins

If you have an [Android app](https://developer.android.com/identity/sign-in/credential-manager?hl=en#verify-origin), or any other remote frontends, software or interfaces outside your app server to handle credentials on behalf of your app, you may need to add these as valid origins. These are additional to your main Relying Party ID, which is your app domain.

Simply add these origins as part of the `WEBAUTHN_ORIGINS` environment variable. If you have more than one, you can separate them using a comma.

```dotenv
WEBAUTHN_ORIGINS=mirror-myapp.com,android:apk-key-hash:kffL-daBUxvHpY-4M8yhTavt5QnFEI2LsexohxrGPYU
```

## Advanced Configuration

Laragear WebAuthn was made to work out-of-the-box, but you can override the configuration by simply publishing the config file.
Expand All @@ -679,6 +689,7 @@ return [
'name' => env('WEBAUTHN_NAME', env('APP_NAME')),
'id' => env('WEBAUTHN_ID'),
],
'origins' => env('WEBAUTHN_ORIGINS'),
'challenge' => [
'bytes' => 16,
'timeout' => 60,
Expand Down Expand Up @@ -714,6 +725,16 @@ WEBAUTHN_NAME=SecureBank
WEBAUTHN_ID=auth.securebank.com
```

### Origins

```php
return [
'origins' => env('WEBAUTHN_ORIGINS'),
];
```

This holds the [additional origins](#origins) your application may accept for attestation and assertion. You should use the `WEBAUTHN_ORIGINS` environment variable to change this value.

### Challenge configuration

```php
Expand Down
14 changes: 14 additions & 0 deletions config/webauthn.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,20 @@
'id' => env('WEBAUTHN_ID'),
],

/*
|--------------------------------------------------------------------------
| Origins
|--------------------------------------------------------------------------
|
| By default, only your application domain is used as a valid origin for
| all ceremonies. If you are using your app as a backend for an app or
| UI you may set additional origins to check against the ceremonies.
|
| For multiple origins, separate them using comma, like `foo,bar`.
*/

'origins' => env('WEBAUTHN_ORIGINS'),

/*
|--------------------------------------------------------------------------
| Challenge configuration
Expand Down
5 changes: 5 additions & 0 deletions src/Assertion/Creator/Pipes/AddConfiguration.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ public function handle(AssertionCreation $assertion, Closure $next): mixed
{
$assertion->json->set('timeout', $this->config->get('webauthn.challenge.timeout') * 1000);

// If the Relying Party has been set, we will also tell the authenticator about it.
if ($id = $this->config->get('webauthn.relying_party.id')) {
$assertion->json->set('rpId', $id);
}

return $next($assertion);
}
}
1 change: 0 additions & 1 deletion src/Assertion/Validator/AssertionValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ class AssertionValidator extends Pipeline
Pipes\CheckChallengeSame::class,
Pipes\RetrievesCredentialId::class,
Pipes\CheckCredentialIsForUser::class,
Pipes\CheckOriginSecure::class,
Pipes\CheckRelyingPartyIdContained::class,
Pipes\CheckRelyingPartyHashSame::class,
Pipes\CheckUserInteraction::class,
Expand Down
15 changes: 0 additions & 15 deletions src/Assertion/Validator/Pipes/CheckOriginSecure.php

This file was deleted.

2 changes: 1 addition & 1 deletion src/Assertion/Validator/Pipes/CheckPublicKeySignature.php
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ protected function validateWithOpenSsl(string $signature, string $verifiable, We
{
if (! $publicKey = openssl_pkey_get_public($credential->public_key)) {
throw AssertionException::make('Public key is invalid: '.openssl_error_string());
}
}https://
DarkGhostHunter marked this conversation as resolved.
Show resolved Hide resolved

if (openssl_verify($verifiable, $signature, $publicKey, OPENSSL_ALGO_SHA256) !== 1) {
throw AssertionException::make('Signature is invalid: '.openssl_error_string());
Expand Down
1 change: 0 additions & 1 deletion src/Attestation/Validator/AttestationValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ class AttestationValidator extends Pipeline
Pipes\AttestationIsForCreation::class,
Pipes\RetrieveChallenge::class,
Pipes\CheckChallengeSame::class,
Pipes\CheckOriginSecure::class,
Pipes\CheckRelyingPartyIdContained::class,
Pipes\CheckRelyingPartyHashSame::class,
Pipes\CheckUserInteraction::class,
Expand Down
10 changes: 0 additions & 10 deletions src/Attestation/Validator/Pipes/CheckOriginSecure.php

This file was deleted.

45 changes: 0 additions & 45 deletions src/SharedPipes/CheckOriginSecure.php

This file was deleted.

113 changes: 103 additions & 10 deletions src/SharedPipes/CheckRelyingPartyIdContained.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,29 @@
use Laragear\WebAuthn\Assertion\Validator\AssertionValidation;
use Laragear\WebAuthn\Attestation\Validator\AttestationValidation;

use function array_filter;
use function array_map;
use function explode;
use function hash_equals;
use function is_string;
use function parse_url;

use const PHP_URL_HOST;

/**
* This pipe checks if the Relying Party ID from the authenticator data is contained in a list.
*
* This list can be either hosts, or special strings like custom identifiers created in mobile
* or remote apps. If these are domains, it checks if the credential origin is part of one of
* these entries, otherwise it checks if that origin has an exact match for each entry list.
*
* The Credential Origin is either a fully qualified RFC6454 (https://something.com:90), or
* a random special string. Meanwhile, the application RP ID is always either a domain
* (something.com) or another random string.
*
* @see https://www.w3.org/TR/webauthn-2/#dom-collectedclientdata-origin
* @see https://www.w3.org/TR/webauthn-2/#relying-party-identifier
*
* @internal
*/
abstract class CheckRelyingPartyIdContained
Expand All @@ -36,20 +53,96 @@ public function __construct(protected Repository $config)
*/
public function handle(AttestationValidation|AssertionValidation $validation, Closure $next): mixed
{
if (! $host = parse_url($validation->clientDataJson->origin, PHP_URL_HOST)) {
static::throw($validation, 'Relying Party ID is invalid.');
$origin = $validation->clientDataJson->origin;

if (empty($origin)) {
static::throw($validation, 'Response has an empty origin.');
}

$checkAsUrl = false;

// If the Origin is an RFC6454 URL, as it should bem we will ensure it comes from
// a secure place. Once done, we will take its host (domain) and use it to match
// any of the Relying Party IDs entries already configured in this application.
if ($url = $this->toUrlArray($origin)) {
if ($this->originUrlIsUnsecure($url)) {
static::throw($validation, 'Response origin not made from a secure server (localhost or HTTPS).');
}

$origin = $url['host'];
$checkAsUrl = true;
}

if ($this->originNotContained($origin, $checkAsUrl)) {
static::throw($validation, 'Response origin not allowed for this app.');
}

return $next($validation);
}

/**
* Check if the string is a URL.
*
* @return array{scheme: string, host:string}|false
*/
protected function toUrlArray(string $origin): array|false
{
$url = parse_url($origin);

return $url && isset($url['scheme'], $url['host'])
? array_intersect_key($url, array_flip(['scheme', 'host']))
: false;
}

/**
* Check the origin was not made from either localhost, or under the HTTPS protocol.
*
* @param array{scheme: string, host:string} $url
*/
protected function originUrlIsUnsecure(array $url): bool
{
if ($url['scheme'] === 'https' || $url['host'] === 'localhost') {
return false;
}

return ! Str::is('*.localhost', $url['host']);
}

/**
* Check that the origin is not contained on the accepted Relying Party IDs.
*/
protected function originNotContained(string $origin, bool $checkAsUrl): bool
{
// If we need to check the origin as a URL, we will also check if it's a valid subdomain.
$test = $checkAsUrl
? static fn (string $id, string $origin): bool => hash_equals($id, $origin) || Str::is("*.$id", $origin)
: hash_equals(...);

foreach ($this->relyingPartyIds() as $id) {
if ($test($id, $origin)) {
return false;
}
}

// Get the current Relying Party ID for this server request. If is not set,
// fall back to extract the domain name from the application default URL.
$current = $this->config->get('webauthn.relying_party.id')
?? parse_url($this->config->get('app.url'), PHP_URL_HOST);
return true;
}

/**
* Gather all valid RP ids that this application should accept.
*
* @return string[]
*/
protected function relyingPartyIds(): array
{
$origins = $this->config->get('webauthn.origins') ?: [];

// Check the host is the same or is a subdomain of the current domain.
if (hash_equals($current, $host) || Str::is("*.$current", $host)) {
return $next($validation);
if (is_string($origins)) {
$origins = array_map('trim', explode(',', $this->config->get('webauthn.origins', '')));
}

static::throw($validation, 'Relying Party ID not scoped to current.');
return array_filter([
$this->config->get('webauthn.relying_party.id') ?? parse_url($this->config->get('app.url'), PHP_URL_HOST),
...$origins,
]);
}
}
Loading