Add passkeysRoute for managing user passkeys and extend OpenAPI documentation

- Introduce `passkeysRoute` with operations to list, create, rename, and delete passkeys for authenticated users.
- Update OpenAPI with endpoints and request/response schemas for passkey management.
This commit is contained in:
Jeppe Bundgaard
2026-02-11 15:59:10 +01:00
parent a37e01f7f3
commit 6ad28a26eb
2 changed files with 288 additions and 91 deletions
+136 -91
View File
@@ -1,93 +1,3 @@
openapi: 3.0.3
info:
title: Copenhagen Truck Wash API
description: |
This API provides access to the Copenhagen Truck Wash system, managing orders, bookings,
departments, products, customers, and various integrations including e-conomic, Stripe,
XLVask, and more.
## Authentication
Most endpoints require authentication using a Bearer token obtained from the `/auth/login`
or `/auth/employee/login` endpoints.
## Permissions
Many endpoints require specific permissions that are assigned to user groups/roles.
version: 1.0.0
contact:
name: Copenhagen Truck Wash
email: support@truckwash.dk
servers:
- url: https://api.truckwash.dk
description: Production server
- url: http://localhost
description: Local development server
security:
- BearerAuth: []
tags:
- name: Authentication
description: User and employee authentication endpoints
- name: Users
description: User management and customer operations
- name: Orders
description: Order creation, management, and retrieval
- name: Order Items
description: Managing items within orders
- name: Bookings
description: Booking management for wash services
- name: Departments
description: Department and location management
- name: Products
description: Product catalog and pricing
- name: Categories
description: Product category management
- name: Invoices
description: Invoice generation and management
- name: Payments
description: Payment processing and collection
- name: Vehicles
description: Vehicle registration and management
- name: Notifications
description: System notifications and alerts
- name: Statistics
description: Business analytics and reporting
- name: Modules
description: Third-party integrations and modules
- name: Attachments
description: File upload and attachment management
- name: Forms
description: Form submissions and management
- name: Worker
description: System worker status and maintenance
- name: Plate Scans
description: License plate scanning operations
- name: Config
description: Module configuration management
- name: Branding
description: Branding options management
- name: Roles
description: Role and permission management
- name: Self-Serve
description: Self-serve lane operations and questions
- name: Goals
description: Department goals management
- name: Subusers
description: Subuser registration and setup
paths:
# Subusers (public registration + setup)
/subusers:
post:
tags:
- Subusers
summary: Create a subuser registration
description: |
Creates a subuser (driver) account using a company's CVR and a phone number. Validates the
CVR via e-conomic, ensures the phone number is not already in use, and if SMS is enabled
sends a setup link by SMS for the user to complete registration.
operationId: createSubuser
security: []
requestBody:
required: true
content:
@@ -5601,6 +5511,88 @@ paths:
'200':
description: Success
# Account Security - Passkeys
/account/security/passkeys:
get:
tags:
- Security
summary: List passkeys for the authenticated user
operationId: listPasskeys
responses:
'200':
description: A list of passkeys
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Passkey'
'400':
description: Invalid session or request
post:
tags:
- Security
summary: Create/add a passkey for the authenticated user
operationId: createPasskey
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/PasskeyCreateRequest'
responses:
'200':
description: Passkey created
content:
application/json:
schema:
type: object
properties:
id:
type: integer
'400':
description: Invalid session or request
/account/security/passkeys/{id}:
patch:
tags:
- Security
summary: Rename a passkey
operationId: renamePasskey
parameters:
- in: path
name: id
required: true
schema:
type: integer
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/PasskeyRenameRequest'
responses:
'200':
description: Passkey renamed
'404':
description: Not found
delete:
tags:
- Security
summary: Delete a passkey
operationId: deletePasskey
parameters:
- in: path
name: id
required: true
schema:
type: integer
responses:
'200':
description: Passkey deleted
'404':
description: Not found
components:
securitySchemes:
BearerAuth:
@@ -6654,4 +6646,57 @@ components:
type: array
items: { type: integer }
criteria:
$ref: '#/components/schemas/GoalsCriteria'
$ref: '#/components/schemas/GoalsCriteria'
Passkey:
type: object
properties:
id:
type: integer
credential_id:
type: string
description: Base64URL-encoded credential ID
name:
type: string
nullable: true
algorithm:
type: string
example: ES256
transports:
type: array
items:
type: string
example: ["usb", "nfc", "ble", "internal"]
sign_count:
type: integer
created_at:
type: string
format: date-time
updated_at:
type: string
format: date-time
PasskeyCreateRequest:
type: object
required: [credential_id, public_key, algorithm, transports]
properties:
credential_id:
type: string
description: Base64URL-encoded credential ID returned from WebAuthn
public_key:
type: string
description: Base64URL-encoded public key (COSE or PEM as stored)
algorithm:
type: string
example: ES256
transports:
type: array
items:
type: string
name:
type: string
nullable: true
PasskeyRenameRequest:
type: object
required: [name]
properties:
name:
type: string
+152
View File
@@ -0,0 +1,152 @@
<?php
namespace routes;
use classes\authentication;
use objects\logs_o;
use objects\passkeys_o;
use traits\route_t;
class passkeysRoute
{
use route_t;
public function run(): void
{
// List passkeys for current authenticated user
$this->get('/account/security/passkeys', function () {
global $response;
self::requirePermission('user_security_passkeys_list');
$user = (new authentication())->get_user();
if (!$user) {
(new logs_o())->add('user_security', 'global', 0, 0, 'USER_SECURITY_PASSKEYS_LIST', 'User not logged in');
$response->error('Invalid session', 400);
}
$passkeys = new passkeys_o();
// Restrict to current user (customer) and non-subuser records
$passkeys->setAdditionalWhereClause('WHERE user_id = ' . (int)$user->id . ' AND is_subuser = 0 AND deleted_at IS NULL');
$list = $passkeys->listObjects(function ($o) {
return [
'id' => $o->id,
'credential_id' => $o->credential_id->value(),
'name' => $o->name->value(),
'algorithm' => $o->algorithm->value(),
'transports' => $o->transports->value(),
'sign_count' => $o->sign_count->value(),
'created_at' => $o->created_at->value(),
'updated_at' => $o->updated_at->value(),
];
});
(new logs_o())->add('user_security', 'global', 1, $user->id, 'USER_SECURITY_PASSKEYS_LIST', 'Listed passkeys');
$response->success($list);
}, [
'user_security_passkeys_list' => 'List passkeys for the authenticated user',
]);
// Create/add a passkey (store after client-side WebAuthn attestation)
$this->post('/account/security/passkeys', function () {
global $response;
self::requirePermission('user_security_passkeys_create');
$user = (new authentication())->get_user();
if (!$user) {
(new logs_o())->add('user_security', 'global', 0, 0, 'USER_SECURITY_PASSKEYS_CREATE', 'User not logged in');
$response->error('Invalid session', 400);
}
self::requireParameters(['credential_id', 'public_key', 'algorithm', 'transports']);
$credential_id = (string)self::getParameter('credential_id');
self::requireType($credential_id, self::type_string());
self::requireMinLength('credential_id', 16);
self::requireMaxLength('credential_id', 4096);
$public_key = (string)self::getParameter('public_key');
self::requireType($public_key, self::type_string());
self::requireMinLength('public_key', 32);
self::requireMaxLength('public_key', 8192);
$algorithm = (string)self::getParameter('algorithm');
self::requireType($algorithm, self::type_string());
self::requireMinLength('algorithm', 3);
self::requireMaxLength('algorithm', 32);
$transports = self::getParameter('transports');
self::requireType($transports, self::TYPE_ARRAY());
$name = self::getParameter('name');
if ($name !== null) {
$name = (string)$name;
self::requireType($name, self::type_string());
self::requireMinLength('name', 1);
self::requireMaxLength('name', 255);
}
$obj = new passkeys_o();
$obj->add((int)$user->id, false, $credential_id, $public_key, $algorithm, (array)$transports, $name);
(new logs_o())->add('user_security', 'global', 1, $user->id, 'USER_SECURITY_PASSKEYS_CREATE', 'Created passkey: ' . $obj->id);
$response->success(['id' => $obj->id]);
}, [
'user_security_passkeys_create' => 'Create/add a new passkey for the authenticated user',
]);
// Rename a passkey
$this->patch('/account/security/passkeys/{id}', function () {
global $response;
self::requirePermission('user_security_passkeys_rename');
$user = (new authentication())->get_user();
if (!$user) {
(new logs_o())->add('user_security', 'global', 0, 0, 'USER_SECURITY_PASSKEYS_RENAME', 'User not logged in');
$response->error('Invalid session', 400);
}
$id = (int)self::fromRoute('id');
self::requireParameterIntPositive($id, 'id');
self::requireParameters(['name']);
$name = (string)self::getParameter('name');
self::requireType($name, self::type_string());
self::requireMinLength('name', 1);
self::requireMaxLength('name', 255);
$obj = (new passkeys_o())->select($id);
if (!$obj->exists() || (int)$obj->user_id->value() !== (int)$user->id || (int)$obj->is_subuser->value() !== 0) {
(new logs_o())->add('user_security', 'global', 0, $user->id, 'USER_SECURITY_PASSKEYS_RENAME', 'Passkey not found or not owned');
$response->error('Not found', 404);
}
$obj->update(['name' => $name]);
(new logs_o())->add('user_security', 'global', 1, $user->id, 'USER_SECURITY_PASSKEYS_RENAME', 'Renamed passkey ' . $id);
$response->success(['message' => 'Renamed', 'id' => $id]);
}, [
'user_security_passkeys_rename' => 'Rename a passkey that belongs to the authenticated user',
]);
// Delete a passkey (soft delete)
$this->delete('/account/security/passkeys/{id}', function () {
global $response;
self::requirePermission('user_security_passkeys_delete');
$user = (new authentication())->get_user();
if (!$user) {
(new logs_o())->add('user_security', 'global', 0, 0, 'USER_SECURITY_PASSKEYS_DELETE', 'User not logged in');
$response->error('Invalid session', 400);
}
$id = (int)self::fromRoute('id');
self::requireParameterIntPositive($id, 'id');
$obj = (new passkeys_o())->select($id);
if (!$obj->exists() || (int)$obj->user_id->value() !== (int)$user->id || (int)$obj->is_subuser->value() !== 0) {
(new logs_o())->add('user_security', 'global', 0, $user->id, 'USER_SECURITY_PASSKEYS_DELETE', 'Passkey not found or not owned');
$response->error('Not found', 404);
}
$obj->delete();
(new logs_o())->add('user_security', 'global', 1, $user->id, 'USER_SECURITY_PASSKEYS_DELETE', 'Deleted passkey ' . $id);
$response->success(['message' => 'Deleted', 'id' => $id]);
}, [
'user_security_passkeys_delete' => 'Delete a passkey that belongs to the authenticated user',
]);
}
}