Add WebAuthn passkey challenge and verification endpoints

- Introduced endpoints for WebAuthn-based authentication flow (`/auth/passkey/challenge` and `/auth/passkey/verify`).
- Added support for generating and verifying WebAuthn PublicKeyCredentialRequestOptions and challenge tokens.
- Extended routing logic to expose matched route templates for improved parameter handling.
- Updated OpenAPI specifications to document passkey challenge and verification workflows.
- Included unit tests for validating both existing and non-existing user scenarios during passkey challenges.
This commit is contained in:
Jeppe Bundgaard
2026-02-23 23:03:28 +01:00
parent 63c88a463d
commit 61db62212c
4 changed files with 526 additions and 7 deletions
+47 -7
View File
@@ -15,6 +15,11 @@ trait route_t
{
protected array $permissions = [];
private string $route;
/**
* The route template (e.g., "/account/security/passkeys/{id}") of the currently matched route.
* This is populated just before the route callback is executed.
*/
private ?string $__current_route_template = null;
/**
* Lightweight per-request caches to avoid repeated DB/auth checks during a single request lifecycle.
*/
@@ -566,7 +571,15 @@ trait route_t
private function registerRoute($route, $method, $callback, $permissions = []): void
{
global $router;
$router->add($route, $method, $callback, self::registerPermissions($permissions, $route, $method));
// Wrap the original callback so we can expose the matched route template to fromRoute()
$self = $this;
$wrapped = function () use ($callback, $route, $self) {
// Set the current route template for parameter extraction
$self->__current_route_template = $route;
// Execute the original callback
$callback();
};
$router->add($route, $method, $wrapped, self::registerPermissions($permissions, $route, $method));
}
/**
@@ -643,15 +656,42 @@ trait route_t
}
/**
* Get the parameter from the route URL by index
* @param string $index
* @return string|null
* Get the value of a route parameter by name from the currently matched route.
* Example: for template "/users/{id}", current URL "/users/123" → fromRoute('id') === "123".
* @param string $index The parameter name (without braces), e.g., 'id'
* @return string|null The extracted value or null if not present
*/
public function fromRoute(string $index): ?string
{
$params = explode('/', $this->route);
$index = array_search($index, $params);
return $params[$index] ?? null;
// Resolve current path without query string
$currentPath = explode('?', $this->route)[0] ?? '';
$currentPath = trim($currentPath, '/');
// We need the route template used to register this callback
$template = $this->__current_route_template;
if ($template === null) {
// Fallback: no template context — cannot reliably parse; return null
return null;
}
$template = trim($template, '/');
$pathSegments = $currentPath === '' ? [] : explode('/', $currentPath);
$tplSegments = $template === '' ? [] : explode('/', $template);
// Quick length guard: router allows alnum-only for params; still, differing counts means no match
if (count($pathSegments) !== count($tplSegments)) {
return null;
}
foreach ($tplSegments as $i => $seg) {
if (preg_match('/^{([a-zA-Z0-9_]+)}$/', $seg, $m)) {
$name = $m[1];
if ($name === $index) {
return $pathSegments[$i] ?? null;
}
}
}
return null;
}
/**