diff --git a/CHANGELOG.md b/CHANGELOG.md index 9769dc1c7..705bbd331 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,7 +25,18 @@ version, see the upgrade guides in `docs/`. - **server:** `UserMixin.login()` reads the credentials only from the request body, where Passport's local strategy also read them from the query string. - **server:** Dito.js logs users in and restores them from the session itself, instead of through koa-passport. Passport's `ctx.req.user` mirror of `ctx.state.user` and custom Passport strategies through Dito.js are gone. - **server:** `UsersController` only treats users of its own model class as logged in, not users of its sub-classes, which have their own tables and ids. This affects apps that serve sub-classed user models through the parent's `UsersController`: their `session`, `self` and `logout` actions no longer mistake such users for the parent's user with the same id. +- **server:** `Controller.setup()` is internal, like `Model.setup()`, and may be asynchronous: apps override `initialize()` instead. - **server:** `UserMixin.sessionQuery()` is internal: `sessionScope` sets the scopes of the users of logins and sessions. +- **admin:** Dialogs validate their data when submitted, like forms, and only resolve once it's valid, where they resolved with invalid data, e.g. with empty `required` fields. + +### New + +- **server:** Users log in with passkeys, with `app.auth.passkeys`, sessions stored on the server and the optional peer dependency `@simplewebauthn/server`. `UsersController` adds `'post passkey-login-options'` and `'post passkey-login'`, and lets users manage their own passkeys through `'get passkeys'`, `'post passkey-options'`, `'post passkeys'` and `'delete passkeys/:id'`, see [Passkeys](docs/guide/server/controllers.md#passkeys). Passkeys are named after their providers, e.g. `'1Password'`, through the new dependency `aaguids`. +- **server:** `UsersController` lets users change their password through `'post password'`, with the current password. Subclasses that set `collection.allow` list it to route it. +- **server:** `CredentialMixin` and `CredentialModel` store the credentials of all user models in one table, set in `app.auth.credentials`. Deleting users deletes their credentials. +- **admin:** `login: { passkeys: true }` adds a passkey login to the login dialog, and a Security dialog to the account menu that changes the user's password, and lists, adds and removes the user's passkeys. Requires the new peer dependency `@simplewebauthn/browser`. +- **admin:** The `empty` option of lists shows a row with its text while the list has no items, e.g. `empty: 'No books yet.'`. +- **admin:** `context.reload()` reloads the items of the list or object that the component is or is in, e.g. after a list button changed them on the server. ### New @@ -43,6 +54,7 @@ version, see the upgrade guides in `docs/`. ### Fixes +- **admin:** Lists outside of routes, e.g. in dialogs, keep their scope, page, order and filters to themselves, instead of loading the query of the current route and navigating to their changes. Lists in routes still change them through the route. - **router:** A trailing slash of the `prefix` option is removed unless in strict mode, so `new Router({ prefix: '/api/' })` routes `/users` as `/api/users` instead of `/api//users` (#336). - **router:** `**` routes added after a `*` route at the same position match, e.g. `/a/**` after `/a/*`, instead of being nested under the `*` route and never matching (#330). - **router:** Params and `*` at the same position are both tried, e.g. `/a/1/y` finds `/a/*/y` next to `/a/:id/x`, where only the route added first was tried. A param and `*` that end at the same position, e.g. `/a/:id` and `/a/*`, are the same route, so the one added last wins, as with two params, where the one added first won (#331). diff --git a/docs/api/admin/admin.md b/docs/api/admin/admin.md index ace46717d..a1c444a25 100644 --- a/docs/api/admin/admin.md +++ b/docs/api/admin/admin.md @@ -45,6 +45,7 @@ new DitoAdmin(element, options) | `options.dito` | [`GlobalConfig`](#globalconfig) | The global configuration. Defaults to `globalThis.dito`, which `AdminController` provides. | | `options.login.additionalComponents` | `Components` | Components added to the login dialog after `username` and `password`. Their values are sent with the credentials. | | `options.login.redirectAfterLogin` | `string` | A URL to load after a successful login, instead of resolving the views in place. | +| `options.login.passkeys` | `boolean` | Adds a Sign In with Passkey button to the login dialog, and a Security item to the account menu, whose dialog changes the user's password through [`api.users.password`](#users), and lists, adds and removes the user's passkeys, named after their providers. Needs passkeys on the server, see [Passkeys](../../guide/server/controllers.md#passkeys), and the peer dependency `@simplewebauthn/browser`. | | `options.spinner` | `{ size?: string, color?: string } \| null` | Size and color of the loading spinner in the header. `null` hides it. | | `options.account.settings` | `Form \| Promise \| () => …` | The form of the account settings, which the account menu opens at `/account/settings`, to edit the logged-in user through [`api.users.self`](#users). Also a promise or a function returning it, e.g. a dynamic `import()`. Its `label` is the view's label, default `'Settings'`. See [Account settings](../../guide/admin/views.md#account-settings). | @@ -147,15 +148,21 @@ api: { login: { path: 'login', method: 'post' }, logout: { path: 'logout', method: 'post' }, session: { path: 'session', method: 'get' }, - self: { path: 'self' } + self: { path: 'self' }, + password: { path: 'password', method: 'post' }, + passkeyLoginOptions: { path: 'passkey-login-options', method: 'post' }, + passkeyLogin: { path: 'passkey-login', method: 'post' }, + passkeyOptions: { path: 'passkey-options', method: 'post' }, + passkeys: { path: 'passkeys' } } } ``` `users.path` is the collection of the user model; `users: 'users'` is short -for `users: { path: 'users' }`. `login`, `logout`, `session` and `self` are -resources nested under it, shown here with their defaults, so the admin logs -in through `POST users/login`. The server's `UsersController` provides them. +for `users: { path: 'users' }`. `login`, `logout`, `session`, `self`, +`password` and the passkey resources of `login.passkeys` are resources nested +under it, shown here with their defaults, so the admin logs in through +`POST users/login`. The server's `UsersController` provides them. The admin reads `user` from the responses of `login` and `session`, and `success` from the response of `logout`. The [account settings](#parameters) load the logged-in user from `self` and patch @@ -352,8 +359,9 @@ responses cached with `request({ cache: 'global' })`. session.login(): Promise ``` -Shows the login dialog and posts the credentials to `api.users.login`, asking -again after each failure, until it succeeds or the user cancels. With +Shows the login dialog and posts the credentials to `api.users.login`, or +logs in with a passkey through `api.users.passkeyLogin`, asking again after +each failure, until it succeeds or the user cancels. With `login.redirectAfterLogin`, a successful login loads that URL instead. ##### Return value diff --git a/docs/api/admin/context.md b/docs/api/admin/context.md index 132614c05..614533f2b 100644 --- a/docs/api/admin/context.md +++ b/docs/api/admin/context.md @@ -677,6 +677,31 @@ buttons of sources and forms. Whether the submit succeeded. +### reload() + +```ts +context.reload(): Promise +``` + +Reloads the items of the list or object that the component is or is in, e.g. +of a list button after it changed the items on the server, and resolves once +they're loaded. Sources without their own `resource` are part of the data +around them and have nothing to load. Throws outside of lists and objects. + +#### Example + +```js +buttons: { + import: { + text: 'Import', + async onClick({ request, reload }) { + await request({ method: 'post', resource: { path: 'import' } }) + await reload() + } + } +} +``` + ### closeForm() ```ts @@ -712,8 +737,8 @@ Also available outside of schemas as `admin.context.showDialog()`, see | `options.settings.width` | `number` | The CSS max-width of the dialog, in pixels. Defaults to `480`. | | `options.settings.clickToClose` | `boolean` | Whether clicking outside of the dialog cancels it. Defaults to `false`. | -Submitting doesn't validate the data. Escape cancels dialogs that have a -`cancel` button or no buttons. +Submitting validates the data first, like forms do. Escape cancels dialogs +that have a `cancel` button or no buttons. #### Return value diff --git a/docs/api/admin/schemas.md b/docs/api/admin/schemas.md index 5eba0fbe6..0b41abe46 100644 --- a/docs/api/admin/schemas.md +++ b/docs/api/admin/schemas.md @@ -957,6 +957,7 @@ loads as too. | ------ | ---- | ------- | ----------- | | `type` | `'list'` | | Required. | | `filters` | `object` | | Filters shown in a panel in the sidebar, see [Filters](#filters). | +| `empty` | `SchemaValue` | | The text of a row shown while the list has no items, e.g. `'No books yet.'`. Without it, empty lists show no row. | ```js books: { diff --git a/docs/api/server/application.md b/docs/api/server/application.md index 81282af5b..d94b183b3 100644 --- a/docs/api/server/application.md +++ b/docs/api/server/application.md @@ -106,6 +106,33 @@ augmentation of `ApplicationConfig` for project-specific settings. | `bodyParser` | `object` | | Options of [koa-bodyparser](https://github.com/koajs/bodyparser#options). | | `session` | `boolean \| object` | `false` | Sessions through [koa-session](https://github.com/koajs/session). `modelClass` (a name or a model class) stores them in a model, e.g. one using [`SessionMixin`](model.md#sessionmixin). `autoCommit` (default `true`) commits the session after each request, on transacted routes only when the request succeeds. | | `passport` | `boolean` | | Deprecated, without effect: users are always handled, see [`UserMixin`](model.md#usermixin). | +| `auth` | `object` | | Authentication beyond passwords, see [`config.app.auth`](#configappauth). | + +### config.app.auth + +```js +app: { + session: { modelClass: 'Session' }, + auth: { + credentials: { modelClass: 'Credential' }, + passkeys: { + name: 'Example', + id: 'example.com', + origins: ['https://example.com', 'https://admin.example.com'] + } + } +} +``` + +| Key | Type | Description | +| ------------------------ | -------------------------- | ------------------------------------------------------------------------------------------------ | +| `credentials.modelClass` | `string \| Class` | The model that stores the credentials of all user models, using [`CredentialMixin`](model.md#credentialmixin). | +| `passkeys` | `object` | Enables the passkeys of [`UsersController`](controllers.md#userscontroller). Needs `credentials` and a `session` stored on the server, through `modelClass` or `store`, else the setup fails. | +| `passkeys.name` | `string` | The name of the app that authenticators show. | +| `passkeys.id` | `string` | The domain that the passkeys belong to, the WebAuthn relying party id. Frontends on this domain or its subdomains can use them. | +| `passkeys.origins` | `string[]` | The origins of the frontends that log in with passkeys, which may differ from the API's. | + +Passkeys need the optional peer dependency `@simplewebauthn/server`. ### config.admin diff --git a/docs/api/server/controllers.md b/docs/api/server/controllers.md index 79bb58094..7776a1acc 100644 --- a/docs/api/server/controllers.md +++ b/docs/api/server/controllers.md @@ -9,7 +9,7 @@ exported by `@ditojs/server`: | [`CollectionController`](#collectioncontroller) | Base class of model and relation controllers. Not extended directly. | | [`ModelController`](#modelcontroller) | CRUD routes for a model's collection and members, plus its relations. | | [`RelationController`](#relationcontroller) | Routes for a relation of a model controller. Created from `relations`, never extended. | -| [`UsersController`](#userscontroller) | A model controller with login, logout and session actions. | +| [`UsersController`](#userscontroller) | A model controller with login, logout, session and passkey actions. | | [`AdminController`](#admincontroller) | Serves Dito.js Admin. | Controllers are registered through the `controllers` option of the @@ -286,8 +286,9 @@ application. Sets up `hooks`, `name`, `path` and `url`. Call controller.setup() ``` -Sets up the routes of the actions and asset uploads. Call `super.setup()`. -*Hook.* +Sets up the routes of the actions and asset uploads, before `initialize()`. +Internal: dito's controllers override it, and may do so asynchronously. Apps +override [`initialize()`](#initialize) instead, which needs no `super`. ### initialize() @@ -786,7 +787,8 @@ Extends [`ModelController`](#modelcontroller) for models with [`UserMixin`](./model.md#usermixin), with these collection actions. They are inherited actions, see [`allow`](#allow): a subclass that sets `collection.allow` has to list them too, e.g. -`allow: ['get', 'post login', 'post logout', 'get session', 'get self']`. +`allow: ['get', 'post login', 'post logout', 'get session', 'get self']`, and +`'post password'` to let users change their password. | Action | Response | | -------------------- | ------------------------------------------------------- | @@ -794,11 +796,35 @@ inherited actions, see [`allow`](#allow): a subclass that sets | `'post logout'` | `{ success, authenticated }` | | `'get session'` | `{ authenticated, user }` | | `'get self'` | The authenticated user through `member.get`, or `null`. | - -The controller's [`authorize`](#authorize) doesn't apply to `login`, `logout` -and `session`, which authenticate users; `self` follows it. Its member actions -default to `authorize: ['$self']`, and are routed when listed in `allow` like -on other model controllers. In member routes, `self` stands for the +| `'post password'` | Changes the password of the authenticated user to `password` in the body, after verifying `currentPassword`: `{ success }`, or 400 if `currentPassword` is wrong. The new password is validated by the user model, e.g. its `minLength`. | + +With [`app.auth.passkeys`](./application.md#configappauth), users log in with +passkeys and manage their own ones through these collection actions, which +aren't routed without it: + +| Action | Response | +| ------------------------------ | ------------------------------------------------------- | +| `'post passkey-login-options'` | The options of a passkey login for `startAuthentication()` of `@simplewebauthn/browser`, without a username. | +| `'post passkey-login'` | Logs in with the response of `startAuthentication()` in the body, like `'post login'`: `{ success, authenticated, user, error }`. | +| `'get passkeys'` | The passkeys of the authenticated user: `[{ id, name, createdAt, lastUsedAt }]`. | +| `'post passkey-options'` | The options of a new passkey for `startRegistration()`, which excludes the user's passkeys. | +| `'post passkeys'` | Stores the passkey of the response of `startRegistration()` in the body, named after its provider, e.g. `'1Password'`, or `'Passkey'` for unknown ones: `{ id, name, createdAt, lastUsedAt }`, or 409 for passkeys that are already registered. | +| `'delete passkeys/:id'` | Deletes a passkey of the authenticated user: `{ success }`, or 404 for other passkeys. | + +A subclass that sets `collection.allow` has to list the passkey actions too, +else they aren't routed. The challenges of the options are kept in the +session, and can be answered once within 5 minutes. Failed passkey logins +answer `'Passkey is incorrect'`, and emit the reason as `'error'` on the app. +Passkeys are discoverable and verify the user, e.g. with a fingerprint, so a +passkey login counts as two factors. It only logs in users of the controller's +`modelClass`, through their `sessionScope`, and fires the +[login events](./model.md#login-events). + +The controller's [`authorize`](#authorize) doesn't apply to `login`, `logout`, +`session`, `password` and the passkey actions, which authenticate users, or +are only allowed for the authenticated user; `self` follows it. Its member +actions default to `authorize: ['$self']`, and are routed when listed in +`allow` like on other model controllers. In member routes, `self` stands for the authenticated user, see [`getMemberId()`](#getmemberid-1), e.g. `PATCH /users/self` runs `member.patch` on them. diff --git a/docs/api/server/model.md b/docs/api/server/model.md index 1ab9e25e2..0aa36ced1 100644 --- a/docs/api/server/model.md +++ b/docs/api/server/model.md @@ -871,6 +871,7 @@ import { TimestampedMixin, UserMixin, SessionMixin, + CredentialMixin, AssetMixin } from '@ditojs/server' ``` @@ -885,6 +886,7 @@ already applied to [`Model`](#class-model), as a ready-made base class: | `TimestampedMixin` | `TimestampedModel` | | `UserMixin` | `UserModel` | | `SessionMixin` | `SessionModel` | +| `CredentialMixin` | `CredentialModel` | | `AssetMixin` | `AssetModel` | The properties a mixin adds merge with the model's own @@ -1076,6 +1078,33 @@ const app = new Application({ Sessions are read and written in the transaction of the request, if there is one. +### CredentialMixin + +```js +import { CredentialModel } from '@ditojs/server' + +export class Credential extends CredentialModel {} +``` + +A model to store the credentials of users beyond their password, for now +their [passkeys](controllers.md#userscontroller). One table holds the +credentials of all user models. Use it with `app.auth.credentials.modelClass`, +the model's name or class, see [`config.app`](application.md#configapp). + +| Property | Description | +| ------------ | --------------------------------------------------------------------------- | +| `type` | `string`, required: `'passkey'`. | +| `userModel` | `string`, required: the name of the user's model class. | +| `userId` | `string`, required: the id of the user, also of integer ids. | +| `key` | `string`, nullable, unique per `userModel`: the credential id of passkeys. | +| `name` | `string`, nullable: the name of the provider of passkeys, e.g. `'1Password'`. | +| `data` | `object`, hidden: the public key, signature counter and transports of passkeys. | +| `createdAt` | `timestamp`, set on insert. | +| `lastUsedAt` | `timestamp`, nullable: the time of the last login with the credential. | + +Credentials refer to their users without foreign keys, so deleting users +through their model deletes their credentials too. + ### AssetMixin ```js diff --git a/docs/guide/admin/context.md b/docs/guide/admin/context.md index 41007b0a6..30d4c3d61 100644 --- a/docs/guide/admin/context.md +++ b/docs/guide/admin/context.md @@ -154,10 +154,11 @@ Inside the `itemLabel()` callback itself, it's `null`. `isDirty`, `isCreating`, `validate()`, `isValid()`, `clearErrors()`, `markDirty()`, `resetItem()` and `submit()` act on the data of the closest form, view, dialog or panel with its own data. `closeForm()` closes the form -and `closeDialog()` the dialog, and `selectedTab` names the selected tab of -the form. Where they don't apply, the methods throw, e.g. `closeForm()` -outside of forms, `isDirty` and `isCreating` are `false`, and `selectedTab` is -`null`, also in forms without tabs. They're described in +and `closeDialog()` the dialog, `reload()` reloads the closest list or object, +and `selectedTab` names the selected tab of the form. Where they don't apply, +the methods throw, e.g. `closeForm()` outside of forms, `isDirty` and +`isCreating` are `false`, and `selectedTab` is `null`, also in forms without +tabs. They're described in [Forms](./forms.md#acting-on-the-form-from-code), [Validation](./validation.md), [Dirty state](./dirty-state.md), [Buttons](./buttons.md) and [Dialogs](./dialogs.md). diff --git a/docs/guide/server/controllers.md b/docs/guide/server/controllers.md index d6194a5b9..5655e39f6 100644 --- a/docs/guide/server/controllers.md +++ b/docs/guide/server/controllers.md @@ -594,6 +594,76 @@ list the routes above too, e.g. In actions, `ctx.state.user` is the logged-in user. The admin logs in through these routes, see the [admin guide](../admin/index.md). +### Passkeys + +Users can also log in with passkeys, without a password. Install the optional +peer dependency `@simplewebauthn/server`, add a model for the credentials, and +turn on passkeys: + +```js +import { Model, CredentialMixin } from '@ditojs/server' + +export class Credential extends CredentialMixin(Model) {} +``` + +```js +// config/index.js +export default { + app: { + session: { modelClass: 'Session' }, + auth: { + credentials: { modelClass: 'Credential' }, + passkeys: { + name: 'Example', + id: 'example.com', + origins: ['https://example.com'] + } + } + } +} +``` + +The users controller then adds the passkey routes, see +[`UsersController`](../../api/server/controllers.md#userscontroller). In the +browser, `@simplewebauthn/browser` runs them against the authenticator. A +logged-in user adds a passkey: + +```js +import { startRegistration } from '@simplewebauthn/browser' + +const post = async (path, body) => { + const response = await fetch(`/api/users/${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }) + return response.json() +} + +const optionsJSON = await post('passkey-options') +// Named after its provider, e.g. '1Password': +const passkey = await post( + 'passkeys', + await startRegistration({ optionsJSON }) +) +``` + +And logs in with it later, without a username: + +```js +import { startAuthentication } from '@simplewebauthn/browser' + +const optionsJSON = await post('passkey-login-options') +const { user } = await post( + 'passkey-login', + await startAuthentication({ optionsJSON }) +) +``` + +`GET /users/passkeys` lists the user's passkeys, and +`DELETE /users/passkeys/:id` removes one. The admin offers both with its +`login.passkeys` option, see [`DitoAdmin`](../../api/admin/admin.md#parameters). + ## Admin The `AdminController` serves the admin interface at its path. In development diff --git a/packages/admin/package.json b/packages/admin/package.json index bb0914d1d..94dba81ce 100644 --- a/packages/admin/package.json +++ b/packages/admin/package.json @@ -49,6 +49,7 @@ "peerDependencies": { "@floating-ui/dom": "^1.8.0", "@kyvg/vue3-notification": "^3.4.2", + "@simplewebauthn/browser": "^14.0.0", "@tiptap/core": "^3.31.4", "@tiptap/extension-blockquote": "^3.31.4", "@tiptap/extension-bold": "^3.31.4", @@ -92,6 +93,7 @@ "@ditojs/build": "workspace:^", "@floating-ui/dom": "^1.8.0", "@kyvg/vue3-notification": "^3.4.2", + "@simplewebauthn/browser": "^14.0.0", "@tiptap/core": "^3.31.4", "@tiptap/extension-blockquote": "^3.31.4", "@tiptap/extension-bold": "^3.31.4", diff --git a/packages/admin/src/DitoAdmin.js b/packages/admin/src/DitoAdmin.js index 0a6a485e7..280bd9cf4 100644 --- a/packages/admin/src/DitoAdmin.js +++ b/packages/admin/src/DitoAdmin.js @@ -55,12 +55,28 @@ export default class DitoAdmin { // Allow configuring the auth resources, e.g. `api.users.path = '/admins'`: const users = getResource(api.users, { type: 'collection' }) || {} - const methods = { login: 'post', logout: 'post', session: 'get' } + const methods = { + login: 'post', + logout: 'post', + session: 'get', + password: 'post', + passkeyLoginOptions: 'post', + passkeyLogin: 'post', + passkeyOptions: 'post' + } for (const [name, method] of Object.entries(methods)) { - users[name] = getResource(users[name] || name, { method, parent: users }) + users[name] = getResource(users[name] || hyphenate(name), { + method, + parent: users + }) } // The own user, that the account settings load and patch: users.self = getResource(users.self || 'self', { parent: users }) + // The passkeys of the own user, that the security dialog manages: + users.passkeys = getResource(users.passkeys || 'passkeys', { + type: 'collection', + parent: users + }) api.users = users // Apps may override the handlers of resource paths: diff --git a/packages/admin/src/SchemaContext.js b/packages/admin/src/SchemaContext.js index cb43e00b5..4b7cc2749 100644 --- a/packages/admin/src/SchemaContext.js +++ b/packages/admin/src/SchemaContext.js @@ -377,6 +377,17 @@ export default class SchemaContext { } } + // Reload the items of the list or object that the component is or is in: + get reload() { + return async () => { + const { sourceContext } = getServices(this) + if (!sourceContext) { + throw new Error('Unable to reload: There is no list or object.') + } + await sourceContext.reload() + } + } + get closeForm() { return () => { const { routeContext } = getServices(this) diff --git a/packages/admin/src/components/DitoAccount.test.js b/packages/admin/src/components/DitoAccount.test.js index be366b6dd..18d603baf 100644 --- a/packages/admin/src/components/DitoAccount.test.js +++ b/packages/admin/src/components/DitoAccount.test.js @@ -1,7 +1,14 @@ import { vi } from 'vitest' import { flushPromises } from '@vue/test-utils' +import { startRegistration } from '@simplewebauthn/browser' import { mountAdmin, isForm, settle, enterValue } from '../test/mount.js' +// The DOM of the tests has no WebAuthn, so we answer registrations with a fixed +// response. The e2e tests run them in a browser: +vi.mock('@simplewebauthn/browser', () => ({ + startRegistration: vi.fn(async () => ({ id: 'passkey' })) +})) + describe('DitoAccount', () => { const views = { test: { type: 'view', label: 'Test', components: {} } } @@ -109,4 +116,240 @@ describe('DitoAccount', () => { expect(admin.wrapper.find('.dito-account').text()).toBe('editor') }) }) + + describe('security', () => { + // A server with passkeys, which adds and deletes them, and changes the + // password 'secret': + const mountWithPasskeys = async ({ + passkeys = [ + { + id: 1, + name: '1Password', + createdAt: '2026-01-02T12:00:00Z', + lastUsedAt: null + } + ] + } = {}) => { + const request = vi.fn(({ method = 'get', url, data }) => { + if (url === '/users/session') { + return { data: { user: { id: 1, username: 'tester' } } } + } else if (url === '/users/passkeys' && method === 'get') { + return { data: passkeys } + } else if (url === '/users/passkey-options') { + return { data: { challenge: 'challenge' } } + } else if (url === '/users/passkeys' && method === 'post') { + passkeys = [ + ...passkeys, + { + id: 2, + name: 'Passkey', + createdAt: '2026-01-03T12:00:00Z', + lastUsedAt: null + } + ] + return { data: passkeys.at(-1) } + } else if (url === '/users/passkeys/1' && method === 'delete') { + passkeys = passkeys.filter(passkey => passkey.id !== 1) + return { data: { success: true } } + } else if (url === '/users/password' && method === 'post') { + if (data.currentPassword !== 'secret') { + throw Object.assign(new Error('Request failed'), { + response: { + status: 400, + data: { message: 'Password is incorrect' } + } + }) + } + return { data: { success: true } } + } + throw new Error(`Unexpected request: ${method} ${url}`) + }) + const admin = await mountAdmin({ + views, + api: { users: { path: 'users' } }, + request, + options: { login: { passkeys: true } } + }) + const { items } = await openMenu(admin) + expect(items.map(item => item.text())).toEqual(['Security', 'Sign Out']) + await items[0].trigger('click') + await flushPromises() + const getRows = () => + admin.wrapper + .findAll('.dito-dialog .dito-list tbody tr') + .map(row => row.text()) + return { admin, request, getRows } + } + + const findButton = (admin, text) => + admin.wrapper.findAll('button').find(button => button.text() === text) + + const getLastDialog = admin => admin.wrapper.findAll('.dito-dialog').at(-1) + + it('lists the passkeys of the user', async () => { + const { getRows } = await mountWithPasskeys() + expect(getRows()).toEqual(['1PasswordJanuary 2, 2026Never']) + }) + + it('shows a row when there are no passkeys', async () => { + const { admin, getRows } = await mountWithPasskeys({ passkeys: [] }) + expect(getRows()).toEqual(['No passkeys yet.']) + const cell = admin.wrapper.find('.dito-table__row--empty td') + expect(cell.attributes('colspan')).toBe('3') + }) + + it('adds passkeys, named by the server', async () => { + const { admin, request, getRows } = await mountWithPasskeys() + await findButton(admin, 'Add Passkey').trigger('click') + await flushPromises() + expect(startRegistration).toHaveBeenCalledWith({ + optionsJSON: { challenge: 'challenge' } + }) + expect(request).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'post', + url: '/users/passkeys', + data: { id: 'passkey' } + }) + ) + expect(getRows()).toEqual([ + '1PasswordJanuary 2, 2026Never', + 'PasskeyJanuary 3, 2026Never' + ]) + }) + + it('stops quietly when the passkey prompt is cancelled', async () => { + startRegistration.mockRejectedValueOnce( + Object.assign(new Error('The operation was cancelled.'), { + name: 'NotAllowedError' + }) + ) + const { admin, request, getRows } = await mountWithPasskeys() + const notify = vi.spyOn(admin.context, 'notify') + await findButton(admin, 'Add Passkey').trigger('click') + await flushPromises() + expect(startRegistration).toHaveBeenCalled() + expect(request).not.toHaveBeenCalledWith( + expect.objectContaining({ method: 'post', url: '/users/passkeys' }) + ) + expect(notify).not.toHaveBeenCalled() + expect(getRows()).toEqual(['1PasswordJanuary 2, 2026Never']) + }) + + it('deletes passkeys after asking', async () => { + const { admin, request, getRows } = await mountWithPasskeys() + await admin.wrapper + .find('.dito-dialog .dito-list .dito-button--delete') + .trigger('click') + await flushPromises() + expect(getLastDialog(admin).text()).toContain( + 'Do you really want to delete the 1Password passkey?' + ) + await findButton(admin, 'Delete').trigger('click') + await flushPromises() + expect(request).toHaveBeenCalledWith( + expect.objectContaining({ method: 'delete', url: '/users/passkeys/1' }) + ) + expect(getRows()).toEqual(['No passkeys yet.']) + }) + + it('names unknown passkeys plainly when asking', async () => { + const { admin } = await mountWithPasskeys({ + passkeys: [{ id: 1, name: 'Passkey', createdAt: '2026-01-02' }] + }) + await admin.wrapper + .find('.dito-dialog .dito-list .dito-button--delete') + .trigger('click') + await flushPromises() + expect(getLastDialog(admin).text()).toContain( + 'Do you really want to delete the passkey?' + ) + }) + + describe('password', () => { + const changePassword = async (admin, values) => { + await findButton(admin, 'Change').trigger('click') + await flushPromises() + const dialog = getLastDialog(admin) + for (const [name, value] of Object.entries(values)) { + await enterValue(dialog.find(`input[name="${name}"]`), value) + } + await dialog.find('button[type="submit"]').trigger('click') + await flushPromises() + return dialog + } + + it('shows a placeholder instead of the password', async () => { + const { admin } = await mountWithPasskeys() + const row = getLastDialog(admin).find('.dito-object') + expect(row.text()).toBe('••••••••Change') + }) + + it('asks for the current and the new password', async () => { + const { admin } = await mountWithPasskeys() + await findButton(admin, 'Change').trigger('click') + await flushPromises() + const dialog = getLastDialog(admin) + const getAutocomplete = name => + dialog.find(`input[name="${name}"]`).attributes('autocomplete') + expect(getAutocomplete('currentPassword')).toBe('current-password') + expect(getAutocomplete('password')).toBe('new-password') + expect(getAutocomplete('confirmation')).toBe('new-password') + }) + + it('changes the password', async () => { + const { admin, request } = await mountWithPasskeys() + const notify = vi.spyOn(admin.context, 'notify') + await changePassword(admin, { + currentPassword: 'secret', + password: 'changed', + confirmation: 'changed' + }) + expect(request).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'post', + url: '/users/password', + data: { currentPassword: 'secret', password: 'changed' } + }) + ) + expect(notify).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'success', + text: 'Your password was changed.' + }) + ) + }) + + it(`doesn't submit a confirmation that doesn't match`, async () => { + const { admin, request } = await mountWithPasskeys() + const dialog = await changePassword(admin, { + currentPassword: 'secret', + password: 'changed', + confirmation: 'chagned' + }) + expect(dialog.text()).toContain('must match the new password') + expect(request).not.toHaveBeenCalledWith( + expect.objectContaining({ url: '/users/password' }) + ) + }) + + it('reports wrong current passwords', async () => { + const { admin } = await mountWithPasskeys() + const notify = vi + .spyOn(admin.context, 'notify') + .mockImplementation(() => {}) + await changePassword(admin, { + currentPassword: 'wrong', + password: 'changed', + confirmation: 'changed' + }) + expect(notify).toHaveBeenCalledWith( + expect.objectContaining({ type: 'error' }) + ) + expect(notify).not.toHaveBeenCalledWith( + expect.objectContaining({ type: 'success' }) + ) + }) + }) + }) }) diff --git a/packages/admin/src/components/DitoAccount.vue b/packages/admin/src/components/DitoAccount.vue index bb39f626a..c0b2ca571 100644 --- a/packages/admin/src/components/DitoAccount.vue +++ b/packages/admin/src/components/DitoAccount.vue @@ -14,11 +14,16 @@ import { computed, inject } from 'vue' import { useRouter } from 'vue-router' import { DitoMenuButton } from '@ditojs/ui/src' import { adminContextKey } from '../utils/keys.ts' +import { showSecurityDialog } from '../utils/security.js' export default { components: { DitoMenuButton }, - setup() { + props: { + passkeys: { type: Boolean, default: false } + }, + + setup(props) { const adminContext = inject(adminContextKey) const router = useRouter() const settingsRoute = computed( @@ -29,6 +34,7 @@ export default { items: computed(() => [ settingsRoute.value && { value: 'settings', label: 'Settings' }, + props.passkeys && { value: 'security', label: 'Security' }, { value: 'logout', label: 'Sign Out' } ].filter(Boolean) ), @@ -37,6 +43,8 @@ export default { if (value === 'settings') { // Navigate to the path, which also opens the view's object form: router.push(router.resolve(settingsRoute.value).path) + } else if (value === 'security') { + showSecurityDialog(adminContext, { passkeys: props.passkeys }) } else if (value === 'logout') { adminContext.session.logout() } diff --git a/packages/admin/src/components/DitoDialog.vue b/packages/admin/src/components/DitoDialog.vue index 8b6f4758f..6144c30de 100644 --- a/packages/admin/src/components/DitoDialog.vue +++ b/packages/admin/src/components/DitoDialog.vue @@ -93,7 +93,9 @@ export default { const submit = async () => { // Wait for computed values that depend on pending loads, e.g. options. await waitUntilDataModelSettled(services) - resolve(dialogData.value) + if (dataContext.validate()) { + resolve(dialogData.value) + } } const buttonSchemas = computed(() => diff --git a/packages/admin/src/components/DitoRoot.test.js b/packages/admin/src/components/DitoRoot.test.js index a4eebe3a5..097a89cb4 100644 --- a/packages/admin/src/components/DitoRoot.test.js +++ b/packages/admin/src/components/DitoRoot.test.js @@ -1,5 +1,6 @@ import { vi } from 'vitest' import { flushPromises } from '@vue/test-utils' +import { startAuthentication } from '@simplewebauthn/browser' import appState from '../appState.js' import DitoAdmin from '../DitoAdmin.js' import { @@ -9,6 +10,12 @@ import { enterValue } from '../test/mount.js' +// The DOM of the tests has no WebAuthn, so we answer passkey logins with a +// fixed response. The e2e tests run them in a browser: +vi.mock('@simplewebauthn/browser', () => ({ + startAuthentication: vi.fn(async () => ({ id: 'passkey' })) +})) + // Dispatches `beforeunload` like the browser does when the page is reloaded or // closed, and returns whether the page asks to stay. function isUnloadPrevented() { @@ -163,6 +170,77 @@ describe('DitoRoot', () => { }) }) + describe('login dialog', () => { + const mountLoggedOut = async options => { + const request = vi.fn(({ method, url, data }) => + method === 'post' && url === '/logout' + ? { data: { success: true } } + : method === 'post' && url === '/login' + ? { data: { user: { id: 1, username: data.username } } } + : method === 'post' && url === '/passkey-login-options' + ? { data: { challenge: 'challenge' } } + : method === 'post' && url === '/passkey-login' + ? { data: { user: { id: 2, username: 'passkey-user' } } } + : null + ) + const admin = await mountAdmin({ views: {}, request, options }) + await admin.context.session.logout() + await flushPromises() + await admin.wrapper.find('button.dito-login').trigger('click') + await flushPromises() + return { admin, request, dialog: admin.wrapper.find('.dito-dialog') } + } + + // Browsers with WebAuthn have `PublicKeyCredential`: + beforeEach(() => vi.stubGlobal('PublicKeyCredential', class {})) + afterEach(() => vi.unstubAllGlobals()) + + it('logs in with a passkey', async () => { + const { admin, request, dialog } = await mountLoggedOut({ + login: { passkeys: true } + }) + const button = dialog + .findAll('button') + .find(button => button.text() === 'Sign In with Passkey') + await button.trigger('click') + await flushPromises() + expect(startAuthentication).toHaveBeenCalledWith({ + optionsJSON: { challenge: 'challenge' } + }) + expect(request).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'post', + url: '/passkey-login', + data: { id: 'passkey' } + }) + ) + expect(admin.context.user.username).toBe('passkey-user') + }) + + it('sends no passkey with the password', async () => { + const { admin, request, dialog } = await mountLoggedOut({ + login: { passkeys: true } + }) + await enterValue(dialog.find('input[name="username"]'), 'reader') + await enterValue(dialog.find('input[name="password"]'), 'secret') + await dialog.find('button[type="submit"]').trigger('click') + await flushPromises() + expect(request).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'post', + url: '/login', + data: { username: 'reader', password: 'secret' } + }) + ) + expect(admin.context.user.username).toBe('reader') + }) + + it('offers passkeys only with the option', async () => { + const { dialog } = await mountLoggedOut({}) + expect(dialog.text()).not.toContain('Sign In with Passkey') + }) + }) + describe('showDialog()', () => { it('rejects when the components fail to set up', async () => { const admin = await mountAdmin({ views: {} }) @@ -226,6 +304,25 @@ describe('DitoRoot', () => { await closeTopDialog() expect(getInert()).toEqual({ page: false, dialogs: [] }) }) + + it('resolves only with valid data', async () => { + const admin = await mountAdmin({ views: {} }) + const promise = admin.context.showDialog({ + components: { title: { type: 'text', required: true } }, + buttons: { save: { type: 'submit' } } + }) + await flushPromises() + const dialog = admin.wrapper.find('.dito-dialog') + const submit = async () => { + await dialog.find('button[type="submit"]').trigger('click') + await flushPromises() + } + await submit() + expect(dialog.find('.dito-errors').text()).toContain('required') + await enterValue(dialog.find('input[name="title"]'), 'Emma') + await submit() + expect(await promise).toEqual({ title: 'Emma' }) + }) }) describe('session start', () => { diff --git a/packages/admin/src/components/DitoRoot.vue b/packages/admin/src/components/DitoRoot.vue index e54a13e6f..9caa0ff44 100644 --- a/packages/admin/src/components/DitoRoot.vue +++ b/packages/admin/src/components/DitoRoot.vue @@ -53,6 +53,7 @@ ) DitoAccount( v-if="adminContext.user" + :passkeys="!!options.login?.passkeys" ) button.dito-login( v-else-if="hasSessionStarted" @@ -164,9 +165,12 @@ export default { const detachSessionUserInterface = adminContext.session.attachUserInterface( { notify, - // Return the entered credentials, or `null` if the user cancels: + // Return the entered credentials, `'passkey'` to log in with a passkey, + // or `null` if the user cancels: requestLoginData() { - const { additionalComponents } = props.options.login || {} + const { additionalComponents, passkeys } = props.options.login || {} + // Browsers with WebAuthn have `PublicKeyCredential`: + const hasPasskeys = passkeys && !!window.PublicKeyCredential return showDialog({ components: { username: { @@ -186,6 +190,14 @@ export default { text: 'Sign In' }, + ...(hasPasskeys && { + passkey: { + type: 'button', + text: 'Sign In with Passkey', + onClick: ({ closeDialog }) => closeDialog('passkey') + } + }), + // `DitoDialog` adds the click event of the cancel button: cancel: { type: 'button', diff --git a/packages/admin/src/composables/useListQuery.ts b/packages/admin/src/composables/useListQuery.ts index 885f5d157..2d3b5233a 100644 --- a/packages/admin/src/composables/useListQuery.ts +++ b/packages/admin/src/composables/useListQuery.ts @@ -8,17 +8,21 @@ import type { Schema } from '../utils/types.ts' import type { FieldProps } from './useField.ts' // The scope, page, order and filters of a list, remembered in the store, and -// held by the route for lists that load their data, see `ListQuery`. +// held by the route for lists that load their data in routes, see `ListQuery`. export function useListQuery( props: FieldProps, { getPaginate, + isInRoute, isRouted, onChange }: { getPaginate: () => number | undefined + // Lists in routes change the query through the route... + isInRoute: () => boolean + // ...and those that load their data also follow it: isRouted: () => boolean - // Called once the route changed only the query, which the list loads: + // Called once the query changed, which the list loads: onChange: () => void } ) { @@ -55,7 +59,9 @@ export function useListQuery( scope: defaultScope.value?.name, page: props.schema.page, order: defaultOrder.value - }) + }), + isInRoute, + onChange }) ) diff --git a/packages/admin/src/composables/useSource.test.js b/packages/admin/src/composables/useSource.test.js index 6c19616b1..866b075b6 100644 --- a/packages/admin/src/composables/useSource.test.js +++ b/packages/admin/src/composables/useSource.test.js @@ -504,6 +504,71 @@ describe('useSource()', () => { ]) }) + it('changes the query of lists without resource through the route', async () => { + const { admin, getComponent } = await mountSchema({ + schema: { + components: { + books: { type: 'list', columns: { title: { sortable: true } } } + } + }, + data: { books: [{ id: 1, title: 'Emma' }] } + }) + const books = getComponent('books') + await books.listQuery.update({ order: 'title desc' }) + await flushPromises() + expect(admin.router.currentRoute.value.query).toEqual({ + order: 'title desc' + }) + }) + + describe('in dialogs', () => { + async function showAuthorsDialog(admin) { + admin.context.showDialog({ + components: { + authors: { + type: 'list', + resource: { path: 'authors' }, + columns: { name: { sortable: true } } + } + }, + buttons: { cancel: {} } + }) + await flushPromises() + const source = admin.wrapper + .findAllComponents(DitoTypeList) + .map(({ vm }) => vm) + .find(({ schema }) => schema.name === 'authors') + const getLoadQueries = () => + admin.request.mock.calls + .map(([options]) => options) + .filter(({ url }) => url === '/authors') + .map(({ query }) => query) + return { source, getLoadQueries } + } + + it(`doesn't load the query of the route`, async () => { + const { admin } = await mountBooksView() + await admin.navigate('/books?order=title+desc&page=2') + const { getLoadQueries } = await showAuthorsDialog(admin) + expect(getLoadQueries()).toEqual([{}]) + expect(admin.router.currentRoute.value.query).toEqual({ + order: 'title desc', + page: '2' + }) + }) + + it('loads its changed query without navigating', async () => { + const { admin } = await mountBooksView() + const { source, getLoadQueries } = await showAuthorsDialog(admin) + await source.listQuery.update({ order: 'name asc' }) + await flushPromises() + expect(getLoadQueries()).toEqual([{}, { order: 'name asc' }]) + expect(admin.router.currentRoute.value.query).toEqual({ + order: 'title asc' + }) + }) + }) + it(`doesn't load once unmounted when the navigation fails`, async () => { const { admin, source, getLoadQueries } = await mountBooksView() const loadCount = getLoadQueries().length @@ -521,6 +586,64 @@ describe('useSource()', () => { }) }) + describe('context.reload()', () => { + it('reloads the list of the button', async () => { + let titles = ['Emma'] + const { findField } = await mountSchema({ + schema: { + components: { + books: { + type: 'list', + resource: { path: 'books' }, + columns: ['title'], + buttons: { + reload: { + text: 'Reload', + async onClick({ reload }) { + titles = ['Emma', 'Persuasion'] + await reload() + } + } + } + } + } + }, + request: () => ({ + data: titles.map((title, index) => ({ id: index + 1, title })) + }) + }) + const books = findField('books') + await books + .findAll('button') + .find(button => button.text() === 'Reload') + .trigger('click') + await flushPromises() + expect(books.findAll('tbody tr').map(row => row.text())).toEqual([ + 'Emma', + 'Persuasion' + ]) + }) + + it('throws outside of lists and objects', async () => { + let context = null + await mountSchema({ + schema: { + components: { + title: { + type: 'text', + computeValue: computeContext => { + context = computeContext + } + } + } + } + }) + await expect(context.reload()).rejects.toThrow( + 'Unable to reload: There is no list or object.' + ) + }) + }) + describe('serializing', () => { it('serializes the items of lists without forms as copies', async () => { // Copying to the clipboard removes the ids of the items, which mustn't diff --git a/packages/admin/src/composables/useSource.ts b/packages/admin/src/composables/useSource.ts index 7e60be2ed..a067b0764 100644 --- a/packages/admin/src/composables/useSource.ts +++ b/packages/admin/src/composables/useSource.ts @@ -101,7 +101,9 @@ export function useSource( getRequestQuery } = useListQuery(props, { getPaginate: () => paginate.value, - isRouted: () => providesData.value, + // Lists outside of routes, e.g. in dialogs, keep the query to themselves: + isInRoute: () => !!routeContext, + isRouted: () => providesData.value && !!routeContext, onChange: () => loadData() }) @@ -161,7 +163,7 @@ export function useSource( // item, have nothing to load: const loadData = () => { if (!isTransient.value) { - requestData() + return requestData() } } diff --git a/packages/admin/src/styles/_table.scss b/packages/admin/src/styles/_table.scss index 349df9f0f..49bc1f70a 100644 --- a/packages/admin/src/styles/_table.scss +++ b/packages/admin/src/styles/_table.scss @@ -90,6 +90,10 @@ } } + &__row--empty > td { + color: $color-placeholder; + } + &--larger-padding { // Add more spacing to cells in tables except those containing edit buttons, // to better align cell texts with edit buttons. diff --git a/packages/admin/src/types/DitoTypeList.vue b/packages/admin/src/types/DitoTypeList.vue index 00881d7f2..0155b8afc 100644 --- a/packages/admin/src/types/DitoTypeList.vue +++ b/packages/admin/src/types/DitoTypeList.vue @@ -136,6 +136,10 @@ @delete="deleteItem(item, index)" @move="delta => moveItem(index, delta)" ) + tr.dito-table__row.dito-table__row--empty( + v-if="emptyText && !listData.length && !isLoading" + ) + td(:colspan="numColumns") {{ emptyText }} //- Render the list buttons inside the table when not in a single //- component view, with the item that holds the list as their data: tfoot( @@ -274,6 +278,7 @@ const DitoTypeList = { hasToggleAll, toggleAllTitle, toggleAll, + emptyText: computed(() => getSchemaValue('empty', { type: String })), numColumns: computed(() => { const columns = source.columns.value diff --git a/packages/admin/src/types/DitoTypeObject.vue b/packages/admin/src/types/DitoTypeObject.vue index abb87b614..eededa421 100644 --- a/packages/admin/src/types/DitoTypeObject.vue +++ b/packages/admin/src/types/DitoTypeObject.vue @@ -3,6 +3,7 @@ v-if="isReady" :id="componentPath" ref="element" + :class="{ 'dito-object--text': !isInlined && !schema.component }" ) .dito-object-content( v-if="objectData" @@ -90,5 +91,10 @@ export default DitoTypeObject flex: 1 0 0%; margin-left: $form-spacing; } + + // Align the text of rendered items with the text of their buttons: + &--text { + align-items: baseline; + } } diff --git a/packages/admin/src/utils/ListQuery.js b/packages/admin/src/utils/ListQuery.js index 90704824d..f88cf75bf 100644 --- a/packages/admin/src/utils/ListQuery.js +++ b/packages/admin/src/utils/ListQuery.js @@ -3,13 +3,23 @@ import { formatQuery } from './route.js' // The scope, page, order and filters of a list: The route holds them, for // links and reloads, and the store of the source remembers them for when we -// navigate back. All changes go through the router. +// navigate back. All changes of lists in routes go through the router, lists +// outside of routes, e.g. in dialogs, only store them. export class ListQuery { - constructor({ router, getRoute, getSourceStore, getDefaultQuery }) { + constructor({ + router, + getRoute, + getSourceStore, + getDefaultQuery, + isInRoute, + onChange + }) { this.router = router this.getRoute = getRoute this.getSourceStore = getSourceStore this.getDefaultQuery = getDefaultQuery + this.isInRoute = isInRoute + this.onChange = onChange this.pendingNavigation = null } @@ -27,20 +37,26 @@ export class ListQuery { if (resetPage && changedQuery.page != null) { changedQuery.page = 0 } - const promise = this.router.push({ - query: changedQuery, - // Preserve the hash for tabs: - hash: this.getRoute().hash - }) - const navigation = { query: changedQuery, promise } - this.pendingNavigation = navigation - const settle = () => { - if (this.pendingNavigation === navigation) { - this.pendingNavigation = null + if (this.isInRoute()) { + const promise = this.router.push({ + query: changedQuery, + // Preserve the hash for tabs: + hash: this.getRoute().hash + }) + const navigation = { query: changedQuery, promise } + this.pendingNavigation = navigation + const settle = () => { + if (this.pendingNavigation === navigation) { + this.pendingNavigation = null + } } + promise.then(settle, settle) + return promise + } else { + this.getSourceStore().query = changedQuery + this.onChange() + return Promise.resolve() } - promise.then(settle, settle) - return promise } // Resolve to whether the pending navigation reached its query, as guards diff --git a/packages/admin/src/utils/ListQuery.test.js b/packages/admin/src/utils/ListQuery.test.js index 48cdcf2f0..a2b08fbd1 100644 --- a/packages/admin/src/utils/ListQuery.test.js +++ b/packages/admin/src/utils/ListQuery.test.js @@ -4,8 +4,10 @@ import { ListQuery } from './ListQuery.js' function createListQuery({ routeQuery = {}, storedQuery, - defaultQuery = { scope: 'all', order: 'title asc' } + defaultQuery = { scope: 'all', order: 'title asc' }, + isInRoute = true } = {}) { + const onChange = vi.fn() const route = { path: '/books', query: routeQuery, hash: '#main' } const store = { query: storedQuery } const router = { @@ -20,9 +22,11 @@ function createListQuery({ router, getRoute: () => route, getSourceStore: () => store, - getDefaultQuery: () => defaultQuery + getDefaultQuery: () => defaultQuery, + isInRoute: () => isInRoute, + onChange }) - return { listQuery, router, route, store } + return { listQuery, router, route, store, onChange } } describe('ListQuery', () => { @@ -72,6 +76,17 @@ describe('ListQuery', () => { }) }) + it('stores the changed query of lists outside of routes', async () => { + const { listQuery, router, store, onChange } = createListQuery({ + storedQuery: { scope: 'all', order: 'title asc' }, + isInRoute: false + }) + await listQuery.update({ order: 'title desc' }) + expect(router.push).not.toHaveBeenCalled() + expect(store.query).toEqual({ scope: 'all', order: 'title desc' }) + expect(onChange).toHaveBeenCalledOnce() + }) + it('removes values that are changed to `undefined`', async () => { const { listQuery, router } = createListQuery({ storedQuery: { filter: ['title:"Emma"'] } diff --git a/packages/admin/src/utils/Session.test.js b/packages/admin/src/utils/Session.test.js index 6672d014c..36bc097d4 100644 --- a/packages/admin/src/utils/Session.test.js +++ b/packages/admin/src/utils/Session.test.js @@ -1,8 +1,15 @@ import { vi } from 'vitest' import { shallowRef } from 'vue' +import { startAuthentication } from '@simplewebauthn/browser' import { Session } from './Session.ts' import AdminUser from '../AdminUser.js' +// The DOM of the tests has no WebAuthn, so we answer the passkey login with a +// fixed response. The e2e tests run it in a browser: +vi.mock('@simplewebauthn/browser', () => ({ + startAuthentication: vi.fn(async () => ({ id: 'passkey' })) +})) + const user = { id: 1, username: 'tester' } // Creates a session with an API that answers the `api.users` requests through @@ -18,7 +25,9 @@ function createSession({ users: { login: { method: 'post', path: 'login' }, logout: { method: 'post', path: 'logout' }, - session: { method: 'get', path: 'session' } + session: { method: 'get', path: 'session' }, + passkeyLoginOptions: { method: 'post', path: 'passkey-login-options' }, + passkeyLogin: { method: 'post', path: 'passkey-login' } }, resources: { any: resource => `/${resource.path}` }, getApiUrl: ({ url }) => url, @@ -122,6 +131,44 @@ describe('Session', () => { }) }) + it('logs in with a passkey', async () => { + const { session, sessionUser, api } = createSession({ + responses: { + 'post /passkey-login-options': { challenge: 'challenge' }, + 'post /passkey-login': { user: { ...user } } + }, + loginData: ['passkey'] + }) + expect(await session.login()).toEqual(user) + expect(startAuthentication).toHaveBeenCalledWith({ + optionsJSON: { challenge: 'challenge' } + }) + expect(api.request).toHaveBeenLastCalledWith({ + method: 'post', + url: '/passkey-login', + data: { id: 'passkey' } + }) + expect(sessionUser.value).toEqual(user) + }) + + it('asks again quietly when the passkey prompt is cancelled', async () => { + startAuthentication.mockRejectedValueOnce( + Object.assign(new Error('The operation was cancelled.'), { + name: 'NotAllowedError' + }) + ) + const { session, userInterface } = createSession({ + responses: { + 'post /passkey-login-options': { challenge: 'challenge' }, + 'post /passkey-login': { user: { ...user } } + }, + loginData: ['passkey', 'passkey'] + }) + expect(await session.login()).toEqual(user) + expect(userInterface.requestLoginData).toHaveBeenCalledTimes(2) + expect(userInterface.notify).not.toHaveBeenCalled() + }) + it('returns `null` when the user cancels', async () => { const { session, api } = createSession({ loginData: [] }) expect(await session.login()).toBe(null) diff --git a/packages/admin/src/utils/Session.ts b/packages/admin/src/utils/Session.ts index d01a3a92e..205c0820e 100644 --- a/packages/admin/src/utils/Session.ts +++ b/packages/admin/src/utils/Session.ts @@ -1,4 +1,5 @@ import type { ShallowRef } from 'vue' +import { startAuthentication } from '@simplewebauthn/browser' import AdminUser from '../AdminUser.js' import type { ViewRegistry } from './ViewRegistry.js' import type { NotifyOptions } from './AdminContext.ts' @@ -7,7 +8,7 @@ import type { Api } from './types.ts' export type User = Record interface UserInterface { - requestLoginData(): Promise | null> + requestLoginData(): Promise | 'passkey' | null> notify(options: NotifyOptions): void } @@ -41,7 +42,8 @@ export class Session { this.#redirectAfterLogin = redirectAfterLogin } - // `requestLoginData()` returns the credentials, or `null` when cancelled. + // `requestLoginData()` returns the credentials, `'passkey'` to log in with a + // passkey, or `null` when cancelled. attachUserInterface({ requestLoginData, notify }: UserInterface) { const userInterface = { requestLoginData, notify } this.#userInterface = userInterface @@ -68,10 +70,21 @@ export class Session { } let user try { - const response = await this.#request(this.#api.users.login, loginData) + const { users } = this.#api + let response + if (loginData === 'passkey') { + const options = await this.#request(users.passkeyLoginOptions) + const passkey = await startAuthentication({ optionsJSON: options.data }) + response = await this.#request(users.passkeyLogin, passkey) + } else { + response = await this.#request(users.login, loginData) + } user = response.data.user } catch (error) { - this.#notifyError(error) + // Ask again quietly when the passkey prompt is cancelled: + if (!isPasskeyPromptCancelled(error)) { + this.#notifyError(error) + } return this.login() } if (this.#redirectAfterLogin) { @@ -150,3 +163,9 @@ export class Session { } } } + +// Browsers reject passkey prompts that users cancel or let time out with these +// WebAuthn errors: +export function isPasskeyPromptCancelled(error: any) { + return ['NotAllowedError', 'AbortError'].includes(error.name) +} diff --git a/packages/admin/src/utils/SourceContext.ts b/packages/admin/src/utils/SourceContext.ts index 621924565..5f7738d9b 100644 --- a/packages/admin/src/utils/SourceContext.ts +++ b/packages/admin/src/utils/SourceContext.ts @@ -21,7 +21,7 @@ export interface SourceContext { createItem(schema: Schema, type?: string | null, index?: number | null): any // Each item reads its own label, so that edits only update theirs: getSourceItemLabel(item: any, index?: number | null): any - reload(): void + reload(): Promise | undefined // Through the resource of the source or of the button: submit(button: SubmitButton): Promise } diff --git a/packages/admin/src/utils/security.js b/packages/admin/src/utils/security.js new file mode 100644 index 000000000..c9be00e4e --- /dev/null +++ b/packages/admin/src/utils/security.js @@ -0,0 +1,121 @@ +import { startRegistration } from '@simplewebauthn/browser' +import { isPasskeyPromptCancelled } from './Session.ts' + +// Dialogs are as wide as their content, up to their `width` setting, so +// components as wide as the setting fill them: +const securityWidth = 600 + +// The Security dialog of the account menu, which changes the password and +// manages the passkeys of the logged-in user: +export function showSecurityDialog(adminContext, { passkeys }) { + const { users } = adminContext.api + const formatDate = ({ value, format }) => + value ? format(value, { date: true }) : 'Never' + return adminContext.showDialog({ + components: { + // The password itself is never shown, so render a placeholder: + password: { + type: 'object', + label: 'Password', + width: `${securityWidth}px`, + render: () => '••••••••', + buttons: { + change: { + text: 'Change', + async onClick({ showDialog, request, notify }) { + const data = await showDialog({ + components: { + currentPassword: { + type: 'password', + label: 'Current Password', + autocomplete: 'current-password', + required: true, + autofocus: true + }, + password: { + type: 'password', + label: 'New Password', + autocomplete: 'new-password', + required: true + }, + confirmation: { + type: 'password', + label: 'Confirm New Password', + autocomplete: 'new-password', + required: true, + validate: ({ value, item }) => ( + value === item.password || 'must match the new password' + ) + } + }, + buttons: { + cancel: {}, + change: { type: 'submit', text: 'Change Password' } + }, + settings: { label: 'Change Password' } + }) + if (data) { + const { currentPassword, password } = data + await request({ + resource: users.password, + data: { currentPassword, password } + }) + notify({ type: 'success', text: 'Your password was changed.' }) + } + } + } + } + }, + + ...(passkeys && { + passkeys: { + type: 'list', + label: 'Passkeys', + width: `${securityWidth}px`, + resource: users.passkeys, + deletable: true, + itemLabel: ({ item }) => + item.name === 'Passkey' + ? 'the passkey' + : `the ${item.name} passkey`, + empty: 'No passkeys yet.', + columns: { + name: {}, + createdAt: { label: 'Created', render: formatDate }, + lastUsedAt: { label: 'Last Used', render: formatDate } + }, + buttons: { + add: { + text: 'Add Passkey', + // The server names passkeys after their providers: + async onClick({ request, reload }) { + const optionsJSON = await request({ + resource: users.passkeyOptions + }) + await request({ + resource: users.passkeys, + method: 'post', + data: await startRegistration({ optionsJSON }) + }) + await reload() + }, + + // Cancelled passkey prompts end quietly: + onError({ error }) { + if (isPasskeyPromptCancelled(error)) { + return false + } + } + } + } + } + }) + }, + buttons: { + cancel: { text: 'Close' } + }, + // The password row has nothing to load, but objects need a value: + data: { password: {} }, + settings: { label: 'Security', width: securityWidth } + }) +} diff --git a/packages/admin/types/index.d.ts b/packages/admin/types/index.d.ts index 63b1e3ac4..0af7c9ca7 100644 --- a/packages/admin/types/index.d.ts +++ b/packages/admin/types/index.d.ts @@ -292,6 +292,29 @@ export interface ApiConfig { */ path?: string } + /** + * Posted to by the security dialog with the current and the new password, + * with the default path `'password'`. + */ + password?: { path?: string; method?: HttpMethod } + /** + * The passkey endpoints of `login.passkeys`, posted to, with the default + * paths `'passkey-login-options'`, `'passkey-login'` and + * `'passkey-options'`. + */ + passkeyLoginOptions?: { path?: string; method?: HttpMethod } + passkeyLogin?: { path?: string; method?: HttpMethod } + passkeyOptions?: { path?: string; method?: HttpMethod } + /** + * The passkeys of the logged-in user, listed, added and deleted by the + * security dialog. + */ + passkeys?: { + /** + * @defaultValue `'passkeys'` + */ + path?: string + } } /** * Custom resource path handlers, keyed by resource type. Merged @@ -2046,6 +2069,12 @@ export interface ListSchema<$Item = { [key: string]: any }> */ collapsible?: SchemaValue<$Item, {}, boolean | 'all'> + /** + * The text of the row shown when the list has no items, e.g. + * `'No passkeys yet.'`. Without it, empty lists show no row. + */ + empty?: SchemaValue<$Item, {}, string> + /** * Filter definitions that render a filter panel above the * list, allowing users to filter displayed items. Each @@ -2387,6 +2416,13 @@ export type SchemaContext<$Item = any> = { validate?: boolean closeForm?: boolean }): Promise + /** + * Reloads the items of the list or object that the + * component is or is in, from its resource, and resolves + * once they're loaded. Sources without their own resource + * have nothing to load. Throws outside of sources. + */ + reload(): Promise /** * Closes the form, navigating to its parent route. * Resolves to `true` if the form closed, `false` if it's @@ -3164,6 +3200,11 @@ interface AdminOptions { login?: { additionalComponents?: Components redirectAfterLogin?: string + /** + * Adds a passkey login to the login dialog, and a security dialog to the + * account menu that manages the user's passkeys. + */ + passkeys?: boolean } /** * Controls the loading spinner displayed in the diff --git a/packages/server/package.json b/packages/server/package.json index 4cf2e3fdc..84f5cd27e 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -38,6 +38,7 @@ "@koa/cors": "^5.0.0", "@koa/etag": "^5.0.2", "@koa/multer": "^4.0.0", + "aaguids": "^0.0.71", "ajv": "^8.20.0", "ajv-formats": "^3.0.1", "bcryptjs": "^3.0.3", @@ -75,6 +76,7 @@ "peerDependencies": { "@aws-sdk/client-s3": "^3.0.0", "@ditojs/build": "workspace:^", + "@simplewebauthn/server": "^14.0.0", "@vitejs/plugin-vue": "^6.0.0", "knex": "^3.3.0", "objection": "^3.3.0", @@ -87,6 +89,9 @@ "@ditojs/build": { "optional": true }, + "@simplewebauthn/server": { + "optional": true + }, "@vitejs/plugin-vue": { "optional": true }, @@ -96,6 +101,7 @@ }, "devDependencies": { "@aws-sdk/client-s3": "^3.1147.0", + "@simplewebauthn/server": "^14.0.3", "@types/koa-bodyparser": "^4.3.13", "@types/koa-compress": "^4.0.7", "@types/koa-mount": "^4.0.5", diff --git a/packages/server/src/app/Application.js b/packages/server/src/app/Application.js index 9bd3d2007..901c3209c 100644 --- a/packages/server/src/app/Application.js +++ b/packages/server/src/app/Application.js @@ -417,7 +417,7 @@ export class Application extends Koa { Object.values(this.controllers) .filter(controller => !controller.isInitialized) .map(async controller => { - controller.setup() + await controller.setup() await controller.initialize() // Each controller can also compose their own middleware (or app), // e.g. as used in `AdminController`: diff --git a/packages/server/src/controllers/UsersController.js b/packages/server/src/controllers/UsersController.js index bbf8de187..c7d75d8f3 100644 --- a/packages/server/src/controllers/UsersController.js +++ b/packages/server/src/controllers/UsersController.js @@ -1,5 +1,11 @@ +import aaguids from 'aaguids' import { ModelController } from './ModelController.js' -import { AuthorizationError } from '../errors/index.js' +import { + AuthenticationError, + AuthorizationError, + ControllerError, + NotFoundError +} from '../errors/index.js' export class UsersController extends ModelController { collection = { @@ -8,28 +14,19 @@ export class UsersController extends ModelController { 'authorize': { 'post login': true, 'post logout': true, - 'get session': true + 'get session': true, + 'post passkey-login-options': true, + 'post passkey-login': true, + // Users manage their own password and passkeys: + 'post password': ctx => this.isAuthenticated(ctx), + 'get passkeys': ctx => this.isAuthenticated(ctx), + 'post passkey-options': ctx => this.isAuthenticated(ctx), + 'post passkeys': ctx => this.isAuthenticated(ctx), + 'delete passkeys/:id': ctx => this.isAuthenticated(ctx) }, - async 'post login'(ctx) { - let user - let error - try { - user = await this.modelClass.login(ctx) - await user.$patch({ lastLogin: new Date() }, ctx.transaction) - } catch (err) { - this.app.emit('error', err, ctx) - user = null - error = err.data?.message || err.message - ctx.status = err.status || 401 - } - const success = !!user - return { - success, - authenticated: success && this.isAuthenticated(ctx), - user, - error - } + 'post login'(ctx) { + return this.handleLogin(ctx, () => this.modelClass.login(ctx)) }, async 'post logout'(ctx) { @@ -52,6 +49,30 @@ export class UsersController extends ModelController { } }, + 'post password': { + parameters: { + currentPassword: { type: 'string', required: true }, + password: { type: 'string', required: true } + }, + + async handler(ctx, { currentPassword, password }) { + const { user } = ctx.state + if (await user.$verifyPassword(currentPassword)) { + const { passwordProperty } = this.modelClass.definition.options + // The password property validates the password and sets its hash: + await user + .$query(ctx.transaction) + .patch({ [passwordProperty]: password }) + return { success: true } + } else { + throw new AuthenticationError({ + message: 'Password is incorrect', + status: 400 + }) + } + } + }, + 'get self'(ctx) { return this.isAuthenticated(ctx) ? this.member.get.call( @@ -59,6 +80,154 @@ export class UsersController extends ModelController { this.getContextWithMemberId(ctx, ctx.state.user.$id()) ) : null + }, + + async 'post passkey-login-options'(ctx) { + const { generateAuthenticationOptions } = this.webAuthn + // Passkeys are discoverable, so the browser offers them without a + // username: + const options = await generateAuthenticationOptions({ + rpID: this.passkeys.id, + userVerification: 'required' + }) + storeChallenge(ctx, options) + return options + }, + + 'post passkey-login'(ctx) { + return this.handleLogin(ctx, async () => { + const { verifyAuthenticationResponse } = this.webAuthn + const response = ctx.request.body + const challenge = consumeChallenge(ctx) + const credential = await this.modelClass + .credentialQuery(ctx.transaction) + .findOne({ type: 'passkey', key: response.id ?? null }) + const user = credential + ? await this.modelClass + .sessionQuery(ctx.transaction) + .findById(credential.userId) + : null + if (user) { + const { publicKey, counter, transports } = credential.data + const { verified, authenticationInfo } = + await verifyAuthenticationResponse({ + response, + expectedChallenge: challenge, + expectedOrigin: this.passkeys.origins, + expectedRPID: this.passkeys.id, + credential: { + id: credential.key, + publicKey: Buffer.from(publicKey, 'base64url'), + counter, + transports + } + }).catch(err => { + // Don't tell clients why, but keep the reason for the logs: + throw new AuthenticationError({ + message: 'Passkey is incorrect', + cause: err + }) + }) + if (verified) { + await credential.$patch( + { + data: { + ...credential.data, + counter: authenticationInfo.newCounter + }, + lastUsedAt: new Date() + }, + ctx.transaction + ) + await ctx.login(user) + return user + } + } + throw new AuthenticationError('Passkey is incorrect') + }) + }, + + 'get passkeys'(ctx) { + return getPasskeyQuery(this.modelClass, ctx).select( + 'id', + 'name', + 'createdAt', + 'lastUsedAt' + ) + }, + + async 'post passkey-options'(ctx) { + const { generateRegistrationOptions } = this.webAuthn + const { user } = ctx.state + const { usernameProperty } = this.modelClass.definition.options + const passkeys = await getPasskeyQuery(this.modelClass, ctx) + const options = await generateRegistrationOptions({ + rpName: this.passkeys.name, + rpID: this.passkeys.id, + userName: user[usernameProperty], + // Don't register the same authenticator twice: + excludeCredentials: passkeys.map(({ key, data }) => ({ + id: key, + transports: data.transports + })), + authenticatorSelection: { + residentKey: 'required', + userVerification: 'required' + } + }) + storeChallenge(ctx, options) + return options + }, + + async 'post passkeys'(ctx) { + const { verifyRegistrationResponse } = this.webAuthn + const { verified, registrationInfo } = await verifyRegistrationResponse({ + response: ctx.request.body, + expectedChallenge: consumeChallenge(ctx), + expectedOrigin: this.passkeys.origins, + expectedRPID: this.passkeys.id + }).catch(err => { + // Failed verifications are bad requests, not server errors: + throw new AuthenticationError({ message: err.message, status: 400 }) + }) + if (verified) { + const { credential, aaguid } = registrationInfo + // Name passkeys after their providers, e.g. '1Password'. Anonymous + // providers send an all-zero AAGUID, which isn't listed: + const name = aaguids[aaguid] ?? 'Passkey' + const { id, createdAt } = await this.modelClass.credentialClass + .query(ctx.transaction) + .insert({ + type: 'passkey', + userModel: this.modelClass.name, + userId: `${ctx.state.user.$id()}`, + key: credential.id, + name, + data: { + publicKey: Buffer.from(credential.publicKey).toString( + 'base64url' + ), + counter: credential.counter, + transports: credential.transports + } + }) + return { id, name, createdAt, lastUsedAt: null } + } else { + throw new AuthenticationError({ + message: 'Passkey is incorrect', + status: 400 + }) + } + }, + + async 'delete passkeys/:id'(ctx) { + const count = await getPasskeyQuery(this.modelClass, ctx) + .deleteById(ctx.params.id) + if (count > 0) { + return { success: true } + } else { + throw new NotFoundError() + } } } @@ -66,6 +235,71 @@ export class UsersController extends ModelController { authorize: ['$self'] } + // @override + setup() { + const { session, auth } = this.app.config.app + this.passkeys = auth?.passkeys ?? null + if (this.passkeys) { + const missingKey = ['name', 'id', 'origins'].find( + key => !this.passkeys[key] + ) + // Passkey challenges are kept in the session, stored on the server so + // that old cookies can't replay them, and passkeys are credentials: + if (!(session?.modelClass || session?.store || session?.ContextStore)) { + throw new ControllerError( + this, + `Unable to use passkeys without sessions stored on the server, ` + + `see 'app.session'.` + ) + } else if (!this.modelClass.credentialClass) { + throw new ControllerError( + this, + `Unable to use passkeys without credentials, see ` + + `'app.auth.credentials'.` + ) + } else if (missingKey) { + throw new ControllerError( + this, + `Invalid passkeys, missing 'app.auth.passkeys.${missingKey}'.` + ) + } + } + // Set up the routes before importing, so that sub-classes that don't await + // `super.setup()` still get them before `initialize()`, and configuration + // errors are thrown right away: + super.setup() + if (this.passkeys) { + // `@simplewebauthn/server` is an optional peer dependency, so we only + // import it when passkeys are used: + return import('@simplewebauthn/server').then( + webAuthn => { + this.webAuthn = webAuthn + }, + err => { + throw err.code === 'ERR_MODULE_NOT_FOUND' + ? new ControllerError( + this, + `Unable to use passkeys without '@simplewebauthn/server'.` + ) + : err + } + ) + } + } + + // @override + processValues(values) { + const processed = super.processValues(values) + // Without passkeys, we don't route their actions, which are all in + // `collection`: + if (!this.passkeys && values === this.collection) { + for (const name of passkeyActions) { + delete processed.values[name] + } + } + return processed + } + // @override getMemberId(ctx) { // `self` stands for the logged-in user in member routes, e.g. in @@ -87,4 +321,66 @@ export class UsersController extends ModelController { ctx.state.user.constructor === this.modelClass ) } + + async handleLogin(ctx, login) { + let user + let error + try { + user = await login() + await user.$patch({ lastLogin: new Date() }, ctx.transaction) + } catch (err) { + this.app.emit('error', err, ctx) + user = null + error = err.data?.message || err.message + ctx.status = err.status || 401 + } + const success = !!user + return { + success, + authenticated: success && this.isAuthenticated(ctx), + user, + error + } + } +} + +// Only routed with `app.auth.passkeys`, as they import the optional peer +// dependency `@simplewebauthn/server`: +const passkeyActions = [ + 'post passkey-login-options', + 'post passkey-login', + 'get passkeys', + 'post passkey-options', + 'post passkeys', + 'delete passkeys/:id' +] + +const challengeTimeout = 5 * 60 * 1000 // 5 minutes + +function getPasskeyQuery(modelClass, ctx) { + return modelClass.credentialQuery(ctx.transaction).where({ + type: 'passkey', + userId: `${ctx.state.user.$id()}` + }) +} + +function storeChallenge(ctx, { challenge }) { + ctx.session.passkeyChallenge = { + challenge, + expires: Date.now() + challengeTimeout + } +} + +// Each challenge can only be answered once, before it expires: +function consumeChallenge(ctx) { + const { challenge, expires } = ctx.session.passkeyChallenge ?? {} + delete ctx.session.passkeyChallenge + if (challenge && expires > Date.now()) { + return challenge + } else { + throw new AuthenticationError({ + message: 'Missing or expired passkey challenge', + status: 400 + }) + } } diff --git a/packages/server/src/mixins/CredentialMixin.js b/packages/server/src/mixins/CredentialMixin.js new file mode 100644 index 000000000..e0b07471e --- /dev/null +++ b/packages/server/src/mixins/CredentialMixin.js @@ -0,0 +1,62 @@ +import { mixin } from '@ditojs/utils' + +export const CredentialMixin = mixin( + Model => + class extends Model { + static properties = { + type: { + type: 'string', + required: true + }, + + // The credentials of all user models share one table, so they refer to + // their users by model name and id, without foreign keys: + userModel: { + type: 'string', + required: true, + unique: 'userModel_key' + }, + + userId: { + type: 'string', + required: true + }, + + // The credential id of passkeys, registered only once: + key: { + type: 'string', + nullable: true, + unique: 'userModel_key' + }, + + name: { + type: 'string', + nullable: true + }, + + data: { + type: 'object', + hidden: true + }, + + createdAt: { + type: 'timestamp', + default: 'now()' + }, + + lastUsedAt: { + type: 'timestamp', + nullable: true + } + } + + static hooks = { + 'before:insert'({ inputItems }) { + const now = new Date() + for (const item of inputItems) { + item.createdAt = now + } + } + } + } +) diff --git a/packages/server/src/mixins/UserMixin.js b/packages/server/src/mixins/UserMixin.js index 20aa530d2..c7bf6681b 100644 --- a/packages/server/src/mixins/UserMixin.js +++ b/packages/server/src/mixins/UserMixin.js @@ -1,5 +1,5 @@ import bcrypt from 'bcryptjs' -import { mixin, asArray } from '@ditojs/utils' +import { mixin, asArray, isString } from '@ditojs/utils' import { AuthenticationError } from '../errors/index.js' export const UserMixin = mixin( @@ -13,6 +13,9 @@ export const UserMixin = mixin( sessionScope: undefined } + // The model class of `app.auth.credentials`, set up in `setup()`: + static credentialClass = null + static get properties() { const { usernameProperty, @@ -42,6 +45,22 @@ export const UserMixin = mixin( } } + static hooks = { + // Credentials refer to their users without foreign keys, so we delete + // them along with their users: + async 'before:delete'({ asFindQuery, transaction }) { + if (this.credentialClass) { + const users = await asFindQuery().select( + this.getIdPropertyArray() + ) + const userIds = users.map(user => `${user.$id()}`) + await this.credentialQuery(transaction) + .whereIn('userId', userIds) + .delete() + } + } + } + async $verifyPassword(password) { // Users without a password, e.g. invited ones, can't log in: return this.hash ? bcrypt.compare(password, this.hash) : false @@ -75,6 +94,17 @@ export const UserMixin = mixin( configurable: true }) + const { modelClass } = this.app.config.app.auth?.credentials ?? {} + if (modelClass) { + this.credentialClass = isString(modelClass) + ? this.app.models[modelClass] + : modelClass + if (!this.credentialClass) { + throw new Error( + `Unable to find credential model class: '${modelClass}'.` + ) + } + } } static async login(ctx, options) { @@ -110,5 +140,15 @@ export const UserMixin = mixin( ...asArray(this.definition.options.sessionScope) ) } + + static credentialQuery(trx) { + if (this.credentialClass) { + return this.credentialClass.query(trx).where('userModel', this.name) + } else { + throw new Error( + `Unable to query credentials without 'app.auth.credentials'.` + ) + } + } } ) diff --git a/packages/server/src/mixins/index.js b/packages/server/src/mixins/index.js index 47e2638de..b2bffcdff 100644 --- a/packages/server/src/mixins/index.js +++ b/packages/server/src/mixins/index.js @@ -1,4 +1,5 @@ export * from './AssetMixin.js' +export * from './CredentialMixin.js' export * from './SessionMixin.js' export * from './TimestampedMixin.js' export * from './UserMixin.js' diff --git a/packages/server/src/models/CredentialModel.js b/packages/server/src/models/CredentialModel.js new file mode 100644 index 000000000..52d2cb751 --- /dev/null +++ b/packages/server/src/models/CredentialModel.js @@ -0,0 +1,4 @@ +import { CredentialMixin } from '../mixins/index.js' +import { Model } from './Model.js' + +export const CredentialModel = CredentialMixin(Model) diff --git a/packages/server/src/models/index.js b/packages/server/src/models/index.js index 790b0a440..a64f8d700 100644 --- a/packages/server/src/models/index.js +++ b/packages/server/src/models/index.js @@ -1,5 +1,6 @@ export * from './Model.js' export * from './AssetModel.js' +export * from './CredentialModel.js' export * from './SessionModel.js' export * from './TimestampedModel.js' export * from './UserModel.js' diff --git a/packages/server/types/index.d.ts b/packages/server/types/index.d.ts index 0d0e9e8f0..19904af04 100644 --- a/packages/server/types/index.d.ts +++ b/packages/server/types/index.d.ts @@ -197,6 +197,30 @@ export interface ApplicationConfig { * set. This setting has no effect. */ passport?: boolean + auth?: { + /** + * The model that stores the credentials of all user models, e.g. their + * passkeys. + */ + credentials?: { + /** + * The model class, or the name it was registered with, e.g. + * `Credential`. Its model uses `CredentialMixin`. + */ + modelClass: string | Class + } + /** + * Enables passkeys, which need `session` and `credentials`. + */ + passkeys?: { + /** The name of the app shown by authenticators. */ + name: string + /** The domain that the passkeys belong to, e.g. `'example.com'`. */ + id: string + /** The origins of the frontends that use the passkeys. */ + origins: string[] + } + } /** * Set signed cookie keys. * @@ -2405,8 +2429,11 @@ export class Controller { * @overridable */ configure(): void - /* @overridable */ - setup(): void + /** + * Sets up the routes of the actions and asset uploads. Internal: apps + * override `initialize()` instead. + */ + setup(): void | Promise /** * To be overridden in sub-classes, if the controller needs to initialize. * @overridable @@ -4330,6 +4357,28 @@ export const SessionMixin: >( export const SessionModel: ReturnType> +export const CredentialMixin: >( + target: T +) => T & + Constructor<{ + /** `'passkey'` */ + type: string + /** The name of the user's model class. */ + userModel: string + userId: string + /** The credential id of passkeys. */ + key: string | null + /** The label that the user gave the credential. */ + name: string | null + data: Record + createdAt: Date + lastUsedAt: Date | null + }> + +export const CredentialModel: ReturnType< + typeof CredentialMixin +> + export const UserMixin: >( target: T ) => T & diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 09d0cf392..e492b046e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -341,6 +341,9 @@ importers: '@kyvg/vue3-notification': specifier: ^3.4.2 version: 3.4.2(vue@3.5.43(typescript@6.0.3)) + '@simplewebauthn/browser': + specifier: ^14.0.0 + version: 14.0.0 '@tiptap/core': specifier: ^3.31.4 version: 3.31.4(@tiptap/pm@3.31.4) @@ -536,6 +539,9 @@ importers: '@vitejs/plugin-vue': specifier: ^6.0.0 version: 6.0.9(vite@8.3.4(@types/node@26.6.4)(sass@1.105.1)(yaml@2.9.1))(vue@3.5.43(typescript@6.0.3)) + aaguids: + specifier: ^0.0.71 + version: 0.0.71 ajv: specifier: ^8.20.0 version: 8.20.0 @@ -642,6 +648,9 @@ importers: '@aws-sdk/client-s3': specifier: ^3.1147.0 version: 3.1147.0 + '@simplewebauthn/server': + specifier: ^14.0.3 + version: 14.0.3 '@types/koa-bodyparser': specifier: ^4.3.13 version: 4.3.13 @@ -725,6 +734,9 @@ importers: '@playwright/test': specifier: ^1.64.0 version: 1.64.0 + '@simplewebauthn/server': + specifier: ^14.0.3 + version: 14.0.3 better-sqlite3: specifier: ^13.0.3 version: 13.0.3 @@ -1013,6 +1025,9 @@ packages: '@hapi/bourne@3.0.0': resolution: {integrity: sha512-Waj1cwPXJDucOib4a3bAISsKJVb15MKi9IvmTI/7ssVEm6sywXGjVJDhl6/umt1pK1ZS7PacXU3A1PmFKHEZ2w==} + '@hexagon/base64@1.1.28': + resolution: {integrity: sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==} + '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} @@ -1232,6 +1247,9 @@ packages: peerDependencies: vue: ^3.0.0 + '@levischuck/tiny-cbor@0.2.11': + resolution: {integrity: sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==} + '@napi-rs/wasm-runtime@0.2.4': resolution: {integrity: sha512-9zESzOO5aDByvhIAsOy9TbpZ0Ur2AJbUI7UT73kcUTS2mxAMHOBaa1st/jAymNoCtvrit99kkzT1FZuXVcgfIQ==} @@ -1543,6 +1561,65 @@ packages: resolution: {integrity: sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==} engines: {node: '>= 10.0.0'} + '@peculiar/asn1-android@2.11.0': + resolution: {integrity: sha512-llW/qjavL+tOxt5i9whUv4wsbsLpUwI6k/DrvKsdA/H7A/TB/6CosMFgvei28fcJ8ncD0c5lig1oBBlo4KGMCw==} + engines: {node: '>=14'} + + '@peculiar/asn1-asym-key@2.11.0': + resolution: {integrity: sha512-r2957WdSZle/Zt4t9og7OMQYXNJ9/3ANXVtFwLCDJuHORlBZ4AFmDt1Qee0CzyTbHQg4ZZApBGEz5/6cFsyCRg==} + engines: {node: '>=14'} + + '@peculiar/asn1-cms@2.11.0': + resolution: {integrity: sha512-SlAiDoT/Cd8qaNmbTt78gqyaEvu2fQd6ML6KWJkdjXGrgDKnHHrusVkfQUelkfinL2p665IIADb0JBixtT8W1g==} + engines: {node: '>=14'} + + '@peculiar/asn1-csr@2.11.0': + resolution: {integrity: sha512-M/ED0yJ05qMK+UADPKnIa8vhAeewOJALSxTAZB7RQ/batRHhIJZkwznCcWAbFO5saHJ1yEQ+m4lncEcG9RnQSg==} + engines: {node: '>=14'} + + '@peculiar/asn1-ecc@2.11.0': + resolution: {integrity: sha512-jj1alGEKev7/un+cI+exyVK9m31lmP7cCBMCZuCb0wkFQH2zmKmJzNiG4wqXFNzlcP8timHLnzf3lV3OxBpb/Q==} + engines: {node: '>=14'} + + '@peculiar/asn1-pfx@2.11.0': + resolution: {integrity: sha512-ykSAWRu8KkmzU8LRpk9ZQC3zuLmBEtoSMjIv9XHVamD5W7amqreV5wttKqnvtjWOzzSJQxA/HVT8/tHwpEEGSQ==} + engines: {node: '>=14'} + + '@peculiar/asn1-pkcs8@2.11.0': + resolution: {integrity: sha512-lragjkjQGnQ5luxC+sxoAfAO/jBiQ7hOEGlDKJUk7MC5zY3AzjB2DU0GyPUH1Sswdv10t7DurY0zylOYMLb88w==} + engines: {node: '>=14'} + + '@peculiar/asn1-pkcs9@2.11.0': + resolution: {integrity: sha512-gDdFjpqV0weJjgNUvueUVf4D8uVbpq+VbG0TsxLAY2QGljAkJyL9qwQ7avol9j0jaYSJ7Ey7v7RZp0M3GTcQSA==} + engines: {node: '>=14'} + + '@peculiar/asn1-rsa@2.11.0': + resolution: {integrity: sha512-S2sBJmbfI09Kzdt9cEhE6pDNjHpVTkURs+811+PxS8AvsTLxC4woMNRhxHYaIbClkkNfAVt0tZxJdINVfUmzuA==} + engines: {node: '>=14'} + + '@peculiar/asn1-schema@2.11.0': + resolution: {integrity: sha512-8xh4avo53/vXO6UEv9yaoJ83hY581geaLTygf61NPIXP5AeIZCPjJhpTaYU23IZkG+NpLN/C9FYzWp7hphIT9A==} + engines: {node: '>=14'} + + '@peculiar/asn1-x509-attr@2.11.0': + resolution: {integrity: sha512-Fgvnd9REmLxEj/4cbDP54dNUNUXkzUksXLqEHqs1mHBuSRjSTCUSPM/WQIZYSvrdG7ND9CX1AQtPoIBekhgQSA==} + engines: {node: '>=14'} + + '@peculiar/asn1-x509-post-quantum@2.11.0': + resolution: {integrity: sha512-K6EnquTX3xJ2G+uLPFH0jVkh7W8y6eTzoCxvSoXAj0sWS5uvVB2PEvpBPdzY4+ytaedUM3x9ZVgX0AZKb316ew==} + engines: {node: '>=14'} + + '@peculiar/asn1-x509@2.11.0': + resolution: {integrity: sha512-J1sHAwGNlk+8J+FeR1eLVc6Gq0T9f9ITc3HWyPYgJraoW6U8AJuQiWVJ3SCEpltfTdpOY7fHdHT5oKAsbK/jww==} + engines: {node: '>=14'} + + '@peculiar/utils@2.0.3': + resolution: {integrity: sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==} + + '@peculiar/x509@2.2.0': + resolution: {integrity: sha512-adpW58ySPQMOUPv8pTKT3RIyCFqfUFt8X05ukPdtkQToXDVr8dMtxuenik1yx8tNFTbpApWsM/Hi5SzlIx48+Q==} + engines: {node: '>=20.0.0'} + '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} @@ -1706,6 +1783,13 @@ packages: resolution: {integrity: sha512-fCTuZK4QBa+39Oz9l4OGfJfz+GpwCp3AqO7Zch3to99xHPgstVsRFpeQ8LNd2o1Gv8raL2mCFwiaHh7bFSp5DQ==} engines: {node: '>=22'} + '@simplewebauthn/browser@14.0.0': + resolution: {integrity: sha512-1odWVqeEBTl7lJ9zMKLEsmTlnyrDO5iRcTvfMKKk1WThUnp/i8JJdffdj2icP+tty159s4PgwE3BiMoEW9NFow==} + + '@simplewebauthn/server@14.0.3': + resolution: {integrity: sha512-D39/0fiDiYmGV+nraY9s63D1Ho2AX/waMLy22/9gUadTUuvbAnstfb4bpbHYbwZuV5ZLDYN1SmzDKuiHzKAA4Q==} + engines: {node: '>=20.0.0'} + '@sindresorhus/merge-streams@4.0.0': resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} @@ -2352,6 +2436,9 @@ packages: resolution: {integrity: sha512-nrUSn7hzt7J6JWgWGz78ZYI8wj+gdIJdk0Ynjpp8l+trkn58Uqsf6RYrYkEK+3X18EX+TNdtJI0WxAtc+L84SQ==} hasBin: true + aaguids@0.0.71: + resolution: {integrity: sha512-GytQF7X6eSfSAruwekTLLajT1COqN6qeoPPIE8kLxv9trTnYo6lN1BS1qL6/uBVjzLFo94cm1WkXOrkkFSBurg==} + abbrev@3.0.1: resolution: {integrity: sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==} engines: {node: ^18.17.0 || >=20.5.0} @@ -2453,6 +2540,10 @@ packages: asap@2.0.6: resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + asn1js@3.0.10: + resolution: {integrity: sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==} + engines: {node: '>=12.0.0'} + assert-never@1.4.0: resolution: {integrity: sha512-5oJg84os6NMQNl27T9LnZkvvqzvAnHu03ShCnoj6bsJwS7L8AO4lf+C/XjK/nvzEqQB744moC6V128RucQd1jA==} @@ -5049,6 +5140,13 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + pvtsutils@1.3.6: + resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==} + + pvutils@1.2.0: + resolution: {integrity: sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==} + engines: {node: '>=16.0.0'} + qified@0.10.1: resolution: {integrity: sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==} engines: {node: '>=20'} @@ -5097,6 +5195,9 @@ packages: resolution: {integrity: sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==} engines: {node: '>= 10.13.0'} + reflect-metadata@0.2.2: + resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} + reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} @@ -5632,6 +5733,9 @@ packages: resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==} engines: {node: '>=6'} + tslib@1.14.1: + resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -5639,6 +5743,10 @@ packages: resolution: {integrity: sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==} engines: {node: '>=0.6.x'} + tsyringe@4.10.0: + resolution: {integrity: sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==} + engines: {node: '>= 6.0.0'} + tuf-js@4.1.0: resolution: {integrity: sha512-50QV99kCKH5P/Vs4E2Gzp7BopNV+KzTXqWeaxrfu5IQJBOULRsTIS9seSsOVT8ZnGXzCyx55nYWAi4qJzpZKEQ==} engines: {node: ^20.17.0 || >=22.9.0} @@ -6493,6 +6601,8 @@ snapshots: '@hapi/bourne@3.0.0': {} + '@hexagon/base64@1.1.28': {} + '@humanfs/core@0.19.2': dependencies: '@humanfs/types': 0.15.0 @@ -6692,6 +6802,8 @@ snapshots: dependencies: vue: 3.5.43(typescript@6.0.3) + '@levischuck/tiny-cbor@0.2.11': {} + '@napi-rs/wasm-runtime@0.2.4': dependencies: '@emnapi/core': 1.4.5 @@ -7030,6 +7142,122 @@ snapshots: '@parcel/watcher-win32-x64': 2.6.0 optional: true + '@peculiar/asn1-android@2.11.0': + dependencies: + '@peculiar/asn1-schema': 2.11.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-asym-key@2.11.0': + dependencies: + '@peculiar/asn1-pkcs8': 2.11.0 + '@peculiar/asn1-schema': 2.11.0 + '@peculiar/asn1-x509': 2.11.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-cms@2.11.0': + dependencies: + '@peculiar/asn1-schema': 2.11.0 + '@peculiar/asn1-x509': 2.11.0 + '@peculiar/asn1-x509-attr': 2.11.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-csr@2.11.0': + dependencies: + '@peculiar/asn1-schema': 2.11.0 + '@peculiar/asn1-x509': 2.11.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-ecc@2.11.0': + dependencies: + '@peculiar/asn1-schema': 2.11.0 + '@peculiar/asn1-x509': 2.11.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-pfx@2.11.0': + dependencies: + '@peculiar/asn1-cms': 2.11.0 + '@peculiar/asn1-pkcs8': 2.11.0 + '@peculiar/asn1-rsa': 2.11.0 + '@peculiar/asn1-schema': 2.11.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-pkcs8@2.11.0': + dependencies: + '@peculiar/asn1-schema': 2.11.0 + '@peculiar/asn1-x509': 2.11.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-pkcs9@2.11.0': + dependencies: + '@peculiar/asn1-cms': 2.11.0 + '@peculiar/asn1-pfx': 2.11.0 + '@peculiar/asn1-pkcs8': 2.11.0 + '@peculiar/asn1-schema': 2.11.0 + '@peculiar/asn1-x509': 2.11.0 + '@peculiar/asn1-x509-attr': 2.11.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-rsa@2.11.0': + dependencies: + '@peculiar/asn1-schema': 2.11.0 + '@peculiar/asn1-x509': 2.11.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-schema@2.11.0': + dependencies: + '@peculiar/utils': 2.0.3 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-x509-attr@2.11.0': + dependencies: + '@peculiar/asn1-schema': 2.11.0 + '@peculiar/asn1-x509': 2.11.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-x509-post-quantum@2.11.0': + dependencies: + '@peculiar/asn1-asym-key': 2.11.0 + '@peculiar/asn1-schema': 2.11.0 + '@peculiar/asn1-x509': 2.11.0 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-x509@2.11.0': + dependencies: + '@peculiar/asn1-schema': 2.11.0 + '@peculiar/utils': 2.0.3 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/utils@2.0.3': + dependencies: + tslib: 2.8.1 + + '@peculiar/x509@2.2.0': + dependencies: + '@peculiar/asn1-cms': 2.11.0 + '@peculiar/asn1-csr': 2.11.0 + '@peculiar/asn1-ecc': 2.11.0 + '@peculiar/asn1-pkcs9': 2.11.0 + '@peculiar/asn1-rsa': 2.11.0 + '@peculiar/asn1-schema': 2.11.0 + '@peculiar/asn1-x509': 2.11.0 + '@peculiar/asn1-x509-post-quantum': 2.11.0 + pvtsutils: 1.3.6 + tslib: 2.8.1 + tsyringe: 4.10.0 + '@pinojs/redact@0.4.0': {} '@pkgr/core@0.3.6': {} @@ -7139,6 +7367,21 @@ snapshots: '@simple-libs/stream-utils@2.0.0': {} + '@simplewebauthn/browser@14.0.0': {} + + '@simplewebauthn/server@14.0.3': + dependencies: + '@hexagon/base64': 1.1.28 + '@levischuck/tiny-cbor': 0.2.11 + '@peculiar/asn1-android': 2.11.0 + '@peculiar/asn1-ecc': 2.11.0 + '@peculiar/asn1-rsa': 2.11.0 + '@peculiar/asn1-schema': 2.11.0 + '@peculiar/asn1-x509': 2.11.0 + '@peculiar/asn1-x509-post-quantum': 2.11.0 + '@peculiar/x509': 2.2.0 + reflect-metadata: 0.2.2 + '@sindresorhus/merge-streams@4.0.0': {} '@smithy/core@3.35.1': @@ -7782,6 +8025,8 @@ snapshots: dependencies: argparse: 2.0.1 + aaguids@0.0.71: {} + abbrev@3.0.1: {} abbrev@4.0.0: {} @@ -7892,6 +8137,12 @@ snapshots: asap@2.0.6: {} + asn1js@3.0.10: + dependencies: + pvtsutils: 1.3.6 + pvutils: 1.2.0 + tslib: 2.8.1 + assert-never@1.4.0: {} assertion-error@2.0.1: {} @@ -10847,6 +11098,12 @@ snapshots: punycode@2.3.1: {} + pvtsutils@1.3.6: + dependencies: + tslib: 2.8.1 + + pvutils@1.2.0: {} + qified@0.10.1: dependencies: hookified: 2.2.0 @@ -10891,6 +11148,8 @@ snapshots: dependencies: resolve: 1.22.13 + reflect-metadata@0.2.2: {} + reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.9 @@ -11489,10 +11748,16 @@ snapshots: minimist: 1.2.8 strip-bom: 3.0.0 + tslib@1.14.1: {} + tslib@2.8.1: {} tsscmp@1.0.6: {} + tsyringe@4.10.0: + dependencies: + tslib: 1.14.1 + tuf-js@4.1.0(supports-color@7.2.0): dependencies: '@tufjs/models': 4.1.0 diff --git a/tests/e2e/passkeys/admin/__screenshots__/change-password.png b/tests/e2e/passkeys/admin/__screenshots__/change-password.png new file mode 100644 index 000000000..daa490bb1 Binary files /dev/null and b/tests/e2e/passkeys/admin/__screenshots__/change-password.png differ diff --git a/tests/e2e/passkeys/admin/__screenshots__/delete-passkey.png b/tests/e2e/passkeys/admin/__screenshots__/delete-passkey.png new file mode 100644 index 000000000..65300550c Binary files /dev/null and b/tests/e2e/passkeys/admin/__screenshots__/delete-passkey.png differ diff --git a/tests/e2e/passkeys/admin/__screenshots__/login-passkeys.png b/tests/e2e/passkeys/admin/__screenshots__/login-passkeys.png new file mode 100644 index 000000000..89229ff45 Binary files /dev/null and b/tests/e2e/passkeys/admin/__screenshots__/login-passkeys.png differ diff --git a/tests/e2e/passkeys/admin/__screenshots__/security-empty.png b/tests/e2e/passkeys/admin/__screenshots__/security-empty.png new file mode 100644 index 000000000..bf28c643c Binary files /dev/null and b/tests/e2e/passkeys/admin/__screenshots__/security-empty.png differ diff --git a/tests/e2e/passkeys/admin/__screenshots__/security-passkeys.png b/tests/e2e/passkeys/admin/__screenshots__/security-passkeys.png new file mode 100644 index 000000000..56e6d7ed3 Binary files /dev/null and b/tests/e2e/passkeys/admin/__screenshots__/security-passkeys.png differ diff --git a/tests/e2e/passkeys/admin/index.spec.ts b/tests/e2e/passkeys/admin/index.spec.ts new file mode 100644 index 000000000..ba087f1f8 --- /dev/null +++ b/tests/e2e/passkeys/admin/index.spec.ts @@ -0,0 +1,102 @@ +import type { Page } from '@playwright/test' +import { test, expect, getLocalUrl } from '../fixtures.js' +import { Credential } from '../models/Credential.js' +import { User } from '../models/User.js' + +// Gives the browser a passkey authenticator that verifies the user, like a +// fingerprint reader, through the Chrome DevTools Protocol: +async function addVirtualAuthenticator(page: Page) { + const client = await page.context().newCDPSession(page) + await client.send('WebAuthn.enable') + await client.send('WebAuthn.addVirtualAuthenticator', { + options: { + protocol: 'ctap2', + transport: 'internal', + hasResidentKey: true, + hasUserVerification: true, + isUserVerified: true, + automaticPresenceSimulation: true + } + }) +} + +async function login(page: Page, url: string, password: string) { + await page.goto(`${getLocalUrl(url)}/admin/`) + const dialog = page.getByRole('dialog') + await dialog.getByLabel('Username').fill('ada') + await dialog.getByLabel('Password').fill(password) + await dialog.getByRole('button', { name: 'Sign In', exact: true }).click() + await expect(getAccountButton(page)).toHaveText('ada') +} + +function getAccountButton(page: Page) { + return page.locator('.dito-account').getByRole('button') +} + +async function openSecurity(page: Page) { + await getAccountButton(page).click() + await page.getByRole('menuitem', { name: 'Security' }).click() + return page.getByRole('dialog', { name: 'Security' }) +} + +test.describe('passkeys', () => { + test('add, log in with and delete a passkey', async ({ page, url }) => { + await addVirtualAuthenticator(page) + // Log in with the password first: + await login(page, url, 'secret') + + // Add a passkey in the security dialog, named by the server after its + // provider, which the virtual authenticator doesn't have: + const security = await openSecurity(page) + await expect(security.locator('tbody tr')).toHaveText('No passkeys yet.') + await security.getByRole('button', { name: 'Add Passkey' }).click() + await expect(security.locator('tbody tr')).toContainText('Passkey') + await expect(security.locator('tbody tr')).toContainText('Never') + await security.getByRole('button', { name: 'Close' }).click() + + // Log out, and in again with the passkey: + await getAccountButton(page).click() + await page.getByRole('menuitem', { name: 'Sign Out' }).click() + await page.locator('button.dito-login').click() + await page.getByRole('button', { name: 'Sign In with Passkey' }).click() + await expect(getAccountButton(page)).toHaveText('ada') + const [credential] = await Credential.query() + expect(credential.lastUsedAt).toBeInstanceOf(Date) + + // Delete the passkey: + const dialog = await openSecurity(page) + await expect(dialog.locator('tbody tr')).not.toContainText('Never') + await dialog.getByRole('button', { name: 'Delete' }).click() + await page + .getByRole('dialog', { + name: 'Do you really want to delete the passkey?' + }) + .getByRole('button', { name: 'Delete' }) + .click() + await expect(dialog.locator('tbody tr')).toHaveText('No passkeys yet.') + expect(await Credential.query()).toEqual([]) + }) + + test('change the password', async ({ page, browser, url }) => { + await login(page, url, 'secret') + const security = await openSecurity(page) + await security.getByRole('button', { name: 'Change', exact: true }).click() + const dialog = page.getByRole('dialog', { name: 'Change Password' }) + await dialog.getByLabel('Current Password').fill('secret') + await dialog.getByLabel('New Password', { exact: true }).fill('changed') + await dialog.getByLabel('Confirm New Password').fill('changed') + await dialog.getByRole('button', { name: 'Change Password' }).click() + await expect(dialog).toBeHidden() + const user = await User.query().findOne({ username: 'ada' }) + expect(await user!.$verifyPassword('changed')).toBe(true) + + // The new password works for a fresh login: + const context = await browser.newContext() + try { + const freshPage = await context.newPage() + await login(freshPage, url, 'changed') + } finally { + await context.close() + } + }) +}) diff --git a/tests/e2e/passkeys/admin/security.screenshot.ts b/tests/e2e/passkeys/admin/security.screenshot.ts new file mode 100644 index 000000000..c5c8d088a --- /dev/null +++ b/tests/e2e/passkeys/admin/security.screenshot.ts @@ -0,0 +1,97 @@ +import type { Page } from '@playwright/test' +import { test, expect } from '../fixtures.js' +import { expectScreenshot } from '../../screenshots/scenes.js' +import { Credential } from '../models/Credential.js' +import { User } from '../models/User.js' + +// Screenshots of the login and Security dialogs, compared against the +// baselines in `__screenshots__`, see `pnpm -C tests screenshots`. + +async function login(page: Page, url: string) { + await page.goto(`${url}/admin/`) + const dialog = page.getByRole('dialog') + await dialog.getByLabel('Username').fill('ada') + await dialog.getByLabel('Password').fill('secret') + await dialog.getByRole('button', { name: 'Sign In', exact: true }).click() + await expect(page.locator('.dito-account')).toHaveText('ada') +} + +async function openSecurity(page: Page) { + await page.locator('.dito-account').getByRole('button').click() + await page.getByRole('menuitem', { name: 'Security' }).click() + const dialog = page.getByRole('dialog', { name: 'Security' }) + await expect( + dialog.getByRole('button', { name: 'Add Passkey' }) + ).toBeVisible() + return dialog +} + +// Passkeys with fixed dates, so that the screenshots stay the same: +async function addPasskeys() { + const user = await User.query().findOne({ username: 'ada' }) + const passkeys = [ + { name: '1Password', createdAt: '2026-03-02', lastUsedAt: '2026-05-14' }, + { name: 'iCloud Keychain', createdAt: '2026-04-20', lastUsedAt: null } + ] + for (const [index, { createdAt, ...passkey }] of passkeys.entries()) { + const { id } = await Credential.query().insert({ + ...passkey, + type: 'passkey', + userModel: 'User', + userId: `${user!.id}`, + key: `key-${index}`, + data: {} + } as any) + // The server sets `createdAt` on insert: + await Credential.query() + .findById(id) + .patch({ createdAt } as any) + } +} + +test.describe('screenshots', () => { + test('login dialog with passkeys', async ({ page, url }) => { + await page.goto(`${url}/admin/`) + await expect( + page.getByRole('button', { name: 'Sign In with Passkey' }) + ).toBeVisible() + await expectScreenshot(page, 'login-passkeys.png') + }) + + test('security dialog without passkeys', async ({ page, url }) => { + await login(page, url) + const dialog = await openSecurity(page) + await expect(dialog.locator('tbody tr')).toHaveText('No passkeys yet.') + await expectScreenshot(page, 'security-empty.png') + }) + + test('security dialog with passkeys', async ({ page, url }) => { + await addPasskeys() + await login(page, url) + const dialog = await openSecurity(page) + await expect(dialog.locator('tbody tr')).toHaveCount(2) + await expectScreenshot(page, 'security-passkeys.png') + }) + + test('delete passkey confirmation', async ({ page, url }) => { + await addPasskeys() + await login(page, url) + const security = await openSecurity(page) + await security.getByRole('button', { name: 'Delete' }).first().click() + await expect( + page.getByRole('dialog', { + name: 'Do you really want to delete the 1Password passkey?' + }) + ).toBeVisible() + await expectScreenshot(page, 'delete-passkey.png') + }) + + test('change password dialog', async ({ page, url }) => { + await login(page, url) + const security = await openSecurity(page) + await security.getByRole('button', { name: 'Change', exact: true }).click() + const dialog = page.getByRole('dialog', { name: 'Change Password' }) + await expect(dialog.getByLabel('Current Password')).toBeFocused() + await expectScreenshot(page, 'change-password.png') + }) +}) diff --git a/tests/e2e/passkeys/fixtures.ts b/tests/e2e/passkeys/fixtures.ts new file mode 100644 index 000000000..7eeeb9542 --- /dev/null +++ b/tests/e2e/passkeys/fixtures.ts @@ -0,0 +1,56 @@ +import { UsersController } from '@ditojs/server' +import { getAppUrl } from '../../utils/app.js' +import { defineScenario } from '../../utils/fixture-app.js' +import { Credential } from './models/Credential.js' +import { Session } from './models/Session.js' +import { User } from './models/User.js' + +export { expect } from '@playwright/test' + +class Users extends UsersController { + override modelClass = User +} + +// Browsers only allow passkeys in secure contexts, so the admin runs on +// `localhost` instead of the loopback address of the other scenarios: +export const getLocalUrl = (url: string) => + url.replace('//127.0.0.1:', '//localhost:') + +const passkeys = { + name: 'Example', + id: 'localhost', + origins: [] as string[] +} + +export const test = defineScenario({ + dirname: import.meta.dirname, + models: { Session, User, Credential }, + controllers: { Users }, + api: { users: 'users' }, + config: { + app: { + normalizePaths: true, + helmet: false, + keys: ['passkeys-secret'], + // Passkeys need sessions stored on the server: + session: { modelClass: 'Session' }, + auth: { + credentials: { modelClass: 'Credential' }, + passkeys + } + } + }, + setup: async app => { + // The computed `password` property isn't stored in the database: + await app.knex.schema.alterTable('User', table => + table.dropColumn('password') + ) + // The origin's port is only known once the app listens: + app.on('after:start', () => { + passkeys.origins.push(getLocalUrl(getAppUrl(app))) + }) + }, + beforeEach: async () => { + await User.query().insert({ username: 'ada', password: 'secret' } as any) + } +}) diff --git a/tests/e2e/passkeys/models/Credential.ts b/tests/e2e/passkeys/models/Credential.ts new file mode 100644 index 000000000..6747d534b --- /dev/null +++ b/tests/e2e/passkeys/models/Credential.ts @@ -0,0 +1,7 @@ +import { CredentialModel } from '@ditojs/server' + +export class Credential extends CredentialModel { + declare id: number + declare name: string | null + declare userId: string +} diff --git a/tests/e2e/passkeys/models/Session.ts b/tests/e2e/passkeys/models/Session.ts new file mode 100644 index 000000000..13d0d5f2b --- /dev/null +++ b/tests/e2e/passkeys/models/Session.ts @@ -0,0 +1,3 @@ +import { SessionModel } from '@ditojs/server' + +export class Session extends SessionModel {} diff --git a/tests/e2e/passkeys/models/User.ts b/tests/e2e/passkeys/models/User.ts new file mode 100644 index 000000000..98c13c69b --- /dev/null +++ b/tests/e2e/passkeys/models/User.ts @@ -0,0 +1,6 @@ +import { UserModel } from '@ditojs/server' + +export class User extends UserModel { + declare id: number + declare username: string +} diff --git a/tests/e2e/passkeys/views.ts b/tests/e2e/passkeys/views.ts new file mode 100644 index 000000000..469c6d28d --- /dev/null +++ b/tests/e2e/passkeys/views.ts @@ -0,0 +1,9 @@ +export const dashboard = { + type: 'view', + label: 'Dashboard', + components: {} +} as const + +export const login = { + passkeys: true +} as const diff --git a/tests/package.json b/tests/package.json index 59aa0cdd2..b4f1280ad 100644 --- a/tests/package.json +++ b/tests/package.json @@ -21,6 +21,7 @@ }, "devDependencies": { "@playwright/test": "^1.64.0", + "@simplewebauthn/server": "^14.0.3", "better-sqlite3": "^13.0.3", "knex": "^3.3.0", "objection": "^3.3.0", diff --git a/tests/server/passkeys.test.ts b/tests/server/passkeys.test.ts new file mode 100644 index 000000000..df68c903b --- /dev/null +++ b/tests/server/passkeys.test.ts @@ -0,0 +1,600 @@ +import { + CredentialModel, + SessionModel, + UserModel, + UsersController +} from '@ditojs/server' +import { createTestApp, getAppUrl } from '../utils/app.js' +import { createAuthenticator } from '../utils/authenticator.js' +import { createClient } from '../utils/client.js' +import { createTestDatabase } from '../utils/database.js' +import { isSQLiteTest } from '../utils/test-knex.js' + +const loginEvents: string[] = [] + +class Session extends SessionModel { + declare value: Record +} + +class Credential extends CredentialModel { + declare id: number + declare type: string + declare userModel: string + declare userId: string + declare key: string | null + declare name: string | null + declare data: Record + declare lastUsedAt: Date | null +} + +class Member extends UserModel { + declare id: number + declare username: string + + static override options = { + sessionScope: 'current' + } + + static override scopes = { + current: (query: any) => query.whereNot('username', 'retired') + } + + static override hooks: any = { + 'after:login'(member: Member) { + loginEvents.push(`after login ${member.username}`) + } + } +} + +// Another user model, whose ids overlap with those of members: +class Guest extends UserModel { + declare id: number +} + +class Members extends UsersController { + override modelClass = Member +} + +class Guests extends UsersController { + override modelClass = Guest +} + +const passkeys = { + name: 'Example', + id: 'example.com', + origins: ['https://example.com'] +} + +describe('Passkeys', () => { + const app = createTestApp({ + config: { + app: { + normalizePaths: true, + helmet: false, + keys: ['test-secret'], + session: { modelClass: 'Session' }, + auth: { + credentials: { modelClass: 'Credential' }, + passkeys + } + } + }, + models: { Session, Credential, Member, Guest }, + controllers: { Members, Guests } + }) + + let url: string + let member: Member + + const loginWithPassword = async (username = 'ada') => { + const request = createClient(url) + const login = await request('/members/login', { + method: 'POST', + body: { username, password: 'secret' } + }) + expect(login.data.success).toBe(true) + return request + } + + const registerPasskey = async ( + request: ReturnType, + authenticator = createAuthenticator({ + origin: 'https://example.com', + rpId: 'example.com' + }) + ) => { + const options = await request('/members/passkey-options', { + method: 'POST' + }) + expect(options.status).toBe(200) + const passkey = await request('/members/passkeys', { + method: 'POST', + body: authenticator.register(options.data) + }) + expect(passkey.status).toBe(200) + return { authenticator, passkey: passkey.data } + } + + const loginWithPasskey = async ( + authenticator: ReturnType, + path = '/members' + ) => { + const request = createClient(url) + const options = await request(`${path}/passkey-login-options`, { + method: 'POST' + }) + const login = await request(`${path}/passkey-login`, { + method: 'POST', + body: authenticator.authenticate(options.data) + }) + return { request, login } + } + + beforeAll(async () => { + await createTestDatabase(app, Session, Member, Guest, Credential) + for (const table of ['Member', 'Guest']) { + await app.knex.schema.alterTable(table, table => { + table.dropColumn('password') + }) + } + await app.start() + url = getAppUrl(app) + member = await Member.query().insert({ + username: 'ada', + password: 'secret' + } as any) + }) + + afterAll(async () => { + await app.stop() + await app.knex.destroy() + }) + + afterEach(async () => { + loginEvents.length = 0 + await app.knex('Session').del() + await app.knex('Credential').del() + }) + + it('registers passkeys of the logged-in user', async () => { + const request = await loginWithPassword() + const { authenticator, passkey } = await registerPasskey(request) + expect(passkey).toEqual({ + id: expect.any(Number), + name: 'Passkey', + createdAt: expect.any(String), + lastUsedAt: null + }) + const [credential] = await Credential.query() + expect(credential).toMatchObject({ + type: 'passkey', + userModel: 'Member', + userId: `${member.id}`, + key: authenticator.id, + name: 'Passkey', + data: { counter: 0, transports: ['internal'] } + }) + expect(credential.data.publicKey).toEqual(expect.any(String)) + }) + + it('names passkeys after their providers', async () => { + const request = await loginWithPassword() + const { passkey } = await registerPasskey( + request, + createAuthenticator({ + origin: 'https://example.com', + rpId: 'example.com', + aaguid: 'bada5566-a7aa-401f-bd96-45619a55120d' + }) + ) + expect(passkey.name).toBe('1Password') + // Unknown providers are named like anonymous ones: + const { passkey: unknown } = await registerPasskey( + request, + createAuthenticator({ + origin: 'https://example.com', + rpId: 'example.com', + aaguid: '01020304-0506-0708-0102-030405060708' + }) + ) + expect(unknown.name).toBe('Passkey') + }) + + it('lists the passkeys without their keys', async () => { + const request = await loginWithPassword() + const { passkey } = await registerPasskey(request) + const list = await request('/members/passkeys') + expect(list.data).toEqual([passkey]) + }) + + it(`excludes the user's passkeys from new registrations`, async () => { + const request = await loginWithPassword() + const { authenticator } = await registerPasskey(request) + const options = await request('/members/passkey-options', { + method: 'POST' + }) + expect(options.data).toMatchObject({ + rp: { name: 'Example', id: 'example.com' }, + user: { name: 'ada' }, + excludeCredentials: [{ id: authenticator.id, transports: ['internal'] }] + }) + }) + + it('logs in with passkeys', async () => { + const { authenticator } = await registerPasskey(await loginWithPassword()) + loginEvents.length = 0 + const { request, login } = await loginWithPasskey(authenticator) + expect(login.status).toBe(200) + expect(login.data).toMatchObject({ + success: true, + authenticated: true, + user: { id: member.id, username: 'ada' } + }) + expect(login.data.user).not.toHaveProperty('hash') + expect(loginEvents).toEqual(['after login ada']) + expect((await request('/members/session')).data).toMatchObject({ + authenticated: true, + user: { id: member.id } + }) + const [credential] = await Credential.query() + expect(credential.data.counter).toBe(1) + expect(credential.lastUsedAt).toBeInstanceOf(Date) + const updated = await Member.query().findById(member.id) + expect(updated!.lastLogin).toBeInstanceOf(Date) + }) + + it('updates the counter with each login and rejects replays', async () => { + const { authenticator } = await registerPasskey(await loginWithPassword()) + await loginWithPasskey(authenticator) + await loginWithPasskey(authenticator) + const [credential] = await Credential.query() + expect(credential.data.counter).toBe(2) + + // A cloned authenticator answers with an earlier counter: + authenticator.counter = 1 + const { login } = await loginWithPasskey(authenticator) + expect(login.status).toBe(401) + expect(login.data.success).toBe(false) + }) + + it('rejects logins that answer another challenge', async () => { + const { authenticator } = await registerPasskey(await loginWithPassword()) + const request = createClient(url) + await request('/members/passkey-login-options', { method: 'POST' }) + const login = await request('/members/passkey-login', { + method: 'POST', + body: authenticator.authenticate({ challenge: 'd3Jvbmc' }) + }) + expect(login.status).toBe(401) + // Clients don't learn why passkeys fail: + expect(login.data).toMatchObject({ + success: false, + authenticated: false, + error: 'Passkey is incorrect' + }) + expect((await request('/members/session')).data.authenticated).toBe(false) + }) + + it('answers each challenge only once', async () => { + const { authenticator } = await registerPasskey(await loginWithPassword()) + const request = createClient(url) + const options = await request('/members/passkey-login-options', { + method: 'POST' + }) + const body = authenticator.authenticate(options.data) + await request('/members/passkey-login', { method: 'POST', body }) + const login = await request('/members/passkey-login', { + method: 'POST', + body + }) + expect(login.status).toBe(400) + expect(login.data.error).toBe('Missing or expired passkey challenge') + }) + + it('rejects expired challenges', async () => { + const { authenticator } = await registerPasskey(await loginWithPassword()) + const request = createClient(url) + const options = await request('/members/passkey-login-options', { + method: 'POST' + }) + // Let the challenge of the stored session expire: + const session = (await Session.query()).find( + session => session.value.passkeyChallenge + )! + await session.$query().patch({ + value: { + ...session.value, + passkeyChallenge: { + ...session.value.passkeyChallenge, + expires: Date.now() - 1 + } + } + } as any) + const login = await request('/members/passkey-login', { + method: 'POST', + body: authenticator.authenticate(options.data) + }) + expect(login.status).toBe(400) + expect(login.data.error).toBe('Missing or expired passkey challenge') + }) + + it('rejects passkeys that are already registered', async () => { + const other = await Member.query().insert({ + username: 'bo', + password: 'secret' + } as any) + try { + const { authenticator } = await registerPasskey(await loginWithPassword()) + const request = await loginWithPassword('bo') + const options = await request('/members/passkey-options', { + method: 'POST' + }) + const passkey = await request('/members/passkeys', { + method: 'POST', + body: authenticator.register(options.data) + }) + // db-errors misses the unique violations of better-sqlite3: + expect(passkey.status).toBe(isSQLiteTest ? 500 : 409) + const credentials = await Credential.query() + expect(credentials.map(({ userId }) => userId)).toEqual([ + `${member.id}` + ]) + } finally { + await Member.query().deleteById(other.id) + } + }) + + it('rejects registrations that answer another challenge', async () => { + const request = await loginWithPassword() + await request('/members/passkey-options', { method: 'POST' }) + const authenticator = createAuthenticator({ + origin: 'https://example.com', + rpId: 'example.com' + }) + const passkey = await request('/members/passkeys', { + method: 'POST', + body: authenticator.register({ challenge: 'd3Jvbmc' }) + }) + expect(passkey.status).toBe(400) + expect(await Credential.query()).toEqual([]) + }) + + it('rejects passkeys of other user models', async () => { + const { authenticator } = await registerPasskey(await loginWithPassword()) + const guest = await Guest.query().insert({ username: 'ada' } as any) + try { + expect(guest.id).toBe(member.id) + const { login } = await loginWithPasskey(authenticator, '/guests') + expect(login.status).toBe(401) + expect(login.data).toMatchObject({ + success: false, + error: 'Passkey is incorrect' + }) + } finally { + await Guest.query().deleteById(guest.id) + } + }) + + it('rejects passkeys of users excluded by the session scope', async () => { + const other = await Member.query().insert({ + username: 'bo', + password: 'secret' + } as any) + try { + const { authenticator } = await registerPasskey( + await loginWithPassword('bo') + ) + await Member.query().patchById(other.id, { username: 'retired' }) + const { login } = await loginWithPasskey(authenticator) + expect(login.status).toBe(401) + // The passkey stays unused: + const [credential] = await Credential.query() + expect(credential.data.counter).toBe(0) + expect(credential.lastUsedAt).toBeNull() + } finally { + await Member.query().deleteById(other.id) + } + }) + + it('only lets logged-in users manage their passkeys', async () => { + const request = createClient(url) + expect((await request('/members/passkeys')).status).toBe(401) + for (const path of ['passkey-options', 'passkeys']) { + const response = await request(`/members/${path}`, { method: 'POST' }) + expect(response.status).toBe(401) + } + const remove = await request('/members/passkeys/1', { method: 'DELETE' }) + expect(remove.status).toBe(401) + }) + + it('deletes only the own passkeys', async () => { + const request = await loginWithPassword() + const { passkey } = await registerPasskey(request) + const other = await Credential.query().insert({ + type: 'passkey', + userModel: 'Member', + userId: `${member.id + 1}`, + key: 'other', + data: {} + }) + const guest = await Credential.query().insert({ + type: 'passkey', + userModel: 'Guest', + userId: `${member.id}`, + key: 'guest', + data: {} + }) + for (const { id } of [other, guest]) { + const response = await request(`/members/passkeys/${id}`, { + method: 'DELETE' + }) + expect(response.status).toBe(404) + } + const response = await request(`/members/passkeys/${passkey.id}`, { + method: 'DELETE' + }) + expect(response.data).toEqual({ success: true }) + expect((await Credential.query()).map(({ key }) => key).sort()).toEqual([ + 'guest', + 'other' + ]) + }) + + it('deletes the credentials of deleted users', async () => { + const other = await Member.query().insert({ + username: 'bo', + password: 'secret' + } as any) + await registerPasskey(await loginWithPassword('bo')) + await registerPasskey(await loginWithPassword()) + // A guest with the same id keeps its credentials: + await Credential.query().insert({ + type: 'passkey', + userModel: 'Guest', + userId: `${other.id}`, + key: 'guest', + data: {} + }) + await Member.query().where('username', 'bo').delete() + const credentials = await Credential.query().orderBy('userModel') + expect( + credentials.map(({ userModel, userId }) => `${userModel}-${userId}`) + ).toEqual([`Guest-${other.id}`, `Member-${member.id}`]) + }) +}) + +describe('Passkeys turned off', () => { + // Models are only set up once, so the app needs its own user model: + class User extends UserModel {} + class Users extends UsersController { + override modelClass = User + // A member action of the app, named like a passkey action: + override member: any = { + 'get passkeys'() { + return [] + } + } + } + + const app = createTestApp({ + config: { + app: { normalizePaths: true, keys: ['test-secret'], session: true } + }, + models: { User }, + controllers: { Users } + }) + + afterAll(() => app.knex.destroy()) + + it(`doesn't route the passkey actions`, async () => { + await app.setup() + const getHandler = (method: string, path: string) => + app.router.find(method, `/users/${path}`).handler + expect(getHandler('POST', 'login')).toBeDefined() + for (const [method, path] of [ + ['POST', 'passkey-login-options'], + ['POST', 'passkey-login'], + ['GET', 'passkeys'], + ['POST', 'passkey-options'], + ['POST', 'passkeys'], + ['DELETE', 'passkeys/1'] + ]) { + expect(getHandler(method, path)).toBeUndefined() + } + expect(getHandler('GET', '1/passkeys')).toBeDefined() + }) +}) + +describe('Passkeys setup', () => { + const setup = async ( + app: Record, + { awaitSetup = true } = {} + ) => { + // Models are only set up once, so each app needs its own user model: + class User extends UserModel {} + class Users extends UsersController { + override modelClass = User + + override setup() { + // Sub-classes may not await `super.setup()`, and still expect the + // routes to be set up after calling it: + const promise = super.setup() + const { handler } = this.app.router.find('POST', `${this.url}/passkeys`) + isRouted = !!handler + return awaitSetup ? promise : undefined + } + } + let isRouted = false + const testApp = createTestApp({ + config: { app: { keys: ['test-secret'], ...app } }, + models: { Session, Credential, User }, + controllers: { Users } + }) + try { + await testApp.setup() + } finally { + await testApp.knex.destroy() + } + return isRouted + } + + it('sets up the routes before importing', async () => { + const config = { + session: { modelClass: 'Session' }, + auth: { credentials: { modelClass: 'Credential' }, passkeys } + } + for (const awaitSetup of [true, false]) { + expect(await setup(config, { awaitSetup })).toBe(true) + } + }) + + it(`throws errors also for sub-classes that don't await setup`, async () => { + await expect( + setup({ auth: { passkeys } }, { awaitSetup: false }) + ).rejects.toThrow(`Unable to use passkeys without sessions stored`) + }) + + it('requires sessions stored on the server', async () => { + for (const session of [undefined, true, { maxAge: 1000 }]) { + await expect( + setup({ + session, + auth: { credentials: { modelClass: 'Credential' }, passkeys } + }) + ).rejects.toThrow( + `Unable to use passkeys without sessions stored on the server, ` + + `see 'app.session'.` + ) + } + }) + + it('requires the name, id and origins of passkeys', async () => { + for (const key of ['name', 'id', 'origins']) { + await expect( + setup({ + session: { modelClass: 'Session' }, + auth: { + credentials: { modelClass: 'Credential' }, + passkeys: { ...passkeys, [key]: undefined } + } + }) + ).rejects.toThrow( + `Invalid passkeys, missing 'app.auth.passkeys.${key}'.` + ) + } + }) + + it('requires credentials', async () => { + await expect( + setup({ session: { modelClass: 'Session' }, auth: { passkeys } }) + ).rejects.toThrow( + `Unable to use passkeys without credentials, see 'app.auth.credentials'.` + ) + }) + + it('requires a known credential model', async () => { + await expect( + setup({ auth: { credentials: { modelClass: 'Unknown' } } }) + ).rejects.toThrow(`Unable to find credential model class: 'Unknown'.`) + }) +}) diff --git a/tests/server/users-controller.test.ts b/tests/server/users-controller.test.ts index e8da01e74..3c0271af9 100644 --- a/tests/server/users-controller.test.ts +++ b/tests/server/users-controller.test.ts @@ -1,6 +1,7 @@ import type { ModelProperties } from '@ditojs/server' import { SessionModel, UserModel, UsersController } from '@ditojs/server' import { createTestApp, getAppUrl } from '../utils/app.js' +import { createClient } from '../utils/client.js' import { createTestDatabase } from '../utils/database.js' import { isSQLiteTest } from '../utils/test-knex.js' @@ -21,6 +22,12 @@ class Member extends UserModel { static override properties: ModelProperties = { nickname: { type: 'string' + }, + + password: { + type: 'string', + computed: true, + minLength: 6 } } @@ -145,40 +152,6 @@ describe('UsersController', () => { let url: string let member: Member - // A minimal cookie jar, to keep the session between requests. - const createClient = () => { - const cookies = new Map() - return async ( - path: string, - { method = 'GET', body }: { method?: string; body?: any } = {} - ) => { - const response = await fetch(`${url}${path}`, { - method, - headers: { - 'accept': 'application/json', - 'content-type': 'application/json', - 'cookie': [...cookies] - .map(([key, value]) => `${key}=${value}`) - .join('; ') - }, - body: body !== undefined ? JSON.stringify(body) : undefined - }) - for (const cookie of response.headers.getSetCookie()) { - const [, key, value] = cookie.match(/^([^=]+)=([^;]*)/) ?? [] - if (value) { - cookies.set(key, value) - } else { - cookies.delete(key) - } - } - const isJson = response.headers.get('content-type')?.includes('json') - return { - status: response.status, - data: isJson ? await response.json() : null - } - } - } - beforeAll(async () => { await createTestDatabase(app, Member, Lead) // Session ids are strings, not auto-incrementing integers. @@ -211,7 +184,7 @@ describe('UsersController', () => { }) it('reports unauthenticated sessions', async () => { - const request = createClient() + const request = createClient(url) expect((await request('/members/session')).data).toEqual({ authenticated: false, user: null @@ -222,7 +195,7 @@ describe('UsersController', () => { }) it('logs in members and persists the session in the database', async () => { - const request = createClient() + const request = createClient(url) const login = await request('/members/login', { method: 'POST', body: { username: 'ada', password: 'secret' } @@ -246,7 +219,7 @@ describe('UsersController', () => { }) it('restores the user from the session in later requests', async () => { - const request = createClient() + const request = createClient(url) await request('/members/login', { method: 'POST', body: { username: 'ada', password: 'secret' } @@ -262,7 +235,7 @@ describe('UsersController', () => { }) it('rejects logins with wrong passwords', async () => { - const request = createClient() + const request = createClient(url) const login = await request('/members/login', { method: 'POST', body: { username: 'ada', password: 'wrong' } @@ -282,7 +255,7 @@ describe('UsersController', () => { it('rejects logins of members without a password', async () => { const invited = await Member.query().insert({ username: 'invited' } as any) try { - const request = createClient() + const request = createClient(url) const login = await request('/members/login', { method: 'POST', body: { username: 'invited', password: 'secret' } @@ -295,7 +268,7 @@ describe('UsersController', () => { }) it('rejects logins with missing credentials', async () => { - const request = createClient() + const request = createClient(url) const login = await request('/members/login', { method: 'POST', body: {} @@ -313,7 +286,7 @@ describe('UsersController', () => { password: 'secret' } as any) try { - const request = createClient() + const request = createClient(url) const login = await request('/members/login', { method: 'POST', body: { username: 'locked', password: 'secret' } @@ -330,7 +303,7 @@ describe('UsersController', () => { }) it('logs out members and clears the session', async () => { - const request = createClient() + const request = createClient(url) await request('/members/login', { method: 'POST', body: { username: 'ada', password: 'secret' } @@ -344,7 +317,7 @@ describe('UsersController', () => { }) it('regenerates the session id on login', async () => { - const request = createClient() + const request = createClient(url) const credentials = { username: 'ada', password: 'secret' } await request('/members/login', { method: 'POST', body: credentials }) const [before] = await Session.query() @@ -355,7 +328,7 @@ describe('UsersController', () => { }) it('does not read credentials from the query string', async () => { - const request = createClient() + const request = createClient(url) const login = await request('/members/login?username=ada&password=secret', { method: 'POST', body: {} @@ -365,7 +338,7 @@ describe('UsersController', () => { }) it('leaves no session behind when a transacted login fails', async () => { - const request = createClient() + const request = createClient(url) const login = await request('/members/broken-login', { method: 'POST', body: { username: 'ada', password: 'secret' } @@ -390,7 +363,7 @@ describe('UsersController', () => { }) const loginOther = async () => { - const request = createClient() + const request = createClient(url) await request('/members/login', { method: 'POST', body: { username: 'bo', password: 'secret' } @@ -434,7 +407,7 @@ describe('UsersController', () => { password: 'secret' } as any) try { - const request = createClient() + const request = createClient(url) await request('/leads/login', { method: 'POST', body: { username: 'lea', password: 'secret' } @@ -454,7 +427,7 @@ describe('UsersController', () => { }) it('does not log out unauthenticated requests', async () => { - const request = createClient() + const request = createClient(url) const logout = await request('/members/logout', { method: 'POST' }) expect(logout.data).toEqual({ success: false, authenticated: false }) }) @@ -464,7 +437,7 @@ describe('UsersController', () => { username: 'bo', password: 'secret' } as any) - const request = createClient() + const request = createClient(url) expect((await request(`/members/${member.id}`)).status).toBe(401) await request('/members/login', { method: 'POST', @@ -476,7 +449,7 @@ describe('UsersController', () => { }) it('patches members through `self` with the settings of `patch`', async () => { - const request = createClient() + const request = createClient(url) await request('/members/login', { method: 'POST', body: { username: 'ada', password: 'secret' } @@ -496,7 +469,7 @@ describe('UsersController', () => { password: 'secret' } as any) try { - const request = createClient() + const request = createClient(url) await request('/members/login', { method: 'POST', body: { username: 'bo', password: 'secret' } @@ -510,7 +483,7 @@ describe('UsersController', () => { expect(self.data).not.toHaveProperty('hash') expect((await Member.query().findById(member.id))!.nickname).toBeNull() // The password is hashed like in other patches: - const login = await createClient()('/members/login', { + const login = await createClient(url)('/members/login', { method: 'POST', body: { username: 'bo', password: 'changed' } }) @@ -521,7 +494,7 @@ describe('UsersController', () => { }) it('validates the patches of members', async () => { - const request = createClient() + const request = createClient(url) await request('/members/login', { method: 'POST', body: { username: 'ada', password: 'secret' } @@ -539,7 +512,7 @@ describe('UsersController', () => { }) it('rejects patches of the user without a login', async () => { - const request = createClient() + const request = createClient(url) const self = await request('/members/self', { method: 'PATCH', body: { nickname: 'Anonymous' } @@ -552,7 +525,7 @@ describe('UsersController', () => { ['readers', 405], ['editors', 401] ] as const) { - const request = createClient() + const request = createClient(url) await request(`/${path}/login`, { method: 'POST', body: { username: 'ada', password: 'secret' } @@ -567,8 +540,67 @@ describe('UsersController', () => { expect((await Member.query().findById(member.id))!.nickname).toBeNull() }) + describe('password changes', () => { + const changePassword = async (body: Record) => { + const request = createClient(url) + await request('/readers/login', { + method: 'POST', + body: { username: 'ada', password: 'secret' } + }) + return request('/readers/password', { method: 'POST', body }) + } + + const isPassword = async (password: string) => + (await Member.query().findById(member.id))!.$verifyPassword(password) + + afterEach(async () => { + await Member.query().patchById(member.id, { password: 'secret' } as any) + }) + + it('changes the password of the user', async () => { + const response = await changePassword({ + currentPassword: 'secret', + password: 'changed' + }) + expect(response.status).toBe(200) + expect(response.data).toEqual({ success: true }) + expect(await isPassword('changed')).toBe(true) + }) + + it('rejects wrong current passwords', async () => { + const response = await changePassword({ + currentPassword: 'wrong', + password: 'changed' + }) + expect(response.status).toBe(400) + expect(response.data).toEqual({ message: 'Password is incorrect' }) + expect(await isPassword('secret')).toBe(true) + }) + + it('validates the new password', async () => { + for (const [body, type] of [ + [{ currentPassword: 'secret' }, 'ParameterValidation'], + [{ currentPassword: 'secret', password: 'short' }, 'ModelValidation'] + ] as const) { + const response = await changePassword(body) + expect(response.status).toBe(400) + expect(response.data.type).toBe(type) + } + expect(await isPassword('secret')).toBe(true) + }) + + it('rejects password changes without a login', async () => { + const response = await createClient(url)('/readers/password', { + method: 'POST', + body: { currentPassword: 'secret', password: 'changed' } + }) + expect(response.status).toBe(401) + expect(await isPassword('secret')).toBe(true) + }) + }) + it(`doesn't apply the controller's authorize to logins and sessions`, async () => { - const request = createClient() + const request = createClient(url) const login = await request('/admins/login', { method: 'POST', body: { username: 'ada', password: 'secret' } diff --git a/tests/types/admin/admin.test-d.ts b/tests/types/admin/admin.test-d.ts index 47780dd08..a0f5a813e 100644 --- a/tests/types/admin/admin.test-d.ts +++ b/tests/types/admin/admin.test-d.ts @@ -66,7 +66,7 @@ describe('DitoAdmin', () => { assertType>([ '#app', { - login: { redirectAfterLogin: '/books' }, + login: { redirectAfterLogin: '/books', passkeys: true }, spinner: null } ]) diff --git a/tests/types/admin/context.test-d.ts b/tests/types/admin/context.test-d.ts index d489c13af..06b053195 100644 --- a/tests/types/admin/context.test-d.ts +++ b/tests/types/admin/context.test-d.ts @@ -79,6 +79,7 @@ describe('SchemaContext', () => { expectTypeOf().returns.toEqualTypeOf() expectTypeOf().toBeCallableWith() expectTypeOf().returns.toEqualTypeOf>() + expectTypeOf().returns.toEqualTypeOf>() expectTypeOf().returns.toEqualTypeOf>() expectTypeOf().toBeCallableWith({ path: '/items' }) expectTypeOf().toExtend | null>() diff --git a/tests/types/server/application.test-d.ts b/tests/types/server/application.test-d.ts index 3bd79ccf9..4ec8ae9f8 100644 --- a/tests/types/server/application.test-d.ts +++ b/tests/types/server/application.test-d.ts @@ -1,6 +1,7 @@ -import { expectTypeOf, describe, it } from 'vitest' +import { assertType, expectTypeOf, describe, it } from 'vitest' import type { Application, + ApplicationConfig, AssetReference, Model, ModelAsset, @@ -55,4 +56,20 @@ describe('Application', () => { Awaited>['missing'][number]['references'] >().toEqualTypeOf() }) + + it('accepts credentials and passkeys in app.auth', () => { + assertType({ + app: { + session: { modelClass: 'Session' }, + auth: { + credentials: { modelClass: 'Credential' }, + passkeys: { + name: 'Example', + id: 'example.com', + origins: ['https://example.com'] + } + } + } + }) + }) }) diff --git a/tests/utils/admin-app/index.ts b/tests/utils/admin-app/index.ts index 358ea4a8d..04edb7cce 100644 --- a/tests/utils/admin-app/index.ts +++ b/tests/utils/admin-app/index.ts @@ -3,7 +3,17 @@ import '@ditojs/admin/style.css' // Shared admin entry for all e2e scenarios. `#views` is aliased to the // scenario's own views module, see `createTestApp()` in `../app.ts`. Views -// are either named exports, or a default export for generated views. An -// `account` export is the admin's `account` option: -const { default: defaultViews, account, ...views } = await import('#views') -new DitoAdmin('#dito-admin', { dito, views: defaultViews ?? views, account }) +// are either named exports, or a default export for generated views. The +// `account` and `login` exports are the admin's options of the same name: +const { + default: defaultViews, + account, + login, + ...views +} = await import('#views') +new DitoAdmin('#dito-admin', { + dito, + views: defaultViews ?? views, + account, + login +}) diff --git a/tests/utils/authenticator.ts b/tests/utils/authenticator.ts new file mode 100644 index 000000000..e9789c7c4 --- /dev/null +++ b/tests/utils/authenticator.ts @@ -0,0 +1,115 @@ +// tests/utils/authenticator.ts +import crypto from 'node:crypto' +import { isoCBOR } from '@simplewebauthn/server/helpers' + +// A software authenticator with a P-256 key, which answers the options of +// `@simplewebauthn/server` like a browser with a passkey does, with 'none' +// attestation. Without `aaguid`, it's anonymous with an all-zero AAGUID. +export function createAuthenticator({ + origin, + rpId, + aaguid = '00000000-0000-0000-0000-000000000000' +}: { + origin: string + rpId: string + aaguid?: string +}) { + const { privateKey, publicKey } = crypto.generateKeyPairSync('ec', { + namedCurve: 'P-256' + }) + const id = crypto.randomBytes(16) + const { x, y } = publicKey.export({ format: 'jwk' }) + // The COSE key of ES256 (-7) on P-256 (1): + const cosePublicKey = isoCBOR.encode( + new Map([ + [1, 2], + [3, -7], + [-1, 1], + [-2, Buffer.from(x!, 'base64url')], + [-3, Buffer.from(y!, 'base64url')] + ]) + ) + const rpIdHash = sha256(rpId) + + // User present (0x01) and verified (0x04), with attested data (0x40): + const getAuthenticatorData = (attestedData: Buffer | null = null) => { + const counterBytes = Buffer.alloc(4) + counterBytes.writeUInt32BE(authenticator.counter) + return Buffer.concat([ + rpIdHash, + Buffer.from([attestedData ? 0x45 : 0x05]), + counterBytes, + ...(attestedData ? [attestedData] : []) + ]) + } + + const getClientData = (type: string, challenge: string) => + Buffer.from(JSON.stringify({ type, challenge, origin, crossOrigin: false })) + + const authenticator = { + id: id.toString('base64url'), + // The signature counter, which tests can set back to clone authenticators: + counter: 0, + + register({ challenge }: { challenge: string }) { + const idLength = Buffer.alloc(2) + idLength.writeUInt16BE(id.length) + const authenticatorData = getAuthenticatorData( + Buffer.concat([ + Buffer.from(aaguid.replaceAll('-', ''), 'hex'), + idLength, + id, + cosePublicKey + ]) + ) + const attestationObject = Buffer.from( + isoCBOR.encode( + new Map([ + ['fmt', 'none'], + ['attStmt', new Map()], + ['authData', authenticatorData] + ]) + ) + ) + const clientData = getClientData('webauthn.create', challenge) + return { + id: id.toString('base64url'), + rawId: id.toString('base64url'), + type: 'public-key', + response: { + clientDataJSON: clientData.toString('base64url'), + attestationObject: attestationObject.toString('base64url'), + transports: ['internal'] + }, + clientExtensionResults: {} + } + }, + + authenticate({ challenge }: { challenge: string }) { + authenticator.counter++ + const authenticatorData = getAuthenticatorData() + const clientData = getClientData('webauthn.get', challenge) + const signature = crypto.sign( + 'sha256', + Buffer.concat([authenticatorData, sha256(clientData)]), + privateKey + ) + return { + id: id.toString('base64url'), + rawId: id.toString('base64url'), + type: 'public-key', + response: { + clientDataJSON: clientData.toString('base64url'), + authenticatorData: authenticatorData.toString('base64url'), + signature: signature.toString('base64url') + }, + clientExtensionResults: {} + } + } + } + return authenticator +} + +function sha256(data: string | Buffer) { + return crypto.createHash('sha256').update(data).digest() +} diff --git a/tests/utils/client.ts b/tests/utils/client.ts new file mode 100644 index 000000000..7c8e0614a --- /dev/null +++ b/tests/utils/client.ts @@ -0,0 +1,36 @@ +// tests/utils/client.ts + +// A JSON client of the app at `url` with a minimal cookie jar, to keep the +// session between requests. +export function createClient(url: string) { + const cookies = new Map() + return async ( + path: string, + { method = 'GET', body }: { method?: string; body?: any } = {} + ) => { + const response = await fetch(`${url}${path}`, { + method, + headers: { + 'accept': 'application/json', + 'content-type': 'application/json', + 'cookie': [...cookies] + .map(([key, value]) => `${key}=${value}`) + .join('; ') + }, + body: body !== undefined ? JSON.stringify(body) : undefined + }) + for (const cookie of response.headers.getSetCookie()) { + const [, key, value] = cookie.match(/^([^=]+)=([^;]*)/) ?? [] + if (value) { + cookies.set(key, value) + } else { + cookies.delete(key) + } + } + const isJson = response.headers.get('content-type')?.includes('json') + return { + status: response.status, + data: isJson ? await response.json() : null + } + } +} diff --git a/tests/utils/database.ts b/tests/utils/database.ts index ca82c567a..bf658f317 100644 --- a/tests/utils/database.ts +++ b/tests/utils/database.ts @@ -7,6 +7,7 @@ interface PropertyDefinition { primary?: boolean nullable?: boolean index?: boolean + unique?: boolean | string } type TestApp = Application & { knex: Knex } @@ -23,6 +24,9 @@ export async function createTestDatabase( await app.knex.schema.createTable( modelClass.tableName, (table: Knex.CreateTableBuilder) => { + // Properties with the same string in `unique` share one constraint, + // like in migrations: + const uniqueComposites: Record = {} for (const [name, property] of Object.entries( properties as Record< string, @@ -33,6 +37,14 @@ export async function createTestDatabase( continue } addColumn(table, name, property) + const { unique } = property + if (typeof unique === 'string') { + uniqueComposites[unique] ||= [] + uniqueComposites[unique].push(name) + } + } + for (const columns of Object.values(uniqueComposites)) { + table.unique(columns) } } ) @@ -48,7 +60,11 @@ function addColumn( let column: Knex.ColumnBuilder if (property.primary) { - column = table.increments(name).primary() + // Sessions have string ids, other models auto-incrementing integers: + column = + type === 'string' + ? table.string(name).primary() + : table.increments(name).primary() return column }