Files
api/services/nginx/app/tests/auth/PasskeyChallengeTest.php
T
Jeppe Bundgaard 61db62212c 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.
2026-02-23 23:03:28 +01:00

166 lines
5.8 KiB
PHP

<?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";
}