Skip to content

Commit c6a3ef0

Browse files
committed
**Add Webauthn authentication implementation**
Introduce Webauthn-based authentication handler with supporting classes and updates. This includes a new `WebauthnAuthenticator`, `WebauthnBadge`, `WebauthnBadgeListener`, and associated passport classes to handle the Webauthn authentication workflow. Additionally, various configuration, security services, and tests were updated to integrate Webauthn support.
1 parent 98088fa commit c6a3ef0

25 files changed

+504
-131
lines changed

composer.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,16 +45,16 @@
4545
"ext-json": "*",
4646
"ext-openssl": "*",
4747
"paragonie/constant_time_encoding": "^2.6|^3.0",
48+
"phpdocumentor/reflection-docblock": "^5.3",
4849
"psr/clock": "^1.0",
4950
"psr/event-dispatcher": "^1.0",
5051
"psr/log": "^1.0|^2.0|^3.0",
51-
"phpdocumentor/reflection-docblock": "^5.3",
5252
"spomky-labs/cbor-php": "^3.0",
5353
"spomky-labs/pki-framework": "^1.0",
54+
"symfony/clock": "^6.4|^7.0",
5455
"symfony/config": "^6.4|^7.0",
5556
"symfony/dependency-injection": "^6.4|^7.0",
5657
"symfony/deprecation-contracts": "^3.2",
57-
"symfony/clock": "^6.4|^7.0",
5858
"symfony/framework-bundle": "^6.4|^7.0",
5959
"symfony/http-client": "^6.4|^7.0",
6060
"symfony/property-access": "^6.4|^7.0",
@@ -93,6 +93,7 @@
9393
"doctrine/orm": "^2.14|^3.0",
9494
"doctrine/persistence": "^3.1|^4.0",
9595
"ekino/phpstan-banned-code": "^3.0",
96+
"ergebnis/phpunit-slow-test-detector": "^2.18",
9697
"infection/infection": "^0.29",
9798
"matthiasnoback/symfony-dependency-injection-test": "^5.1|^6.0",
9899
"php-parallel-lint/php-parallel-lint": "^1.3",

phpunit.xml.dist

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,7 @@
3232
<file>src/symfony/src/Repository/DummyPublicKeyCredentialUserEntityRepository.php</file>
3333
</exclude>
3434
</source>
35+
<extensions>
36+
<bootstrap class="Ergebnis\PHPUnit\SlowTestDetector\Extension"/>
37+
</extensions>
3538
</phpunit>

src/stimulus/assets/dist/controller.d.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ export default class extends Controller {
99
type: StringConstructor;
1010
default: string;
1111
};
12+
requestResultField: {
13+
type: StringConstructor;
14+
default: null;
15+
};
1216
requestSuccessRedirectUri: StringConstructor;
1317
creationResultUrl: {
1418
type: StringConstructor;
@@ -59,6 +63,7 @@ export default class extends Controller {
5963
};
6064
readonly requestResultUrlValue: string;
6165
readonly requestOptionsUrlValue: string;
66+
readonly requestResultFieldValue?: string;
6267
readonly requestSuccessRedirectUriValue?: string;
6368
readonly creationResultUrlValue: string;
6469
readonly creationOptionsUrlValue: string;

src/stimulus/assets/dist/controller.js

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,17 @@ class default_1 extends Controller {
55
constructor() {
66
super(...arguments);
77
this.connect = async () => {
8-
var _a, _b;
8+
var _a, _b, _c;
99
const options = {
1010
requestResultUrl: this.requestResultUrlValue,
1111
requestOptionsUrl: this.requestOptionsUrlValue,
1212
requestSuccessRedirectUri: (_a = this.requestSuccessRedirectUriValue) !== null && _a !== undefined ? _a : null,
13+
requestResultField: (_a = this.requestResultFieldValue) !== null && _a !== undefined ? _a : null,
14+
requestSuccessRedirectUri: (_b = this.requestSuccessRedirectUriValue) !== null && _b !== undefined ? _b : null,
1315
creationResultUrl: this.creationResultUrlValue,
1416
creationOptionsUrl: this.creationOptionsUrlValue,
1517
creationSuccessRedirectUri: (_b = this.creationSuccessRedirectUriValue) !== null && _b !== undefined ? _b : null,
18+
creationSuccessRedirectUri: (_c = this.creationSuccessRedirectUriValue) !== null && _c !== undefined ? _c : null,
1619
};
1720
this._dispatchEvent('webauthn:connect', { options });
1821
const supportAutofill = await browserSupportsWebAuthnAutofill();
@@ -38,9 +41,16 @@ class default_1 extends Controller {
3841
this._processSignin(optionsResponseJson, false);
3942
}
4043
async _processSignin(optionsResponseJson, useBrowserAutofill) {
44+
var _a;
4145
try {
4246
const authenticatorResponse = await startAuthentication({ optionsJSON: optionsResponseJson, useBrowserAutofill });
4347
this._dispatchEvent('webauthn:authenticator:response', { response: authenticatorResponse });
48+
console.log(authenticatorResponse, this.requestResultFieldValue, this.element instanceof HTMLFormElement);
49+
if (this.requestResultFieldValue && this.element instanceof HTMLFormElement) {
50+
(_a = this.element.querySelector(this.requestResultFieldValue)) === null || _a === void 0 ? void 0 : _a.setAttribute('value', JSON.stringify(authenticatorResponse));
51+
this.element.submit();
52+
return;
53+
}
4454
const assertionResponse = await this._getAssertionResponse(authenticatorResponse);
4555
if (assertionResponse !== false && this.requestSuccessRedirectUriValue) {
4656
window.location.replace(this.requestSuccessRedirectUriValue);
@@ -151,6 +161,7 @@ class default_1 extends Controller {
151161
default_1.values = {
152162
requestResultUrl: { type: String, default: '/request' },
153163
requestOptionsUrl: { type: String, default: '/request/options' },
164+
requestResultField: { type: String, default: null },
154165
requestSuccessRedirectUri: String,
155166
creationResultUrl: { type: String, default: '/creation' },
156167
creationOptionsUrl: { type: String, default: '/creation/options' },

src/stimulus/assets/src/controller.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,11 @@ export default class extends Controller {
1111
static values = {
1212
requestResultUrl: { type: String, default: '/request' },
1313
requestOptionsUrl: { type: String, default: '/request/options' },
14+
requestResultField: { type: String, default: null },
1415
requestSuccessRedirectUri: String,
1516
creationResultUrl: { type: String, default: '/creation' },
1617
creationOptionsUrl: { type: String, default: '/creation/options' },
18+
creationResultField: { type: String, default: null },
1719
creationSuccessRedirectUri: String,
1820
usernameField: { type: String, default: 'username' },
1921
displayNameField: { type: String, default: 'displayName' },
@@ -32,9 +34,11 @@ export default class extends Controller {
3234

3335
declare readonly requestResultUrlValue: string;
3436
declare readonly requestOptionsUrlValue: string;
37+
declare readonly requestResultFieldValue?: string;
3538
declare readonly requestSuccessRedirectUriValue?: string;
3639
declare readonly creationResultUrlValue: string;
3740
declare readonly creationOptionsUrlValue: string;
41+
declare readonly creationResultFieldValue?: string;
3842
declare readonly creationSuccessRedirectUriValue?: string;
3943
declare readonly usernameFieldValue: string;
4044
declare readonly displayNameFieldValue: string;
@@ -49,6 +53,8 @@ export default class extends Controller {
4953
const options = {
5054
requestResultUrl: this.requestResultUrlValue,
5155
requestOptionsUrl: this.requestOptionsUrlValue,
56+
requestResultField: this.requestResultFieldValue ?? null,
57+
creationResultField: this.creationResultFieldValue ?? null,
5258
requestSuccessRedirectUri: this.requestSuccessRedirectUriValue ?? null,
5359
creationResultUrl: this.creationResultUrlValue,
5460
creationOptionsUrl: this.creationOptionsUrlValue,
@@ -85,6 +91,11 @@ export default class extends Controller {
8591
// @ts-ignore
8692
const authenticatorResponse = await startAuthentication({ optionsJSON: optionsResponseJson, useBrowserAutofill });
8793
this._dispatchEvent('webauthn:authenticator:response', { response: authenticatorResponse });
94+
if (this.requestResultFieldValue && this.element instanceof HTMLFormElement) {
95+
this.element.querySelector(this.requestResultFieldValue)?.setAttribute('value', JSON.stringify(authenticatorResponse));
96+
this.element.submit();
97+
return;
98+
}
8899

89100
const assertionResponse = await this._getAssertionResponse(authenticatorResponse);
90101
if (assertionResponse !== false && this.requestSuccessRedirectUriValue) {
@@ -111,6 +122,11 @@ export default class extends Controller {
111122
// @ts-ignore
112123
const authenticatorResponse = await startRegistration({ optionsJSON: optionsResponseJson });
113124
this._dispatchEvent('webauthn:authenticator:response', { response: authenticatorResponse });
125+
if (this.creationResultFieldValue && this.element instanceof HTMLFormElement) {
126+
this.element.querySelector(this.creationResultFieldValue)?.setAttribute('value', JSON.stringify(authenticatorResponse));
127+
this.element.submit();
128+
return;
129+
}
114130

115131
const attestationResponseJSON = await this._getAttestationResponse(authenticatorResponse);
116132
if (attestationResponseJSON !== false && this.creationSuccessRedirectUriValue) {

src/symfony/src/Controller/AssertionControllerFactory.php

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ final class AssertionControllerFactory implements CanLogData
2323

2424
public function __construct(
2525
private readonly SerializerInterface $serializer,
26+
private readonly OptionsStorage $optionStorage,
2627
private readonly AuthenticatorAssertionResponseValidator $authenticatorAssertionResponseValidator,
2728
private readonly PublicKeyCredentialSourceRepositoryInterface $publicKeyCredentialSourceRepository,
2829
) {
@@ -36,21 +37,21 @@ public function setLogger(LoggerInterface $logger): void
3637

3738
public function createRequestController(
3839
PublicKeyCredentialRequestOptionsBuilder $optionsBuilder,
39-
OptionsStorage $optionStorage,
40+
null|OptionsStorage $optionStorage,
4041
RequestOptionsHandler $optionsHandler,
4142
FailureHandler|AuthenticationFailureHandlerInterface $failureHandler
4243
): AssertionRequestController {
4344
return new AssertionRequestController(
4445
$optionsBuilder,
45-
$optionStorage,
46+
$optionStorage ?? $this->optionStorage,
4647
$optionsHandler,
4748
$failureHandler,
4849
$this->logger,
4950
);
5051
}
5152

5253
public function createResponseController(
53-
OptionsStorage $optionStorage,
54+
null|OptionsStorage $optionStorage,
5455
SuccessHandler $successHandler,
5556
FailureHandler|AuthenticationFailureHandlerInterface $failureHandler,
5657
null|AuthenticatorAssertionResponseValidator $authenticatorAssertionResponseValidator = null,
@@ -59,7 +60,7 @@ public function createResponseController(
5960
$this->serializer,
6061
$authenticatorAssertionResponseValidator ?? $this->authenticatorAssertionResponseValidator,
6162
$this->logger,
62-
$optionStorage,
63+
$optionStorage ?? $this->optionStorage,
6364
$successHandler,
6465
$failureHandler,
6566
$this->publicKeyCredentialSourceRepository

src/symfony/src/Controller/AttestationControllerFactory.php

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
final readonly class AttestationControllerFactory
1919
{
2020
public function __construct(
21+
private OptionsStorage $optionStorage,
2122
private SerializerInterface $serializer,
2223
private AuthenticatorAttestationResponseValidator $attestationResponseValidator,
2324
private PublicKeyCredentialSourceRepositoryInterface $publicKeyCredentialSourceRepository
@@ -27,23 +28,23 @@ public function __construct(
2728
public function createRequestController(
2829
PublicKeyCredentialCreationOptionsBuilder $optionsBuilder,
2930
UserEntityGuesser $userEntityGuesser,
30-
OptionsStorage $optionStorage,
31+
null|OptionsStorage $optionStorage,
3132
CreationOptionsHandler $creationOptionsHandler,
3233
FailureHandler|AuthenticationFailureHandlerInterface $failureHandler,
3334
bool $hideExistingExcludedCredentials = false
3435
): AttestationRequestController {
3536
return new AttestationRequestController(
3637
$optionsBuilder,
3738
$userEntityGuesser,
38-
$optionStorage,
39+
$optionStorage ?? $this->optionStorage,
3940
$creationOptionsHandler,
4041
$failureHandler,
4142
$hideExistingExcludedCredentials
4243
);
4344
}
4445

4546
public function createResponseController(
46-
OptionsStorage $optionStorage,
47+
null|OptionsStorage $optionStorage,
4748
SuccessHandler $successHandler,
4849
FailureHandler|AuthenticationFailureHandlerInterface $failureHandler,
4950
null|AuthenticatorAttestationResponseValidator $attestationResponseValidator = null,
@@ -52,7 +53,7 @@ public function createResponseController(
5253
$this->serializer,
5354
$attestationResponseValidator ?? $this->attestationResponseValidator,
5455
$this->publicKeyCredentialSourceRepository,
55-
$optionStorage,
56+
$optionStorage ?? $this->optionStorage,
5657
$successHandler,
5758
$failureHandler,
5859
);

src/symfony/src/DependencyInjection/Configuration.php

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,10 @@ public function getConfigTreeBuilder(): TreeBuilder
6464
->defaultValue('webauthn.clock.default')
6565
->info('PSR-20 Clock service.')
6666
->end()
67+
->scalarNode('options_storage')
68+
->defaultValue(SessionStorage::class)
69+
->info('Service responsible of the options/user entity storage during the ceremony')
70+
->end()
6771
->scalarNode('event_dispatcher')
6872
->defaultValue(EventDispatcherInterface::class)
6973
->info('PSR-14 Event Dispatcher service.')
@@ -330,7 +334,7 @@ private function addControllersConfig(ArrayNodeDefinition $rootNode): void
330334
->defaultValue(Request::METHOD_POST)
331335
->end()
332336
->scalarNode('result_path')
333-
->isRequired()
337+
->defaultNull()
334338
->end()
335339
->scalarNode('host')
336340
->defaultValue(null)
@@ -354,6 +358,11 @@ private function addControllersConfig(ArrayNodeDefinition $rootNode): void
354358
->defaultFalse()
355359
->end()
356360
->scalarNode('options_storage')
361+
->setDeprecated(
362+
'web-auth/webauthn-symfony-bundle',
363+
'5.2.0',
364+
'The child node "%node%" at path "%path%" is deprecated. Please use the root option "options_storage" instead.'
365+
)
357366
->defaultValue(SessionStorage::class)
358367
->info('Service responsible of the options/user entity storage during the ceremony')
359368
->end()
@@ -411,7 +420,7 @@ private function addControllersConfig(ArrayNodeDefinition $rootNode): void
411420
->defaultValue(Request::METHOD_POST)
412421
->end()
413422
->scalarNode('result_path')
414-
->isRequired()
423+
->defaultNull()
415424
->end()
416425
->scalarNode('host')
417426
->defaultValue(null)
@@ -426,6 +435,11 @@ private function addControllersConfig(ArrayNodeDefinition $rootNode): void
426435
->defaultNull()
427436
->end()
428437
->scalarNode('options_storage')
438+
->setDeprecated(
439+
'web-auth/webauthn-symfony-bundle',
440+
'5.2.0',
441+
'The child node "%node%" at path "%path%" is deprecated. Please use the root option "options_storage" instead.'
442+
)
429443
->defaultValue(SessionStorage::class)
430444
->info('Service responsible of the options/user entity storage during the ceremony')
431445
->end()

src/symfony/src/DependencyInjection/Factory/Security/WebauthnFactory.php

Lines changed: 26 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@
8888
private const PRIORITY = 0;
8989

9090
public function __construct(
91-
private WebauthnServicesFactory $servicesFactory
91+
private WebauthnServicesFactory $servicesFactory,
9292
) {
9393
}
9494

@@ -112,6 +112,11 @@ public function addConfiguration(NodeDefinition $builder): void
112112
->defaultNull()
113113
->end()
114114
->scalarNode('options_storage')
115+
->setDeprecated(
116+
'web-auth/webauthn-symfony-bundle',
117+
'5.2.0',
118+
'The child node "%node%" at path "%path%" is deprecated. Please use the root option "options_storage" instead.'
119+
)
115120
->defaultValue(self::DEFAULT_SESSION_STORAGE_SERVICE)
116121
->end()
117122
->scalarNode('success_handler')
@@ -306,14 +311,16 @@ private function createAssertionControllersAndRoutes(
306311
$config['authentication']['options_handler'],
307312
$config['failure_handler'],
308313
);
309-
$this->createResponseControllerAndRoute(
310-
$container,
311-
$firewallName,
312-
'request',
313-
$config['authentication']['routes']['result_method'],
314-
$config['authentication']['routes']['result_path'],
315-
$config['authentication']['routes']['host']
316-
);
314+
if ($config['authentication']['routes']['result_path'] !== null) {
315+
$this->createResponseControllerAndRoute(
316+
$container,
317+
$firewallName,
318+
'request',
319+
$config['authentication']['routes']['result_method'],
320+
$config['authentication']['routes']['result_path'],
321+
$config['authentication']['routes']['host']
322+
);
323+
}
317324
}
318325

319326
/**
@@ -340,14 +347,16 @@ private function createAttestationControllersAndRoutes(
340347
$config['registration']['options_handler'],
341348
$config['failure_handler'],
342349
);
343-
$this->createResponseControllerAndRoute(
344-
$container,
345-
$firewallName,
346-
'creation',
347-
$config['registration']['routes']['result_method'],
348-
$config['registration']['routes']['result_path'],
349-
$config['registration']['routes']['host']
350-
);
350+
if ($config['registration']['routes']['result_path'] !== null) {
351+
$this->createResponseControllerAndRoute(
352+
$container,
353+
$firewallName,
354+
'creation',
355+
$config['registration']['routes']['result_method'],
356+
$config['registration']['routes']['result_path'],
357+
$config['registration']['routes']['host']
358+
);
359+
}
351360
}
352361

353362
private function createAssertionRequestControllerAndRoute(

0 commit comments

Comments
 (0)