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
+132
View File
@@ -1131,6 +1131,138 @@ paths:
'401':
$ref: '#/components/responses/Unauthorized'
/auth/passkey/challenge:
post:
tags:
- Authentication
summary: Initiate passkey authentication challenge
description: Generates a WebAuthn PublicKeyCredentialRequestOptions payload. If customer_number is provided, allowCredentials will be populated with existing passkeys for that account. Otherwise, a challenge is issued for discoverable credentials.
operationId: passkeyChallenge
security: []
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
customer_number:
type: integer
description: Optional customer's e-conomic customer number
example: 12345
responses:
'200':
description: Challenge generated
content:
application/json:
schema:
type: object
properties:
challenge_token:
type: string
description: Temporary token binding the challenge to the login attempt
publicKey:
type: object
properties:
challenge:
type: string
description: Base64URL-encoded challenge
rpId:
type: string
description: Relying party ID (domain)
timeout:
type: integer
description: Timeout in milliseconds
userVerification:
type: string
enum: [required, preferred, discouraged]
allowCredentials:
type: array
items:
type: object
properties:
type:
type: string
example: public-key
id:
type: string
description: Base64URL-encoded credential ID
transports:
type: array
items:
type: string
'400':
$ref: '#/components/responses/BadRequest'
/auth/passkey/verify:
post:
tags:
- Authentication
summary: Verify passkey authentication and start session
description: Verifies the WebAuthn assertion and challenge token. Returns a session token on success.
operationId: passkeyVerify
security: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- challenge_token
- id
- response
properties:
challenge_token:
type: string
description: The token returned by the challenge endpoint
id:
type: string
description: The credential ID (base64url)
response:
type: object
required:
- clientDataJSON
- authenticatorData
- signature
properties:
clientDataJSON:
type: string
description: Base64URL-encoded client data
authenticatorData:
type: string
description: Base64URL-encoded authenticator data
signature:
type: string
description: Base64URL-encoded signature
userHandle:
type: string
nullable: true
description: Base64URL-encoded user handle
responses:
'200':
description: Verification successful, session started
content:
application/json:
schema:
oneOf:
- type: object
required: [token]
properties:
token:
type: string
description: Bearer token for customer
- type: object
required: [session]
properties:
session:
type: string
description: Session token for subuser
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
/auth/logout:
get:
tags:
+182
View File
@@ -14,6 +14,7 @@ use objects\logs_o;
use objects\tokens_o;
use objects\users_o;
use objects\subusers_o;
use objects\passkeys_o;
use traits\route_t;
class authRoute
@@ -493,5 +494,186 @@ class authRoute
$response->error($e->getMessage(), 400);
}
});
$this->post('/auth/passkey/challenge', function () {
global $response;
$this->requireRecaptcha();
$customer_number = self::getParameter('customer_number');
$user_id = 0;
$allowCredentials = [];
if ($customer_number !== null) {
$customer_number = (int)$customer_number;
self::requireType($customer_number, $this->type_int());
self::requireMinValue($customer_number, 1);
// Attempt to locate user; do not reveal existence in response
$user = (new users_o())->getUserByCustomerNumber($customer_number);
if ($user->exists()) {
$user_id = (int)$user->id;
// Load passkeys for this user (non-subuser)
$passkeys = new passkeys_o();
$passkeys->setAdditionalWhereClause('`user_id` = ' . (int)$user_id . ' AND `is_subuser` = 0');
$list = $passkeys->listObjectsWithPaginationIfSet(function ($o) {
$transports = null;
if (isset($o['transports'])) {
$decoded = json_decode($o['transports'], true);
$transports = is_array($decoded) ? $decoded : null;
}
return [
'type' => 'public-key',
'id' => $o['credential_id'] ?? null,
'transports' => $transports,
];
});
// Ensure we return a simple array of credentials (without pagination wrapper)
if (isset($list['items']) && is_array($list['items'])) {
$allowCredentials = array_values(array_filter($list['items'], function ($item) {
return isset($item['id']) && is_string($item['id']) && strlen($item['id']) > 0;
}));
} elseif (is_array($list)) {
$allowCredentials = array_values(array_filter($list, function ($item) {
return isset($item['id']) && is_string($item['id']) && strlen($item['id']) > 0;
}));
}
}
}
// Generate a challenge (Base64URL without padding)
$challenge_token = bin2hex(random_bytes(32));
$rawChallenge = hex2bin($challenge_token);
$challenge = rtrim(strtr(base64_encode($rawChallenge), '+/', '-_'), '=');
// rpId from current host (strip port)
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
$rpId = explode(':', $host)[0];
// Create an ephemeral token to bind the challenge to the (potential) user
(new tokens_o())->create($user_id, $challenge_token, 'PASSKEY_CHALLENGE');
$logDetails = $customer_number ? 'Issued passkey challenge for customer ' . $customer_number : 'Issued passkey challenge (discoverable)';
(new logs_o())->add('auth', 'global', 1, $user_id, 'AUTH_PASSKEY_CHALLENGE', $logDetails);
$response->success([
'challenge_token' => $challenge_token,
'publicKey' => [
'challenge' => $challenge,
'rpId' => $rpId,
'timeout' => 60000,
'userVerification' => 'preferred',
'allowCredentials' => $allowCredentials
]
]);
});
$this->post('/auth/passkey/verify', function () {
global $response, $db;
$this->requireRecaptcha();
self::requireParameters(['challenge_token', 'id', 'response']);
$challenge_token_str = (string)self::getParameter('challenge_token');
$credential_id = (string)self::getParameter('id');
$webauthn_response = self::getParameter('response');
if (!is_array($webauthn_response)) {
$response->error('Invalid response format', 400);
}
$token_o = new tokens_o();
try {
$token = $token_o->getToken($challenge_token_str);
} catch (Exception $e) {
$response->error('Invalid or expired challenge token', 401);
}
if ($token->type->value() !== 'PASSKEY_CHALLENGE') {
$response->error('Invalid token type', 401);
}
// Find the passkey
$passkeys = new passkeys_o();
$fields = ['id', 'user_id', 'is_subuser', 'public_key', 'sign_count', 'algorithm'];
$found = $passkeys->getFieldsWhere(['credential_id' => $credential_id], $fields);
if (empty($found)) {
$response->error('Passkey not found', 401);
}
$passkey_data = $found[0];
$user_id = (int)$passkey_data['user_id'];
$is_subuser = (bool)$passkey_data['is_subuser'];
// If the challenge was bound to a specific user, verify it matches
if ((int)$token->user_id->value() > 0 && (int)$token->user_id->value() !== $user_id) {
$response->error('Passkey does not match requested user', 401);
}
// WebAuthn signature verification
$clientDataJSON = $webauthn_response['clientDataJSON'] ?? null;
$authenticatorData = $webauthn_response['authenticatorData'] ?? null;
$signature = $webauthn_response['signature'] ?? null;
if (!$clientDataJSON || !$authenticatorData || !$signature) {
$response->error('Missing WebAuthn response fields', 400);
}
$decode = function ($data) {
return base64_decode(strtr($data, '-_', '+/'));
};
$rawClientDataJSON = $decode($clientDataJSON);
$rawAuthenticatorData = $decode($authenticatorData);
$rawSignature = $decode($signature);
$clientData = json_decode($rawClientDataJSON, true);
if (!$clientData || !isset($clientData['challenge'])) {
$response->error('Invalid clientDataJSON', 400);
}
// Reconstruct and verify challenge
$expectedChallenge = rtrim(strtr(base64_encode(hex2bin($challenge_token_str)), '+/', '-_'), '=');
if ($clientData['challenge'] !== $expectedChallenge) {
$response->error('Challenge mismatch', 401);
}
// Verify signature: S = ES256(authenticatorData || hash(clientDataJSON))
$clientDataHash = hash('sha256', $rawClientDataJSON, true);
$dataToVerify = $rawAuthenticatorData . $clientDataHash;
$publicKey = $passkey_data['public_key'];
// WebAuthn signatures are DER encoded, which openssl_verify accepts.
$algo = OPENSSL_ALGO_SHA256;
$verifyResult = openssl_verify($dataToVerify, $rawSignature, $publicKey, $algo);
if ($verifyResult !== 1) {
(new logs_o())->add('auth', 'global', 0, $user_id, 'AUTH_PASSKEY_VERIFY_FAILURE', 'Signature verification failed for credential ' . $credential_id);
$response->error('Invalid signature', 401);
}
// Success! Update sign_count
$p_obj = (new passkeys_o())->select((int)$passkey_data['id']);
$p_obj->update(['sign_count' => (int)$passkey_data['sign_count'] + 1]);
$token_o->delete($challenge_token_str);
$auth = new authentication();
if ($is_subuser) {
$subuser = (new subusers_o())->select($user_id);
$session = $subuser->generateSession();
(new logs_o())->add('auth', 'global', 1, $user_id, 'AUTH_SUCCESS', 'Subuser login via passkey');
$response->success(['session' => $session]);
} else {
$user = (new users_o())->getUserById($user_id);
$customer_number = (int)$user->customer_number->value();
$new_token = $auth->create_token($customer_number);
(new logs_o())->add('auth', 'global', 1, $user_id, 'AUTH_SUCCESS', 'Customer login via passkey');
$response->success(['token' => $new_token]);
}
});
}
}
@@ -0,0 +1,165 @@
<?php
namespace {
if (!defined('WD')) { define('WD', dirname(__DIR__, 2)); }
// Setup request context
global $response, $router, $DEBUG;
$DEBUG = true;
$_SERVER['REQUEST_URI'] = '/auth/passkey/challenge';
$_SERVER['REQUEST_METHOD'] = 'POST';
}
// Mock response class in classes namespace
namespace classes {
class MockExitException extends \Error {}
class response {
public static $last_success = null;
public static $last_error = null;
public static $last_status = null;
public static $request_parameters = [];
public static function reset() {
self::$last_success = null;
self::$last_error = null;
self::$last_status = null;
self::$request_parameters = [];
}
public function success($data, $status = 200) {
self::$last_success = $data;
self::$last_status = $status;
throw new MockExitException("SUCCESS_EXIT");
}
public function error($data, $status = 400) {
self::$last_error = $data;
self::$last_status = $status;
throw new MockExitException("ERROR_EXIT");
}
public function getRequestParameter($key) {
return self::$request_parameters[$key] ?? null;
}
public function isRequestParameterSet($key) {
return array_key_exists($key, self::$request_parameters);
}
}
class recaptcha {
public static $mock_valid = true;
public function validate($resp) { return self::$mock_valid; }
}
}
// Mock objects needed by the route
namespace objects {
class logs_o { public function add($a,$b,$c,$d,$e,$f) {} }
class tokens_o {
public static $created = [];
public function create($user_id, $token, $type = 'AUTH_TOKEN') { self::$created[] = compact('user_id','token','type'); }
}
class MockUserObj {
public $id;
public function __construct($id) { $this->id = $id; }
public function exists() { return $this->id > 0; }
}
class users_o {
public static $existing_numbers = [];
public function getUserByCustomerNumber($num) {
if (in_array((int)$num, self::$existing_numbers, true)) { return new MockUserObj(42); }
return new MockUserObj(0);
}
}
class passkeys_o {
public $where;
public function setAdditionalWhereClause($w) { $this->where = $w; }
public function listObjectsWithPaginationIfSet($parser) {
$items = [
['credential_id' => 'cred_abcd', 'transports' => json_encode(['internal'])],
['credential_id' => 'cred_efgh', 'transports' => json_encode(['hybrid','usb'])],
];
$out = [];
foreach ($items as $o) { $out[] = $parser($o); }
return $out;
}
}
}
// Provide a minimal router mock and run the route
namespace {
class MockRouter {
public $routes = [];
public function add($route, $method, $callback, $permissions) { $this->routes[$method][$route] = $callback; }
}
$router = new MockRouter();
$response = new \classes\response();
require_once WD . '/traits/route_t.php';
require_once WD . '/routes/authRoute.php';
use routes\authRoute;
function ok($m){ echo "\033[32m✔ $m\033[0m\n"; }
function fail($m){ echo "\033[31m✖ $m\033[0m\n"; }
$authRoute = new authRoute();
$authRoute->run();
if (!isset($router->routes['POST']['/auth/passkey/challenge'])) {
die("Route /auth/passkey/challenge not found\n");
}
$callback = $router->routes['POST']['/auth/passkey/challenge'];
// Test case: valid request for existing user
\classes\response::reset();
\classes\recaptcha::$mock_valid = true;
\objects\users_o::$existing_numbers = [12345];
\classes\response::$request_parameters = [
'customer_number' => 12345,
'g_recaptcha_response' => 'valid'
];
try {
$callback();
} catch (\classes\MockExitException $e) {
if ($e->getMessage() !== 'SUCCESS_EXIT') { fail('Expected SUCCESS_EXIT'); exit(1); }
}
$data = \classes\response::$last_success;
if (!is_array($data)) { fail('Response should be array'); exit(1); }
if (!isset($data['challenge_token']) || !is_string($data['challenge_token'])) { fail('Missing challenge_token'); exit(1); }
if (!isset($data['publicKey']) || !is_array($data['publicKey'])) { fail('Missing publicKey'); exit(1); }
$pk = $data['publicKey'];
if (!isset($pk['challenge']) || !is_string($pk['challenge'])) { fail('Missing publicKey.challenge'); exit(1); }
if (!isset($pk['rpId']) || !is_string($pk['rpId'])) { fail('Missing publicKey.rpId'); exit(1); }
if (!isset($pk['allowCredentials']) || !is_array($pk['allowCredentials'])) { fail('Missing publicKey.allowCredentials'); exit(1); }
if (count($pk['allowCredentials']) !== 2) { fail('Expected 2 allowCredentials'); exit(1); }
ok('Passkey challenge returns expected structure for existing user');
// Test case: valid request for non-existing user → allowCredentials may be empty but still returns challenge
\classes\response::reset();
\objects\users_o::$existing_numbers = [];
\classes\response::$request_parameters = [
'customer_number' => 99999,
'g_recaptcha_response' => 'valid'
];
try {
$callback();
} catch (\classes\MockExitException $e) {
if ($e->getMessage() !== 'SUCCESS_EXIT') { fail('Expected SUCCESS_EXIT for non-existing user'); exit(1); }
}
$data = \classes\response::$last_success;
if (!isset($data['publicKey']['allowCredentials'])) { fail('Missing allowCredentials for non-existing'); exit(1); }
ok('Passkey challenge works for non-existing user (empty allowCredentials)');
echo "PasskeyChallengeTest completed.\n";
}
+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;
}
/**