From 6ad28a26eb2cec11e04c04a67292a319e15b482b Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Wed, 11 Feb 2026 15:59:10 +0100 Subject: [PATCH] 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. --- openapi.yaml | 227 ++++++++++++-------- services/nginx/app/routes/passkeysRoute.php | 152 +++++++++++++ 2 files changed, 288 insertions(+), 91 deletions(-) create mode 100644 services/nginx/app/routes/passkeysRoute.php diff --git a/openapi.yaml b/openapi.yaml index d5eff643..ea8a6771 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -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' \ No newline at end of file + $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 diff --git a/services/nginx/app/routes/passkeysRoute.php b/services/nginx/app/routes/passkeysRoute.php new file mode 100644 index 00000000..16000e3b --- /dev/null +++ b/services/nginx/app/routes/passkeysRoute.php @@ -0,0 +1,152 @@ +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', + ]); + } +}