From 41999b7fbe4ddd7db0caa72c1e1de18b658bddd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ju=CC=88rg=20Lehni?= Date: Sat, 17 Oct 2026 00:12:06 +0200 Subject: [PATCH] Server, Admin: Add passkey authentication - Add CredentialMixin and CredentialModel, one table for the credentials of all user models, deleted with their users - Configure credentials and passkeys in app.auth, checked on setup, with sessions stored on the server - Add passkey login, the user's own passkeys named after their providers, and 'post password' to UsersController, passkeys only routed with them, through the optional peer @simplewebauthn/server - Add login.passkeys to the admin: a passkey login and a Security dialog in the account menu that changes the password and manages passkeys, with context.reload(), the list option empty, dialogs that validate on submit, and lists in dialogs that keep their query to themselves - Test with a software authenticator on PGlite and SQLite, and with a virtual authenticator in e2e --- CHANGELOG.md | 12 + docs/api/admin/admin.md | 20 +- docs/api/admin/context.md | 29 +- docs/api/admin/schemas.md | 1 + docs/api/server/application.md | 27 + docs/api/server/controllers.md | 44 +- docs/api/server/model.md | 29 + docs/guide/admin/context.md | 9 +- docs/guide/server/controllers.md | 70 ++ packages/admin/package.json | 2 + packages/admin/src/DitoAdmin.js | 20 +- packages/admin/src/SchemaContext.js | 11 + .../admin/src/components/DitoAccount.test.js | 243 +++++++ packages/admin/src/components/DitoAccount.vue | 10 +- packages/admin/src/components/DitoDialog.vue | 4 +- .../admin/src/components/DitoRoot.test.js | 97 +++ packages/admin/src/components/DitoRoot.vue | 16 +- .../admin/src/composables/useListQuery.ts | 12 +- .../admin/src/composables/useSource.test.js | 123 ++++ packages/admin/src/composables/useSource.ts | 6 +- packages/admin/src/styles/_table.scss | 4 + packages/admin/src/types/DitoTypeList.vue | 5 + packages/admin/src/types/DitoTypeObject.vue | 6 + packages/admin/src/utils/ListQuery.js | 44 +- packages/admin/src/utils/ListQuery.test.js | 21 +- packages/admin/src/utils/Session.test.js | 49 +- packages/admin/src/utils/Session.ts | 27 +- packages/admin/src/utils/SourceContext.ts | 2 +- packages/admin/src/utils/security.js | 121 ++++ packages/admin/types/index.d.ts | 41 ++ packages/server/package.json | 6 + packages/server/src/app/Application.js | 2 +- .../server/src/controllers/UsersController.js | 338 +++++++++- packages/server/src/mixins/CredentialMixin.js | 62 ++ packages/server/src/mixins/UserMixin.js | 42 +- packages/server/src/mixins/index.js | 1 + packages/server/src/models/CredentialModel.js | 4 + packages/server/src/models/index.js | 1 + packages/server/types/index.d.ts | 53 +- pnpm-lock.yaml | 265 ++++++++ .../admin/__screenshots__/change-password.png | Bin 0 -> 53345 bytes .../admin/__screenshots__/delete-passkey.png | Bin 0 -> 55733 bytes .../admin/__screenshots__/login-passkeys.png | Bin 0 -> 29299 bytes .../admin/__screenshots__/security-empty.png | Bin 0 -> 36457 bytes .../__screenshots__/security-passkeys.png | Bin 0 -> 42369 bytes tests/e2e/passkeys/admin/index.spec.ts | 102 +++ .../e2e/passkeys/admin/security.screenshot.ts | 97 +++ tests/e2e/passkeys/fixtures.ts | 56 ++ tests/e2e/passkeys/models/Credential.ts | 7 + tests/e2e/passkeys/models/Session.ts | 3 + tests/e2e/passkeys/models/User.ts | 6 + tests/e2e/passkeys/views.ts | 9 + tests/package.json | 1 + tests/server/passkeys.test.ts | 600 ++++++++++++++++++ tests/server/users-controller.test.ts | 144 +++-- tests/types/admin/admin.test-d.ts | 2 +- tests/types/admin/context.test-d.ts | 1 + tests/types/server/application.test-d.ts | 19 +- tests/utils/admin-app/index.ts | 18 +- tests/utils/authenticator.ts | 115 ++++ tests/utils/client.ts | 36 ++ tests/utils/database.ts | 18 +- 62 files changed, 2970 insertions(+), 143 deletions(-) create mode 100644 packages/admin/src/utils/security.js create mode 100644 packages/server/src/mixins/CredentialMixin.js create mode 100644 packages/server/src/models/CredentialModel.js create mode 100644 tests/e2e/passkeys/admin/__screenshots__/change-password.png create mode 100644 tests/e2e/passkeys/admin/__screenshots__/delete-passkey.png create mode 100644 tests/e2e/passkeys/admin/__screenshots__/login-passkeys.png create mode 100644 tests/e2e/passkeys/admin/__screenshots__/security-empty.png create mode 100644 tests/e2e/passkeys/admin/__screenshots__/security-passkeys.png create mode 100644 tests/e2e/passkeys/admin/index.spec.ts create mode 100644 tests/e2e/passkeys/admin/security.screenshot.ts create mode 100644 tests/e2e/passkeys/fixtures.ts create mode 100644 tests/e2e/passkeys/models/Credential.ts create mode 100644 tests/e2e/passkeys/models/Session.ts create mode 100644 tests/e2e/passkeys/models/User.ts create mode 100644 tests/e2e/passkeys/views.ts create mode 100644 tests/server/passkeys.test.ts create mode 100644 tests/utils/authenticator.ts create mode 100644 tests/utils/client.ts 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 0000000000000000000000000000000000000000..daa490bb17fb34d85165fc58cc4ec17208777b95 GIT binary patch literal 53345 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

M>&;iX`FDsIilBZJ62zu-5F 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

Khz)yuSqkJ*`#V$?Vj|IaC|zQb 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

(~0FVg?o4@vJgIJt-3;v8SZD z^MCyj6IFusT|CstBBe;{|7UUvbKmg%{TST-$6s1HaG2yjncq z-Q*PqZF|+^go%ffTOiae1?764>=lEMIhMvpFtg_`OwDBk-bXY1*f@1%YjCuARO#~# z%5G9_Ed^^9nj)kPBmIgXv2!cboek7MVnrO0ExSY`dY$)Js(VwEX-r5B&slTOpr56Y8+f@YFPx29-W}tuRAxuL zx!hyVH}L_~YD^yy<1eo+oHq7bNIZ#F5K@D+{k2*ylT~itr9EtI>QE3pcoXE@)M;(y zsglU871T^;V-ZW^Ge2#~xt+=3^f0%2Pl+zU&m_@2)Ss`I2hi0{so}|1OaX?pS2^Rg zQv1iYa@S2EluQgrJH7F<6@k={vREz%5(=*X_q1VBI>cU1z>&>|(7dCFhS{nKE|*OrP7&MHP4x6u)^&aHnz*U_+&f5UBhwy7+yUr z-r_aNk&%)#Uo2WfLpgGk;HKZB_vTTw>fg4MTKnLw{^7baEQ=yw=O48_6P4!2EFk#H zZ*}?N$c#J!Hcr@M*0T8|no=9n@@B=@SCM$_Mrq!&;-sggq9)H#a$bWL6|qfh^%uK2 zAQE{8EB6pV=HI$K205P(qcElC>u}gaYvgc%7RAjME9+m69eH@zAfdcdJMUVPGUQ)R zg9oaae`KMmXVf^d&2)KPW09Vd1{cNcBDa$|xGUL?>nf(_RnPBQg$#9*^Ayev_`CQ? zp~V%@=JEYE%R~j7&$0KZWd@g~B>xpIb@DWO8W$-q%L3_`PnhGk?oxrhqg?kNjjPIx zU|%@xQ-WCkfi_RcuBRAWPblZu0%`{sCoz;u!ZL0y9qm|50OQ-k7xMat3tYbvLl$o zPgu!h$pqYzovDLoYP1hp%LHQCjG80ZqdQ92IJMR+gNpbYWtoOBduwD4+ILY|k5`uQ z+Wr;b)d5yPdRMh|p;0WSJMI2TFqX!V1k%bnHgV8uz@c~Hq!la2envjfe~N~9pJ5YC zt5?ownB4>Ti4Mk?{gy)?!7;#Tm~2>51GpjFf$b&c=dALnJOjDV!;m1*)013HLt(DA z?`|C>1YiHZTtK4_Q0Sicd)qQ1#MWPv;?$)ZP%E1jsYzG;B>aoJgyOR#kSa&{I z!F6%7(qJB&jY#8`@6m&-vqufNA^u*IHLuF5nswVRG-Py;M15G<@@4C;AA0K)w$$C{ zd?zsF4T`jecO8`Ov3|;5ZQ}m7z3=ID>UgPdAxq<1;EDyocb-+vJrnJO+EwTsq2}=s zhC+utD#=PN1B%V~1UxAB(Rk)~ow&yGMgc9Y)i3K{_8bcG^P)1W9i|p3`8S9--Ww2l z^)C?~UrB=7w~o%|0EJe}YE`TegxBFe^6LQ`NU*@j6g0Y%0$Wf^Jrz#EFJ$u z9=7EHDPsRF=fh^JbyDLNy`Bp4&%O7x>K3y%LlW(}W^8)C+99j>5Fu&+7XUyTZ}tP? ziAd$-50>~8&lL}dZ7!D;yGoUW1@>!a_;&5m)8ahT11QL|Voi;vdkHPNNL7j(&*{a6 zKek^r=yS~{&S4<6OanaF{>YXvC)I?nvQp7^t8ULBOP{=!-b0>okV+lZxo^I?<)7Gz zQx6AW|7Z*FGpV!-qxgY^K^H zjpCp!c~zE4&^q+$Z7Jo4*_IH_ghpCNs1JTB6gX-aUYgk8rO~& z)Rg6T>S8gAf60a~&NF*Zp%y;Pj!=->iAUb+zZKY@y$v6=K{&XjxgUnGgP@hn)1FgR zk90p{hv>P(ZzsckTxDAZfc&eC?x4;{lz-m4$&9CbUqfMF*D$dYpObUr{fz?MNVB#{ zn-JaaSKY W7m)YuIO9WZ4w=F1^E*(I>nbNOB_p;XL6`L4{KB^6vZJ`<7pr zvIukOpEXh1p64Q^5~Ah2)Ll5vmhA`~PjZmBi}5$rC_Q^~C7MYq=Od{2Inw=-|j z%le?nyXl4 z_Oq1(PQ%CQARBsG_ocFKDwY_}{~kN~nmX!^#Ec;Nnh3#x{T@SxC8BxS6voY=_HVR` z;W&e$NHa(+0BgxRx-(|gT(xNe9z5cmhEd8{Uo^KBi7^8&u@8RZ3^A;a zzR*mZn=+$`+?igetQqHnaChEPRQ|h*&s~-&slS`qERwB}Dij>@9x8F8>eAzl?;odH z5GrD&hv>(^i3mxSDK>|1yWGFaEpBNL)!#50f+7IPBRw%9wjy@0wfPSW5S1NPob^rY=PyR;d~1A%zZ$gN{BX&Ri|t zc?$bno~r35q6~>1@-{|GmR?_(@nLeWEFM5LiYUWs6b7Q9U77XYEaFD{hfD6Zs}H04 zm+m{ALVJu8qWArxP7Ij*)+NT2whzo-R&Be^2HTi5Yg?w?&N}RU%l~=J+Bx)mlA#y0 zW3V6_iCGo$%coUf)@h6K_mZ$Ii>JU1?hBzJD=ag!tPpMFcCRSq6=@|>kdBXXvv2)| zI2G42JXG)OF4QX0HsAU~Y+>=znOH8L%VRk29w|tpxRY4sqYrSwr~L-`WAG%f7Iyu= z^V1Bg2X0NEq))teBD8$u3zoftL=Z>%AKP?Y1fL(Sp8u0(VZKm_BwIU zA#sG_In%ZYMh9TBhZXy9iEE**7Ov%f`;7h zRK-^fvp-J>n`g=rXg<-H1Jle38ftL!h=L-(a{I+WuO2ot9&}Vrt#EmgupwN(G$zO- zGMPd8zShrBZEFp2DJ8kMU@}Q*J(o=yuI$cIq@e(J zjBzexzIPj7*#DXsqbT;{UeE1X-c3Xz_jr9Z^W+s3(42tS?pQ3TY%|tgE~?rV*y9Jw zO6VJ6Y1AR@ho3c>{u=Z;>HP>QV=!+DKUSt)gG&UY7`s5HpJ)C{*re1y3azem0O4%$ z4K)izz|;|?sC~Z`2kof(y~=WCkEuF7RgKlAAvs?v#mSTlUOc7+sr8B%*7Yyxpdmrl;kuKo?EjBa{%$B z^yljP0kK7L-h2wp;C+&qP-<_QgsQSh0feHD5*lG)C2GfPXZ;sSe$gA+nrXd@F;Vvk z^b7y8Wwu9H$rxtR&r`nFT&x-Q&ASN}SDL5rPW~p53S8Q6@L7xc8KI`?ss+JyQQWs`(rx_5yu~hy0CN z%v@jeXg66@ zGZ-<(&^ir~aD8Q83B!E-y3>0LNOm{L-qIJkAi$JjC}an8uqPXBOS~IS;Y8#qAkd%a zFa%oMY^nTN=f^c2^EQcjL)P$SrzxA|{;-K!xid?iIP`)-HHye<^<)?k-V8_{@=gwl zM3{&}3oz2}4JE0DLpj_+Lqj7$$xbYb38MxMWhI| zsBaTR57=>4wtd2qo|ZCnS*_Ny#er|JnleP9cGI+eIJUKpKu#$BE(uy0UmmM2(Tzfn zvXSQC11FW}i*A&}i1w0ylPR_h>sIHym zE)RI;#-E7tF=#_6;482DK^rHO_IW@{JD1p;>Al)ct2uA-6kJ!2NiI^8X3_;5`Zy7~ zFYH^=VF2`@iH_ zz%>zrm}?biDRf*{5|ETqc&A&%xbFr6P;C`18x4&)lD>cut7DTx!8&zo9JU-_Ns_;$ z{Sz-FPLCzN)43WI_q4c)d^x-g zq>Btba|7d>@9h@<7;wbu+e?-grJ}f9`}bR4@~b_Bq(AGQ>`!hOVhyX;J8-huIcLjc zAW7t>vU;BZKipRqF%en8MTN?4_RYH>^B)b={_eJ6x)kUAtB$y>-c$+xML$!+F%NbX zkdD${0@BgIkvA$r7&_gqxVera7g|zSn{j3YVU?W z(i=m#%OSQEqZ}lA9mR7s|H)f4wLOp!&r#;)R7Ie=CGtap&Ip`cYC=4lrp@5&I6|z1gqZXW z^m=}a>gFE(Tn%}aqul(85_lH&5XSs3aOS;Z;^W?J`1lYKDWt^vEZ#>>HR|zA+Mw$o zEW%6~$+rzI?PuxwTD-v#Zbe4(ovS*eb{%YpUb}fS8*rGy=PgvDlPp+-^_l;CXY?r9 z5_~8{R|{o+=m|a)^O{s7)GM)k9r)lcnGCbV4{~X044~|yj^GAJ0hxj22Nw-P?uUUxc5tA` i^*{^!!N&A#SRsSo{V9}NrLc=1< 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 GyZk@?_^=BA literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..bf28c643c750143ea51b4ceda6602d766e80064b GIT binary patch literal 36457 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

tPvj~e9+h?%RMMWbv{WZ&`v97-= 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%0DAlt#G?KM(#fjnRKYr4g5J@F@$ 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 literal 0 HcmV?d00001 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 }