added passkey support (closes #6)
All checks were successful
test-and-lint / test-and-lint (pull_request) Successful in 2m50s

This commit is contained in:
2026-06-10 03:24:31 +02:00
parent 01ec41288a
commit fb3be56959
18 changed files with 1680 additions and 61 deletions

View File

@@ -6,13 +6,16 @@ MiauInv uses SQLite for persistent storage. The schema is initialized in `storag
PRAGMA foreign_keys = ON;
```
The database stores users, refresh tokens, 2FA recovery codes, inventory items, locations, projects, stock mappings, and project allocations.
The database stores users, refresh tokens, 2FA recovery codes, passkey credentials, passkey challenge state, inventory items, locations, projects, stock mappings, and project allocations.
## Entity Overview
```text
[users] 1 ──── N [refresh_tokens]
[users] 1 ──── N [two_factor_recovery_codes]
[users] 1 ──── N [passkey_credentials]
[passkey_challenges] stores short-lived WebAuthn ceremony state
[items] 1 ──── N [stock] N ──── 1 [locations]
[items] 1 ──── N [project_items] N ──── 1 [projects]
@@ -65,6 +68,34 @@ Stores recovery-code hashes for 2FA fallback login.
Recovery codes are deleted and replaced when the user regenerates them. A recovery code is consumed with an atomic update that only matches unused codes.
### `passkey_credentials`
Stores WebAuthn passkey credentials for account login. Private keys are not stored by MiauInv; they remain in the authenticator, platform passkey provider, browser, or security key.
| Column | Type | Constraints | Description |
| --- | --- | --- | --- |
| `id` | `TEXT` | Primary key | Passkey row UUID. |
| `user_id` | `TEXT` | Not null, foreign key to `users(id)` with `ON DELETE CASCADE` | Owning user. |
| `credential_id` | `TEXT` | Not null, unique | Base64url-encoded WebAuthn credential ID. |
| `name` | `TEXT` | Not null | User-visible passkey name. |
| `credential_data` | `TEXT` | Not null | Serialized WebAuthn credential data, including public-key material and authenticator metadata. |
| `created_at` | `INTEGER` | Not null | Unix timestamp for creation. |
| `last_used_at` | `INTEGER` | Nullable | Unix timestamp when the passkey was last used successfully. |
### `passkey_challenges`
Stores short-lived server-side WebAuthn session data for registration and login ceremonies. The browser receives only an opaque `session_token`.
| Column | Type | Constraints | Description |
| --- | --- | --- | --- |
| `token` | `TEXT` | Primary key | Opaque challenge token returned to the client. |
| `user_id` | `TEXT` | Not null, default `''` | Owning user for registration challenges. Empty for discoverable login challenges. |
| `ceremony` | `TEXT` | Not null | Challenge type, for example `register` or `login`. |
| `session_data` | `TEXT` | Not null | Serialized WebAuthn session data. |
| `expires_at` | `INTEGER` | Not null | Unix timestamp after which the challenge is rejected. |
Challenge rows are consumed once during the finish step and expired rows are cleaned up opportunistically.
### `items`
Stores tracked inventory items.
@@ -124,7 +155,7 @@ Maps item quantities to projects.
Foreign keys are enabled per connection. Because most inventory foreign keys do not define explicit cascade behavior, SQLite blocks deletion of referenced items, locations, or projects while dependent rows exist.
`two_factor_recovery_codes.user_id` uses `ON DELETE CASCADE`, so deleting a user also deletes their recovery-code rows.
`two_factor_recovery_codes.user_id` and `passkey_credentials.user_id` use `ON DELETE CASCADE`, so deleting a user also deletes their recovery-code and passkey rows.
### Uniqueness
@@ -134,6 +165,7 @@ The following values are unique:
- `locations.name`
- `projects.name`
- `two_factor_recovery_codes(user_id, code_hash)`
- `passkey_credentials.credential_id`
### Current schema limitations
@@ -162,6 +194,36 @@ When the password is updated:
3. Existing refresh tokens for that user are revoked.
4. A new session is issued.
### Passkey registration
When a passkey is registered:
1. The current password is verified.
2. A WebAuthn registration challenge is stored in `passkey_challenges`.
3. The finish step consumes the challenge.
4. The WebAuthn credential is verified.
5. A row is inserted into `passkey_credentials`.
6. Existing refresh tokens for the user are revoked.
7. A new session is issued for the current browser.
### Passkey login
When passkey login completes:
1. A discoverable WebAuthn login challenge is consumed from `passkey_challenges`.
2. The credential assertion is verified against the stored credential data.
3. The stored credential data and `last_used_at` value are updated.
4. A normal access/refresh session is issued.
### Passkey removal
When a passkey is removed or all passkeys are disabled:
1. The current password is verified.
2. The matching passkey row, or all rows for the user, are deleted.
3. Existing refresh tokens for the user are revoked.
4. A new session is issued for the current browser.
### 2FA enable
When 2FA is enabled: