- 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.
166 lines
5.8 KiB
PHP
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";
|
|
}
|