From 4501fbc0bd5f382786021fa907f8f5477a22293a Mon Sep 17 00:00:00 2001 From: John Ferlito Date: Wed, 28 Oct 2026 09:33:07 +1100 Subject: [PATCH 01/10] feat: sign admins in to the admin pages Server-rendered admin pages at /admin/, registered when sign-in is on. Admins sign in through our own provider as a built-in confidential client, with the token request made in-process. ADMIN_USERS lists the admins; others get a 403 page showing their sub. Sessions last 8 hours, POSTs must carry our Origin, and responses carry a strict CSP. --- .env.example | 3 + GLOSSARY.md | 10 + README.md | 20 ++ docs/adr/0001-own-oidc-provider.md | 2 +- docs/adr/0002-server-rendered-admin-pages.md | 25 ++ package.json | 2 + pnpm-lock.yaml | 243 +++++++++++++++++++ src/admin/admin-users.test.ts | 78 ++++++ src/admin/client.ts | 14 ++ src/admin/html.ts | 31 +++ src/admin/layout.ts | 31 +++ src/admin/off.test.ts | 35 +++ src/admin/plugin.ts | 115 +++++++++ src/admin/sign-in.test.ts | 161 ++++++++++++ src/admin/sign-in.ts | 90 +++++++ src/app.ts | 4 + src/configuration.ts | 10 + src/oidc/provider.ts | 6 +- test/oidc.ts | 19 +- 19 files changed, 893 insertions(+), 6 deletions(-) create mode 100644 docs/adr/0002-server-rendered-admin-pages.md create mode 100644 src/admin/admin-users.test.ts create mode 100644 src/admin/client.ts create mode 100644 src/admin/html.ts create mode 100644 src/admin/layout.ts create mode 100644 src/admin/off.test.ts create mode 100644 src/admin/plugin.ts create mode 100644 src/admin/sign-in.test.ts create mode 100644 src/admin/sign-in.ts diff --git a/.env.example b/.env.example index 361a595..d120983 100644 --- a/.env.example +++ b/.env.example @@ -41,3 +41,6 @@ # Required in production when sign-in is on; otherwise throwaway keys are generated at each start. Generate with `pnpm generate-keys` # OIDC_SIGNING_KEYS='{"keys":[...]}' # OIDC_COOKIE_KEYS= + +# The `sub`s of admins, who can use the admin pages at /admin/, comma-separated. Unset means nobody +# ADMIN_USERS= diff --git a/GLOSSARY.md b/GLOSSARY.md index 1540249..8863784 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -13,3 +13,13 @@ _Avoid_: IdP, CILogon (one instance of it), login provider **User**: A person who has signed in at the upstream provider, identified by that provider's subject identifier. REMS knows the same person by the same identifier. _Avoid_: Account, member + +### Admin + +**Admin**: +A user who can use the admin pages, currently one whose `sub` is in `ADMIN_USERS`. +_Avoid_: Administrator, superuser, staff + +**Admin pages**: +The server-rendered admin UI at `/admin/`. +_Avoid_: Portal, admin API diff --git a/README.md b/README.md index af51d81..1a8017a 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,8 @@ app mounts as a Fastify plugin. ldacapi adds what LDaCA needs on top: document per entity, into arocapi's OpenSearch search backend (`src/indexer/`). The search fields are declared in `src/indexer/search.ts`. - **Admin API**: list, rebuild and delete indexes (`src/routes/admin.ts`). +- **Admin pages**: server-rendered pages at `/admin/` for admins, who sign in + through our own OIDC provider (`src/admin/`). - **Access control**: licences are checked against REMS, with sign-in through an OIDC provider (`src/auth.ts`, `src/routes/auth.ts`). - **File downloads**: short-lived signed URLs that stream files out of OCFL @@ -119,6 +121,7 @@ set. | `OIDC_ISSUER` | `http://localhost:8080` | | `OIDC_CLIENTS` | `[]` | | `OIDC_SIGNING_KEYS`, `OIDC_COOKIE_KEYS` | generated at each start outside production | +| `ADMIN_USERS` | none (nobody is an admin) | With `NODE_ENV=production`, `DATABASE_URL`, `OPENSEARCH_URL`, `TOKEN_ADMIN` and `OCFL_ROOT` have no defaults and must be set. @@ -175,6 +178,23 @@ Discovery is at `/.well-known/openid-configuration`. Oni's `api.oidc.endpoint` is `OIDC_ISSUER` and its `api.oidc.clientId` is a `client_id` in `OIDC_CLIENTS`. +### Admin pages + +The admin pages are at `/admin/`. They are on only when sign-in +is; otherwise startup logs a warning and `/admin/` is not found. Visiting them +signs you in through our own OIDC provider, as Oni does, so you need no other +password or key. + +`ADMIN_USERS` is a comma-separated list of the `sub`s of admins. Anyone else who +signs in gets a page saying they aren't an admin, showing the `sub` to add. +Every request checks the list, so removing someone takes effect as soon as the +API restarts. An admin session lasts 8 hours, and signing out of the admin +pages also ends the session at our provider. + +The admin pages are a client of our provider that needs no entry in +`OIDC_CLIENTS`; their client secret and session keys are derived from +`OIDC_COOKIE_KEYS`, so they rotate with it. + ## Database The schema is managed with Prisma migrations in `prisma/migrations`. arocapi diff --git a/docs/adr/0001-own-oidc-provider.md b/docs/adr/0001-own-oidc-provider.md index 012d27c..631c5af 100644 --- a/docs/adr/0001-own-oidc-provider.md +++ b/docs/adr/0001-own-oidc-provider.md @@ -12,7 +12,7 @@ The API is an OIDC provider, built on `oidc-provider`, with its own issuer. It s - The upstream provider must speak OIDC, and there is never a page for choosing one. - Our `sub` is the upstream provider's `sub`, because REMS signs in at the same upstream provider and knows users by it. Changing the upstream provider gives every user a new identity, in REMS as well as here. -- Only clients listed in configuration can get tokens. Oni is a public client using PKCE. +- Only clients listed in configuration, and the admin pages' built-in confidential client, can get tokens. Oni is a public client using PKCE. - Access tokens are short-lived JWTs that the API checks itself. Revoking a grant doesn't cut off access tokens already issued from it; they keep working until they expire, at most 15 minutes later. - Discovery is at `/.well-known/openid-configuration`, and the other endpoints are under `/oauth/`, as in the spec. - Provider state lives in Postgres. The keys that sign tokens and cookies come from the environment, so rotating them is a configuration change. diff --git a/docs/adr/0002-server-rendered-admin-pages.md b/docs/adr/0002-server-rendered-admin-pages.md new file mode 100644 index 0000000..828a36d --- /dev/null +++ b/docs/adr/0002-server-rendered-admin-pages.md @@ -0,0 +1,25 @@ +# The admin pages are server-rendered and the only way into admin + +## Context + +Admins manage the API's indexes. A separate browser app calling an admin API with one shared key exposes the key to anyone who loads the app, records nobody's identity, and can only take access away from everyone at once. The API is already an OIDC provider that signs users in at the upstream provider (ADR 0001), and it can reach the repository and indexer directly. + +## Decision + +The API serves its own admin pages at `/admin/`, rendered on the server, on the issuer's origin and outside `/api`. They are registered only when sign-in is configured. + +- The admin pages are a built-in confidential client of our provider, not listed in `OIDC_CLIENTS`. Its client secret is derived from `OIDC_COOKIE_KEYS` with HKDF, and it uses only the authorisation code grant, asking for `openid profile`. +- Sign-in is a server-side authorisation code flow with PKCE, state and nonce. The browser goes through the provider as usual, but the token request reaches the provider in-process, with its metadata built from configuration rather than discovery, so the API never calls its own public URL. Only the ID token is kept. +- Each sign-in's state, PKCE verifier, nonce and page to return to are held in a short-lived encrypted cookie. The session is an encrypted cookie holding the `sub`, sign-in time and ID token, with an absolute lifetime of 8 hours. Both cookies are scoped to `/admin`, `HttpOnly`, `Secure` on an https issuer, and `SameSite=Lax`, because sign-in returns through a redirect chain that started at the upstream provider, where a `Strict` cookie wouldn't be sent. Their keys are derived from every key in `OIDC_COOKIE_KEYS`, so they rotate with it. +- An admin is a user whose `sub` is in `ADMIN_USERS`, checked on every request. Others who sign in get a 403 page showing their `sub`. +- Signing out clears the session and ends the session at our provider, via its end-session endpoint with the ID token as `id_token_hint`. +- Every POST must carry an `Origin` matching the issuer. Responses carry a Content-Security-Policy allowing only same-origin scripts and styles, and no framing. +- Pages are built with an `html` tagged template that escapes every interpolated value. + +## Consequences + +- There is no shared admin secret, and each admin acts as themselves. +- Removing an admin from `ADMIN_USERS` takes effect when the API restarts, whatever sessions they hold. +- Signing out of the admin pages also signs the user out of our provider, which Oni relies on; Oni keeps working until its tokens can't be refreshed. It doesn't sign them out at the upstream provider. +- Admin needs sign-in configured, and so a working upstream provider, even locally. +- A non-human caller has no way into admin. diff --git a/package.json b/package.json index 5eb2c16..9e4287f 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,8 @@ "dependencies": { "@fastify/bearer-auth": "^10.1.4", "@fastify/cors": "^11.3.1", + "@fastify/formbody": "^9.0.1", + "@fastify/secure-session": "^8.4.1", "@fastify/sensible": "^6.0.7", "@ocfl/ocfl-fs": "^0.4.0", "@opensearch-project/opensearch": "^3.9.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b530447..7e150d7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -172,6 +172,12 @@ importers: '@fastify/cors': specifier: ^11.3.1 version: 11.3.1 + '@fastify/formbody': + specifier: ^9.0.1 + version: 9.0.1 + '@fastify/secure-session': + specifier: ^8.4.1 + version: 8.4.1(bare-events@2.9.2) '@fastify/sensible': specifier: ^6.0.7 version: 6.0.7 @@ -433,6 +439,9 @@ packages: '@fastify/bearer-auth@10.1.4': resolution: {integrity: sha512-lOHC/Ek4Zlqv7qb1EAYze3wmzq8s8chrrik4vCIzFfOGysSLP5HM5vbcHfywf7JVZZp+BcPcaIRBwUAqHNStiA==} + '@fastify/cookie@11.1.3': + resolution: {integrity: sha512-RBhlMNsmSG6C5KGiTkhSXBF75xIrUMSytDDBcpnVewhJ8XVYnJTu+SV6FYKXClIRsBuRjOfKbBSULvnPD3X/mg==} + '@fastify/cors@11.3.1': resolution: {integrity: sha512-gZXNzd6FLHzOIfvoK4+Az3Wko2V6Y0MIsLxpyo4AEYl7a9q1JBPwPaxLea4nkr++8FCiJiPpYYo6E02uZW0dqw==} @@ -442,6 +451,9 @@ packages: '@fastify/fast-json-stringify-compiler@5.1.0': resolution: {integrity: sha512-PxcYtKLbQ8Z+yApiqjK8FwxIwvEj38k2OiLc17u8dkJSlmfi2wHHPaSnaoqBPQqtvF8YVsDgDpP2snDCfFrpfw==} + '@fastify/formbody@9.0.1': + resolution: {integrity: sha512-QaUet4NQavwnl6iPYMaf5hJC2N7kRPOmC58RhDiFThfg7Hcc8X2LdpcEfmaOEBimq5z7yZLy1e1q+PIv3o3tFQ==} + '@fastify/forwarded@3.0.2': resolution: {integrity: sha512-NE8HgKLgYejV9lDpqkEFaDKMLYelJBVfHekhB0UKvX0ghagXRJqg68feg8er1NPXxG4N9i6vPxzt8E+3wHfcmA==} @@ -451,6 +463,10 @@ packages: '@fastify/proxy-addr@5.1.1': resolution: {integrity: sha512-zv07Y9GEuDsJPegZoDFd4SDWaZOW8N2pa0GSrYmKpId/tjt1Hgo3BjZBVjdVpfVrHaA+Qv5jawtS2O50J5xM9g==} + '@fastify/secure-session@8.4.1': + resolution: {integrity: sha512-V9khuSlcbpupL3ZVKvAsnPtxFWDj41946W17vmRYvL4Ov57lh7ZVyYYioB4SYNf7xdV8qa+ZRQ86Ni9sBE2oQQ==} + hasBin: true + '@fastify/sensible@6.0.7': resolution: {integrity: sha512-e4+bJEcAPGdKsIljulWtEKeECuYGLkVTB1dgWh19m2u4ttETS/5EWZhw6KPXlgi6ZiSet5IqYrcMRMrRub0Hrg==} @@ -1316,6 +1332,74 @@ packages: aws4@1.13.2: resolution: {integrity: sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==} + b4a@1.9.0: + resolution: {integrity: sha512-dpfcF9fDNR6++cthXR67iyhgqWy9CBouAvIWhIntzBG6cvK/cnIPiZQjBwi/ZqjjBEDGfoNDtmB0kTjroOJ3pQ==} + peerDependencies: + react-native-b4a: '*' + peerDependenciesMeta: + react-native-b4a: + optional: true + + bare-addon-resolve@1.11.0: + resolution: {integrity: sha512-bTUBSqBjzQQzo2/iIk6KxAClZfahy38EPsJV2bPJ+p2+kS8h+hT5TE5QggpkFUrCnLL4HDTA4DDM+YiNdyol0w==} + peerDependencies: + bare-url: '*' + peerDependenciesMeta: + bare-url: + optional: true + + bare-ansi-escapes@2.2.3: + resolution: {integrity: sha512-02ES4/E2RbrtZSnHJ9LntBhYkLA6lPpSEeP8iqS3MccBIVhVBlEmruF1I7HZqx5Q8aiTeYfQVeqmrU9YO2yYoQ==} + peerDependencies: + bare-buffer: '*' + peerDependenciesMeta: + bare-buffer: + optional: true + + bare-assert@1.3.0: + resolution: {integrity: sha512-tF3A1xa5WHzOwn2Y6Pm+mV1NGOztWUKyUQycmtMCnIcEx/0xv5MMgv2qerXQVvhkv6IZJNaEbiGBJNFD6RmunA==} + + bare-events@2.9.2: + resolution: {integrity: sha512-AIPKioV7/Y/8KfZ3AAhjPJxLLbY49S64Ym5DakZlUg75qQiTgUq9hEJoEwa4eUezPUlXRy/i5NpsKvo9jgKmoA==} + peerDependencies: + bare-abort-controller: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + + bare-inspect@3.1.10: + resolution: {integrity: sha512-Qu01XZNTIT2/bS9k7c6A3OXuAzBnWwDCejGri76BUltdFqVe6Et/IxVOSSbcDpreSSWwqQ5pAu/bxFE6hPDWRQ==} + engines: {bare: '>=1.18.0'} + + bare-module-resolve@1.12.6: + resolution: {integrity: sha512-tsoOqHmtF6VmNX4LYDBZwvQVkSmEagnbykNjK31LMxz/amyKfjCs4K976n7uJd1bvWfxbpLQ3Fy7qq8an6jR6Q==} + peerDependencies: + bare-url: '*' + peerDependenciesMeta: + bare-url: + optional: true + + bare-semver@1.1.0: + resolution: {integrity: sha512-1Hw5qJ7hXdVt3uPUqjeFTuxyvBUJauvz5A1I2jk8gzjZMHp04n//6nV9MDbG9CMw78JHY2lGV0w6s//LrASm2w==} + + bare-stream@2.13.4: + resolution: {integrity: sha512-PcrQ8lVLbiJscNm1Kez+Yp4Gy4AHGcN1lzwjvf5NybWen7VvEgUfyfnXYJ2zNqWnzOfCb1Abq6lH8ti0syQszA==} + peerDependencies: + bare-abort-controller: '*' + bare-buffer: '*' + bare-events: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + bare-buffer: + optional: true + bare-events: + optional: true + + bare-type@1.4.0: + resolution: {integrity: sha512-6anDbVGruQLK8m6zxlCPNkUuxXnzChT1aOmJbFRjEGn8MWZqviFP6vGi9NAnG8aRXyRAfXIGLlBJwYGa4Ylcug==} + engines: {bare: '>=1.32.0'} + better-result@2.10.0: resolution: {integrity: sha512-oQhh0y1qo2/ZKdAAEvHZAqKKiHOFU5k/bW96fE2ScgQOVkJRiHwB+nOS1SgFsYqRlxMDWvefXi9Q3px7QvgNDw==} @@ -1392,6 +1476,10 @@ packages: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} + cookie@2.0.1: + resolution: {integrity: sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==} + engines: {node: '>=22'} + cookies@0.9.2: resolution: {integrity: sha512-8BIbcC6tPZMv2/PD4PrB3CtKks7LmAWhA7FRZK/6/b/WJ2RUz00KCga7gq50S4/RhZ22kxefRFGgsuts0v3eoA==} engines: {node: '>= 0.8'} @@ -1568,6 +1656,9 @@ packages: estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + events-universal@1.0.1: + resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + expect-type@1.4.0: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} @@ -1588,6 +1679,9 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-fifo@1.3.2: + resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + fast-json-stringify@7.0.1: resolution: {integrity: sha512-eRSayARSbbwlBjpP4vnTTIRD5QPcIrmihPxDeN1DtKnHPg66UuJLx+8hlK1kaFdjvzyQ/dzALoi4vwAQ+T+iZA==} @@ -2211,6 +2305,10 @@ packages: remeda@2.33.4: resolution: {integrity: sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ==} + require-addon@1.3.0: + resolution: {integrity: sha512-j8pifaPNZI7ShKY3ihXZwF0qaEjua8zmlqPvDil0a8SBnyRvjCbAiEQuzO0UcoXH1sqU5U/ilXSjYd1W5NYwAA==} + engines: {bare: '>=1.10.0'} + require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} @@ -2304,6 +2402,10 @@ packages: resolution: {integrity: sha512-hpd+HLON7HdZXqYchMM/+LaTTbdK0AU3NngIJ4KVyWbY9bfQqdL9cD+4yf6dUoU2Ap4VsU0JkQi6FxAI1B2mXQ==} engines: {node: '>= 18'} + sodium-native@5.2.0: + resolution: {integrity: sha512-ntJqNSc9bWmWqcJkx8NA5eS0HUwQnmDlluUzoKCOQUWey8i7LXptk+E/qo2T2RPO9uK2br+3N0zYQme8qPXG+g==} + engines: {bare: '>=1.16.0'} + sonic-boom@4.2.1: resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} @@ -2336,6 +2438,9 @@ packages: std-env@4.3.0: resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==} + streamx@2.28.1: + resolution: {integrity: sha512-zEzXb0s5Cds7tqMH6rhZ05lcJydCWiQPEwiNngVqzsxCc962vLY4Uw+mW7od8kDH258k2Uz/JrOkdIAAhSh9VA==} + string-width@7.2.0: resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} engines: {node: '>=18'} @@ -2352,6 +2457,12 @@ packages: resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} engines: {node: '>=14.16'} + teex@1.0.1: + resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==} + + text-decoder@1.2.7: + resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} + thread-stream@4.2.0: resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==} engines: {node: '>=20'} @@ -2508,6 +2619,9 @@ packages: whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + which-runtime@1.4.0: + resolution: {integrity: sha512-0ugbP4CJW4e2D20jvEcC4973dCgIaHI4Rw1PT+26U9zEve7FyYdWAIwUnoeOYvoCfn+wXHoHTKb1KhkYlb60Pw==} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -2751,6 +2865,11 @@ snapshots: '@fastify/error': 4.2.0 fastify-plugin: 6.0.1 + '@fastify/cookie@11.1.3': + dependencies: + cookie: 2.0.1 + fastify-plugin: 6.0.1 + '@fastify/cors@11.3.1': dependencies: fastify-plugin: 6.0.1 @@ -2762,6 +2881,11 @@ snapshots: dependencies: fast-json-stringify: 7.0.1 + '@fastify/formbody@9.0.1': + dependencies: + fast-querystring: 1.1.2 + fastify-plugin: 6.0.1 + '@fastify/forwarded@3.0.2': {} '@fastify/merge-json-schemas@0.2.1': @@ -2773,6 +2897,18 @@ snapshots: '@fastify/forwarded': 3.0.2 ipaddr.js: 2.5.0 + '@fastify/secure-session@8.4.1(bare-events@2.9.2)': + dependencies: + '@fastify/cookie': 11.1.3 + fastify-plugin: 6.0.1 + sodium-native: 5.2.0(bare-events@2.9.2) + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - bare-events + - bare-url + - react-native-b4a + '@fastify/sensible@6.0.7': dependencies: '@lukeed/ms': 2.0.2 @@ -3508,6 +3644,61 @@ snapshots: aws4@1.13.2: {} + b4a@1.9.0: {} + + bare-addon-resolve@1.11.0: + dependencies: + bare-module-resolve: 1.12.6 + bare-semver: 1.1.0 + + bare-ansi-escapes@2.2.3(bare-events@2.9.2): + dependencies: + bare-stream: 2.13.4(bare-events@2.9.2) + transitivePeerDependencies: + - bare-abort-controller + - bare-events + - react-native-b4a + + bare-assert@1.3.0(bare-events@2.9.2): + dependencies: + bare-inspect: 3.1.10(bare-events@2.9.2) + bare-type: 1.4.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - bare-events + - react-native-b4a + + bare-events@2.9.2: {} + + bare-inspect@3.1.10(bare-events@2.9.2): + dependencies: + bare-ansi-escapes: 2.2.3(bare-events@2.9.2) + bare-type: 1.4.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - bare-events + - react-native-b4a + + bare-module-resolve@1.12.6: + dependencies: + bare-semver: 1.1.0 + + bare-semver@1.1.0: {} + + bare-stream@2.13.4(bare-events@2.9.2): + dependencies: + b4a: 1.9.0 + streamx: 2.28.1 + teex: 1.0.1 + optionalDependencies: + bare-events: 2.9.2 + transitivePeerDependencies: + - react-native-b4a + + bare-type@1.4.0: {} + better-result@2.10.0: {} c12@3.3.4: @@ -3572,6 +3763,8 @@ snapshots: cookie@1.1.1: {} + cookie@2.0.1: {} + cookies@0.9.2: dependencies: depd: 2.0.0 @@ -3717,6 +3910,12 @@ snapshots: dependencies: '@types/estree': 1.0.9 + events-universal@1.0.1: + dependencies: + bare-events: 2.9.2 + transitivePeerDependencies: + - bare-abort-controller + expect-type@1.4.0: {} exsolve@1.1.3: {} @@ -3731,6 +3930,8 @@ snapshots: fast-deep-equal@3.1.3: {} + fast-fifo@1.3.2: {} + fast-json-stringify@7.0.1: dependencies: '@fastify/merge-json-schemas': 0.2.1 @@ -4378,6 +4579,12 @@ snapshots: remeda@2.33.4: {} + require-addon@1.3.0: + dependencies: + bare-addon-resolve: 1.11.0 + transitivePeerDependencies: + - bare-url + require-from-string@2.0.2: {} resolve-from@4.0.0: {} @@ -4456,6 +4663,18 @@ snapshots: smol-toml@1.9.0: {} + sodium-native@5.2.0(bare-events@2.9.2): + dependencies: + bare-assert: 1.3.0(bare-events@2.9.2) + require-addon: 1.3.0 + which-runtime: 1.4.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - bare-events + - bare-url + - react-native-b4a + sonic-boom@4.2.1: dependencies: atomic-sleep: 1.0.0 @@ -4478,6 +4697,15 @@ snapshots: std-env@4.3.0: {} + streamx@2.28.1: + dependencies: + events-universal: 1.0.1 + fast-fifo: 1.3.2 + text-decoder: 1.2.7 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + string-width@7.2.0: dependencies: emoji-regex: 10.6.0 @@ -4495,6 +4723,19 @@ snapshots: strip-json-comments@5.0.3: {} + teex@1.0.1: + dependencies: + streamx: 2.28.1 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + text-decoder@1.2.7: + dependencies: + b4a: 1.9.0 + transitivePeerDependencies: + - react-native-b4a + thread-stream@4.2.0: dependencies: real-require: 1.0.0 @@ -4601,6 +4842,8 @@ snapshots: tr46: 0.0.3 webidl-conversions: 3.0.1 + which-runtime@1.4.0: {} + which@2.0.2: dependencies: isexe: 2.0.0 diff --git a/src/admin/admin-users.test.ts b/src/admin/admin-users.test.ts new file mode 100644 index 0000000..24e666d --- /dev/null +++ b/src/admin/admin-users.test.ts @@ -0,0 +1,78 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { buildTestApp, type TestApp } from '../../test/app.ts'; +import { startFakeUpstream, type UpstreamUser } from '../../test/fake-upstream.ts'; +import { Browser } from '../../test/oidc.ts'; + +const ISSUER = 'http://localhost:8080'; +const UPSTREAM_CLIENT = { clientId: 'ldacapi', clientSecret: 'upstream-secret' }; +const ALICE: UpstreamUser = { sub: 'alice@upstream', name: 'Alice' }; +const COOKIE_KEYS = 'a-cookie-key-of-at-least-32-characters'; + +let upstream: Awaited>; +const running: Awaited>[] = []; + +/** Starts the app afresh with `env`, as a restart with new configuration would */ +const start = async (env: Record) => { + vi.resetModules(); + const started = await buildTestApp({ + OIDC_ISSUER: ISSUER, + OIDC_CLIENTS: JSON.stringify([{ client_id: 'oni', redirect_uris: ['http://localhost:5173/callback'] }]), + OIDC_UPSTREAM_ISSUER: upstream.issuer, + OIDC_UPSTREAM_CLIENT_ID: UPSTREAM_CLIENT.clientId, + OIDC_UPSTREAM_CLIENT_SECRET: UPSTREAM_CLIENT.clientSecret, + OIDC_COOKIE_KEYS: COOKIE_KEYS, + ...env, + }); + running.push(started); + return { app: started.app }; +}; + +const signIn = async (app: TestApp, user: UpstreamUser) => { + const browser = new Browser(app, ISSUER); + const toUpstream = await browser.follow('/admin/'); + const page = await browser.follow(upstream.signIn(toUpstream.headers.location as string, user)); + return { browser, page }; +}; + +beforeAll(async () => { + upstream = await startFakeUpstream(UPSTREAM_CLIENT); +}); + +afterAll(async () => { + for (const { app, prisma } of running) { + await app.close(); + await prisma.$disconnect(); + } + await upstream.close(); +}); + +describe('who is an admin', () => { + it('lets nobody in when ADMIN_USERS lists nobody', async () => { + const { app } = await start({ ADMIN_USERS: ' , ' }); + + const { page } = await signIn(app, ALICE); + + expect(page.statusCode).toBe(403); + }); + + it('stops an existing session once its user is removed from ADMIN_USERS', async () => { + const { app: before } = await start({ ADMIN_USERS: ALICE.sub }); + const { browser, page } = await signIn(before, ALICE); + expect(page.statusCode).toBe(200); + + const { app: after } = await start({}); + + expect((await browser.get('/admin/', after)).statusCode).toBe(403); + }); + + it('treats a session from an app with other cookie keys as no session', async () => { + const { app: other } = await start({ ADMIN_USERS: ALICE.sub, OIDC_COOKIE_KEYS: 'another-cookie-key-of-at-least-32-characters' }); + const { browser } = await signIn(other, ALICE); + const { app } = await start({ ADMIN_USERS: ALICE.sub }); + + const page = await browser.get('/admin/', app); + + expect(page.statusCode).toBe(303); + expect(new URL(page.headers.location as string).pathname).toBe('/oauth/authorize'); + }); +}); diff --git a/src/admin/client.ts b/src/admin/client.ts new file mode 100644 index 0000000..4851548 --- /dev/null +++ b/src/admin/client.ts @@ -0,0 +1,14 @@ +import { hkdfSync } from 'node:crypto'; +import type { AuthConfig } from '../configuration.ts'; + +export const derivedKey = (cookieKey: string, purpose: string) => Buffer.from(hkdfSync('sha256', cookieKey, '', `ldacapi ${purpose}`, 32)); + +/** The admin pages' own client of our provider, which needs no configuration */ +export const adminClient = ({ issuer, cookieKeys }: AuthConfig) => ({ + client_id: 'ldacapi-admin', + client_secret: derivedKey(cookieKeys[0] ?? '', 'admin client secret').toString('base64url'), + redirect_uris: [`${issuer}/admin/callback`], + post_logout_redirect_uris: [`${issuer}/admin/`], + grant_types: ['authorization_code'], + scope: 'openid profile', +}); diff --git a/src/admin/html.ts b/src/admin/html.ts new file mode 100644 index 0000000..ad0d3a8 --- /dev/null +++ b/src/admin/html.ts @@ -0,0 +1,31 @@ +/** HTML that is already safe to include in a page */ +export class Html { + readonly value: string; + + constructor(value: string) { + this.value = value; + } + + toString() { + return this.value; + } +} + +const escapes: Record = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; + +const escapeValue = (value: unknown): string => { + if (value instanceof Html) { + return value.value; + } + if (Array.isArray(value)) { + return value.map(escapeValue).join(''); + } + if (value === undefined || value === null || value === false) { + return ''; + } + return String(value).replace(/[&<>"']/g, (char) => escapes[char] ?? char); +}; + +/** Builds HTML, escaping every interpolated value that isn't already `Html` */ +export const html = (strings: TemplateStringsArray, ...values: unknown[]) => + new Html(strings.reduce((built, string, i) => built + escapeValue(values[i - 1]) + string)); diff --git a/src/admin/layout.ts b/src/admin/layout.ts new file mode 100644 index 0000000..515c509 --- /dev/null +++ b/src/admin/layout.ts @@ -0,0 +1,31 @@ +import { type Html, html } from './html.ts'; + +type LayoutOptions = { + title: string; + /** Who is signed in, if anyone */ + user?: string | undefined; + body: Html; +}; + +export const layout = ({ title, user, body }: LayoutOptions) => html` + + + + + ${title} · LDaCA admin + + +
+

LDaCA admin

+ ${ + user && + html`

Signed in as ${user}

+
` + } +
+
+

${title}

+ ${body} +
+ +`; diff --git a/src/admin/off.test.ts b/src/admin/off.test.ts new file mode 100644 index 0000000..1cfc513 --- /dev/null +++ b/src/admin/off.test.ts @@ -0,0 +1,35 @@ +import { Writable } from 'node:stream'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { buildTestApp, type TestApp } from '../../test/app.ts'; + +const logs = vi.hoisted(() => [] as { level: number; msg: string }[]); + +vi.mock('../logger.ts', async () => { + const { pino } = await import('pino'); + const stream = new Writable({ + write(chunk, _encoding, done) { + logs.push(JSON.parse(chunk.toString())); + done(); + }, + }); + return { logger: pino({ level: 'warn' }, stream) }; +}); + +let app: TestApp; +let prisma: Awaited>['prisma']; + +beforeAll(async () => { + ({ app, prisma } = await buildTestApp({ ADMIN_USERS: 'alice@upstream' })); +}); + +afterAll(async () => { + await app.close(); + await prisma.$disconnect(); +}); + +describe('the admin pages without sign-in', () => { + it('are not found, and startup warns that they are off', async () => { + expect((await app.inject({ method: 'GET', url: '/admin/' })).statusCode).toBe(404); + expect(logs.map(({ msg }) => msg)).toContainEqual(expect.stringContaining('OIDC_UPSTREAM_ISSUER')); + }); +}); diff --git a/src/admin/plugin.ts b/src/admin/plugin.ts new file mode 100644 index 0000000..8585281 --- /dev/null +++ b/src/admin/plugin.ts @@ -0,0 +1,115 @@ +import formBody from '@fastify/formbody'; +import secureSession, { type Session, type SessionData } from '@fastify/secure-session'; +import type { FastifyPluginAsync, FastifyReply } from 'fastify'; +import { decodeJwt } from 'jose'; +import type { AuthConfig } from '../configuration.ts'; +import { logger } from '../logger.ts'; +import { derivedKey } from './client.ts'; +import { type Html, html } from './html.ts'; +import { layout } from './layout.ts'; +import { createSignIn, type SignInTransaction } from './sign-in.ts'; + +const log = logger.child({ module: 'admin' }); + +type AdminSession = { sub: string; signedInAt: number; idToken: string }; + +declare module '@fastify/secure-session' { + interface SessionData { + admin: AdminSession; + transaction: SignInTransaction; + } +} + +declare module 'fastify' { + interface FastifyRequest { + signIn: Session; + } +} + +const SESSION_SECONDS = 8 * 60 * 60; +const SIGN_IN_SECONDS = 10 * 60; + +const CSP = "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'"; + +const send = (reply: FastifyReply, page: Html, status = 200) => reply.code(status).type('text/html; charset=utf-8').send(page.toString()); + +const displayName = (idToken: string) => { + const { name, sub } = decodeJwt(idToken); + return typeof name === 'string' && name ? name : sub; +}; + +type AdminOptions = { auth: AuthConfig; adminUsers: string[] }; + +/** The admin pages, for users whose `sub` is in `adminUsers` */ +export const adminPages: FastifyPluginAsync = async (fastify, { auth, adminUsers }) => { + const issuer = new URL(auth.issuer); + const signIn = createSignIn(fastify, auth); + const admins = new Set(adminUsers); + const cookie = { path: '/admin', httpOnly: true, secure: issuer.protocol === 'https:', sameSite: 'lax' } as const; + const keys = (purpose: string) => auth.cookieKeys.map((key) => derivedKey(key, purpose)); + + fastify.register(formBody); + fastify.register(secureSession, [ + { + sessionName: 'session', + cookieName: 'admin_session', + key: keys('admin session'), + expiry: SESSION_SECONDS, + cookie: { ...cookie, maxAge: SESSION_SECONDS }, + }, + { sessionName: 'signIn', cookieName: 'admin_sign_in', key: keys('admin sign-in'), expiry: SIGN_IN_SECONDS, cookie: { ...cookie, maxAge: SIGN_IN_SECONDS } }, + ]); + + fastify.addHook('onRequest', async (request, reply) => { + reply.header('content-security-policy', CSP).header('x-frame-options', 'DENY'); + if (request.method === 'POST' && request.headers.origin !== issuer.origin) { + return reply.forbidden('Cross-origin request refused'); + } + }); + + fastify.get<{ Querystring: Record }>('/callback', async (request, reply) => { + const transaction = request.signIn.get('transaction'); + request.signIn.delete(); + if (!transaction || request.query.state !== transaction.state) { + return reply.badRequest('Sign-in has expired or was started elsewhere'); + } + let signedIn: Awaited>; + try { + signedIn = await signIn.finish(request.query, transaction); + } catch (err) { + log.warn({ err }, 'Sign-in to the admin pages failed'); + return reply.badRequest('Sign-in failed'); + } + request.session.set('admin', { ...signedIn, signedInAt: Date.now() }); + return reply.redirect(transaction.returnTo, 303); + }); + + fastify.post('/sign-out', async (request, reply) => { + const session = request.session.get('admin'); + request.session.delete(); + return reply.redirect(session ? signIn.signOutUrl(session.idToken).toString() : '/admin/', 303); + }); + + // Every page below needs a current session belonging to an admin + fastify.register(async (pages) => { + pages.addHook('onRequest', async (request, reply) => { + const session = request.session.get('admin'); + if (!session || Date.now() - session.signedInAt >= SESSION_SECONDS * 1000) { + const { url, transaction } = await signIn.start(request.url); + request.signIn.set('transaction', transaction); + return reply.redirect(url.toString(), 303); + } + if (!admins.has(session.sub)) { + const body = html`

You are signed in, but you are not an admin.

+

To be given access, ask an operator to add this sub to ADMIN_USERS:

+

${session.sub}

`; + return send(reply, layout({ title: 'Not an admin', user: displayName(session.idToken), body }), 403); + } + }); + + pages.get('/', async (request, reply) => { + const user = displayName(request.session.get('admin')?.idToken ?? ''); + return send(reply, layout({ title: 'Admin', user, body: html`

You are signed in as ${user}, and you are an admin.

` })); + }); + }); +}; diff --git a/src/admin/sign-in.test.ts b/src/admin/sign-in.test.ts new file mode 100644 index 0000000..aeee53f --- /dev/null +++ b/src/admin/sign-in.test.ts @@ -0,0 +1,161 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import { buildTestApp, type TestApp } from '../../test/app.ts'; +import { startFakeUpstream, type UpstreamUser } from '../../test/fake-upstream.ts'; +import { authorizationRequest, Browser } from '../../test/oidc.ts'; + +const ISSUER = 'http://localhost:8080'; +const UPSTREAM_CLIENT = { clientId: 'ldacapi', clientSecret: 'upstream-secret' }; +const ALICE: UpstreamUser = { sub: 'alice@upstream', name: 'Alice ' }; +const MALLORY: UpstreamUser = { sub: 'mallory@upstream', name: 'Mallory' }; + +let upstream: Awaited>; +let app: TestApp; +let prisma: Awaited>['prisma']; + +/** Follows `url` to the upstream provider, signs `user` in there, and follows the way back */ +const signIn = async (browser: Browser, url: string, user: UpstreamUser) => { + const toUpstream = await browser.follow(url); + expect(new URL(toUpstream.headers.location as string).origin).toBe(upstream.issuer); + return browser.follow(upstream.signIn(toUpstream.headers.location as string, user)); +}; + +beforeAll(async () => { + upstream = await startFakeUpstream(UPSTREAM_CLIENT); + ({ app, prisma } = await buildTestApp({ + OIDC_ISSUER: ISSUER, + OIDC_CLIENTS: JSON.stringify([{ client_id: 'oni', redirect_uris: ['http://localhost:5173/callback'] }]), + OIDC_UPSTREAM_ISSUER: upstream.issuer, + OIDC_UPSTREAM_CLIENT_ID: UPSTREAM_CLIENT.clientId, + OIDC_UPSTREAM_CLIENT_SECRET: UPSTREAM_CLIENT.clientSecret, + ADMIN_USERS: ` other@upstream , ${ALICE.sub} `, + })); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +afterAll(async () => { + await app.close(); + await prisma.$disconnect(); + await upstream.close(); +}); + +describe('signing in to the admin pages', () => { + it('sends a visitor with no session to sign in, then back to the page they asked for', async () => { + const browser = new Browser(app, ISSUER); + + const first = await browser.get('/admin/?from=test'); + expect(first.statusCode).toBe(303); + const authorize = new URL(first.headers.location as string, ISSUER); + expect(authorize.pathname).toBe('/oauth/authorize'); + + const page = await signIn(browser, '/admin/?from=test', ALICE); + expect(browser.url).toBe('/admin/?from=test'); + expect(page.statusCode).toBe(200); + expect(page.body).toContain('Alice <Admin>'); + }); + + it('lets in a user already signed in at our provider without visiting the upstream provider', async () => { + const browser = new Browser(app, ISSUER); + const { url } = await authorizationRequest({ client_id: 'oni', redirect_uri: 'http://localhost:5173/callback', scope: 'openid' }); + await signIn(browser, url, ALICE); + + const page = await browser.follow('/admin/'); + + expect(page.statusCode).toBe(200); + expect(page.body).toContain('Alice <Admin>'); + }); + + it('shows a user who is not an admin their sub and a way to sign out', async () => { + const browser = new Browser(app, ISSUER); + + const page = await signIn(browser, '/admin/', MALLORY); + + expect(page.statusCode).toBe(403); + expect(page.body).toContain(MALLORY.sub); + expect(page.body).toMatch(/
/); + }); + + it('sends a session older than 8 hours to sign in again', async () => { + const browser = new Browser(app, ISSUER); + await signIn(browser, '/admin/', ALICE); + vi.useFakeTimers({ toFake: ['Date'] }); + + vi.setSystemTime(Date.now() + 8 * 60 * 60 * 1000 - 60 * 1000); + expect((await browser.get('/admin/')).statusCode).toBe(200); + vi.setSystemTime(Date.now() + 60 * 1000); + const expired = await browser.get('/admin/'); + + expect(expired.statusCode).toBe(303); + expect(new URL(expired.headers.location as string).pathname).toBe('/oauth/authorize'); + }); + + it('treats a tampered session cookie as no session', async () => { + const browser = new Browser(app, ISSUER); + await signIn(browser, '/admin/', ALICE); + const cookie = browser.cookie('admin_session') ?? ''; + browser.setCookie('admin_session', `${cookie[0] === 'A' ? 'B' : 'A'}${cookie.slice(1)}`); + + const page = await browser.get('/admin/'); + + expect(page.statusCode).toBe(303); + expect(new URL(page.headers.location as string).pathname).toBe('/oauth/authorize'); + }); + + it('refuses a callback whose state does not match the sign-in', async () => { + const browser = new Browser(app, ISSUER); + const toUpstream = await browser.follow('/admin/'); + let toAdmin = new URL(upstream.signIn(toUpstream.headers.location as string, ALICE)); + while (toAdmin.pathname !== '/admin/callback') { + toAdmin = new URL((await browser.get(toAdmin.toString())).headers.location as string, ISSUER); + } + toAdmin.searchParams.set('state', 'forged'); + + const refused = await browser.get(toAdmin.toString()); + + expect(refused.statusCode).toBe(400); + expect(refused.headers.location).toBeUndefined(); + expect((await browser.get('/admin/')).statusCode).toBe(303); + }); + + it('signs out of the admin pages and our provider', async () => { + const browser = new Browser(app, ISSUER); + await signIn(browser, '/admin/', ALICE); + + const signedOut = await browser.post('/admin/sign-out', {}); + + expect(signedOut.statusCode).toBe(303); + const endSession = new URL(signedOut.headers.location as string); + expect(`${endSession.origin}${endSession.pathname}`).toBe(`${ISSUER}/oauth/session/end`); + expect(endSession.searchParams.get('id_token_hint')).toEqual(expect.any(String)); + expect(endSession.searchParams.get('post_logout_redirect_uri')).toBe(`${ISSUER}/admin/`); + expect(browser.cookie('admin_session')).toBeUndefined(); + + const confirm = await browser.get(endSession.toString()); + const xsrf = confirm.body.match(/name="xsrf" value="([^"]+)"/)?.[1] ?? ''; + const backToAdmin = await browser.post('/oauth/session/end/confirm', { xsrf, logout: 'yes' }); + expect(backToAdmin.headers.location).toBe(`${ISSUER}/admin/`); + const again = await browser.follow('/admin/'); + expect(new URL(again.headers.location as string).origin).toBe(upstream.issuer); + }); + + it('refuses a POST with a missing or foreign Origin', async () => { + const browser = new Browser(app, ISSUER); + await signIn(browser, '/admin/', ALICE); + + for (const headers of [{}, { origin: 'https://evil.example.org' }]) { + expect((await browser.post('/admin/sign-out', {}, headers)).statusCode).toBe(403); + } + expect((await browser.get('/admin/')).statusCode).toBe(200); + }); + + it('allows only our own scripts and styles, and no framing', async () => { + const page = await new Browser(app, ISSUER).get('/admin/'); + + const csp = page.headers['content-security-policy'] as string; + expect(csp).toContain("script-src 'self'"); + expect(csp).toContain("style-src 'self'"); + expect(csp).toContain("frame-ancestors 'none'"); + }); +}); diff --git a/src/admin/sign-in.ts b/src/admin/sign-in.ts new file mode 100644 index 0000000..406f594 --- /dev/null +++ b/src/admin/sign-in.ts @@ -0,0 +1,90 @@ +import type { FastifyInstance } from 'fastify'; +import { + allowInsecureRequests, + authorizationCodeGrant, + buildAuthorizationUrl, + buildEndSessionUrl, + Configuration, + calculatePKCECodeChallenge, + customFetch, + randomNonce, + randomPKCECodeVerifier, + randomState, +} from 'openid-client'; +import type { AuthConfig } from '../configuration.ts'; +import { adminClient } from './client.ts'; + +/** What the callback checks the provider's response against, and where it sends the browser after */ +export type SignInTransaction = { state: string; codeVerifier: string; nonce: string; returnTo: string }; + +/** Signs admins in at our own provider, reaching its token endpoint in-process rather than through our public URL */ +export const createSignIn = (fastify: FastifyInstance, auth: AuthConfig) => { + const client = adminClient(auth); + const [redirectUri = ''] = client.redirect_uris; + const [postLogoutRedirectUri = ''] = client.post_logout_redirect_uris; + const configuration = new Configuration( + { + issuer: auth.issuer, + authorization_endpoint: `${auth.issuer}/oauth/authorize`, + token_endpoint: `${auth.issuer}/oauth/token`, + end_session_endpoint: `${auth.issuer}/oauth/session/end`, + }, + client.client_id, + client.client_secret, + ); + if (new URL(auth.issuer).protocol === 'http:') { + allowInsecureRequests(configuration); + } + configuration[customFetch] = async (url, { method, headers, body }) => { + const { pathname, search } = new URL(url); + const response = await fastify.inject({ + method: method as 'POST', + url: `${pathname}${search}`, + headers, + ...(body === undefined ? {} : { payload: String(body) }), + }); + const responseHeaders = new Headers(); + for (const [name, value] of Object.entries(response.headers)) { + for (const each of [value ?? []].flat()) { + responseHeaders.append(name, String(each)); + } + } + return new Response(response.body, { status: response.statusCode, headers: responseHeaders }); + }; + + return { + async start(returnTo: string) { + const transaction: SignInTransaction = { state: randomState(), codeVerifier: randomPKCECodeVerifier(), nonce: randomNonce(), returnTo }; + const url = buildAuthorizationUrl(configuration, { + redirect_uri: redirectUri, + scope: client.scope, + code_challenge: await calculatePKCECodeChallenge(transaction.codeVerifier), + code_challenge_method: 'S256', + state: transaction.state, + nonce: transaction.nonce, + }); + return { url, transaction }; + }, + + /** The ID token from the callback's query, once it checks out against the transaction */ + async finish(query: Record, { state, codeVerifier, nonce }: SignInTransaction) { + const currentUrl = new URL(redirectUri); + currentUrl.search = new URLSearchParams(query).toString(); + const tokens = await authorizationCodeGrant(configuration, currentUrl, { + pkceCodeVerifier: codeVerifier, + expectedState: state, + expectedNonce: nonce, + idTokenExpected: true, + }); + const claims = tokens.claims(); + if (!claims || !tokens.id_token) { + throw new Error('No ID token'); + } + return { sub: claims.sub, idToken: tokens.id_token }; + }, + + signOutUrl(idToken: string) { + return buildEndSessionUrl(configuration, { id_token_hint: idToken, post_logout_redirect_uri: postLogoutRedirectUri }); + }, + }; +}; diff --git a/src/app.ts b/src/app.ts index 760b1e2..20d709e 100644 --- a/src/app.ts +++ b/src/app.ts @@ -6,6 +6,7 @@ import { EntityMetaMetadata } from 'arocapi/storage/entity-meta'; import Fastify, { type FastifyPluginAsync } from 'fastify'; import { serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod'; import pkg from '../package.json' with { type: 'json' }; +import { adminPages } from './admin/plugin.ts'; import { access, isAuthenticated } from './auth.ts'; import { config } from './configuration.ts'; import { logger } from './logger.ts'; @@ -68,6 +69,9 @@ export function buildApp({ repository, search }: AppDeps) { ); if (config.auth) { fastify.register(oidcProvider, { auth: config.auth }); + fastify.register(adminPages, { prefix: '/admin', auth: config.auth, adminUsers: config.adminUsers }); + } else { + fastify.log.warn('The admin pages are off because sign-in is off; set OIDC_UPSTREAM_ISSUER to turn them on'); } return fastify; diff --git a/src/configuration.ts b/src/configuration.ts index 57f445e..2f30a83 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -118,6 +118,15 @@ export function loadConfig(processEnv: Env) { OIDC_UPSTREAM_CLIENT_SECRET: z.string().optional(), OIDC_SIGNING_KEYS: signingKeys.optional(), OIDC_COOKIE_KEYS: cookieKeys.optional(), + ADMIN_USERS: z + .string() + .default('') + .transform((value) => + value + .split(',') + .map((sub) => sub.trim()) + .filter(Boolean), + ), }) .transform((env, ctx) => { const { @@ -182,6 +191,7 @@ export function loadConfig(processEnv: Env) { }, enrollmentUrl: env.ENROLLMENT_URL, auth: env.auth, + adminUsers: env.ADMIN_USERS, indexType, search: { entityIndex: env.ENTITY_INDEX }, }; diff --git a/src/oidc/provider.ts b/src/oidc/provider.ts index ba664cf..45c2a07 100644 --- a/src/oidc/provider.ts +++ b/src/oidc/provider.ts @@ -1,5 +1,6 @@ import type { FastifyPluginAsync, FastifyReply, FastifyRequest, onRequestHookHandler } from 'fastify'; import Provider, { type ClientMetadata, type Configuration, errors, type InteractionResults, type KoaContextWithOIDC } from 'oidc-provider'; +import { adminClient } from '../admin/client.ts'; import type { AuthConfig, OidcClient } from '../configuration.ts'; import { logger } from '../logger.ts'; import { deleteExpired, PrismaAdapter } from './adapter.ts'; @@ -65,10 +66,11 @@ const loadExistingGrant = (audience: string) => async (ctx: KoaContextWithOIDC) const toClientMetadata = (client: OidcClient): ClientMetadata => (client.client_secret ? client : { ...client, token_endpoint_auth_method: 'none' }); -const createProvider = ({ issuer, audience, clients, signingKeys, cookieKeys }: AuthConfig) => { +const createProvider = (auth: AuthConfig) => { + const { issuer, audience, clients, signingKeys, cookieKeys } = auth; const provider = new Provider(issuer, { adapter: PrismaAdapter, - clients: clients.map(toClientMetadata), + clients: [...clients.map(toClientMetadata), adminClient(auth)], clientDefaults: { grant_types: ['authorization_code', 'refresh_token'] }, jwks: { keys: signingKeys }, cookies: { keys: cookieKeys }, diff --git a/test/oidc.ts b/test/oidc.ts index 2da9c7e..f4d46c5 100644 --- a/test/oidc.ts +++ b/test/oidc.ts @@ -7,6 +7,8 @@ export class Browser { private readonly cookies = new Map(); private readonly app: TestApp; private readonly origin: string; + /** Where the last `follow` ended */ + url = ''; constructor(app: TestApp, origin: string) { this.app = app; @@ -20,12 +22,13 @@ export class Browser { return response; } - async post(url: string, form: Record) { + /** Posts `form` with the `Origin` a browser sends, unless `headers` say otherwise */ + async post(url: string, form: Record, headers: Record = { origin: this.origin }) { const { pathname, search, host } = new URL(url, this.origin); const response = await this.app.inject({ method: 'POST', url: `${pathname}${search}`, - headers: { host, cookie: this.cookieHeader(), 'content-type': 'application/x-www-form-urlencoded' }, + headers: { host, cookie: this.cookieHeader(), 'content-type': 'application/x-www-form-urlencoded', ...headers }, payload: new URLSearchParams(form).toString(), }); this.store(response); @@ -34,17 +37,27 @@ export class Browser { /** Follows redirects within the app, returning the first response that leaves it */ async follow(url: string) { + this.url = url; let response = await this.get(url); let location = response.headers.location as string | undefined; while (response.statusCode >= 300 && response.statusCode < 400 && location && new URL(location, this.origin).origin === this.origin) { + this.url = location; response = await this.get(location); location = response.headers.location as string | undefined; } return response; } + cookie(name: string) { + return this.cookies.get(name); + } + + setCookie(name: string, value: string) { + this.cookies.set(name, value); + } + private cookieHeader() { - return [...this.cookies].map(([name, value]) => `${name}=${value}`).join('; '); + return [...this.cookies].map(([name, value]) => `${name}=${encodeURIComponent(value)}`).join('; '); } private store(response: LightMyRequestResponse) { From 28ee45d2331ea3e07dc2a909b2e39d49fcb84062 Mon Sep 17 00:00:00 2001 From: John Ferlito Date: Wed, 28 Oct 2026 09:37:20 +1100 Subject: [PATCH 02/10] feat: style the admin pages with Tailwind @tailwindcss/cli builds src/admin/public/admin.css (gitignored) from the admin templates: pnpm build builds it and pnpm dev watches it. @fastify/static serves it under /admin/. Tests build it in global setup when it is missing. --- .gitignore | 2 + README.md | 9 +- biome.jsonc | 1 + package.json | 8 +- pnpm-lock.yaml | 653 ++++++++++++++++++++++++++++++++++++++ pnpm-workspace.yaml | 2 + src/admin/admin.css | 3 + src/admin/layout.ts | 30 +- src/admin/plugin.ts | 11 +- src/admin/sign-in.test.ts | 10 + test/global-setup.ts | 6 + 11 files changed, 719 insertions(+), 16 deletions(-) create mode 100644 src/admin/admin.css diff --git a/.gitignore b/.gitignore index ea5a8ae..9f6a659 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,8 @@ node_modules/ # Generated files /src/generated/prisma /storage/ +# Built from src/admin/admin.css by `pnpm build` (Tailwind); served at /admin/admin.css +/src/admin/public/admin.css # AI .claude/worktrees diff --git a/README.md b/README.md index 1a8017a..7ec49fc 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,8 @@ No configuration is needed for development. To override a default, copy | Script | Does | | -------------------- | -------------------------------------------------------------------------------- | -| `pnpm dev` | Run the API, restarting on changes | +| `pnpm dev` | Run the API, restarting on changes, and rebuild the admin pages' stylesheet | +| `pnpm build` | Build the admin pages' stylesheet (run before deploying) | | `pnpm test` | Run the tests (needs `docker compose up`) | | `pnpm lint` | Biome, Knip and TypeScript | | `pnpm bootstrap` | `db:deploy`, `seed` and `index` in one go | @@ -195,6 +196,12 @@ The admin pages are a client of our provider that needs no entry in `OIDC_CLIENTS`; their client secret and session keys are derived from `OIDC_COOKIE_KEYS`, so they rotate with it. +The admin pages are styled with [Tailwind](https://tailwindcss.com/), built by +`@tailwindcss/cli` from `src/admin/admin.css`, which scans the admin module's +templates, into `src/admin/public/admin.css`. That file is not checked in: a +deploy must run `pnpm build` to create it, and `pnpm dev` rebuilds it as you +edit the templates. + ## Database The schema is managed with Prisma migrations in `prisma/migrations`. arocapi diff --git a/biome.jsonc b/biome.jsonc index cd7fc18..c612eb6 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -20,6 +20,7 @@ } } }, + "css": { "parser": { "tailwindDirectives": true } }, "javascript": { "formatter": { "quoteStyle": "single" diff --git a/package.json b/package.json index 9e4287f..9a3defa 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,10 @@ "postinstall": "prisma generate", "test": "vitest run", "test:watch": "vitest", - "dev": "node --env-file-if-exists=.env --watch src/index.ts", + "build": "tailwindcss -i src/admin/admin.css -o src/admin/public/admin.css --minify", + "dev": "pnpm run --parallel \"/^dev:/\"", + "dev:api": "node --env-file-if-exists=.env --watch src/index.ts", + "dev:css": "tailwindcss -i src/admin/admin.css -o src/admin/public/admin.css --watch=always", "seed": "node --env-file-if-exists=.env scripts/seed.ts", "index": "node --env-file-if-exists=.env scripts/index.ts", "bootstrap": "pnpm db:deploy && pnpm seed && pnpm index", @@ -49,6 +52,7 @@ "@fastify/formbody": "^9.0.1", "@fastify/secure-session": "^8.4.1", "@fastify/sensible": "^6.0.7", + "@fastify/static": "^10.1.7", "@ocfl/ocfl-fs": "^0.4.0", "@opensearch-project/opensearch": "^3.9.0", "@prisma/adapter-pg": "^7.10.1", @@ -69,11 +73,13 @@ "@commitlint/cli": "^21.2.4", "@commitlint/config-conventional": "^21.2.4", "@ocfl/ocfl": "^0.4.0", + "@tailwindcss/cli": "^4.3.3", "@types/node": "^24.19.2", "@types/oidc-provider": "^9.12.1", "knip": "^6.42.1", "lefthook": "^2.2.2", "pino-pretty": "^13.2.0", + "tailwindcss": "^4.3.3", "typescript": "^7.0.2", "vitest": "^5.0.5" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7e150d7..921e0d3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -181,6 +181,9 @@ importers: '@fastify/sensible': specifier: ^6.0.7 version: 6.0.7 + '@fastify/static': + specifier: ^10.1.7 + version: 10.1.7 '@ocfl/ocfl-fs': specifier: ^0.4.0 version: 0.4.0 @@ -236,6 +239,9 @@ importers: '@ocfl/ocfl': specifier: ^0.4.0 version: 0.4.0 + '@tailwindcss/cli': + specifier: ^4.3.3 + version: 4.3.3 '@types/node': specifier: ^24.19.2 version: 24.19.2 @@ -251,6 +257,9 @@ importers: pino-pretty: specifier: ^13.2.0 version: 13.2.0 + tailwindcss: + specifier: ^4.3.3 + version: 4.3.3 typescript: specifier: ^7.0.2 version: 7.0.2 @@ -433,6 +442,9 @@ packages: '@emnapi/wasi-threads@1.2.2': resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + '@fastify/accept-negotiator@2.1.2': + resolution: {integrity: sha512-LV7MZqTwXTEt3GxBT6BU8ACztWnv++hSkMEsP3usIHiI0tA4F5EO4THhDCOr5J/ginM9DCnLCmZ6u3JLA9x8Iw==} + '@fastify/ajv-compiler@4.0.6': resolution: {integrity: sha512-NtuzM0SfaMJbGlnjr9LWQUN5LzgSrbB8tf/wRZNas+4E1O/Nmzl53e7ruT61HDZyRCJGC6FxIogmNZO1c5ETBA==} @@ -467,12 +479,24 @@ packages: resolution: {integrity: sha512-V9khuSlcbpupL3ZVKvAsnPtxFWDj41946W17vmRYvL4Ov57lh7ZVyYYioB4SYNf7xdV8qa+ZRQ86Ni9sBE2oQQ==} hasBin: true + '@fastify/send@4.2.0': + resolution: {integrity: sha512-B5Hr+IFsPbqJJQu6FHE2MFpnHfbPlCOVQUPTdm6PxCcilYGrEQsulbO0z9blgbjndIyLJwa2NWFIIjXpPhbwUg==} + '@fastify/sensible@6.0.7': resolution: {integrity: sha512-e4+bJEcAPGdKsIljulWtEKeECuYGLkVTB1dgWh19m2u4ttETS/5EWZhw6KPXlgi6ZiSet5IqYrcMRMrRub0Hrg==} + '@fastify/static@10.1.7': + resolution: {integrity: sha512-7+lUiPRraENCiULgjhcqZyWSW6qnwIT87Is0u3c2Z3iGEEEGB45wJhYxzrSdzu12gHM4qcviEYcPEiCe4J6MCQ==} + '@fastify/swagger@9.9.1': resolution: {integrity: sha512-RlpdOjU6NC29799y5v0beSlmEu8spEbx7cYNzVaCCihEM0xh9hIdxSQDzom2U9W9ar0KuMA6JnXuFCrcuz9B5A==} + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} @@ -736,6 +760,94 @@ packages: cpu: [x64] os: [win32] + '@parcel/watcher-android-arm64@2.5.1': + resolution: {integrity: sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [android] + + '@parcel/watcher-darwin-arm64@2.5.1': + resolution: {integrity: sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [darwin] + + '@parcel/watcher-darwin-x64@2.5.1': + resolution: {integrity: sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [darwin] + + '@parcel/watcher-freebsd-x64@2.5.1': + resolution: {integrity: sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [freebsd] + + '@parcel/watcher-linux-arm-glibc@2.5.1': + resolution: {integrity: sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@parcel/watcher-linux-arm-musl@2.5.1': + resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + libc: [musl] + + '@parcel/watcher-linux-arm64-glibc@2.5.1': + resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@parcel/watcher-linux-arm64-musl@2.5.1': + resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@parcel/watcher-linux-x64-glibc@2.5.1': + resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@parcel/watcher-linux-x64-musl@2.5.1': + resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@parcel/watcher-win32-arm64@2.5.1': + resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [win32] + + '@parcel/watcher-win32-ia32@2.5.1': + resolution: {integrity: sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==} + engines: {node: '>= 10.0.0'} + cpu: [ia32] + os: [win32] + + '@parcel/watcher-win32-x64@2.5.1': + resolution: {integrity: sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [win32] + + '@parcel/watcher@2.5.1': + resolution: {integrity: sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==} + engines: {node: '>= 10.0.0'} + '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} @@ -986,6 +1098,99 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@tailwindcss/cli@4.3.3': + resolution: {integrity: sha512-ZvS/n1ZHOBKcVlhkt8l5NNr1EDXk1NboYO5CYDOs6NUmvT9z6bzkwsosaJftY57T/3gWNzWMJzIXLodZC8ssdw==} + hasBin: true + + '@tailwindcss/node@4.3.3': + resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==} + + '@tailwindcss/oxide-android-arm64@4.3.3': + resolution: {integrity: sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.3.3': + resolution: {integrity: sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.3.3': + resolution: {integrity: sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.3.3': + resolution: {integrity: sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': + resolution: {integrity: sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': + resolution: {integrity: sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': + resolution: {integrity: sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': + resolution: {integrity: sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-x64-musl@4.3.3': + resolution: {integrity: sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-wasm32-wasi@4.3.3': + resolution: {integrity: sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': + resolution: {integrity: sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': + resolution: {integrity: sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.3.3': + resolution: {integrity: sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==} + engines: {node: '>= 20'} + '@tybys/wasm-util@0.10.4': resolution: {integrity: sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==} @@ -1340,6 +1545,10 @@ packages: react-native-b4a: optional: true + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + bare-addon-resolve@1.11.0: resolution: {integrity: sha512-bTUBSqBjzQQzo2/iIk6KxAClZfahy38EPsJV2bPJ+p2+kS8h+hT5TE5QggpkFUrCnLL4HDTA4DDM+YiNdyol0w==} peerDependencies: @@ -1403,6 +1612,14 @@ packages: better-result@2.10.0: resolution: {integrity: sha512-oQhh0y1qo2/ZKdAAEvHZAqKKiHOFU5k/bW96fE2ScgQOVkJRiHwB+nOS1SgFsYqRlxMDWvefXi9Q3px7QvgNDw==} + brace-expansion@5.0.12: + resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==} + engines: {node: 20 || >=22} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + c12@3.3.4: resolution: {integrity: sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==} peerDependencies: @@ -1598,6 +1815,11 @@ packages: resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + detect-libc@1.0.3: + resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==} + engines: {node: '>=0.10'} + hasBin: true + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -1629,6 +1851,10 @@ packages: end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + enhanced-resolve@5.26.0: + resolution: {integrity: sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw==} + engines: {node: '>=10.13.0'} + env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -1726,6 +1952,10 @@ packages: picomatch: optional: true + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + find-my-way@9.7.0: resolution: {integrity: sha512-f2JHn75x2JlwUwLenZypgczR7YWMb/uO9BvUXtus+JMgkbIkLADd38cI4EiV+OQqrGo1Zlq6V8wnqMJ8e62wUQ==} engines: {node: '>=20'} @@ -1777,6 +2007,10 @@ packages: resolution: {integrity: sha512-r+mvuDjrjMpsdw46Kmeydb8bdHm7wOKw8wNBtTndkjbPjgAp5oUJUxRE76wZFknxIPokfWvep2qSXK37aXE6zg==} hasBin: true + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + global-directory@5.0.0: resolution: {integrity: sha512-1pgFdhK3J2LeM+dVf2Pd424yHx2ou338lC0ErNP2hPx4j8eW1Sp0XqSjNxtk6Tc4Kr5wlWtSvz8cn2yb7/SG/w==} engines: {node: '>=20'} @@ -1834,6 +2068,18 @@ packages: is-arrayish@0.2.1: resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + is-plain-obj@4.1.0: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} @@ -1960,36 +2206,73 @@ packages: light-my-request@6.6.0: resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + lightningcss-android-arm64@1.33.0: resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [android] + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + lightningcss-darwin-arm64@1.33.0: resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [darwin] + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + lightningcss-darwin-x64@1.33.0: resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [darwin] + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + lightningcss-freebsd-x64@1.33.0: resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [freebsd] + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + lightningcss-linux-arm-gnueabihf@1.33.0: resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} engines: {node: '>= 12.0.0'} cpu: [arm] os: [linux] + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + lightningcss-linux-arm64-gnu@1.33.0: resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} engines: {node: '>= 12.0.0'} @@ -1997,6 +2280,13 @@ packages: os: [linux] libc: [glibc] + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + lightningcss-linux-arm64-musl@1.33.0: resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} engines: {node: '>= 12.0.0'} @@ -2004,6 +2294,13 @@ packages: os: [linux] libc: [musl] + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + lightningcss-linux-x64-gnu@1.33.0: resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} engines: {node: '>= 12.0.0'} @@ -2011,6 +2308,13 @@ packages: os: [linux] libc: [glibc] + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + lightningcss-linux-x64-musl@1.33.0: resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} engines: {node: '>= 12.0.0'} @@ -2018,18 +2322,34 @@ packages: os: [linux] libc: [musl] + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + lightningcss-win32-arm64-msvc@1.33.0: resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [win32] + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + lightningcss-win32-x64-msvc@1.33.0: resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [win32] + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + lightningcss@1.33.0: resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} engines: {node: '>= 12.0.0'} @@ -2043,10 +2363,17 @@ packages: long@5.3.2: resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + lru-cache@11.5.3: + resolution: {integrity: sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==} + engines: {node: 20 || >=22} + lru.min@1.1.5: resolution: {integrity: sha512-5J9ysMYUpYIg9RF2vJpy9SinEmSviFSe0GyPpCQ4L5QSkLAgeLXlTAOu2ZwWUU5m+0SBl6gUU1R1ZQB3aKypfA==} engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magic-string@1.4.3: resolution: {integrity: sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==} @@ -2054,6 +2381,10 @@ packages: resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==} engines: {node: '>= 0.8'} + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + mime-db@1.52.0: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} @@ -2070,9 +2401,26 @@ packages: resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} engines: {node: '>=18'} + mime@3.0.0: + resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} + engines: {node: '>=10.0.0'} + hasBin: true + + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + mri@1.2.0: + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} + engines: {node: '>=4'} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -2095,6 +2443,9 @@ packages: resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} engines: {node: '>= 0.6'} + node-addon-api@7.1.1: + resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + node-fetch@2.7.0: resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} engines: {node: 4.x || >=6.0.0} @@ -2160,6 +2511,10 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -2203,6 +2558,10 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + picomatch@4.0.7: resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} engines: {node: '>=12'} @@ -2457,6 +2816,13 @@ packages: resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} engines: {node: '>=14.16'} + tailwindcss@4.3.3: + resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} + + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + teex@1.0.1: resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==} @@ -2479,6 +2845,10 @@ packages: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + toad-cache@3.7.4: resolution: {integrity: sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==} engines: {node: '>=20'} @@ -2854,6 +3224,8 @@ snapshots: tslib: 2.8.1 optional: true + '@fastify/accept-negotiator@2.1.2': {} + '@fastify/ajv-compiler@4.0.6': dependencies: ajv: 8.20.0 @@ -2909,6 +3281,14 @@ snapshots: - bare-url - react-native-b4a + '@fastify/send@4.2.0': + dependencies: + '@lukeed/ms': 2.0.2 + escape-html: 1.0.3 + fast-decode-uri-component: 1.0.1 + http-errors: 2.0.1 + mime: 3.0.0 + '@fastify/sensible@6.0.7': dependencies: '@lukeed/ms': 2.0.2 @@ -2919,6 +3299,16 @@ snapshots: type-is: 2.1.0 vary: 1.1.2 + '@fastify/static@10.1.7': + dependencies: + '@fastify/accept-negotiator': 2.1.2 + '@fastify/error': 4.2.0 + '@fastify/send': 4.2.0 + content-disposition: 3.0.0 + fastify-plugin: 6.0.1 + fastq: 1.20.3 + glob: 13.0.6 + '@fastify/swagger@9.9.1': dependencies: fastify-plugin: 6.0.1 @@ -2929,6 +3319,16 @@ snapshots: transitivePeerDependencies: - supports-color + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + '@jridgewell/resolve-uri@3.1.2': {} '@jridgewell/sourcemap-codec@1.6.0': {} @@ -3088,6 +3488,66 @@ snapshots: '@oxc-resolver/binding-win32-x64-msvc@11.24.2': optional: true + '@parcel/watcher-android-arm64@2.5.1': + optional: true + + '@parcel/watcher-darwin-arm64@2.5.1': + optional: true + + '@parcel/watcher-darwin-x64@2.5.1': + optional: true + + '@parcel/watcher-freebsd-x64@2.5.1': + optional: true + + '@parcel/watcher-linux-arm-glibc@2.5.1': + optional: true + + '@parcel/watcher-linux-arm-musl@2.5.1': + optional: true + + '@parcel/watcher-linux-arm64-glibc@2.5.1': + optional: true + + '@parcel/watcher-linux-arm64-musl@2.5.1': + optional: true + + '@parcel/watcher-linux-x64-glibc@2.5.1': + optional: true + + '@parcel/watcher-linux-x64-musl@2.5.1': + optional: true + + '@parcel/watcher-win32-arm64@2.5.1': + optional: true + + '@parcel/watcher-win32-ia32@2.5.1': + optional: true + + '@parcel/watcher-win32-x64@2.5.1': + optional: true + + '@parcel/watcher@2.5.1': + dependencies: + detect-libc: 1.0.3 + is-glob: 4.0.3 + micromatch: 4.0.8 + node-addon-api: 7.1.1 + optionalDependencies: + '@parcel/watcher-android-arm64': 2.5.1 + '@parcel/watcher-darwin-arm64': 2.5.1 + '@parcel/watcher-darwin-x64': 2.5.1 + '@parcel/watcher-freebsd-x64': 2.5.1 + '@parcel/watcher-linux-arm-glibc': 2.5.1 + '@parcel/watcher-linux-arm-musl': 2.5.1 + '@parcel/watcher-linux-arm64-glibc': 2.5.1 + '@parcel/watcher-linux-arm64-musl': 2.5.1 + '@parcel/watcher-linux-x64-glibc': 2.5.1 + '@parcel/watcher-linux-x64-musl': 2.5.1 + '@parcel/watcher-win32-arm64': 2.5.1 + '@parcel/watcher-win32-ia32': 2.5.1 + '@parcel/watcher-win32-x64': 2.5.1 + '@pinojs/redact@0.4.0': {} '@prisma/adapter-pg@7.10.1': @@ -3305,6 +3765,77 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@tailwindcss/cli@4.3.3': + dependencies: + '@parcel/watcher': 2.5.1 + '@tailwindcss/node': 4.3.3 + '@tailwindcss/oxide': 4.3.3 + enhanced-resolve: 5.26.0 + mri: 1.2.0 + picocolors: 1.1.1 + tailwindcss: 4.3.3 + + '@tailwindcss/node@4.3.3': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.26.0 + jiti: 2.7.0 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.2 + tailwindcss: 4.3.3 + + '@tailwindcss/oxide-android-arm64@4.3.3': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.3.3': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.3.3': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.3.3': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.3.3': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': + optional: true + + '@tailwindcss/oxide@4.3.3': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-x64': 4.3.3 + '@tailwindcss/oxide-freebsd-x64': 4.3.3 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.3 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.3 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-x64-musl': 4.3.3 + '@tailwindcss/oxide-wasm32-wasi': 4.3.3 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.3 + '@tybys/wasm-util@0.10.4': dependencies: tslib: 2.8.1 @@ -3646,6 +4177,8 @@ snapshots: b4a@1.9.0: {} + balanced-match@4.0.4: {} + bare-addon-resolve@1.11.0: dependencies: bare-module-resolve: 1.12.6 @@ -3701,6 +4234,14 @@ snapshots: better-result@2.10.0: {} + brace-expansion@5.0.12: + dependencies: + balanced-match: 4.0.4 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + c12@3.3.4: dependencies: chokidar: 5.0.0 @@ -3867,6 +4408,8 @@ snapshots: destroy@1.2.0: {} + detect-libc@1.0.3: {} + detect-libc@2.1.2: {} dotenv@17.4.2: {} @@ -3890,6 +4433,11 @@ snapshots: dependencies: once: 1.4.0 + enhanced-resolve@5.26.0: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + env-paths@2.2.1: {} env-paths@3.0.0: {} @@ -3991,6 +4539,10 @@ snapshots: optionalDependencies: picomatch: 4.0.7 + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + find-my-way@9.7.0: dependencies: fast-deep-equal: 3.1.3 @@ -4036,6 +4588,12 @@ snapshots: giget@3.3.1: {} + glob@13.0.6: + dependencies: + minimatch: 10.2.6 + minipass: 7.1.3 + path-scurry: 2.0.2 + global-directory@5.0.0: dependencies: ini: 6.0.0 @@ -4090,6 +4648,14 @@ snapshots: is-arrayish@0.2.1: {} + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-number@7.0.0: {} + is-plain-obj@4.1.0: {} is-property@1.0.2: {} @@ -4223,39 +4789,88 @@ snapshots: process-warning: 4.0.1 set-cookie-parser: 2.7.2 + lightningcss-android-arm64@1.32.0: + optional: true + lightningcss-android-arm64@1.33.0: optional: true + lightningcss-darwin-arm64@1.32.0: + optional: true + lightningcss-darwin-arm64@1.33.0: optional: true + lightningcss-darwin-x64@1.32.0: + optional: true + lightningcss-darwin-x64@1.33.0: optional: true + lightningcss-freebsd-x64@1.32.0: + optional: true + lightningcss-freebsd-x64@1.33.0: optional: true + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + lightningcss-linux-arm-gnueabihf@1.33.0: optional: true + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + lightningcss-linux-arm64-gnu@1.33.0: optional: true + lightningcss-linux-arm64-musl@1.32.0: + optional: true + lightningcss-linux-arm64-musl@1.33.0: optional: true + lightningcss-linux-x64-gnu@1.32.0: + optional: true + lightningcss-linux-x64-gnu@1.33.0: optional: true + lightningcss-linux-x64-musl@1.32.0: + optional: true + lightningcss-linux-x64-musl@1.33.0: optional: true + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + lightningcss-win32-arm64-msvc@1.33.0: optional: true + lightningcss-win32-x64-msvc@1.32.0: + optional: true + lightningcss-win32-x64-msvc@1.33.0: optional: true + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + lightningcss@1.33.0: dependencies: detect-libc: 2.1.2 @@ -4278,14 +4893,25 @@ snapshots: long@5.3.2: {} + lru-cache@11.5.3: {} + lru.min@1.1.5: {} + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + magic-string@1.4.3: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 media-typer@1.1.1: {} + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + mime-db@1.52.0: {} mime-db@1.54.0: {} @@ -4298,8 +4924,18 @@ snapshots: dependencies: mime-db: 1.54.0 + mime@3.0.0: {} + + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.12 + minimist@1.2.8: {} + minipass@7.1.3: {} + + mri@1.2.0: {} + ms@2.1.3: {} mysql2@3.24.4(@types/node@24.19.2): @@ -4321,6 +4957,8 @@ snapshots: negotiator@0.6.3: {} + node-addon-api@7.1.1: {} + node-fetch@2.7.0: dependencies: whatwg-url: 5.0.0 @@ -4419,6 +5057,11 @@ snapshots: path-key@3.1.1: {} + path-scurry@2.0.2: + dependencies: + lru-cache: 11.5.3 + minipass: 7.1.3 + pathe@2.0.3: {} perfect-debounce@2.1.0: {} @@ -4460,6 +5103,8 @@ snapshots: picocolors@1.1.1: {} + picomatch@2.3.2: {} + picomatch@4.0.7: {} pino-abstract-transport@3.0.0: @@ -4723,6 +5368,10 @@ snapshots: strip-json-comments@5.0.3: {} + tailwindcss@4.3.3: {} + + tapable@2.3.3: {} + teex@1.0.1: dependencies: streamx: 2.28.1 @@ -4749,6 +5398,10 @@ snapshots: fdir: 6.5.0(picomatch@4.0.7) picomatch: 4.0.7 + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + toad-cache@3.7.4: {} toidentifier@1.0.1: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 16c912c..0deed1b 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -8,5 +8,7 @@ allowBuilds: "@prisma/engines": true prisma: true lefthook: true + # ships prebuilt binaries; its install script only builds from source + "@parcel/watcher": false minimumReleaseAgeExclude: - arocapi@5.0.0-beta.7 diff --git a/src/admin/admin.css b/src/admin/admin.css new file mode 100644 index 0000000..42885b2 --- /dev/null +++ b/src/admin/admin.css @@ -0,0 +1,3 @@ +@import "tailwindcss" source(none); +@source "./**/*.ts"; +@source not "./**/*.test.ts"; diff --git a/src/admin/layout.ts b/src/admin/layout.ts index 515c509..5567b89 100644 --- a/src/admin/layout.ts +++ b/src/admin/layout.ts @@ -8,23 +8,31 @@ type LayoutOptions = { }; export const layout = ({ title, user, body }: LayoutOptions) => html` - + ${title} · LDaCA admin + - -
-

LDaCA admin

- ${ - user && - html`

Signed in as ${user}

- ` - } + +
+
+

+ LDaCA + Admin +

+ ${ + user && + html`
+

Signed in as ${user}

+
+
` + } +
-
-

${title}

+
+

${title}

${body}
diff --git a/src/admin/plugin.ts b/src/admin/plugin.ts index 8585281..93d8df3 100644 --- a/src/admin/plugin.ts +++ b/src/admin/plugin.ts @@ -1,5 +1,7 @@ +import { fileURLToPath } from 'node:url'; import formBody from '@fastify/formbody'; import secureSession, { type Session, type SessionData } from '@fastify/secure-session'; +import fastifyStatic from '@fastify/static'; import type { FastifyPluginAsync, FastifyReply } from 'fastify'; import { decodeJwt } from 'jose'; import type { AuthConfig } from '../configuration.ts'; @@ -49,6 +51,7 @@ export const adminPages: FastifyPluginAsync = async (fastify, { au const keys = (purpose: string) => auth.cookieKeys.map((key) => derivedKey(key, purpose)); fastify.register(formBody); + fastify.register(fastifyStatic, { root: fileURLToPath(new URL('public', import.meta.url)), decorateReply: false, index: false }); fastify.register(secureSession, [ { sessionName: 'session', @@ -100,9 +103,11 @@ export const adminPages: FastifyPluginAsync = async (fastify, { au return reply.redirect(url.toString(), 303); } if (!admins.has(session.sub)) { - const body = html`

You are signed in, but you are not an admin.

-

To be given access, ask an operator to add this sub to ADMIN_USERS:

-

${session.sub}

`; + const body = html`
+

You are signed in, but you are not an admin.

+

To be given access, ask an operator to add this sub to ADMIN_USERS:

+

${session.sub}

+
`; return send(reply, layout({ title: 'Not an admin', user: displayName(session.idToken), body }), 403); } }); diff --git a/src/admin/sign-in.test.ts b/src/admin/sign-in.test.ts index aeee53f..c903ece 100644 --- a/src/admin/sign-in.test.ts +++ b/src/admin/sign-in.test.ts @@ -158,4 +158,14 @@ describe('signing in to the admin pages', () => { expect(csp).toContain("style-src 'self'"); expect(csp).toContain("frame-ancestors 'none'"); }); + + it('serves the stylesheet to anyone, and links it from every page', async () => { + const stylesheet = await new Browser(app, ISSUER).get('/admin/admin.css'); + expect(stylesheet.statusCode).toBe(200); + expect(stylesheet.headers['content-type']).toMatch(/^text\/css/); + + const link = ''; + expect((await signIn(new Browser(app, ISSUER), '/admin/', ALICE)).body).toContain(link); + expect((await signIn(new Browser(app, ISSUER), '/admin/', MALLORY)).body).toContain(link); + }); }); diff --git a/test/global-setup.ts b/test/global-setup.ts index 71b6fdd..9128ef6 100644 --- a/test/global-setup.ts +++ b/test/global-setup.ts @@ -1,5 +1,6 @@ import { execFileSync } from 'node:child_process'; import { randomBytes } from 'node:crypto'; +import { existsSync } from 'node:fs'; import { Client } from '@opensearch-project/opensearch'; import { PrismaPg } from '@prisma/adapter-pg'; import { loadConfig } from '../src/configuration.ts'; @@ -7,6 +8,11 @@ import { PrismaClient } from '../src/generated/prisma/client.ts'; // Each run gets its own database and index so runs never share or destroy each other's data export default async function setup() { + // The admin pages link the stylesheet; tests only need it to exist + if (!existsSync('src/admin/public/admin.css')) { + execFileSync('pnpm', ['build'], { stdio: 'ignore' }); + } + const config = loadConfig({ ...process.env, NODE_ENV: 'test' }); const suffix = `${process.pid}_${randomBytes(3).toString('hex')}`; From 0ffba5a2a69beff9837b84963aac79a69b97e15c Mon Sep 17 00:00:00 2001 From: John Ferlito Date: Wed, 28 Oct 2026 09:39:03 +1100 Subject: [PATCH 03/10] feat: show the crate tree with each index's state on the admin pages --- knip.jsonc | 4 +- src/admin/crates.test.ts | 196 ++++++++++++++++++++++++++++++++++++++ src/admin/crates.ts | 56 +++++++++++ src/admin/layout.ts | 6 +- src/admin/plugin.ts | 9 +- src/admin/public/admin.js | 21 ++++ src/app.ts | 2 +- src/repository.ts | 9 +- test/app.ts | 17 ++-- 9 files changed, 305 insertions(+), 15 deletions(-) create mode 100644 src/admin/crates.test.ts create mode 100644 src/admin/crates.ts create mode 100644 src/admin/public/admin.js diff --git a/knip.jsonc b/knip.jsonc index 491f38b..da2a9cf 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -1,6 +1,6 @@ { "$schema": "https://unpkg.com/knip@6/schema-jsonc.json", - // src/ocfl.ts is loaded dynamically by initRepository() - "entry": ["src/ocfl.ts"], + // src/ocfl.ts is loaded dynamically by initRepository(); admin.js is served to browsers + "entry": ["src/ocfl.ts", "src/admin/public/admin.js"], "tags": ["-lintignore"] } diff --git a/src/admin/crates.test.ts b/src/admin/crates.test.ts new file mode 100644 index 0000000..237890e --- /dev/null +++ b/src/admin/crates.test.ts @@ -0,0 +1,196 @@ +import { execFileSync } from 'node:child_process'; +import { cp, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { buildTestApp, type TestApp } from '../../test/app.ts'; +import { startFakeUpstream, type UpstreamUser } from '../../test/fake-upstream.ts'; +import { Browser } from '../../test/oidc.ts'; +import type { Repository } from '../repository.ts'; + +const ISSUER = 'http://localhost:8080'; +const UPSTREAM_CLIENT = { clientId: 'ldacapi', clientSecret: 'upstream-secret' }; +const ALICE: UpstreamUser = { sub: 'alice@upstream', name: 'Alice' }; + +const BUNDLED = 'arcp://name,bundled'; +const PEOPLE = 'arcp://name,people'; +const EXTRA = 'arcp://name,people/extra'; +const MORE = 'arcp://name,people/extra/more'; +const EXTRA_NAME = ' Extra people'; + +type Node = { id: string; row: string; children: Node[] }; + +/** The crate tree as a browser sees it: nested `
  • `, each with the text of its own row */ +const crateTree = (body: string) => { + const root: Node = { id: '', row: '', children: [] }; + const stack = [root]; + for (const match of body.matchAll(/<(\/?)li\b(?:[^>]*?\bdata-id="([^"]*)")?[^>]*>/g)) { + if (match[1]) { + stack.pop(); + continue; + } + const rest = body.slice((match.index ?? 0) + match[0].length); + const node: Node = { + id: (match[2] ?? '').replaceAll('&', '&'), + row: rest + .slice(0, rest.search(/<\/?(ul|li)\b/)) + .replace(/<[^>]*>/g, ' ') + .replace(/\s+/g, ' ') + .trim(), + children: [], + }; + stack.at(-1)?.children.push(node); + stack.push(node); + } + return root.children; +}; + +const shape = (nodes: Node[]): unknown[] => nodes.map(({ id, children }) => (children.length ? { id, children: shape(children) } : { id })); + +const find = (nodes: Node[], id: string): Node | undefined => { + for (const node of nodes) { + const found = node.id === id ? node : find(node.children, id); + if (found) { + return found; + } + } +}; + +const crate = (id: string, name: string) => ({ + '@context': ['https://w3id.org/ro/crate/1.1/context', { '@vocab': 'http://schema.org/' }], + '@graph': [ + { '@id': 'ro-crate-metadata.json', '@type': 'CreativeWork', about: { '@id': id } }, + { '@id': id, '@type': ['Dataset', 'RepositoryCollection'], name }, + ], +}); + +let storage: string; +let upstream: Awaited>; +const running: Awaited>[] = []; + +const start = async (repository: Repository | 'ocfl') => { + const started = await buildTestApp( + { + OCFL_ROOT: join(storage, 'ocfl'), + OIDC_ISSUER: ISSUER, + OIDC_CLIENTS: JSON.stringify([{ client_id: 'oni', redirect_uris: ['http://localhost:5173/callback'] }]), + OIDC_UPSTREAM_ISSUER: upstream.issuer, + OIDC_UPSTREAM_CLIENT_ID: UPSTREAM_CLIENT.clientId, + OIDC_UPSTREAM_CLIENT_SECRET: UPSTREAM_CLIENT.clientSecret, + ADMIN_USERS: ALICE.sub, + }, + { repository }, + ); + running.push(started); + return started; +}; + +const signIn = async (app: TestApp) => { + const browser = new Browser(app, ISSUER); + const toUpstream = await browser.follow('/admin/'); + const page = await browser.follow(upstream.signIn(toUpstream.headers.location as string, ALICE)); + expect(page.statusCode).toBe(200); + return { browser, page }; +}; + +beforeAll(async () => { + storage = await mkdtemp(join(tmpdir(), 'ldacapi-crates-')); + const source = join(storage, 'source'); + await mkdir(source); + for (const name of ['bundled', 'people']) { + await cp(resolve('test-data', name), join(source, name), { recursive: true }); + } + for (const [directory, id, name] of [ + ['extra', EXTRA, EXTRA_NAME], + ['more', MORE, 'More people'], + ] as const) { + await mkdir(join(source, directory)); + await writeFile(join(source, directory, 'ro-crate-metadata.json'), JSON.stringify(crate(id, name))); + } + execFileSync('node', ['scripts/seed.ts', source], { env: { ...process.env, OCFL_ROOT: join(storage, 'ocfl') }, stdio: 'ignore' }); + upstream = await startFakeUpstream(UPSTREAM_CLIENT); +}); + +afterAll(async () => { + for (const { app, prisma } of running) { + await app.close(); + await prisma.$disconnect(); + } + await upstream.close(); + await rm(storage, { recursive: true, force: true }); +}); + +describe('the crate tree', () => { + let app: TestApp; + let repository: Repository; + + beforeAll(async () => { + ({ app, repository } = await start('ocfl')); + await repository.deleteIndex(); + }); + + it('lists every crate, with collections containing their objects', async () => { + const { page } = await signIn(app); + + expect(shape(crateTree(page.body))).toEqual([{ id: BUNDLED }, { id: PEOPLE, children: [{ id: EXTRA, children: [{ id: MORE }] }] }]); + }); + + it('shows each crate’s real state for each index', async () => { + const { browser } = await signIn(app); + await repository.createIndex(new RegExp(`^${BUNDLED}$`)); + + await vi.waitFor(async () => { + const page = await browser.get('/admin/'); + const tree = crateTree(page.body); + expect(find(tree, BUNDLED)?.row).toMatch(/Structural: indexed .*Search: indexed/); + expect(find(tree, PEOPLE)?.row).toMatch(/Structural: not indexed .*Search: not indexed/); + expect(page.body).not.toContain('http-equiv="refresh"'); + }); + }); + + it('shows a crate name containing HTML escaped', async () => { + const { page } = await signIn(app); + + expect(find(crateTree(page.body), EXTRA)?.row).toContain('<script>alert(1)</script> Extra people'); + expect(page.body).not.toContain(''); + expect(page.headers['content-security-policy']).toContain("script-src 'self'"); + const script = await browser.get('/admin/admin.js'); + expect(script.statusCode).toBe(200); + expect(script.headers['content-type']).toMatch(/javascript/); + }); +}); + +describe('refreshing while indexes change', () => { + /** A repository whose one crate is in `state` for the search index */ + const repositoryIn = (state: string) => + ({ + storage: () => ({ content: {} }), + async *objects() { + yield { id: PEOPLE, name: 'People', path: '', state: { structural: 'indexed', search: state } }; + }, + }) as unknown as Repository; + + it.each(['queued', 'indexing', 'deleting'])('refreshes the page while an index is %s', async (state) => { + const { app } = await start(repositoryIn(state)); + + const { page } = await signIn(app); + + expect(page.body).toMatch(//); + expect(find(crateTree(page.body), PEOPLE)?.row).toContain(`Search: ${state}`); + }); + + it('does not refresh once every index is settled', async () => { + const { app } = await start(repositoryIn('indexed')); + + const { page } = await signIn(app); + + expect(page.body).not.toContain('http-equiv="refresh"'); + }); +}); diff --git a/src/admin/crates.ts b/src/admin/crates.ts new file mode 100644 index 0000000..aa2b8e6 --- /dev/null +++ b/src/admin/crates.ts @@ -0,0 +1,56 @@ +import { type RepositoryObject, State } from '../repository.ts'; +import { type Html, html } from './html.ts'; + +type Crate = RepositoryObject & { children: Crate[] }; + +const INDEXES = { structural: 'Structural', search: 'Search' } as const; + +const IN_PROGRESS = new Set([State.QUEUED, State.INDEXING, State.DELETING]); + +/** How often the page reloads while an index is changing */ +const REFRESH_SECONDS = 5; + +/** The nearest crate whose id is a path prefix of `id`, as indexing a crate also indexes the crates under it */ +const parentOf = (id: string, crates: Map) => { + let path = id.replace(/\/+$/, ''); + for (let slash = path.lastIndexOf('/'); slash > 0; slash = path.lastIndexOf('/')) { + path = path.slice(0, slash); + const parent = crates.get(path) ?? crates.get(`${path}/`); + if (parent) { + return parent; + } + } +}; + +const byName = (a: Crate, b: Crate) => (a.name ?? a.id).localeCompare(b.name ?? b.id) || a.id.localeCompare(b.id); + +const buildTree = (objects: RepositoryObject[]) => { + const crates = new Map(objects.map((object) => [object.id, { ...object, children: [] as Crate[] }])); + const roots: Crate[] = []; + for (const crate of crates.values()) { + (parentOf(crate.id, crates)?.children ?? roots).push(crate); + } + const sort = (list: Crate[]): Crate[] => list.sort(byName).map((crate) => ({ ...crate, children: sort(crate.children) })); + return sort(roots); +}; + +const renderCrates = (crates: Crate[]): Html => html`
      +${crates.map( + (crate) => html`
    • + ${crate.name ?? crate.id} ${crate.id} + ${Object.entries(INDEXES).map(([index, label]) => html`${label}: ${crate.state[index] ?? 'not indexed'} `)} + ${crate.children.length > 0 && renderCrates(crate.children)} +
    • +`, +)}
    `; + +/** The crates as a tree, and a refresh while any index is changing */ +export const cratesPage = (objects: RepositoryObject[]) => { + const changing = objects.some(({ state }) => Object.values(state).some((value) => value && IN_PROGRESS.has(value))); + const body = + objects.length === 0 + ? html`

    The repository has no crates.

    ` + : html`

    +
    ${renderCrates(buildTree(objects))}
    `; + return { head: changing ? html`` : undefined, body }; +}; diff --git a/src/admin/layout.ts b/src/admin/layout.ts index 5567b89..437cce9 100644 --- a/src/admin/layout.ts +++ b/src/admin/layout.ts @@ -5,15 +5,19 @@ type LayoutOptions = { /** Who is signed in, if anyone */ user?: string | undefined; body: Html; + /** More of the ``, such as a refresh */ + head?: Html | undefined; }; -export const layout = ({ title, user, body }: LayoutOptions) => html` +export const layout = ({ title, user, body, head }: LayoutOptions) => html` ${title} · LDaCA admin + + ${head}
    diff --git a/src/admin/plugin.ts b/src/admin/plugin.ts index 93d8df3..b6de6ea 100644 --- a/src/admin/plugin.ts +++ b/src/admin/plugin.ts @@ -6,7 +6,9 @@ import type { FastifyPluginAsync, FastifyReply } from 'fastify'; import { decodeJwt } from 'jose'; import type { AuthConfig } from '../configuration.ts'; import { logger } from '../logger.ts'; +import type { Repository } from '../repository.ts'; import { derivedKey } from './client.ts'; +import { cratesPage } from './crates.ts'; import { type Html, html } from './html.ts'; import { layout } from './layout.ts'; import { createSignIn, type SignInTransaction } from './sign-in.ts'; @@ -40,10 +42,10 @@ const displayName = (idToken: string) => { return typeof name === 'string' && name ? name : sub; }; -type AdminOptions = { auth: AuthConfig; adminUsers: string[] }; +type AdminOptions = { auth: AuthConfig; adminUsers: string[]; repository: Repository }; /** The admin pages, for users whose `sub` is in `adminUsers` */ -export const adminPages: FastifyPluginAsync = async (fastify, { auth, adminUsers }) => { +export const adminPages: FastifyPluginAsync = async (fastify, { auth, adminUsers, repository }) => { const issuer = new URL(auth.issuer); const signIn = createSignIn(fastify, auth); const admins = new Set(adminUsers); @@ -114,7 +116,8 @@ export const adminPages: FastifyPluginAsync = async (fastify, { au pages.get('/', async (request, reply) => { const user = displayName(request.session.get('admin')?.idToken ?? ''); - return send(reply, layout({ title: 'Admin', user, body: html`

    You are signed in as ${user}, and you are an admin.

    ` })); + const objects = await Array.fromAsync(repository.objects()); + return send(reply, layout({ title: 'Crates', user, ...cratesPage(objects) })); }); }); }; diff --git a/src/admin/public/admin.js b/src/admin/public/admin.js new file mode 100644 index 0000000..0ae7ab9 --- /dev/null +++ b/src/admin/public/admin.js @@ -0,0 +1,21 @@ +// Narrows the crate tree to crates whose name matches the filter, keeping their ancestors +const filter = document.getElementById('crate-filter'); +const tree = document.getElementById('crates'); + +const narrow = (item, words) => { + const children = item.querySelector(':scope > ul'); + const childMatches = children ? [...children.children].map((child) => narrow(child, words)).includes(true) : false; + const name = item.dataset.name.toLowerCase(); + const matches = childMatches || words.every((word) => name.includes(word)); + item.hidden = !matches; + return matches; +}; + +if (filter && tree) { + filter.addEventListener('input', () => { + const words = filter.value.toLowerCase().split(/\s+/).filter(Boolean); + for (const item of tree.querySelectorAll(':scope > ul > li')) { + narrow(item, words); + } + }); +} diff --git a/src/app.ts b/src/app.ts index 20d709e..dd4d289 100644 --- a/src/app.ts +++ b/src/app.ts @@ -69,7 +69,7 @@ export function buildApp({ repository, search }: AppDeps) { ); if (config.auth) { fastify.register(oidcProvider, { auth: config.auth }); - fastify.register(adminPages, { prefix: '/admin', auth: config.auth, adminUsers: config.adminUsers }); + fastify.register(adminPages, { prefix: '/admin', auth: config.auth, adminUsers: config.adminUsers, repository }); } else { fastify.log.warn('The admin pages are off because sign-in is off; set OIDC_UPSTREAM_ISSUER to turn them on'); } diff --git a/src/repository.ts b/src/repository.ts index 7145b71..728f6c0 100644 --- a/src/repository.ts +++ b/src/repository.ts @@ -7,15 +7,20 @@ export const State = { QUEUED: 'queued', } as const; +type IndexState = (typeof State)[keyof typeof State]; + +/** A crate in the repository, with the state of each index that isn't idle */ +export type RepositoryObject = { id: string; path: string; name?: string | undefined; state: Partial> }; + /** * Abstraction for repository operations, such as indexing and listing objects. This is used by the API routes to interact with the underlying data store, which can be an OCFL storage or a database. */ export interface Repository { init(): Promise; - getState(crateId: string, type?: string): Promise>; + getState(crateId: string, type?: string): Promise>; createIndex(crateId?: string | RegExp, type?: string, force?: boolean): Promise; deleteIndex(crateId?: string, type?: string | string[]): Promise; - objects(prefix?: string, refresh?: boolean): AsyncIterable<{ id: string; path: string; name: string }>; + objects(prefix?: string, refresh?: boolean): AsyncIterable; storage(): StorageBackend; } diff --git a/test/app.ts b/test/app.ts index 742b8b7..d9f2af5 100644 --- a/test/app.ts +++ b/test/app.ts @@ -3,7 +3,7 @@ import { vi } from 'vitest'; import type { Repository } from '../src/repository.ts'; /** A repository with no content */ -export const repository = { storage: () => ({ content: {} }) } as unknown as Repository; +export const repository = { storage: () => ({ content: {} }), async *objects() {} } as unknown as Repository; /** A repository whose every file holds `content` */ export const repositoryWithContent = { @@ -12,8 +12,8 @@ export const repositoryWithContent = { }), } as unknown as Repository; -/** Builds the app with `env` set while configuration is read, which happens on first import */ -export const buildTestApp = async (env: Record, options: { repository?: Repository } = {}) => { +/** Builds the app with `env` set while configuration is read, which happens on first import; `'ocfl'` uses the OCFL repository at `OCFL_ROOT` */ +export const buildTestApp = async (env: Record, options: { repository?: Repository | 'ocfl' } = {}) => { for (const [name, value] of Object.entries(env)) { vi.stubEnv(name, value); } @@ -23,10 +23,15 @@ export const buildTestApp = async (env: Record, options: { repos const { buildApp } = await import('../src/app.ts'); const { Client } = await import('@opensearch-project/opensearch'); const { createSearchBackend } = await import('../src/indexer/search.ts'); - const search = createSearchBackend(new Client({ node: config.opensearchUrl })); + const opensearchClient = new Client({ node: config.opensearchUrl }); + const search = createSearchBackend(opensearchClient); + const repo = + options.repository === 'ocfl' + ? await (await import('../src/repository.ts')).initRepository('ocfl', { opensearchClient, search }) + : (options.repository ?? repository); /** Builds another app, as another server process would */ - const build = () => buildApp({ repository: options.repository ?? repository, search }); - return { app: build(), build, config, prisma }; + const build = () => buildApp({ repository: repo, search }); + return { app: build(), build, config, prisma, repository: repo }; } finally { vi.unstubAllEnvs(); } From 0877b0ef2a9682c1786713575b8d931a1a094601 Mon Sep 17 00:00:00 2001 From: John Ferlito Date: Wed, 28 Oct 2026 09:46:02 +1100 Subject: [PATCH 04/10] feat!: index and delete crates from the admin pages, and remove the admin API Admins can index, reindex and delete one crate's indexes, and index or delete all of them, from the crate tree. Each action posts a form and redirects back with a one-time flash message. Delete actions ask for confirmation. BREAKING CHANGE: the admin API under /api/admin is gone, along with the TOKEN_ADMIN setting. Use the admin pages at /admin/ instead. --- .env.example | 1 - README.md | 39 +-- docs/adr/0002-server-rendered-admin-pages.md | 3 +- package.json | 2 - pnpm-lock.yaml | 14 - src/admin/actions.test.ts | 257 +++++++++++++++++++ src/admin/actions.ts | 29 +++ src/admin/crates.ts | 17 +- src/admin/layout.ts | 9 +- src/admin/plugin.ts | 17 +- src/admin/public/admin.js | 9 + src/app.test.ts | 10 + src/app.ts | 9 +- src/configuration.test.ts | 11 +- src/configuration.ts | 2 - src/routes/admin.test.ts | 109 -------- src/routes/admin.ts | 100 -------- 17 files changed, 365 insertions(+), 273 deletions(-) create mode 100644 src/admin/actions.test.ts create mode 100644 src/admin/actions.ts delete mode 100644 src/routes/admin.test.ts delete mode 100644 src/routes/admin.ts diff --git a/.env.example b/.env.example index d120983..ce1ccc4 100644 --- a/.env.example +++ b/.env.example @@ -4,7 +4,6 @@ # Required when NODE_ENV=production # DATABASE_URL=postgresql://ldacapi:ldacapi@localhost:5432/ldacapi # OPENSEARCH_URL=http://localhost:9200 -# TOKEN_ADMIN=1234-1234-1234-1234 # OCFL_ROOT=storage/ocfl # Working directory for OCFL writes. Defaults to `scratch` beside OCFL_ROOT diff --git a/README.md b/README.md index 7ec49fc..4b04a29 100644 --- a/README.md +++ b/README.md @@ -17,9 +17,9 @@ app mounts as a Fastify plugin. ldacapi adds what LDaCA needs on top: index, used by arocapi's entity and file routes) and, as one search document per entity, into arocapi's OpenSearch search backend (`src/indexer/`). The search fields are declared in `src/indexer/search.ts`. -- **Admin API**: list, rebuild and delete indexes (`src/routes/admin.ts`). -- **Admin pages**: server-rendered pages at `/admin/` for admins, who sign in - through our own OIDC provider (`src/admin/`). +- **Admin pages**: server-rendered pages at `/admin/` where admins, who sign in + through our own OIDC provider, see and rebuild or delete indexes + (`src/admin/`). - **Access control**: licences are checked against REMS, with sign-in through an OIDC provider (`src/auth.ts`, `src/routes/auth.ts`). - **File downloads**: short-lived signed URLs that stream files out of OCFL @@ -73,28 +73,6 @@ The indexer skips a crate that has no metadata licence (the `license` of `ro-crate-metadata.json`) or no content licence (the `license` of the root dataset) unless `DEFAULT_METADATA_LICENSE` or `DEFAULT_LICENSE` is set. -## Admin API - -The admin routes need the admin token (`TOKEN_ADMIN`, `1234-1234-1234-1234` in -development) as a bearer token. - -```sh -TOKEN=1234-1234-1234-1234 - -# Index state of every crate, or of one crate -curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/admin/index/ -curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/admin/index/ - -# Re-index everything, or one crate (optionally one index: structural or search) -curl -X POST -H "Authorization: Bearer $TOKEN" 'http://localhost:8080/api/admin/index/?force' -curl -X POST -H "Authorization: Bearer $TOKEN" 'http://localhost:8080/api/admin/index//?force' - -# Delete the indexes of one crate -curl -X DELETE -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/admin/index/ -``` - -URL-encode crate ids, which are usually URIs. - ## Configuration Configuration comes only from environment variables, validated at startup @@ -107,7 +85,6 @@ set. | `NODE_ENV` | `development` | | `DATABASE_URL` | `postgresql://ldacapi:ldacapi@localhost:5432/ldacapi` | | `OPENSEARCH_URL` | `http://localhost:9200` | -| `TOKEN_ADMIN` | `1234-1234-1234-1234` | | `OCFL_ROOT` | `storage/ocfl` | | `OCFL_SCRATCH` | `scratch` beside `OCFL_ROOT` | | `LDACAPI_PORT` | `8080` | @@ -124,8 +101,8 @@ set. | `OIDC_SIGNING_KEYS`, `OIDC_COOKIE_KEYS` | generated at each start outside production | | `ADMIN_USERS` | none (nobody is an admin) | -With `NODE_ENV=production`, `DATABASE_URL`, `OPENSEARCH_URL`, `TOKEN_ADMIN` -and `OCFL_ROOT` have no defaults and must be set. +With `NODE_ENV=production`, `DATABASE_URL`, `OPENSEARCH_URL` and `OCFL_ROOT` +have no defaults and must be set. ### Sign-in @@ -192,6 +169,12 @@ Every request checks the list, so removing someone takes effect as soon as the API restarts. An admin session lasts 8 hours, and signing out of the admin pages also ends the session at our provider. +The crates page shows every crate's state in each index, and can index, +reindex or delete one crate's indexes, or index or delete all of them. +Indexing a crate also indexes the crates under it. A crate that is already +indexed needs reindexing, and indexing is refused while a delete is in +progress. The page reloads itself until the work finishes. + The admin pages are a client of our provider that needs no entry in `OIDC_CLIENTS`; their client secret and session keys are derived from `OIDC_COOKIE_KEYS`, so they rotate with it. diff --git a/docs/adr/0002-server-rendered-admin-pages.md b/docs/adr/0002-server-rendered-admin-pages.md index 828a36d..5c87687 100644 --- a/docs/adr/0002-server-rendered-admin-pages.md +++ b/docs/adr/0002-server-rendered-admin-pages.md @@ -6,13 +6,14 @@ Admins manage the API's indexes. A separate browser app calling an admin API wit ## Decision -The API serves its own admin pages at `/admin/`, rendered on the server, on the issuer's origin and outside `/api`. They are registered only when sign-in is configured. +The API serves its own admin pages at `/admin/`, rendered on the server, on the issuer's origin and outside `/api`. They are registered only when sign-in is configured. They are the only way into admin: there is no admin API and no shared admin key, and the pages call the repository directly. - The admin pages are a built-in confidential client of our provider, not listed in `OIDC_CLIENTS`. Its client secret is derived from `OIDC_COOKIE_KEYS` with HKDF, and it uses only the authorisation code grant, asking for `openid profile`. - Sign-in is a server-side authorisation code flow with PKCE, state and nonce. The browser goes through the provider as usual, but the token request reaches the provider in-process, with its metadata built from configuration rather than discovery, so the API never calls its own public URL. Only the ID token is kept. - Each sign-in's state, PKCE verifier, nonce and page to return to are held in a short-lived encrypted cookie. The session is an encrypted cookie holding the `sub`, sign-in time and ID token, with an absolute lifetime of 8 hours. Both cookies are scoped to `/admin`, `HttpOnly`, `Secure` on an https issuer, and `SameSite=Lax`, because sign-in returns through a redirect chain that started at the upstream provider, where a `Strict` cookie wouldn't be sent. Their keys are derived from every key in `OIDC_COOKIE_KEYS`, so they rotate with it. - An admin is a user whose `sub` is in `ADMIN_USERS`, checked on every request. Others who sign in get a 403 page showing their `sub`. - Signing out clears the session and ends the session at our provider, via its end-session endpoint with the ID token as `id_token_hint`. +- Every action is a POST form that redirects back to the page with a one-time flash message, so reloading doesn't repeat it. - Every POST must carry an `Origin` matching the issuer. Responses carry a Content-Security-Policy allowing only same-origin scripts and styles, and no framing. - Pages are built with an `html` tagged template that escapes every interpolated value. diff --git a/package.json b/package.json index 9a3defa..af91368 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,6 @@ "license": "GPL-3.0-or-later", "packageManager": "pnpm@12.14.0", "dependencies": { - "@fastify/bearer-auth": "^10.1.4", "@fastify/cors": "^11.3.1", "@fastify/formbody": "^9.0.1", "@fastify/secure-session": "^8.4.1", @@ -59,7 +58,6 @@ "@prisma/client": "^7.10.1", "arocapi": "5.0.0-beta.7", "fastify": "^5.12.7", - "fastify-type-provider-zod": "^7.0.0", "jose": "^6.2.13", "oidc-provider": "^9.12.2", "openid-client": "^6.8.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 921e0d3..8ce9e5a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -166,9 +166,6 @@ importers: .: dependencies: - '@fastify/bearer-auth': - specifier: ^10.1.4 - version: 10.1.4 '@fastify/cors': specifier: ^11.3.1 version: 11.3.1 @@ -202,9 +199,6 @@ importers: fastify: specifier: ^5.12.7 version: 5.12.7 - fastify-type-provider-zod: - specifier: ^7.0.0 - version: 7.0.0(@fastify/swagger@9.9.1)(fastify@5.12.7)(openapi-types@12.1.3)(zod@4.6.7) jose: specifier: ^6.2.13 version: 6.2.13 @@ -448,9 +442,6 @@ packages: '@fastify/ajv-compiler@4.0.6': resolution: {integrity: sha512-NtuzM0SfaMJbGlnjr9LWQUN5LzgSrbB8tf/wRZNas+4E1O/Nmzl53e7ruT61HDZyRCJGC6FxIogmNZO1c5ETBA==} - '@fastify/bearer-auth@10.1.4': - resolution: {integrity: sha512-lOHC/Ek4Zlqv7qb1EAYze3wmzq8s8chrrik4vCIzFfOGysSLP5HM5vbcHfywf7JVZZp+BcPcaIRBwUAqHNStiA==} - '@fastify/cookie@11.1.3': resolution: {integrity: sha512-RBhlMNsmSG6C5KGiTkhSXBF75xIrUMSytDDBcpnVewhJ8XVYnJTu+SV6FYKXClIRsBuRjOfKbBSULvnPD3X/mg==} @@ -3232,11 +3223,6 @@ snapshots: ajv-formats: 3.0.1 fast-uri: 4.2.1 - '@fastify/bearer-auth@10.1.4': - dependencies: - '@fastify/error': 4.2.0 - fastify-plugin: 6.0.1 - '@fastify/cookie@11.1.3': dependencies: cookie: 2.0.1 diff --git a/src/admin/actions.test.ts b/src/admin/actions.test.ts new file mode 100644 index 0000000..30e3c89 --- /dev/null +++ b/src/admin/actions.test.ts @@ -0,0 +1,257 @@ +import { execFileSync } from 'node:child_process'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Client } from '@opensearch-project/opensearch'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { buildTestApp, type TestApp } from '../../test/app.ts'; +import { startFakeUpstream, type UpstreamUser } from '../../test/fake-upstream.ts'; +import { Browser } from '../../test/oidc.ts'; +import type { Repository } from '../repository.ts'; + +const ISSUER = 'http://localhost:8080'; +const UPSTREAM_CLIENT = { clientId: 'ldacapi', clientSecret: 'upstream-secret' }; +const ALICE: UpstreamUser = { sub: 'alice@upstream', name: 'Alice' }; + +const BUNDLED = 'arcp://name,bundled'; +const PEOPLE = 'arcp://name,people'; + +let storage: string; +let upstream: Awaited>; +let opensearch: Client; +const running: Awaited>[] = []; + +const start = async (repository: Repository | 'ocfl') => { + const started = await buildTestApp( + { + OCFL_ROOT: join(storage, 'ocfl'), + OIDC_ISSUER: ISSUER, + OIDC_CLIENTS: JSON.stringify([{ client_id: 'oni', redirect_uris: ['http://localhost:5173/callback'] }]), + OIDC_UPSTREAM_ISSUER: upstream.issuer, + OIDC_UPSTREAM_CLIENT_ID: UPSTREAM_CLIENT.clientId, + OIDC_UPSTREAM_CLIENT_SECRET: UPSTREAM_CLIENT.clientSecret, + ADMIN_USERS: ALICE.sub, + }, + { repository }, + ); + running.push(started); + return started; +}; + +const signIn = async (app: TestApp) => { + const browser = new Browser(app, ISSUER); + const toUpstream = await browser.follow('/admin/'); + expect((await browser.follow(upstream.signIn(toUpstream.headers.location as string, ALICE))).statusCode).toBe(200); + return browser; +}; + +/** Submits an action's form, as its button does, and follows the redirect back */ +const act = async (browser: Browser, action: string, form: Record = {}) => { + const response = await browser.post(`/admin/${action}`, form); + expect(response.statusCode).toBe(303); + expect(response.headers.location).toBe('/admin/'); + return browser.get('/admin/'); +}; + +const flash = (body: string) => + body + .match(/<[^>]*role="status"[^>]*>([\s\S]*?)<\/[a-z]+>/)?.[1] + ?.replace(/<[^>]*>/g, '') + .replace(/\s+/g, ' ') + .trim(); + +beforeAll(async () => { + storage = await mkdtemp(join(tmpdir(), 'ldacapi-actions-')); + execFileSync('node', ['scripts/seed.ts'], { env: { ...process.env, OCFL_ROOT: join(storage, 'ocfl') }, stdio: 'ignore' }); + upstream = await startFakeUpstream(UPSTREAM_CLIENT); +}); + +afterAll(async () => { + for (const { app, prisma } of running) { + await app.close(); + await prisma.$disconnect(); + } + await opensearch?.close(); + await upstream.close(); + await rm(storage, { recursive: true, force: true }); +}); + +describe('index and delete actions', () => { + let app: TestApp; + let repository: Repository; + let prisma: Awaited>['prisma']; + let entityIndex: string; + + const entityCount = (crateId: string) => prisma.entity.count({ where: { rootCollection: crateId } }); + + const searchCount = async (crateId: string) => { + await opensearch.indices.refresh({ index: entityIndex, ignore_unavailable: true }); + const { body } = await opensearch.count({ index: entityIndex, ignore_unavailable: true, body: { query: { term: { rootCollection: crateId } } } }); + return body.count; + }; + + const waitUntilIndexed = (...crateIds: string[]) => + vi.waitFor( + async () => { + for (const crateId of crateIds) { + expect(await repository.getState(crateId)).toEqual({ structural: 'indexed', search: 'indexed' }); + } + }, + { timeout: 10_000, interval: 100 }, + ); + + beforeAll(async () => { + let config: Awaited>['config']; + ({ app, repository, prisma, config } = await start('ocfl')); + entityIndex = config.search.entityIndex; + opensearch = new Client({ node: config.opensearchUrl }); + }); + + beforeEach(async () => { + await repository.deleteIndex(); + }); + + it('indexes one crate', async () => { + const browser = await signIn(app); + + const page = await act(browser, 'index', { crateId: BUNDLED }); + + expect(flash(page.body)).toBe(`Indexing ${BUNDLED} has been queued.`); + await waitUntilIndexed(BUNDLED); + expect(await entityCount(BUNDLED)).toBeGreaterThan(0); + expect(await searchCount(BUNDLED)).toBeGreaterThan(0); + expect(await entityCount(PEOPLE)).toBe(0); + expect(await searchCount(PEOPLE)).toBe(0); + }); + + it('refuses to index an indexed crate, asking for a reindex', async () => { + const browser = await signIn(app); + await act(browser, 'index', { crateId: BUNDLED }); + await waitUntilIndexed(BUNDLED); + + const page = await act(browser, 'index', { crateId: BUNDLED }); + + expect(flash(page.body)).toBe(`Indexing ${BUNDLED} was refused: it is already indexed, so reindex it instead.`); + }); + + it('reindexes an indexed crate', async () => { + const browser = await signIn(app); + await act(browser, 'index', { crateId: BUNDLED }); + await waitUntilIndexed(BUNDLED); + const entities = await entityCount(BUNDLED); + const documents = await searchCount(BUNDLED); + + const page = await act(browser, 'index', { crateId: BUNDLED, force: 'true' }); + + expect(flash(page.body)).toBe(`Reindexing ${BUNDLED} has been queued.`); + await waitUntilIndexed(BUNDLED); + expect(await entityCount(BUNDLED)).toBe(entities); + expect(await searchCount(BUNDLED)).toBe(documents); + }); + + it('indexes every crate', async () => { + const browser = await signIn(app); + + const page = await act(browser, 'index'); + + expect(flash(page.body)).toBe('Indexing every crate has been queued.'); + await waitUntilIndexed(BUNDLED, PEOPLE); + }); + + it('deletes one crate’s indexes', async () => { + const browser = await signIn(app); + await act(browser, 'index'); + await waitUntilIndexed(BUNDLED, PEOPLE); + + const page = await act(browser, 'delete', { crateId: BUNDLED }); + + expect(flash(page.body)).toBe(`Deleting the indexes of ${BUNDLED} has started.`); + await vi.waitFor(async () => { + expect(await entityCount(BUNDLED)).toBe(0); + expect(await searchCount(BUNDLED)).toBe(0); + }); + expect(await entityCount(PEOPLE)).toBeGreaterThan(0); + expect(await searchCount(PEOPLE)).toBeGreaterThan(0); + }); + + it('deletes every index', async () => { + const browser = await signIn(app); + await act(browser, 'index'); + await waitUntilIndexed(BUNDLED, PEOPLE); + + const page = await act(browser, 'delete'); + + expect(flash(page.body)).toBe('Deleting every index has started.'); + await vi.waitFor(async () => { + expect(await prisma.entity.count()).toBe(0); + expect(await searchCount(BUNDLED)).toBe(0); + expect(await searchCount(PEOPLE)).toBe(0); + }); + }); + + it('shows the message once, so reloading doesn’t repeat the action', async () => { + const browser = await signIn(app); + await act(browser, 'index', { crateId: BUNDLED }); + + const reloaded = await browser.get('/admin/'); + + expect(reloaded.statusCode).toBe(200); + expect(flash(reloaded.body)).toBeUndefined(); + }); + + it('asks before deleting, by a script the page loads', async () => { + const browser = await signIn(app); + const page = await browser.get('/admin/'); + + const deletes = [...page.body.matchAll(/
    ]*action="\/admin\/delete"[^>]*>/g)].map(([form]) => form); + expect(deletes.length).toBeGreaterThan(1); + for (const form of deletes) { + expect(form).toMatch(/data-confirm="[^"]+"/); + } + expect((await browser.get('/admin/admin.js')).body).toMatch(/\[data-confirm\][\s\S]*confirm\(/); + }); +}); + +describe('protecting the actions', () => { + /** A repository with one crate whose search index is in `state`, recording what it is asked to do */ + const repositoryIn = (state: string) => ({ + storage: () => ({ content: {} }), + async *objects() { + yield { id: PEOPLE, name: 'People', path: '', state: { structural: 'indexed', search: state } }; + }, + getState: async () => ({ structural: 'indexed', search: state }), + createIndex: vi.fn(async () => {}), + deleteIndex: vi.fn(async () => {}), + }); + + it.each([ + ['one crate', { crateId: PEOPLE, force: 'true' }, `Reindexing ${PEOPLE}`], + ['every crate', {}, 'Indexing every crate'], + ])('refuses to index %s while deleting is in progress', async (_, form, asked) => { + const repository = repositoryIn('deleting'); + const { app } = await start(repository as unknown as Repository); + const browser = await signIn(app); + + const page = await act(browser, 'index', form); + + expect(flash(page.body)).toBe(`${asked} was refused: deleting is in progress.`); + expect(repository.createIndex).not.toHaveBeenCalled(); + }); + + it.each([ + ['without an Origin', {}], + ['from another origin', { origin: 'https://evil.example.org' }], + ])('refuses a POST %s and changes nothing', async (_, headers) => { + const repository = repositoryIn('indexed'); + const { app } = await start(repository as unknown as Repository); + const browser = await signIn(app); + + for (const action of ['index', 'delete']) { + expect((await browser.post(`/admin/${action}`, { crateId: PEOPLE, force: 'true' }, headers)).statusCode).toBe(403); + } + + expect(repository.createIndex).not.toHaveBeenCalled(); + expect(repository.deleteIndex).not.toHaveBeenCalled(); + expect(flash((await browser.get('/admin/')).body)).toBeUndefined(); + }); +}); diff --git a/src/admin/actions.ts b/src/admin/actions.ts new file mode 100644 index 0000000..92ed2f7 --- /dev/null +++ b/src/admin/actions.ts @@ -0,0 +1,29 @@ +import type { FastifyBaseLogger } from 'fastify'; +import { type Repository, State } from '../repository.ts'; + +/** What an action asked for and whether it was accepted, shown once on the next page */ +export type Flash = { accepted: boolean; message: string }; + +const anyIn = (state: Record, value: string) => Object.values(state).includes(value); + +/** Queues indexing `crateId` and the crates under it, or every crate; `force` rebuilds indexes that already exist */ +export const indexCrates = async (repository: Repository, log: FastifyBaseLogger, crateId: string | undefined, force: boolean): Promise => { + const asked = `${force ? 'Reindexing' : 'Indexing'} ${crateId ?? 'every crate'}`; + const states = crateId ? [await repository.getState(crateId)] : (await Array.fromAsync(repository.objects())).map(({ state }) => state); + if (states.some((state) => anyIn(state, State.DELETING))) { + return { accepted: false, message: `${asked} was refused: deleting is in progress.` }; + } + if (crateId && !force && states.some((state) => anyIn(state, State.INDEXED))) { + return { accepted: false, message: `${asked} was refused: it is already indexed, so reindex it instead.` }; + } + repository + .createIndex(crateId ? new RegExp(`^${RegExp.escape(crateId)}`) : undefined, undefined, force) + .catch((err) => log.error({ err, crateId }, 'Indexing failed')); + return { accepted: true, message: `${asked} has been queued.` }; +}; + +/** Starts deleting the indexes of `crateId`, or every index */ +export const deleteIndexes = (repository: Repository, log: FastifyBaseLogger, crateId: string | undefined): Flash => { + repository.deleteIndex(crateId).catch((err) => log.error({ err, crateId }, 'Deleting indexes failed')); + return { accepted: true, message: `Deleting ${crateId ? `the indexes of ${crateId}` : 'every index'} has started.` }; +}; diff --git a/src/admin/crates.ts b/src/admin/crates.ts index aa2b8e6..8bb0706 100644 --- a/src/admin/crates.ts +++ b/src/admin/crates.ts @@ -34,11 +34,25 @@ const buildTree = (objects: RepositoryObject[]) => { return sort(roots); }; +const BUTTON = 'cursor-pointer rounded border px-2 py-0.5 text-sm'; +const INDEX_BUTTON = `${BUTTON} border-zinc-300 bg-white hover:border-amber-500 hover:bg-amber-50`; +const DELETE_BUTTON = `${BUTTON} border-red-300 bg-white text-red-700 hover:bg-red-50`; + +/** A form posting `crateId` (none for every crate) to an action */ +const actionForm = (action: 'index' | 'delete', label: string, crateId?: string, { force = false, confirm }: { force?: boolean; confirm?: string } = {}) => + html`${crateId && html``}${force && html``}`; + +const crateActions = (crate: Crate) => { + const indexed = Object.values(crate.state).includes(State.INDEXED); + return html`${actionForm('index', indexed ? 'Reindex' : 'Index', crate.id, { force: indexed })} ${actionForm('delete', 'Delete', crate.id, { confirm: `Delete the indexes of ${crate.name ?? crate.id}?` })}`; +}; + const renderCrates = (crates: Crate[]): Html => html`
      ${crates.map( (crate) => html`
    • ${crate.name ?? crate.id} ${crate.id} ${Object.entries(INDEXES).map(([index, label]) => html`${label}: ${crate.state[index] ?? 'not indexed'} `)} + ${crateActions(crate)} ${crate.children.length > 0 && renderCrates(crate.children)}
    • `, @@ -50,7 +64,8 @@ export const cratesPage = (objects: RepositoryObject[]) => { const body = objects.length === 0 ? html`

      The repository has no crates.

      ` - : html`

      + : html`

      ${actionForm('index', 'Index all')} ${actionForm('delete', 'Delete all', undefined, { confirm: 'Delete every index?' })}

      +

      ${renderCrates(buildTree(objects))}
      `; return { head: changing ? html`` : undefined, body }; }; diff --git a/src/admin/layout.ts b/src/admin/layout.ts index 437cce9..b45e769 100644 --- a/src/admin/layout.ts +++ b/src/admin/layout.ts @@ -1,3 +1,4 @@ +import type { Flash } from './actions.ts'; import { type Html, html } from './html.ts'; type LayoutOptions = { @@ -5,11 +6,13 @@ type LayoutOptions = { /** Who is signed in, if anyone */ user?: string | undefined; body: Html; + /** The outcome of the last action */ + flash?: Flash | undefined; /** More of the ``, such as a refresh */ head?: Html | undefined; }; -export const layout = ({ title, user, body, head }: LayoutOptions) => html` +export const layout = ({ title, user, body, flash, head }: LayoutOptions) => html` @@ -37,6 +40,10 @@ export const layout = ({ title, user, body, head }: LayoutOptions) => html`

      ${title}

      + ${ + flash && + html`

      ${flash.message}

      ` + } ${body}
      diff --git a/src/admin/plugin.ts b/src/admin/plugin.ts index b6de6ea..79da274 100644 --- a/src/admin/plugin.ts +++ b/src/admin/plugin.ts @@ -7,6 +7,7 @@ import { decodeJwt } from 'jose'; import type { AuthConfig } from '../configuration.ts'; import { logger } from '../logger.ts'; import type { Repository } from '../repository.ts'; +import { deleteIndexes, type Flash, indexCrates } from './actions.ts'; import { derivedKey } from './client.ts'; import { cratesPage } from './crates.ts'; import { type Html, html } from './html.ts'; @@ -21,6 +22,7 @@ declare module '@fastify/secure-session' { interface SessionData { admin: AdminSession; transaction: SignInTransaction; + flash: Flash; } } @@ -116,8 +118,21 @@ export const adminPages: FastifyPluginAsync = async (fastify, { au pages.get('/', async (request, reply) => { const user = displayName(request.session.get('admin')?.idToken ?? ''); + const flash = request.session.get('flash'); + request.session.set('flash', undefined); const objects = await Array.fromAsync(repository.objects()); - return send(reply, layout({ title: 'Crates', user, ...cratesPage(objects) })); + return send(reply, layout({ title: 'Crates', user, flash, ...cratesPage(objects) })); + }); + + pages.post<{ Body: { crateId?: string; force?: string } }>('/index', async (request, reply) => { + const { crateId, force } = request.body ?? {}; + request.session.set('flash', await indexCrates(repository, request.log, crateId || undefined, Boolean(force))); + return reply.redirect('/admin/', 303); + }); + + pages.post<{ Body: { crateId?: string } }>('/delete', async (request, reply) => { + request.session.set('flash', deleteIndexes(repository, request.log, request.body?.crateId || undefined)); + return reply.redirect('/admin/', 303); }); }); }; diff --git a/src/admin/public/admin.js b/src/admin/public/admin.js index 0ae7ab9..9694336 100644 --- a/src/admin/public/admin.js +++ b/src/admin/public/admin.js @@ -19,3 +19,12 @@ if (filter && tree) { } }); } + +// Asks before submitting forms that delete +for (const form of document.querySelectorAll('form[data-confirm]')) { + form.addEventListener('submit', (event) => { + if (!confirm(form.dataset.confirm)) { + event.preventDefault(); + } + }); +} diff --git a/src/app.test.ts b/src/app.test.ts index bacc6f5..2e11c28 100644 --- a/src/app.test.ts +++ b/src/app.test.ts @@ -70,4 +70,14 @@ describe('buildApp', () => { expect((await app.inject({ method: 'POST', url: `${config.prefix}/token` })).statusCode).toBe(404); await app.close(); }); + + it('has no admin API', async () => { + const app = buildApp({ repository, search }); + const headers = { authorization: 'Bearer 1234-1234-1234-1234' }; + + for (const method of ['GET', 'POST', 'DELETE'] as const) { + expect((await app.inject({ method, url: `${config.prefix}/admin/index/`, headers })).statusCode, method).toBe(404); + } + await app.close(); + }); }); diff --git a/src/app.ts b/src/app.ts index dd4d289..ed8f0cc 100644 --- a/src/app.ts +++ b/src/app.ts @@ -4,7 +4,6 @@ import type { SearchBackend } from 'arocapi'; import arocapi, { AllVisibleEntityFilter, AllVisibleSearchFilter, errorHandler, notFoundHandler } from 'arocapi'; import { EntityMetaMetadata } from 'arocapi/storage/entity-meta'; import Fastify, { type FastifyPluginAsync } from 'fastify'; -import { serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod'; import pkg from '../package.json' with { type: 'json' }; import { adminPages } from './admin/plugin.ts'; import { access, isAuthenticated } from './auth.ts'; @@ -13,17 +12,13 @@ import { logger } from './logger.ts'; import { oidcProvider } from './oidc/provider.ts'; import { prisma } from './prisma.ts'; import type { Repository } from './repository.ts'; -import { admin } from './routes/admin.ts'; type AppDeps = { repository: Repository; search: SearchBackend; }; -const ldacapi: FastifyPluginAsync<{ repository: Repository }> = async (fastify, { repository }) => { - fastify.setValidatorCompiler(validatorCompiler); - fastify.setSerializerCompiler(serializerCompiler); - fastify.register(admin, { prefix: '/admin', repository }); +const ldacapi: FastifyPluginAsync = async (fastify) => { fastify.get('/version', async () => ({ version: pkg.version })); }; @@ -63,7 +58,7 @@ export function buildApp({ repository, search }: AppDeps) { ], storage: { metadata: EntityMetaMetadata(), content: repository.storage().content }, }); - api.register(ldacapi, { repository }); + api.register(ldacapi); }, { prefix }, ); diff --git a/src/configuration.test.ts b/src/configuration.test.ts index 4475249..64e45a0 100644 --- a/src/configuration.test.ts +++ b/src/configuration.test.ts @@ -18,7 +18,6 @@ const PRODUCTION = { NODE_ENV: 'production', DATABASE_URL: 'postgresql://prod/ldacapi', OPENSEARCH_URL: 'http://search:9200', - TOKEN_ADMIN: 'secret', OCFL_ROOT: '/data/ocfl', }; @@ -42,10 +41,9 @@ describe('loadConfig', () => { }); it('reads values from the environment', () => { - const config = loadConfig({ LDACAPI_PORT: '9000', TOKEN_ADMIN: 'secret', ENTITY_INDEX: 'other' }); + const config = loadConfig({ LDACAPI_PORT: '9000', ENTITY_INDEX: 'other' }); expect(config.port).toBe(9000); - expect(config.tokenAdmin).toBe('secret'); expect(config.search.entityIndex).toBe('other'); }); @@ -55,11 +53,12 @@ describe('loadConfig', () => { }); it('treats empty values as unset', () => { - expect(loadConfig({ TOKEN_ADMIN: '' }).tokenAdmin).toBe('1234-1234-1234-1234'); + expect(loadConfig({ OPENSEARCH_URL: '' }).opensearchUrl).toBe('http://localhost:9200'); }); - it('requires connection details, the admin token and OCFL root in production', () => { - expect(() => loadConfig({ NODE_ENV: 'production' })).toThrow(/DATABASE_URL[\s\S]*OPENSEARCH_URL[\s\S]*TOKEN_ADMIN[\s\S]*OCFL_ROOT/); + it('requires connection details and OCFL root in production', () => { + expect(() => loadConfig({ NODE_ENV: 'production' })).toThrow(/DATABASE_URL[\s\S]*OPENSEARCH_URL[\s\S]*OCFL_ROOT/); + expect(() => loadConfig({ NODE_ENV: 'production' })).not.toThrow(/TOKEN_ADMIN/); }); it('starts in production once the required values are set', () => { diff --git a/src/configuration.ts b/src/configuration.ts index 2f30a83..cb886f8 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -97,7 +97,6 @@ export function loadConfig(processEnv: Env) { .object({ DATABASE_URL: devDefault(`postgresql://ldacapi:ldacapi@localhost:5432/ldacapi${nodeEnv === 'test' ? '_test' : ''}`), OPENSEARCH_URL: devDefault('http://localhost:9200'), - TOKEN_ADMIN: devDefault('1234-1234-1234-1234'), OCFL_ROOT: devDefault('storage/ocfl'), OCFL_SCRATCH: z.string().optional(), LDACAPI_PORT: z.coerce.number().int().default(8080), @@ -175,7 +174,6 @@ export function loadConfig(processEnv: Env) { host: env.LDACAPI_HOST, logLevel: env.LOG_LEVEL, maxParamLength: env.LDACAPI_MAX_PARAM_LENGTH, - tokenAdmin: env.TOKEN_ADMIN, ocfl: { root: resolve(env.OCFL_ROOT), scratch: resolve(env.OCFL_SCRATCH ?? join(dirname(env.OCFL_ROOT), 'scratch')), diff --git a/src/routes/admin.test.ts b/src/routes/admin.test.ts deleted file mode 100644 index b2eb891..0000000 --- a/src/routes/admin.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { execFileSync } from 'node:child_process'; -import { mkdtemp, rm } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import type { Client } from '@opensearch-project/opensearch'; -import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; - -const BUNDLED = 'arcp://name,bundled'; -const PEOPLE = 'arcp://name,people'; - -let storage: string; -let app: ReturnType; -let opensearch: Client; -let config: typeof import('../configuration.ts').config; -let prisma: typeof import('../prisma.ts').prisma; -let repository: import('../repository.ts').Repository; - -const admin = (method: 'GET' | 'POST' | 'DELETE', path: string) => - app.inject({ method, url: `${config.prefix}/admin/index/${path}`, headers: { authorization: `Bearer ${config.tokenAdmin}` } }); - -const entityCount = (crateId: string) => prisma.entity.count({ where: { rootCollection: crateId } }); - -const searchCount = async (crateId: string) => { - await opensearch.indices.refresh({ index: config.search.entityIndex, ignore_unavailable: true }); - const { body } = await opensearch.count({ - index: config.search.entityIndex, - ignore_unavailable: true, - body: { query: { term: { rootCollection: crateId } } }, - }); - return body.count; -}; - -const waitUntilIndexed = (...crateIds: string[]) => - vi.waitFor( - async () => { - const objects: { id: string; state: unknown }[] = (await admin('GET', '')).json(); - const states = crateIds.map((id) => objects.find((object) => object.id === id)?.state); - expect(states).toEqual(crateIds.map(() => ({ structural: 'indexed', search: 'indexed' }))); - }, - { timeout: 10_000, interval: 100 }, - ); - -beforeAll(async () => { - storage = await mkdtemp(join(tmpdir(), 'ldacapi-admin-')); - process.env.OCFL_ROOT = join(storage, 'ocfl'); - execFileSync('node', ['scripts/seed.ts'], { env: process.env, stdio: 'ignore' }); - - // Imported after OCFL_ROOT is set, as configuration is read on import - ({ config } = await import('../configuration.ts')); - ({ prisma } = await import('../prisma.ts')); - const { Client } = await import('@opensearch-project/opensearch'); - const { createSearchBackend } = await import('../indexer/search.ts'); - const { initRepository } = await import('../repository.ts'); - const { buildApp } = await import('../app.ts'); - - opensearch = new Client({ node: config.opensearchUrl }); - const search = createSearchBackend(opensearch); - repository = await initRepository('ocfl', { opensearchClient: opensearch, search }); - app = buildApp({ repository, search }); -}); - -beforeEach(async () => { - await repository.deleteIndex(); -}); - -afterAll(async () => { - await app.close(); - await opensearch.close(); - await prisma.$disconnect(); - await rm(storage, { recursive: true, force: true }); -}); - -describe('admin indexing', () => { - it('rejects requests without the admin token', async () => { - const response = await app.inject({ method: 'POST', url: `${config.prefix}/admin/index/${encodeURIComponent(BUNDLED)}` }); - - expect(response.statusCode).toBe(401); - }); - - it('indexes a single object', async () => { - const response = await admin('POST', encodeURIComponent(BUNDLED)); - - expect(response.statusCode).toBe(202); - await waitUntilIndexed(BUNDLED); - expect(await entityCount(PEOPLE)).toBe(0); - expect(await searchCount(PEOPLE)).toBe(0); - }); - - it('indexes all objects', async () => { - const response = await admin('POST', ''); - - expect(response.statusCode).toBe(202); - await waitUntilIndexed(BUNDLED, PEOPLE); - }); - - it('deletes the index of a single object', async () => { - await admin('POST', ''); - await waitUntilIndexed(BUNDLED, PEOPLE); - - await admin('DELETE', encodeURIComponent(BUNDLED)); - - await vi.waitFor(async () => { - expect(await entityCount(BUNDLED)).toBe(0); - expect(await searchCount(BUNDLED)).toBe(0); - }); - expect(await entityCount(PEOPLE)).toBeGreaterThan(0); - expect(await searchCount(PEOPLE)).toBeGreaterThan(0); - }); -}); diff --git a/src/routes/admin.ts b/src/routes/admin.ts deleted file mode 100644 index b0235c3..0000000 --- a/src/routes/admin.ts +++ /dev/null @@ -1,100 +0,0 @@ -import bearerAuthPlugin from '@fastify/bearer-auth'; -import type { FastifyPluginAsync } from 'fastify'; -import type { ZodTypeProvider } from 'fastify-type-provider-zod'; -import * as z from 'zod'; -//import { getIndexerState, createIndex, deleteIndex } from '../ocfl.ts'; -import { config } from '../configuration.ts'; -import { type Repository, State } from '../repository.ts'; - -export const admin: FastifyPluginAsync<{ prefix: string; repository: Repository }> = async (fastify, opts) => { - const repo = opts.repository; - const app = fastify.withTypeProvider(); - app.register(bearerAuthPlugin, { keys: [config.tokenAdmin] }); - - app.get('/repository/', async (_request, reply) => reply.redirect('index/', 301)); - app.get('/repository', async (_request, reply) => reply.redirect('index/', 301)); - - app.get( - '/index/:crateId?', - { - schema: { - summary: 'Get the state of the indexes for a given crate or all crates if no crateId is specified.', - params: z.object({ - crateId: z.string().optional(), - }), - querystring: z.object({ - refresh: z.coerce.boolean().optional(), - }), - }, - }, - async (request, reply) => { - const { crateId } = request.params; - const { refresh } = request.query; - const objects = []; - for await (const repoObject of repo.objects(crateId, refresh)) { - objects.push(repoObject); - } - if (objects.length > 0) { - return reply.send(objects); - } else { - return reply.notFound('Index not found'); - } - }, - ); - - app.post('/index', async (_request, reply) => reply.redirect('index/', 301)); - app.post( - '/index/:crateId/:type?', - { - schema: { - summary: 'Index all creates or a specified crate from the OCFL repository.', - description: 'If the index already exists, it will not be re-indexed unless the "force" query parameter is set to true.', - params: z.object({ - crateId: z.string().optional(), - type: z.string().optional(), - }), - querystring: z.object({ force: z.string().optional() }), - }, - }, - async (request, reply) => { - const { type, crateId } = request.params; - const force = request.query.force != null; - const state = crateId ? await repo.getState(crateId, type) : {}; - if (state) { - if (type && state[type] === State.INDEXED && !force) { - throw fastify.httpErrors.conflict('Index already exists, no changes has been made.'); - } else if (type && state[type] === State.DELETING) { - throw fastify.httpErrors.conflict('Deleting is in progress.'); - } - repo - .createIndex(crateId ? new RegExp(`^${crateId}`) : undefined, type, force) - .catch((err) => request.log.error({ err, crateId, type }, 'Indexing failed')); - return reply.status(202).send(State.QUEUED); - } else { - throw fastify.httpErrors.notFound('Indexer does not exist'); - } - }, - ); - - app.delete('/index', async (_request, reply) => reply.redirect('index/*', 301)); - app.delete( - '/index/:crateId/:type?', - { - schema: { - summary: 'Delete the index of all creates or a specified crate.', - params: z.object({ - crateId: z.string().optional(), - type: z.string().optional(), - }), - }, - }, - async (request, reply) => { - let { crateId, type } = request.params; - if (crateId === 'all' || crateId === '*') { - crateId = undefined; - } - repo.deleteIndex(crateId, type).catch((err) => request.log.error({ err, crateId, type }, 'Deleting index failed')); - return reply.send({ message: 'Deleting' }); - }, - ); -}; From 07c77676ac741e1234387b5d57c97fbea9398941 Mon Sep 17 00:00:00 2001 From: John Ferlito Date: Wed, 28 Oct 2026 09:54:43 +1100 Subject: [PATCH 05/10] refactor: simplify the admin pages after review --- src/admin/actions.test.ts | 61 +++----------- src/admin/actions.ts | 8 +- src/admin/admin-users.test.ts | 46 ++--------- src/admin/client.ts | 11 ++- src/admin/crates.test.ts | 57 ++----------- src/admin/crates.ts | 34 ++++---- src/admin/layout.ts | 7 +- src/admin/plugin.ts | 150 +++++++++++++++++----------------- src/admin/sign-in.test.ts | 38 +++------ src/admin/sign-in.ts | 67 ++++----------- src/app.ts | 8 +- src/configuration.ts | 22 ++--- src/oidc/provider.ts | 10 +-- src/oidc/upstream.test.ts | 2 +- src/oidc/upstream.ts | 56 +++++++++---- src/repository.ts | 4 + test/admin.ts | 75 +++++++++++++++++ test/app.ts | 16 ++-- 18 files changed, 313 insertions(+), 359 deletions(-) create mode 100644 test/admin.ts diff --git a/src/admin/actions.test.ts b/src/admin/actions.test.ts index 30e3c89..497b06c 100644 --- a/src/admin/actions.test.ts +++ b/src/admin/actions.test.ts @@ -4,44 +4,22 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { Client } from '@opensearch-project/opensearch'; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; -import { buildTestApp, type TestApp } from '../../test/app.ts'; -import { startFakeUpstream, type UpstreamUser } from '../../test/fake-upstream.ts'; -import { Browser } from '../../test/oidc.ts'; +import { ALICE, adminTestbed, ocflRepository, PEOPLE, repositoryIn } from '../../test/admin.ts'; +import type { buildTestApp, TestApp, TestRepository } from '../../test/app.ts'; +import type { Browser } from '../../test/oidc.ts'; import type { Repository } from '../repository.ts'; -const ISSUER = 'http://localhost:8080'; -const UPSTREAM_CLIENT = { clientId: 'ldacapi', clientSecret: 'upstream-secret' }; -const ALICE: UpstreamUser = { sub: 'alice@upstream', name: 'Alice' }; - const BUNDLED = 'arcp://name,bundled'; -const PEOPLE = 'arcp://name,people'; +const testbed = adminTestbed(); let storage: string; -let upstream: Awaited>; let opensearch: Client; -const running: Awaited>[] = []; - -const start = async (repository: Repository | 'ocfl') => { - const started = await buildTestApp( - { - OCFL_ROOT: join(storage, 'ocfl'), - OIDC_ISSUER: ISSUER, - OIDC_CLIENTS: JSON.stringify([{ client_id: 'oni', redirect_uris: ['http://localhost:5173/callback'] }]), - OIDC_UPSTREAM_ISSUER: upstream.issuer, - OIDC_UPSTREAM_CLIENT_ID: UPSTREAM_CLIENT.clientId, - OIDC_UPSTREAM_CLIENT_SECRET: UPSTREAM_CLIENT.clientSecret, - ADMIN_USERS: ALICE.sub, - }, - { repository }, - ); - running.push(started); - return started; -}; + +const start = (repository: TestRepository) => testbed.start({ OCFL_ROOT: join(storage, 'ocfl'), ADMIN_USERS: ALICE.sub }, { repository }); const signIn = async (app: TestApp) => { - const browser = new Browser(app, ISSUER); - const toUpstream = await browser.follow('/admin/'); - expect((await browser.follow(upstream.signIn(toUpstream.headers.location as string, ALICE))).statusCode).toBe(200); + const { browser, page } = await testbed.signInAsAdmin(app, ALICE); + expect(page.statusCode).toBe(200); return browser; }; @@ -63,16 +41,10 @@ const flash = (body: string) => beforeAll(async () => { storage = await mkdtemp(join(tmpdir(), 'ldacapi-actions-')); execFileSync('node', ['scripts/seed.ts'], { env: { ...process.env, OCFL_ROOT: join(storage, 'ocfl') }, stdio: 'ignore' }); - upstream = await startFakeUpstream(UPSTREAM_CLIENT); }); afterAll(async () => { - for (const { app, prisma } of running) { - await app.close(); - await prisma.$disconnect(); - } await opensearch?.close(); - await upstream.close(); await rm(storage, { recursive: true, force: true }); }); @@ -102,7 +74,7 @@ describe('index and delete actions', () => { beforeAll(async () => { let config: Awaited>['config']; - ({ app, repository, prisma, config } = await start('ocfl')); + ({ app, repository, prisma, config } = await start(ocflRepository)); entityIndex = config.search.entityIndex; opensearch = new Client({ node: config.opensearchUrl }); }); @@ -213,23 +185,12 @@ describe('index and delete actions', () => { }); describe('protecting the actions', () => { - /** A repository with one crate whose search index is in `state`, recording what it is asked to do */ - const repositoryIn = (state: string) => ({ - storage: () => ({ content: {} }), - async *objects() { - yield { id: PEOPLE, name: 'People', path: '', state: { structural: 'indexed', search: state } }; - }, - getState: async () => ({ structural: 'indexed', search: state }), - createIndex: vi.fn(async () => {}), - deleteIndex: vi.fn(async () => {}), - }); - it.each([ ['one crate', { crateId: PEOPLE, force: 'true' }, `Reindexing ${PEOPLE}`], ['every crate', {}, 'Indexing every crate'], ])('refuses to index %s while deleting is in progress', async (_, form, asked) => { const repository = repositoryIn('deleting'); - const { app } = await start(repository as unknown as Repository); + const { app } = await start(repository); const browser = await signIn(app); const page = await act(browser, 'index', form); @@ -243,7 +204,7 @@ describe('protecting the actions', () => { ['from another origin', { origin: 'https://evil.example.org' }], ])('refuses a POST %s and changes nothing', async (_, headers) => { const repository = repositoryIn('indexed'); - const { app } = await start(repository as unknown as Repository); + const { app } = await start(repository); const browser = await signIn(app); for (const action of ['index', 'delete']) { diff --git a/src/admin/actions.ts b/src/admin/actions.ts index 92ed2f7..133c84c 100644 --- a/src/admin/actions.ts +++ b/src/admin/actions.ts @@ -1,19 +1,17 @@ import type { FastifyBaseLogger } from 'fastify'; -import { type Repository, State } from '../repository.ts'; +import { hasState, type Repository, State } from '../repository.ts'; /** What an action asked for and whether it was accepted, shown once on the next page */ export type Flash = { accepted: boolean; message: string }; -const anyIn = (state: Record, value: string) => Object.values(state).includes(value); - /** Queues indexing `crateId` and the crates under it, or every crate; `force` rebuilds indexes that already exist */ export const indexCrates = async (repository: Repository, log: FastifyBaseLogger, crateId: string | undefined, force: boolean): Promise => { const asked = `${force ? 'Reindexing' : 'Indexing'} ${crateId ?? 'every crate'}`; const states = crateId ? [await repository.getState(crateId)] : (await Array.fromAsync(repository.objects())).map(({ state }) => state); - if (states.some((state) => anyIn(state, State.DELETING))) { + if (states.some((state) => hasState(state, State.DELETING))) { return { accepted: false, message: `${asked} was refused: deleting is in progress.` }; } - if (crateId && !force && states.some((state) => anyIn(state, State.INDEXED))) { + if (crateId && !force && states.some((state) => hasState(state, State.INDEXED))) { return { accepted: false, message: `${asked} was refused: it is already indexed, so reindex it instead.` }; } repository diff --git a/src/admin/admin-users.test.ts b/src/admin/admin-users.test.ts index 24e666d..c6a17df 100644 --- a/src/admin/admin-users.test.ts +++ b/src/admin/admin-users.test.ts @@ -1,51 +1,17 @@ -import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; -import { buildTestApp, type TestApp } from '../../test/app.ts'; -import { startFakeUpstream, type UpstreamUser } from '../../test/fake-upstream.ts'; -import { Browser } from '../../test/oidc.ts'; +import { describe, expect, it, vi } from 'vitest'; +import { ALICE, adminTestbed } from '../../test/admin.ts'; -const ISSUER = 'http://localhost:8080'; -const UPSTREAM_CLIENT = { clientId: 'ldacapi', clientSecret: 'upstream-secret' }; -const ALICE: UpstreamUser = { sub: 'alice@upstream', name: 'Alice' }; const COOKIE_KEYS = 'a-cookie-key-of-at-least-32-characters'; -let upstream: Awaited>; -const running: Awaited>[] = []; +const testbed = adminTestbed(); +const signIn = testbed.signInAsAdmin; /** Starts the app afresh with `env`, as a restart with new configuration would */ -const start = async (env: Record) => { +const start = (env: Record) => { vi.resetModules(); - const started = await buildTestApp({ - OIDC_ISSUER: ISSUER, - OIDC_CLIENTS: JSON.stringify([{ client_id: 'oni', redirect_uris: ['http://localhost:5173/callback'] }]), - OIDC_UPSTREAM_ISSUER: upstream.issuer, - OIDC_UPSTREAM_CLIENT_ID: UPSTREAM_CLIENT.clientId, - OIDC_UPSTREAM_CLIENT_SECRET: UPSTREAM_CLIENT.clientSecret, - OIDC_COOKIE_KEYS: COOKIE_KEYS, - ...env, - }); - running.push(started); - return { app: started.app }; -}; - -const signIn = async (app: TestApp, user: UpstreamUser) => { - const browser = new Browser(app, ISSUER); - const toUpstream = await browser.follow('/admin/'); - const page = await browser.follow(upstream.signIn(toUpstream.headers.location as string, user)); - return { browser, page }; + return testbed.start({ OIDC_COOKIE_KEYS: COOKIE_KEYS, ...env }); }; -beforeAll(async () => { - upstream = await startFakeUpstream(UPSTREAM_CLIENT); -}); - -afterAll(async () => { - for (const { app, prisma } of running) { - await app.close(); - await prisma.$disconnect(); - } - await upstream.close(); -}); - describe('who is an admin', () => { it('lets nobody in when ADMIN_USERS lists nobody', async () => { const { app } = await start({ ADMIN_USERS: ' , ' }); diff --git a/src/admin/client.ts b/src/admin/client.ts index 4851548..50ae901 100644 --- a/src/admin/client.ts +++ b/src/admin/client.ts @@ -1,14 +1,21 @@ import { hkdfSync } from 'node:crypto'; import type { AuthConfig } from '../configuration.ts'; +/** Where the admin pages are mounted */ +export const ADMIN_PATH = '/admin'; + export const derivedKey = (cookieKey: string, purpose: string) => Buffer.from(hkdfSync('sha256', cookieKey, '', `ldacapi ${purpose}`, 32)); +export const adminRedirectUri = (issuer: string) => `${issuer}${ADMIN_PATH}/callback`; + +export const adminPostLogoutRedirectUri = (issuer: string) => `${issuer}${ADMIN_PATH}/`; + /** The admin pages' own client of our provider, which needs no configuration */ export const adminClient = ({ issuer, cookieKeys }: AuthConfig) => ({ client_id: 'ldacapi-admin', client_secret: derivedKey(cookieKeys[0] ?? '', 'admin client secret').toString('base64url'), - redirect_uris: [`${issuer}/admin/callback`], - post_logout_redirect_uris: [`${issuer}/admin/`], + redirect_uris: [adminRedirectUri(issuer)], + post_logout_redirect_uris: [adminPostLogoutRedirectUri(issuer)], grant_types: ['authorization_code'], scope: 'openid profile', }); diff --git a/src/admin/crates.test.ts b/src/admin/crates.test.ts index 237890e..1663aa3 100644 --- a/src/admin/crates.test.ts +++ b/src/admin/crates.test.ts @@ -3,17 +3,11 @@ import { cp, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; -import { buildTestApp, type TestApp } from '../../test/app.ts'; -import { startFakeUpstream, type UpstreamUser } from '../../test/fake-upstream.ts'; -import { Browser } from '../../test/oidc.ts'; +import { ALICE, adminTestbed, ocflRepository, PEOPLE, repositoryIn } from '../../test/admin.ts'; +import type { TestApp, TestRepository } from '../../test/app.ts'; import type { Repository } from '../repository.ts'; -const ISSUER = 'http://localhost:8080'; -const UPSTREAM_CLIENT = { clientId: 'ldacapi', clientSecret: 'upstream-secret' }; -const ALICE: UpstreamUser = { sub: 'alice@upstream', name: 'Alice' }; - const BUNDLED = 'arcp://name,bundled'; -const PEOPLE = 'arcp://name,people'; const EXTRA = 'arcp://name,people/extra'; const MORE = 'arcp://name,people/extra/more'; const EXTRA_NAME = ' Extra people'; @@ -64,33 +58,15 @@ const crate = (id: string, name: string) => ({ ], }); +const testbed = adminTestbed(); let storage: string; -let upstream: Awaited>; -const running: Awaited>[] = []; - -const start = async (repository: Repository | 'ocfl') => { - const started = await buildTestApp( - { - OCFL_ROOT: join(storage, 'ocfl'), - OIDC_ISSUER: ISSUER, - OIDC_CLIENTS: JSON.stringify([{ client_id: 'oni', redirect_uris: ['http://localhost:5173/callback'] }]), - OIDC_UPSTREAM_ISSUER: upstream.issuer, - OIDC_UPSTREAM_CLIENT_ID: UPSTREAM_CLIENT.clientId, - OIDC_UPSTREAM_CLIENT_SECRET: UPSTREAM_CLIENT.clientSecret, - ADMIN_USERS: ALICE.sub, - }, - { repository }, - ); - running.push(started); - return started; -}; + +const start = (repository: TestRepository) => testbed.start({ OCFL_ROOT: join(storage, 'ocfl'), ADMIN_USERS: ALICE.sub }, { repository }); const signIn = async (app: TestApp) => { - const browser = new Browser(app, ISSUER); - const toUpstream = await browser.follow('/admin/'); - const page = await browser.follow(upstream.signIn(toUpstream.headers.location as string, ALICE)); - expect(page.statusCode).toBe(200); - return { browser, page }; + const signedIn = await testbed.signInAsAdmin(app, ALICE); + expect(signedIn.page.statusCode).toBe(200); + return signedIn; }; beforeAll(async () => { @@ -108,15 +84,9 @@ beforeAll(async () => { await writeFile(join(source, directory, 'ro-crate-metadata.json'), JSON.stringify(crate(id, name))); } execFileSync('node', ['scripts/seed.ts', source], { env: { ...process.env, OCFL_ROOT: join(storage, 'ocfl') }, stdio: 'ignore' }); - upstream = await startFakeUpstream(UPSTREAM_CLIENT); }); afterAll(async () => { - for (const { app, prisma } of running) { - await app.close(); - await prisma.$disconnect(); - } - await upstream.close(); await rm(storage, { recursive: true, force: true }); }); @@ -125,7 +95,7 @@ describe('the crate tree', () => { let repository: Repository; beforeAll(async () => { - ({ app, repository } = await start('ocfl')); + ({ app, repository } = await start(ocflRepository)); await repository.deleteIndex(); }); @@ -168,15 +138,6 @@ describe('the crate tree', () => { }); describe('refreshing while indexes change', () => { - /** A repository whose one crate is in `state` for the search index */ - const repositoryIn = (state: string) => - ({ - storage: () => ({ content: {} }), - async *objects() { - yield { id: PEOPLE, name: 'People', path: '', state: { structural: 'indexed', search: state } }; - }, - }) as unknown as Repository; - it.each(['queued', 'indexing', 'deleting'])('refreshes the page while an index is %s', async (state) => { const { app } = await start(repositoryIn(state)); diff --git a/src/admin/crates.ts b/src/admin/crates.ts index 8bb0706..f2213cb 100644 --- a/src/admin/crates.ts +++ b/src/admin/crates.ts @@ -1,12 +1,11 @@ -import { type RepositoryObject, State } from '../repository.ts'; +import { hasState, type RepositoryObject, State } from '../repository.ts'; +import { ADMIN_PATH } from './client.ts'; import { type Html, html } from './html.ts'; -type Crate = RepositoryObject & { children: Crate[] }; +type Crate = RepositoryObject & { label: string; children: Crate[] }; const INDEXES = { structural: 'Structural', search: 'Search' } as const; -const IN_PROGRESS = new Set([State.QUEUED, State.INDEXING, State.DELETING]); - /** How often the page reloads while an index is changing */ const REFRESH_SECONDS = 5; @@ -22,16 +21,23 @@ const parentOf = (id: string, crates: Map) => { } }; -const byName = (a: Crate, b: Crate) => (a.name ?? a.id).localeCompare(b.name ?? b.id) || a.id.localeCompare(b.id); +const byLabel = (a: Crate, b: Crate) => a.label.localeCompare(b.label) || a.id.localeCompare(b.id); + +const sortTree = (crates: Crate[]) => { + crates.sort(byLabel); + for (const crate of crates) { + sortTree(crate.children); + } + return crates; +}; const buildTree = (objects: RepositoryObject[]) => { - const crates = new Map(objects.map((object) => [object.id, { ...object, children: [] as Crate[] }])); + const crates = new Map(objects.map((object) => [object.id, { ...object, label: object.name ?? object.id, children: [] as Crate[] }])); const roots: Crate[] = []; for (const crate of crates.values()) { (parentOf(crate.id, crates)?.children ?? roots).push(crate); } - const sort = (list: Crate[]): Crate[] => list.sort(byName).map((crate) => ({ ...crate, children: sort(crate.children) })); - return sort(roots); + return sortTree(roots); }; const BUTTON = 'cursor-pointer rounded border px-2 py-0.5 text-sm'; @@ -40,17 +46,17 @@ const DELETE_BUTTON = `${BUTTON} border-red-300 bg-white text-red-700 hover:bg-r /** A form posting `crateId` (none for every crate) to an action */ const actionForm = (action: 'index' | 'delete', label: string, crateId?: string, { force = false, confirm }: { force?: boolean; confirm?: string } = {}) => - html`
      ${crateId && html``}${force && html``}
      `; + html`
      ${crateId && html``}${force && html``}
      `; const crateActions = (crate: Crate) => { - const indexed = Object.values(crate.state).includes(State.INDEXED); - return html`${actionForm('index', indexed ? 'Reindex' : 'Index', crate.id, { force: indexed })} ${actionForm('delete', 'Delete', crate.id, { confirm: `Delete the indexes of ${crate.name ?? crate.id}?` })}`; + const indexed = hasState(crate.state, State.INDEXED); + return html`${actionForm('index', indexed ? 'Reindex' : 'Index', crate.id, { force: indexed })} ${actionForm('delete', 'Delete', crate.id, { confirm: `Delete the indexes of ${crate.label}?` })}`; }; const renderCrates = (crates: Crate[]): Html => html`
        ${crates.map( - (crate) => html`
      • - ${crate.name ?? crate.id} ${crate.id} + (crate) => html`
      • + ${crate.label} ${crate.id} ${Object.entries(INDEXES).map(([index, label]) => html`${label}: ${crate.state[index] ?? 'not indexed'} `)} ${crateActions(crate)} ${crate.children.length > 0 && renderCrates(crate.children)} @@ -60,7 +66,7 @@ ${crates.map( /** The crates as a tree, and a refresh while any index is changing */ export const cratesPage = (objects: RepositoryObject[]) => { - const changing = objects.some(({ state }) => Object.values(state).some((value) => value && IN_PROGRESS.has(value))); + const changing = objects.some(({ state }) => hasState(state, State.QUEUED, State.INDEXING, State.DELETING)); const body = objects.length === 0 ? html`

        The repository has no crates.

        ` diff --git a/src/admin/layout.ts b/src/admin/layout.ts index b45e769..c373725 100644 --- a/src/admin/layout.ts +++ b/src/admin/layout.ts @@ -1,4 +1,5 @@ import type { Flash } from './actions.ts'; +import { ADMIN_PATH } from './client.ts'; import { type Html, html } from './html.ts'; type LayoutOptions = { @@ -18,8 +19,8 @@ export const layout = ({ title, user, body, flash, head }: LayoutOptions) => htm ${title} · LDaCA admin - - + + ${head} @@ -33,7 +34,7 @@ export const layout = ({ title, user, body, flash, head }: LayoutOptions) => htm user && html`

        Signed in as ${user}

        -
        +
        ` } diff --git a/src/admin/plugin.ts b/src/admin/plugin.ts index 79da274..b20f1a6 100644 --- a/src/admin/plugin.ts +++ b/src/admin/plugin.ts @@ -3,12 +3,11 @@ import formBody from '@fastify/formbody'; import secureSession, { type Session, type SessionData } from '@fastify/secure-session'; import fastifyStatic from '@fastify/static'; import type { FastifyPluginAsync, FastifyReply } from 'fastify'; -import { decodeJwt } from 'jose'; import type { AuthConfig } from '../configuration.ts'; import { logger } from '../logger.ts'; import type { Repository } from '../repository.ts'; import { deleteIndexes, type Flash, indexCrates } from './actions.ts'; -import { derivedKey } from './client.ts'; +import { ADMIN_PATH, derivedKey } from './client.ts'; import { cratesPage } from './crates.ts'; import { type Html, html } from './html.ts'; import { layout } from './layout.ts'; @@ -16,7 +15,7 @@ import { createSignIn, type SignInTransaction } from './sign-in.ts'; const log = logger.child({ module: 'admin' }); -type AdminSession = { sub: string; signedInAt: number; idToken: string }; +type AdminSession = { sub: string; name: string; signedInAt: number; idToken: string }; declare module '@fastify/secure-session' { interface SessionData { @@ -39,11 +38,6 @@ const CSP = "default-src 'none'; script-src 'self'; style-src 'self'; img-src 's const send = (reply: FastifyReply, page: Html, status = 200) => reply.code(status).type('text/html; charset=utf-8').send(page.toString()); -const displayName = (idToken: string) => { - const { name, sub } = decodeJwt(idToken); - return typeof name === 'string' && name ? name : sub; -}; - type AdminOptions = { auth: AuthConfig; adminUsers: string[]; repository: Repository }; /** The admin pages, for users whose `sub` is in `adminUsers` */ @@ -51,21 +45,15 @@ export const adminPages: FastifyPluginAsync = async (fastify, { au const issuer = new URL(auth.issuer); const signIn = createSignIn(fastify, auth); const admins = new Set(adminUsers); - const cookie = { path: '/admin', httpOnly: true, secure: issuer.protocol === 'https:', sameSite: 'lax' } as const; - const keys = (purpose: string) => auth.cookieKeys.map((key) => derivedKey(key, purpose)); - - fastify.register(formBody); - fastify.register(fastifyStatic, { root: fileURLToPath(new URL('public', import.meta.url)), decorateReply: false, index: false }); - fastify.register(secureSession, [ - { - sessionName: 'session', - cookieName: 'admin_session', - key: keys('admin session'), - expiry: SESSION_SECONDS, - cookie: { ...cookie, maxAge: SESSION_SECONDS }, - }, - { sessionName: 'signIn', cookieName: 'admin_sign_in', key: keys('admin sign-in'), expiry: SIGN_IN_SECONDS, cookie: { ...cookie, maxAge: SIGN_IN_SECONDS } }, - ]); + const cookie = { path: ADMIN_PATH, httpOnly: true, secure: issuer.protocol === 'https:', sameSite: 'lax' } as const; + const session = (sessionName: string, cookieName: string, purpose: string, seconds: number) => ({ + sessionName, + cookieName, + key: auth.cookieKeys.map((key) => derivedKey(key, purpose)), + expiry: seconds, + cookie: { ...cookie, maxAge: seconds }, + }); + const home = `${ADMIN_PATH}/`; fastify.addHook('onRequest', async (request, reply) => { reply.header('content-security-policy', CSP).header('x-frame-options', 'DENY'); @@ -74,65 +62,77 @@ export const adminPages: FastifyPluginAsync = async (fastify, { au } }); - fastify.get<{ Querystring: Record }>('/callback', async (request, reply) => { - const transaction = request.signIn.get('transaction'); - request.signIn.delete(); - if (!transaction || request.query.state !== transaction.state) { - return reply.badRequest('Sign-in has expired or was started elsewhere'); - } - let signedIn: Awaited>; - try { - signedIn = await signIn.finish(request.query, transaction); - } catch (err) { - log.warn({ err }, 'Sign-in to the admin pages failed'); - return reply.badRequest('Sign-in failed'); - } - request.session.set('admin', { ...signedIn, signedInAt: Date.now() }); - return reply.redirect(transaction.returnTo, 303); - }); - - fastify.post('/sign-out', async (request, reply) => { - const session = request.session.get('admin'); - request.session.delete(); - return reply.redirect(session ? signIn.signOutUrl(session.idToken).toString() : '/admin/', 303); - }); + fastify.register(fastifyStatic, { root: fileURLToPath(new URL('public', import.meta.url)), decorateReply: false, index: false }); - // Every page below needs a current session belonging to an admin - fastify.register(async (pages) => { - pages.addHook('onRequest', async (request, reply) => { - const session = request.session.get('admin'); - if (!session || Date.now() - session.signedInAt >= SESSION_SECONDS * 1000) { - const { url, transaction } = await signIn.start(request.url); - request.signIn.set('transaction', transaction); - return reply.redirect(url.toString(), 303); + fastify.register(async (sessions) => { + sessions.register(formBody); + sessions.register(secureSession, [ + session('session', 'admin_session', 'admin session', SESSION_SECONDS), + session('signIn', 'admin_sign_in', 'admin sign-in', SIGN_IN_SECONDS), + ]); + + sessions.get<{ Querystring: Record }>('/callback', async (request, reply) => { + const transaction = request.signIn.get('transaction'); + request.signIn.delete(); + if (!transaction || request.query.state !== transaction.state) { + return reply.badRequest('Sign-in has expired or was started elsewhere'); } - if (!admins.has(session.sub)) { - const body = html`
        -

        You are signed in, but you are not an admin.

        -

        To be given access, ask an operator to add this sub to ADMIN_USERS:

        -

        ${session.sub}

        -
        `; - return send(reply, layout({ title: 'Not an admin', user: displayName(session.idToken), body }), 403); + let signedIn: Awaited>; + try { + signedIn = await signIn.finish(request.query, transaction); + } catch (err) { + log.warn({ err }, 'Sign-in to the admin pages failed'); + return reply.badRequest('Sign-in failed'); } + const { sub, name = sub, idToken } = signedIn; + request.session.set('admin', { sub, name, idToken, signedInAt: Date.now() }); + return reply.redirect(transaction.returnTo, 303); }); - pages.get('/', async (request, reply) => { - const user = displayName(request.session.get('admin')?.idToken ?? ''); - const flash = request.session.get('flash'); - request.session.set('flash', undefined); - const objects = await Array.fromAsync(repository.objects()); - return send(reply, layout({ title: 'Crates', user, flash, ...cratesPage(objects) })); + sessions.post('/sign-out', async (request, reply) => { + const admin = request.session.get('admin'); + request.session.delete(); + return reply.redirect(admin ? signIn.signOutUrl(admin.idToken).toString() : home, 303); }); - pages.post<{ Body: { crateId?: string; force?: string } }>('/index', async (request, reply) => { - const { crateId, force } = request.body ?? {}; - request.session.set('flash', await indexCrates(repository, request.log, crateId || undefined, Boolean(force))); - return reply.redirect('/admin/', 303); - }); - - pages.post<{ Body: { crateId?: string } }>('/delete', async (request, reply) => { - request.session.set('flash', deleteIndexes(repository, request.log, request.body?.crateId || undefined)); - return reply.redirect('/admin/', 303); + // Every page below needs a current session belonging to an admin + sessions.register(async (pages) => { + pages.addHook('onRequest', async (request, reply) => { + const admin = request.session.get('admin'); + if (!admin || Date.now() - admin.signedInAt >= SESSION_SECONDS * 1000) { + const { url, transaction } = await signIn.start(request.url); + request.signIn.set('transaction', transaction); + return reply.redirect(url.toString(), 303); + } + if (!admins.has(admin.sub)) { + const body = html`
        +

        You are signed in, but you are not an admin.

        +

        To be given access, ask an operator to add this sub to ADMIN_USERS:

        +

        ${admin.sub}

        +
        `; + return send(reply, layout({ title: 'Not an admin', user: admin.name, body }), 403); + } + }); + + pages.get('/', async (request, reply) => { + const flash = request.session.get('flash'); + if (flash) { + request.session.set('flash', undefined); + } + const objects = await Array.fromAsync(repository.objects()); + return send(reply, layout({ title: 'Crates', user: request.session.get('admin')?.name, flash, ...cratesPage(objects) })); + }); + + pages.post<{ Body: { crateId?: string; force?: string } }>('/index', async (request, reply) => { + const { crateId, force } = request.body ?? {}; + request.session.set('flash', await indexCrates(repository, request.log, crateId || undefined, Boolean(force))); + return reply.redirect(home, 303); + }); + + pages.post<{ Body: { crateId?: string } }>('/delete', async (request, reply) => { + request.session.set('flash', deleteIndexes(repository, request.log, request.body?.crateId || undefined)); + return reply.redirect(home, 303); + }); }); }); }; diff --git a/src/admin/sign-in.test.ts b/src/admin/sign-in.test.ts index c903ece..ac4316d 100644 --- a/src/admin/sign-in.test.ts +++ b/src/admin/sign-in.test.ts @@ -1,46 +1,30 @@ -import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; -import { buildTestApp, type TestApp } from '../../test/app.ts'; -import { startFakeUpstream, type UpstreamUser } from '../../test/fake-upstream.ts'; +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import { ALICE as ADMIN, adminTestbed, ISSUER } from '../../test/admin.ts'; +import type { TestApp } from '../../test/app.ts'; +import type { UpstreamUser } from '../../test/fake-upstream.ts'; import { authorizationRequest, Browser } from '../../test/oidc.ts'; -const ISSUER = 'http://localhost:8080'; -const UPSTREAM_CLIENT = { clientId: 'ldacapi', clientSecret: 'upstream-secret' }; -const ALICE: UpstreamUser = { sub: 'alice@upstream', name: 'Alice ' }; +const ALICE: UpstreamUser = { ...ADMIN, name: 'Alice ' }; const MALLORY: UpstreamUser = { sub: 'mallory@upstream', name: 'Mallory' }; -let upstream: Awaited>; +const testbed = adminTestbed(); let app: TestApp; -let prisma: Awaited>['prisma']; /** Follows `url` to the upstream provider, signs `user` in there, and follows the way back */ const signIn = async (browser: Browser, url: string, user: UpstreamUser) => { const toUpstream = await browser.follow(url); - expect(new URL(toUpstream.headers.location as string).origin).toBe(upstream.issuer); - return browser.follow(upstream.signIn(toUpstream.headers.location as string, user)); + expect(new URL(toUpstream.headers.location as string).origin).toBe(testbed.upstream.issuer); + return browser.follow(testbed.upstream.signIn(toUpstream.headers.location as string, user)); }; beforeAll(async () => { - upstream = await startFakeUpstream(UPSTREAM_CLIENT); - ({ app, prisma } = await buildTestApp({ - OIDC_ISSUER: ISSUER, - OIDC_CLIENTS: JSON.stringify([{ client_id: 'oni', redirect_uris: ['http://localhost:5173/callback'] }]), - OIDC_UPSTREAM_ISSUER: upstream.issuer, - OIDC_UPSTREAM_CLIENT_ID: UPSTREAM_CLIENT.clientId, - OIDC_UPSTREAM_CLIENT_SECRET: UPSTREAM_CLIENT.clientSecret, - ADMIN_USERS: ` other@upstream , ${ALICE.sub} `, - })); + ({ app } = await testbed.start({ ADMIN_USERS: ` other@upstream , ${ALICE.sub} ` })); }); afterEach(() => { vi.useRealTimers(); }); -afterAll(async () => { - await app.close(); - await prisma.$disconnect(); - await upstream.close(); -}); - describe('signing in to the admin pages', () => { it('sends a visitor with no session to sign in, then back to the page they asked for', async () => { const browser = new Browser(app, ISSUER); @@ -106,7 +90,7 @@ describe('signing in to the admin pages', () => { it('refuses a callback whose state does not match the sign-in', async () => { const browser = new Browser(app, ISSUER); const toUpstream = await browser.follow('/admin/'); - let toAdmin = new URL(upstream.signIn(toUpstream.headers.location as string, ALICE)); + let toAdmin = new URL(testbed.upstream.signIn(toUpstream.headers.location as string, ALICE)); while (toAdmin.pathname !== '/admin/callback') { toAdmin = new URL((await browser.get(toAdmin.toString())).headers.location as string, ISSUER); } @@ -137,7 +121,7 @@ describe('signing in to the admin pages', () => { const backToAdmin = await browser.post('/oauth/session/end/confirm', { xsrf, logout: 'yes' }); expect(backToAdmin.headers.location).toBe(`${ISSUER}/admin/`); const again = await browser.follow('/admin/'); - expect(new URL(again.headers.location as string).origin).toBe(upstream.issuer); + expect(new URL(again.headers.location as string).origin).toBe(testbed.upstream.issuer); }); it('refuses a POST with a missing or foreign Origin', async () => { diff --git a/src/admin/sign-in.ts b/src/admin/sign-in.ts index 406f594..95e1852 100644 --- a/src/admin/sign-in.ts +++ b/src/admin/sign-in.ts @@ -1,40 +1,27 @@ import type { FastifyInstance } from 'fastify'; -import { - allowInsecureRequests, - authorizationCodeGrant, - buildAuthorizationUrl, - buildEndSessionUrl, - Configuration, - calculatePKCECodeChallenge, - customFetch, - randomNonce, - randomPKCECodeVerifier, - randomState, -} from 'openid-client'; +import { buildEndSessionUrl, Configuration, customFetch } from 'openid-client'; import type { AuthConfig } from '../configuration.ts'; -import { adminClient } from './client.ts'; +import { routes } from '../oidc/provider.ts'; +import { createUpstream, type UpstreamChecks } from '../oidc/upstream.ts'; +import { adminClient, adminPostLogoutRedirectUri, adminRedirectUri } from './client.ts'; /** What the callback checks the provider's response against, and where it sends the browser after */ -export type SignInTransaction = { state: string; codeVerifier: string; nonce: string; returnTo: string }; +export type SignInTransaction = UpstreamChecks & { returnTo: string }; /** Signs admins in at our own provider, reaching its token endpoint in-process rather than through our public URL */ export const createSignIn = (fastify: FastifyInstance, auth: AuthConfig) => { + const { issuer } = auth; const client = adminClient(auth); - const [redirectUri = ''] = client.redirect_uris; - const [postLogoutRedirectUri = ''] = client.post_logout_redirect_uris; const configuration = new Configuration( { - issuer: auth.issuer, - authorization_endpoint: `${auth.issuer}/oauth/authorize`, - token_endpoint: `${auth.issuer}/oauth/token`, - end_session_endpoint: `${auth.issuer}/oauth/session/end`, + issuer, + authorization_endpoint: `${issuer}${routes.authorization}`, + token_endpoint: `${issuer}${routes.token}`, + end_session_endpoint: `${issuer}${routes.end_session}`, }, client.client_id, client.client_secret, ); - if (new URL(auth.issuer).protocol === 'http:') { - allowInsecureRequests(configuration); - } configuration[customFetch] = async (url, { method, headers, body }) => { const { pathname, search } = new URL(url); const response = await fastify.inject({ @@ -51,40 +38,22 @@ export const createSignIn = (fastify: FastifyInstance, auth: AuthConfig) => { } return new Response(response.body, { status: response.statusCode, headers: responseHeaders }); }; + const flow = createUpstream({ configuration, redirectUri: adminRedirectUri(issuer), scope: client.scope }); return { async start(returnTo: string) { - const transaction: SignInTransaction = { state: randomState(), codeVerifier: randomPKCECodeVerifier(), nonce: randomNonce(), returnTo }; - const url = buildAuthorizationUrl(configuration, { - redirect_uri: redirectUri, - scope: client.scope, - code_challenge: await calculatePKCECodeChallenge(transaction.codeVerifier), - code_challenge_method: 'S256', - state: transaction.state, - nonce: transaction.nonce, - }); - return { url, transaction }; + const checks = flow.newChecks(); + return { url: await flow.authorizationUrl(checks), transaction: { ...checks, returnTo } satisfies SignInTransaction }; }, - /** The ID token from the callback's query, once it checks out against the transaction */ - async finish(query: Record, { state, codeVerifier, nonce }: SignInTransaction) { - const currentUrl = new URL(redirectUri); - currentUrl.search = new URLSearchParams(query).toString(); - const tokens = await authorizationCodeGrant(configuration, currentUrl, { - pkceCodeVerifier: codeVerifier, - expectedState: state, - expectedNonce: nonce, - idTokenExpected: true, - }); - const claims = tokens.claims(); - if (!claims || !tokens.id_token) { - throw new Error('No ID token'); - } - return { sub: claims.sub, idToken: tokens.id_token }; + /** Who signed in, once the callback's query checks out against the transaction */ + async finish(query: Record, transaction: SignInTransaction) { + const { claims, idToken } = await flow.tokens(query, transaction); + return { sub: claims.sub, name: typeof claims.name === 'string' && claims.name ? claims.name : undefined, idToken }; }, signOutUrl(idToken: string) { - return buildEndSessionUrl(configuration, { id_token_hint: idToken, post_logout_redirect_uri: postLogoutRedirectUri }); + return buildEndSessionUrl(configuration, { id_token_hint: idToken, post_logout_redirect_uri: adminPostLogoutRedirectUri(issuer) }); }, }; }; diff --git a/src/app.ts b/src/app.ts index ed8f0cc..3ab57ea 100644 --- a/src/app.ts +++ b/src/app.ts @@ -5,6 +5,7 @@ import arocapi, { AllVisibleEntityFilter, AllVisibleSearchFilter, errorHandler, import { EntityMetaMetadata } from 'arocapi/storage/entity-meta'; import Fastify, { type FastifyPluginAsync } from 'fastify'; import pkg from '../package.json' with { type: 'json' }; +import { ADMIN_PATH, adminClient } from './admin/client.ts'; import { adminPages } from './admin/plugin.ts'; import { access, isAuthenticated } from './auth.ts'; import { config } from './configuration.ts'; @@ -62,9 +63,10 @@ export function buildApp({ repository, search }: AppDeps) { }, { prefix }, ); - if (config.auth) { - fastify.register(oidcProvider, { auth: config.auth }); - fastify.register(adminPages, { prefix: '/admin', auth: config.auth, adminUsers: config.adminUsers, repository }); + const { auth } = config; + if (auth) { + fastify.register(oidcProvider, { auth: { ...auth, clients: [...auth.clients, adminClient(auth)] } }); + fastify.register(adminPages, { prefix: ADMIN_PATH, auth, adminUsers: config.adminUsers, repository }); } else { fastify.log.warn('The admin pages are off because sign-in is off; set OIDC_UPSTREAM_ISSUER to turn them on'); } diff --git a/src/configuration.ts b/src/configuration.ts index cb886f8..74cd6a3 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -53,10 +53,14 @@ const privateRsaJwk = z.looseObject({ kty: z.literal('RSA'), kid: z.string().min const signingKeys = json.pipe(z.object({ keys: z.array(privateRsaJwk).min(1, 'Needs at least one key') })); -const cookieKeys = z - .string() - .transform((value) => value.split(',').map((key) => key.trim())) - .pipe(z.array(z.string().min(32, 'Each key must be at least 32 characters'))); +const commaList = z.string().transform((value) => + value + .split(',') + .map((item) => item.trim()) + .filter(Boolean), +); + +const cookieKeys = commaList.pipe(z.array(z.string().min(32, 'Each key must be at least 32 characters')).min(1, 'Needs at least one key')); const REQUIRED_WITH_UPSTREAM = 'Required when OIDC_UPSTREAM_ISSUER is set'; @@ -117,15 +121,7 @@ export function loadConfig(processEnv: Env) { OIDC_UPSTREAM_CLIENT_SECRET: z.string().optional(), OIDC_SIGNING_KEYS: signingKeys.optional(), OIDC_COOKIE_KEYS: cookieKeys.optional(), - ADMIN_USERS: z - .string() - .default('') - .transform((value) => - value - .split(',') - .map((sub) => sub.trim()) - .filter(Boolean), - ), + ADMIN_USERS: commaList.default([]), }) .transform((env, ctx) => { const { diff --git a/src/oidc/provider.ts b/src/oidc/provider.ts index 45c2a07..44f8bf5 100644 --- a/src/oidc/provider.ts +++ b/src/oidc/provider.ts @@ -1,6 +1,5 @@ import type { FastifyPluginAsync, FastifyReply, FastifyRequest, onRequestHookHandler } from 'fastify'; import Provider, { type ClientMetadata, type Configuration, errors, type InteractionResults, type KoaContextWithOIDC } from 'oidc-provider'; -import { adminClient } from '../admin/client.ts'; import type { AuthConfig, OidcClient } from '../configuration.ts'; import { logger } from '../logger.ts'; import { deleteExpired, PrismaAdapter } from './adapter.ts'; @@ -9,7 +8,7 @@ import { findAccount, saveUser } from './users.ts'; const log = logger.child({ module: 'oidc' }); -const routes = { +export const routes = { authorization: '/oauth/authorize', token: '/oauth/token', jwks: '/oauth/jwks', @@ -66,11 +65,10 @@ const loadExistingGrant = (audience: string) => async (ctx: KoaContextWithOIDC) const toClientMetadata = (client: OidcClient): ClientMetadata => (client.client_secret ? client : { ...client, token_endpoint_auth_method: 'none' }); -const createProvider = (auth: AuthConfig) => { - const { issuer, audience, clients, signingKeys, cookieKeys } = auth; +const createProvider = ({ issuer, audience, clients, signingKeys, cookieKeys }: AuthConfig) => { const provider = new Provider(issuer, { adapter: PrismaAdapter, - clients: [...clients.map(toClientMetadata), adminClient(auth)], + clients: clients.map(toClientMetadata), clientDefaults: { grant_types: ['authorization_code', 'refresh_token'] }, jwks: { keys: signingKeys }, cookies: { keys: cookieKeys }, @@ -208,7 +206,7 @@ export const oidcProvider: FastifyPluginAsync<{ auth: AuthConfig }> = async (fas let result: InteractionResults; try { - const user = await saveUser(await upstream.claims(request.query, checks)); + const user = await saveUser((await upstream.tokens(request.query, checks)).claims); result = { login: { accountId: user.sub } }; } catch (err) { log.warn({ err }, 'Sign-in at the upstream provider failed'); diff --git a/src/oidc/upstream.test.ts b/src/oidc/upstream.test.ts index 0f7f7f7..e1ba708 100644 --- a/src/oidc/upstream.test.ts +++ b/src/oidc/upstream.test.ts @@ -23,7 +23,7 @@ describe('authenticating at the upstream token endpoint', () => { const checks = client.newChecks('state'); const callback = new URL(upstream?.signIn((await client.authorizationUrl(checks)).toString(), ALICE) ?? ''); - const claims = await client.claims(Object.fromEntries(callback.searchParams), checks); + const { claims } = await client.tokens(Object.fromEntries(callback.searchParams), checks); expect(claims).toMatchObject(ALICE); }); diff --git a/src/oidc/upstream.ts b/src/oidc/upstream.ts index 5ed5245..eb5ee2e 100644 --- a/src/oidc/upstream.ts +++ b/src/oidc/upstream.ts @@ -10,14 +10,21 @@ import { type IDToken, randomNonce, randomPKCECodeVerifier, + randomState, type ServerMetadata, } from 'openid-client'; -type UpstreamOptions = { issuer: string; clientId: string; clientSecret: string; redirectUri: string; scope: string }; +type Discovered = { issuer: string; clientId: string; clientSecret: string }; + +/** A provider found by discovery at `issuer`, or one already configured */ +type UpstreamOptions = (Discovered | { configuration: Configuration }) & { redirectUri: string; scope: string }; /** What the callback needs to check the upstream provider's response against */ export type UpstreamChecks = { state: string; codeVerifier: string; nonce: string }; +// Configuration only allows an http issuer outside production +const insecure = (issuer: string) => (new URL(issuer).protocol === 'http:' ? [allowInsecureRequests] : []); + // Prefers Basic, which OIDC makes the default when the provider lists no methods const clientAuth = ({ token_endpoint_auth_methods_supported: supported }: ServerMetadata, clientSecret: string) => { if (!supported || supported.includes('client_secret_basic')) { @@ -29,27 +36,38 @@ const clientAuth = ({ token_endpoint_auth_methods_supported: supported }: Server throw new Error(`The upstream provider supports none of client_secret_basic or client_secret_post at its token endpoint, only: ${supported.join(', ')}`); }; -/** Signs users in at the upstream provider with the authorisation code flow and PKCE */ -export const createUpstream = ({ issuer, clientId, clientSecret, redirectUri, scope }: UpstreamOptions) => { - let discovered: Promise | undefined; - const configuration = () => { - const server = new URL(issuer); - // Configuration only allows an http issuer outside production - const execute = server.protocol === 'http:' ? [allowInsecureRequests] : []; - discovered ??= discovery(server, clientId, undefined, undefined, { execute }) - .then((found) => { - const configured = new Configuration(found.serverMetadata(), clientId, undefined, clientAuth(found.serverMetadata(), clientSecret)); +const discovered = ({ issuer, clientId, clientSecret }: Discovered) => { + let found: Promise | undefined; + return () => { + const execute = insecure(issuer); + found ??= discovery(new URL(issuer), clientId, undefined, undefined, { execute }) + .then((metadata) => { + const configured = new Configuration(metadata.serverMetadata(), clientId, undefined, clientAuth(metadata.serverMetadata(), clientSecret)); for (const apply of execute) { apply(configured); } return configured; }) .catch((error: unknown) => { - discovered = undefined; + found = undefined; throw error; }); - return discovered; + return found; }; +}; + +const configured = (configuration: Configuration) => { + for (const apply of insecure(configuration.serverMetadata().issuer)) { + apply(configuration); + } + const ready = Promise.resolve(configuration); + return () => ready; +}; + +/** Signs users in at a provider with the authorisation code flow and PKCE */ +export const createUpstream = (options: UpstreamOptions) => { + const { redirectUri, scope } = options; + const configuration = 'configuration' in options ? configured(options.configuration) : discovered(options); return { /** Fetches the upstream discovery document, which sign-ins then reuse; a failure is retried at the next sign-in */ @@ -57,7 +75,7 @@ export const createUpstream = ({ issuer, clientId, clientSecret, redirectUri, sc await configuration(); }, - newChecks(state: string): UpstreamChecks { + newChecks(state = randomState()): UpstreamChecks { return { state, codeVerifier: randomPKCECodeVerifier(), nonce: randomNonce() }; }, @@ -72,8 +90,8 @@ export const createUpstream = ({ issuer, clientId, clientSecret, redirectUri, sc }); }, - /** Exchanges the code in the callback's query for the user's validated ID token claims */ - async claims(query: Record, checks: UpstreamChecks): Promise { + /** Exchanges the code in the callback's query for the user's ID token and its validated claims */ + async tokens(query: Record, checks: UpstreamChecks): Promise<{ claims: IDToken; idToken: string }> { const currentUrl = new URL(redirectUri); currentUrl.search = new URLSearchParams(query).toString(); const tokens = await authorizationCodeGrant(await configuration(), currentUrl, { @@ -82,7 +100,11 @@ export const createUpstream = ({ issuer, clientId, clientSecret, redirectUri, sc expectedNonce: checks.nonce, idTokenExpected: true, }); - return tokens.claims() as IDToken; + const claims = tokens.claims(); + if (!claims || !tokens.id_token) { + throw new Error('No ID token'); + } + return { claims, idToken: tokens.id_token }; }, }; }; diff --git a/src/repository.ts b/src/repository.ts index 728f6c0..882f90d 100644 --- a/src/repository.ts +++ b/src/repository.ts @@ -9,6 +9,10 @@ export const State = { type IndexState = (typeof State)[keyof typeof State]; +/** Whether any index is in one of `values` */ +export const hasState = (state: Partial>, ...values: IndexState[]) => + Object.values(state).some((value) => value !== undefined && values.includes(value)); + /** A crate in the repository, with the state of each index that isn't idle */ export type RepositoryObject = { id: string; path: string; name?: string | undefined; state: Partial> }; diff --git a/test/admin.ts b/test/admin.ts new file mode 100644 index 0000000..cb42260 --- /dev/null +++ b/test/admin.ts @@ -0,0 +1,75 @@ +import { afterAll, beforeAll, vi } from 'vitest'; +import { initRepository, type Repository } from '../src/repository.ts'; +import { buildTestApp, type TestApp } from './app.ts'; +import { startFakeUpstream, type UpstreamUser } from './fake-upstream.ts'; +import { Browser } from './oidc.ts'; + +export const ISSUER = 'http://localhost:8080'; +const UPSTREAM_CLIENT = { clientId: 'ldacapi', clientSecret: 'upstream-secret' }; +export const ALICE: UpstreamUser = { sub: 'alice@upstream', name: 'Alice' }; +export const PEOPLE = 'arcp://name,people'; + +/** What turns sign-in on, against the upstream provider at `upstreamIssuer` */ +const oidcEnv = (upstreamIssuer: string) => ({ + OIDC_ISSUER: ISSUER, + OIDC_CLIENTS: JSON.stringify([{ client_id: 'oni', redirect_uris: ['http://localhost:5173/callback'] }]), + OIDC_UPSTREAM_ISSUER: upstreamIssuer, + OIDC_UPSTREAM_CLIENT_ID: UPSTREAM_CLIENT.clientId, + OIDC_UPSTREAM_CLIENT_SECRET: UPSTREAM_CLIENT.clientSecret, +}); + +/** The OCFL repository at `OCFL_ROOT` */ +export const ocflRepository = (deps: Record) => initRepository('ocfl', deps); + +/** A repository with one crate, `PEOPLE`, whose search index is in `state`, recording what it is asked to do */ +export const repositoryIn = (state: string) => { + const fake = { + storage: () => ({ content: {} }), + async *objects() { + yield { id: PEOPLE, name: 'People', path: '', state: { structural: 'indexed', search: state } }; + }, + getState: async () => ({ structural: 'indexed', search: state }), + createIndex: vi.fn(async () => {}), + deleteIndex: vi.fn(async () => {}), + }; + return fake as unknown as Repository & Pick; +}; + +/** Runs a fake upstream provider for a file's tests, then closes it and every app started with `start` */ +export const adminTestbed = () => { + let upstream: Awaited>; + const running: Awaited>[] = []; + + beforeAll(async () => { + upstream = await startFakeUpstream(UPSTREAM_CLIENT); + }); + + afterAll(async () => { + for (const { app, prisma } of running) { + await app.close(); + await prisma.$disconnect(); + } + await upstream.close(); + }); + + return { + get upstream() { + return upstream; + }, + + /** Builds the app with sign-in on, and `env` on top */ + async start(env: Record = {}, options?: Parameters[1]) { + const started = await buildTestApp({ ...oidcEnv(upstream.issuer), ...env }, options); + running.push(started); + return started; + }, + + /** Signs `user` in to the admin pages in a new browser, returning the page it lands on */ + async signInAsAdmin(app: TestApp, user: UpstreamUser) { + const browser = new Browser(app, ISSUER); + const toUpstream = await browser.follow('/admin/'); + const page = await browser.follow(upstream.signIn(toUpstream.headers.location as string, user)); + return { browser, page }; + }, + }; +}; diff --git a/test/app.ts b/test/app.ts index d9f2af5..762b168 100644 --- a/test/app.ts +++ b/test/app.ts @@ -1,7 +1,14 @@ import { Readable } from 'node:stream'; +import type { Client } from '@opensearch-project/opensearch'; import { vi } from 'vitest'; +import type { createSearchBackend } from '../src/indexer/search.ts'; import type { Repository } from '../src/repository.ts'; +type RepositoryDeps = { opensearchClient: Client; search: ReturnType }; + +/** A repository, or how to build one with the app's search */ +export type TestRepository = Repository | ((deps: RepositoryDeps) => Repository | Promise); + /** A repository with no content */ export const repository = { storage: () => ({ content: {} }), async *objects() {} } as unknown as Repository; @@ -12,8 +19,8 @@ export const repositoryWithContent = { }), } as unknown as Repository; -/** Builds the app with `env` set while configuration is read, which happens on first import; `'ocfl'` uses the OCFL repository at `OCFL_ROOT` */ -export const buildTestApp = async (env: Record, options: { repository?: Repository | 'ocfl' } = {}) => { +/** Builds the app with `env` set while configuration is read, which happens on first import */ +export const buildTestApp = async (env: Record, options: { repository?: TestRepository } = {}) => { for (const [name, value] of Object.entries(env)) { vi.stubEnv(name, value); } @@ -25,10 +32,7 @@ export const buildTestApp = async (env: Record, options: { repos const { createSearchBackend } = await import('../src/indexer/search.ts'); const opensearchClient = new Client({ node: config.opensearchUrl }); const search = createSearchBackend(opensearchClient); - const repo = - options.repository === 'ocfl' - ? await (await import('../src/repository.ts')).initRepository('ocfl', { opensearchClient, search }) - : (options.repository ?? repository); + const repo = typeof options.repository === 'function' ? await options.repository({ opensearchClient, search }) : (options.repository ?? repository); /** Builds another app, as another server process would */ const build = () => buildApp({ repository: repo, search }); return { app: build(), build, config, prisma, repository: repo }; From 699800a585b36b9d7e8062ff862ca898fc3113a8 Mon Sep 17 00:00:00 2001 From: John Ferlito Date: Wed, 28 Oct 2026 10:05:01 +1100 Subject: [PATCH 06/10] feat: serve the admin pages on their own origin Crate files are served inline on the API's origin, so script in a crate could act as an admin. The admin pages now answer only on ADMIN_ORIGIN (default http://admin.localhost:8080 outside production; unset in production turns admin off), and nothing else is served there. --- .env.example | 6 +- GLOSSARY.md | 2 +- README.md | 27 ++++++-- docs/adr/0002-server-rendered-admin-pages.md | 9 +-- src/admin/client.ts | 11 ++- src/admin/off.test.ts | 32 ++++++++- src/admin/plugin.ts | 16 +++-- src/admin/sign-in.test.ts | 55 +++++++++------ src/admin/sign-in.ts | 8 +-- src/app.ts | 22 ++++-- src/configuration.test.ts | 28 ++++++++ src/configuration.ts | 6 ++ test/admin.ts | 3 +- test/oidc.ts | 71 +++++++++++++------- 14 files changed, 216 insertions(+), 80 deletions(-) diff --git a/.env.example b/.env.example index ce1ccc4..c3f336b 100644 --- a/.env.example +++ b/.env.example @@ -41,5 +41,9 @@ # OIDC_SIGNING_KEYS='{"keys":[...]}' # OIDC_COOKIE_KEYS= -# The `sub`s of admins, who can use the admin pages at /admin/, comma-separated. Unset means nobody +# The origin (no path) of the admin pages, at /admin/. Must differ from OIDC_ISSUER. Defaults to http://admin.localhost:8080; +# in production, https, and the admin pages are off while it is unset. The reverse proxy must route it to the API with its Host header unchanged +# ADMIN_ORIGIN=http://admin.localhost:8080 + +# The `sub`s of admins, who can use the admin pages, comma-separated. Unset means nobody # ADMIN_USERS= diff --git a/GLOSSARY.md b/GLOSSARY.md index 8863784..2bde55d 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -21,5 +21,5 @@ A user who can use the admin pages, currently one whose `sub` is in `ADMIN_USERS _Avoid_: Administrator, superuser, staff **Admin pages**: -The server-rendered admin UI at `/admin/`. +The server-rendered admin UI at `/admin/` on the admin origin (`ADMIN_ORIGIN`). _Avoid_: Portal, admin API diff --git a/README.md b/README.md index 4b04a29..ddb3c5f 100644 --- a/README.md +++ b/README.md @@ -17,9 +17,9 @@ app mounts as a Fastify plugin. ldacapi adds what LDaCA needs on top: index, used by arocapi's entity and file routes) and, as one search document per entity, into arocapi's OpenSearch search backend (`src/indexer/`). The search fields are declared in `src/indexer/search.ts`. -- **Admin pages**: server-rendered pages at `/admin/` where admins, who sign in - through our own OIDC provider, see and rebuild or delete indexes - (`src/admin/`). +- **Admin pages**: server-rendered pages at `/admin/` on their own origin, + where admins, who sign in through our own OIDC provider, see and rebuild or + delete indexes (`src/admin/`). - **Access control**: licences are checked against REMS, with sign-in through an OIDC provider (`src/auth.ts`, `src/routes/auth.ts`). - **File downloads**: short-lived signed URLs that stream files out of OCFL @@ -99,6 +99,7 @@ set. | `OIDC_ISSUER` | `http://localhost:8080` | | `OIDC_CLIENTS` | `[]` | | `OIDC_SIGNING_KEYS`, `OIDC_COOKIE_KEYS` | generated at each start outside production | +| `ADMIN_ORIGIN` | `http://admin.localhost:8080` outside production | | `ADMIN_USERS` | none (nobody is an admin) | With `NODE_ENV=production`, `DATABASE_URL`, `OPENSEARCH_URL` and `OCFL_ROOT` @@ -158,10 +159,22 @@ Discovery is at `/.well-known/openid-configuration`. Oni's ### Admin pages -The admin pages are at `/admin/`. They are on only when sign-in -is; otherwise startup logs a warning and `/admin/` is not found. Visiting them -signs you in through our own OIDC provider, as Oni does, so you need no other -password or key. +The admin pages are at `/admin/`. `ADMIN_ORIGIN` is an origin, +with no path, such as `https://admin.example.org`, and must differ from +`OIDC_ISSUER`: crate files are served on the API's origin with their own +content type, so HTML or SVG in a crate could run script there, and a separate +origin keeps that away from admin. It defaults to `http://admin.localhost:8080` +outside production, which browsers resolve to this machine; in production it +must be https. The admin pages are on only when sign-in is and `ADMIN_ORIGIN` +is set; otherwise startup logs a warning and `/admin/` is not found. On the +admin origin only `/admin/` is served, and on any other host `/admin/` is not +found. Visiting them signs you in through our own OIDC provider, as Oni does, +so you need no other password or key. + +The admin pages run in the API's process, so the reverse proxy must route the +admin hostname to the API too, and pass the browser's `Host` header through +unchanged: the API tells the two origins apart by `Host`, not by +`X-Forwarded-Host`. `ADMIN_USERS` is a comma-separated list of the `sub`s of admins. Anyone else who signs in gets a page saying they aren't an admin, showing the `sub` to add. diff --git a/docs/adr/0002-server-rendered-admin-pages.md b/docs/adr/0002-server-rendered-admin-pages.md index 5c87687..ed1e0d4 100644 --- a/docs/adr/0002-server-rendered-admin-pages.md +++ b/docs/adr/0002-server-rendered-admin-pages.md @@ -2,19 +2,19 @@ ## Context -Admins manage the API's indexes. A separate browser app calling an admin API with one shared key exposes the key to anyone who loads the app, records nobody's identity, and can only take access away from everyone at once. The API is already an OIDC provider that signs users in at the upstream provider (ADR 0001), and it can reach the repository and indexer directly. +Admins manage the API's indexes. A separate browser app calling an admin API with one shared key exposes the key to anyone who loads the app, records nobody's identity, and can only take access away from everyone at once. The API is already an OIDC provider that signs users in at the upstream provider (ADR 0001), and it can reach the repository and indexer directly. It also serves crate files inline with their own content type and no sandbox, so HTML or SVG in a crate runs script on the API's origin. ## Decision -The API serves its own admin pages at `/admin/`, rendered on the server, on the issuer's origin and outside `/api`. They are registered only when sign-in is configured. They are the only way into admin: there is no admin API and no shared admin key, and the pages call the repository directly. +The API serves its own admin pages at `/admin/`, rendered on the server, in the API's process but on their own origin, `ADMIN_ORIGIN`, which must differ from the issuer's. Every admin route is constrained to the admin origin's host, and nothing else is served there, so script in a crate file runs on another origin than admin, with the browser's boundary between them. They are registered only when sign-in and `ADMIN_ORIGIN` are configured. They are the only way into admin: there is no admin API and no shared admin key, and the pages call the repository directly. - The admin pages are a built-in confidential client of our provider, not listed in `OIDC_CLIENTS`. Its client secret is derived from `OIDC_COOKIE_KEYS` with HKDF, and it uses only the authorisation code grant, asking for `openid profile`. - Sign-in is a server-side authorisation code flow with PKCE, state and nonce. The browser goes through the provider as usual, but the token request reaches the provider in-process, with its metadata built from configuration rather than discovery, so the API never calls its own public URL. Only the ID token is kept. -- Each sign-in's state, PKCE verifier, nonce and page to return to are held in a short-lived encrypted cookie. The session is an encrypted cookie holding the `sub`, sign-in time and ID token, with an absolute lifetime of 8 hours. Both cookies are scoped to `/admin`, `HttpOnly`, `Secure` on an https issuer, and `SameSite=Lax`, because sign-in returns through a redirect chain that started at the upstream provider, where a `Strict` cookie wouldn't be sent. Their keys are derived from every key in `OIDC_COOKIE_KEYS`, so they rotate with it. +- Each sign-in's state, PKCE verifier, nonce and page to return to are held in a short-lived encrypted cookie. The session is an encrypted cookie holding the `sub`, sign-in time and ID token, with an absolute lifetime of 8 hours. Both cookies are on the admin origin, scoped to `/admin`, `HttpOnly`, `Secure` on an https admin origin, and `SameSite=Lax`, because sign-in returns through a redirect chain that started at the upstream provider, where a `Strict` cookie wouldn't be sent. Their keys are derived from every key in `OIDC_COOKIE_KEYS`, so they rotate with it. - An admin is a user whose `sub` is in `ADMIN_USERS`, checked on every request. Others who sign in get a 403 page showing their `sub`. - Signing out clears the session and ends the session at our provider, via its end-session endpoint with the ID token as `id_token_hint`. - Every action is a POST form that redirects back to the page with a one-time flash message, so reloading doesn't repeat it. -- Every POST must carry an `Origin` matching the issuer. Responses carry a Content-Security-Policy allowing only same-origin scripts and styles, and no framing. +- Every POST must carry an `Origin` matching the admin origin. Responses carry a Content-Security-Policy allowing only same-origin scripts and styles, and no framing. - Pages are built with an `html` tagged template that escapes every interpolated value. ## Consequences @@ -23,4 +23,5 @@ The API serves its own admin pages at `/admin/`, rendered on the server, on the - Removing an admin from `ADMIN_USERS` takes effect when the API restarts, whatever sessions they hold. - Signing out of the admin pages also signs the user out of our provider, which Oni relies on; Oni keeps working until its tokens can't be refreshed. It doesn't sign them out at the upstream provider. - Admin needs sign-in configured, and so a working upstream provider, even locally. +- Admin needs a second hostname, which the reverse proxy routes to the API with its `Host` header unchanged. Locally it is `admin.localhost`, which browsers resolve to the machine. - A non-human caller has no way into admin. diff --git a/src/admin/client.ts b/src/admin/client.ts index 50ae901..8c93e4f 100644 --- a/src/admin/client.ts +++ b/src/admin/client.ts @@ -1,21 +1,20 @@ import { hkdfSync } from 'node:crypto'; import type { AuthConfig } from '../configuration.ts'; -/** Where the admin pages are mounted */ export const ADMIN_PATH = '/admin'; export const derivedKey = (cookieKey: string, purpose: string) => Buffer.from(hkdfSync('sha256', cookieKey, '', `ldacapi ${purpose}`, 32)); -export const adminRedirectUri = (issuer: string) => `${issuer}${ADMIN_PATH}/callback`; +export const adminRedirectUri = (adminOrigin: string) => `${adminOrigin}${ADMIN_PATH}/callback`; -export const adminPostLogoutRedirectUri = (issuer: string) => `${issuer}${ADMIN_PATH}/`; +export const adminPostLogoutRedirectUri = (adminOrigin: string) => `${adminOrigin}${ADMIN_PATH}/`; /** The admin pages' own client of our provider, which needs no configuration */ -export const adminClient = ({ issuer, cookieKeys }: AuthConfig) => ({ +export const adminClient = ({ cookieKeys }: AuthConfig, adminOrigin: string) => ({ client_id: 'ldacapi-admin', client_secret: derivedKey(cookieKeys[0] ?? '', 'admin client secret').toString('base64url'), - redirect_uris: [adminRedirectUri(issuer)], - post_logout_redirect_uris: [adminPostLogoutRedirectUri(issuer)], + redirect_uris: [adminRedirectUri(adminOrigin)], + post_logout_redirect_uris: [adminPostLogoutRedirectUri(adminOrigin)], grant_types: ['authorization_code'], scope: 'openid profile', }); diff --git a/src/admin/off.test.ts b/src/admin/off.test.ts index 1cfc513..95edd29 100644 --- a/src/admin/off.test.ts +++ b/src/admin/off.test.ts @@ -1,6 +1,7 @@ import { Writable } from 'node:stream'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { buildTestApp, type TestApp } from '../../test/app.ts'; +import { generateCookieKey, generateSigningKey } from '../oidc/keys.ts'; const logs = vi.hoisted(() => [] as { level: number; msg: string }[]); @@ -29,7 +30,36 @@ afterAll(async () => { describe('the admin pages without sign-in', () => { it('are not found, and startup warns that they are off', async () => { - expect((await app.inject({ method: 'GET', url: '/admin/' })).statusCode).toBe(404); + expect((await app.inject({ method: 'GET', url: '/admin/', headers: { host: 'admin.localhost:8080' } })).statusCode).toBe(404); expect(logs.map(({ msg }) => msg)).toContainEqual(expect.stringContaining('OIDC_UPSTREAM_ISSUER')); }); }); + +describe('the admin pages in production without ADMIN_ORIGIN', () => { + it('are not found, and startup warns that they are off', async () => { + vi.resetModules(); + const started = await buildTestApp({ + NODE_ENV: 'production', + LOG_LEVEL: 'warn', + OPENSEARCH_URL: 'http://localhost:9200', + OCFL_ROOT: 'storage/ocfl', + OIDC_ISSUER: 'https://api.example.org', + OIDC_CLIENTS: JSON.stringify([{ client_id: 'oni', redirect_uris: ['https://oni.example.org/callback'] }]), + OIDC_UPSTREAM_ISSUER: 'https://idp.example.org', + OIDC_UPSTREAM_CLIENT_ID: 'ldacapi', + OIDC_UPSTREAM_CLIENT_SECRET: 'secret', + OIDC_SIGNING_KEYS: JSON.stringify({ keys: [generateSigningKey()] }), + OIDC_COOKIE_KEYS: generateCookieKey(), + ADMIN_USERS: 'alice@upstream', + }); + try { + for (const host of ['api.example.org', 'admin.localhost:8080']) { + expect((await started.app.inject({ method: 'GET', url: '/admin/', headers: { host } })).statusCode).toBe(404); + } + expect(logs.map(({ msg }) => msg)).toContainEqual(expect.stringContaining('ADMIN_ORIGIN')); + } finally { + await started.app.close(); + await started.prisma.$disconnect(); + } + }); +}); diff --git a/src/admin/plugin.ts b/src/admin/plugin.ts index b20f1a6..ed80d86 100644 --- a/src/admin/plugin.ts +++ b/src/admin/plugin.ts @@ -38,14 +38,14 @@ const CSP = "default-src 'none'; script-src 'self'; style-src 'self'; img-src 's const send = (reply: FastifyReply, page: Html, status = 200) => reply.code(status).type('text/html; charset=utf-8').send(page.toString()); -type AdminOptions = { auth: AuthConfig; adminUsers: string[]; repository: Repository }; +type AdminOptions = { auth: AuthConfig; origin: string; adminUsers: string[]; repository: Repository }; /** The admin pages, for users whose `sub` is in `adminUsers` */ -export const adminPages: FastifyPluginAsync = async (fastify, { auth, adminUsers, repository }) => { - const issuer = new URL(auth.issuer); - const signIn = createSignIn(fastify, auth); +export const adminPages: FastifyPluginAsync = async (fastify, { auth, origin, adminUsers, repository }) => { + const { host, protocol } = new URL(origin); + const signIn = createSignIn(fastify, auth, origin); const admins = new Set(adminUsers); - const cookie = { path: ADMIN_PATH, httpOnly: true, secure: issuer.protocol === 'https:', sameSite: 'lax' } as const; + const cookie = { path: ADMIN_PATH, httpOnly: true, secure: protocol === 'https:', sameSite: 'lax' } as const; const session = (sessionName: string, cookieName: string, purpose: string, seconds: number) => ({ sessionName, cookieName, @@ -55,9 +55,13 @@ export const adminPages: FastifyPluginAsync = async (fastify, { au }); const home = `${ADMIN_PATH}/`; + fastify.addHook('onRoute', (route) => { + route.constraints = { ...route.constraints, host }; + }); + fastify.addHook('onRequest', async (request, reply) => { reply.header('content-security-policy', CSP).header('x-frame-options', 'DENY'); - if (request.method === 'POST' && request.headers.origin !== issuer.origin) { + if (request.method === 'POST' && request.headers.origin !== origin) { return reply.forbidden('Cross-origin request refused'); } }); diff --git a/src/admin/sign-in.test.ts b/src/admin/sign-in.test.ts index ac4316d..0ef868c 100644 --- a/src/admin/sign-in.test.ts +++ b/src/admin/sign-in.test.ts @@ -1,10 +1,10 @@ import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; -import { ALICE as ADMIN, adminTestbed, ISSUER } from '../../test/admin.ts'; +import { ADMIN, ALICE as ADMIN_USER, adminTestbed, ISSUER } from '../../test/admin.ts'; import type { TestApp } from '../../test/app.ts'; import type { UpstreamUser } from '../../test/fake-upstream.ts'; import { authorizationRequest, Browser } from '../../test/oidc.ts'; -const ALICE: UpstreamUser = { ...ADMIN, name: 'Alice ' }; +const ALICE: UpstreamUser = { ...ADMIN_USER, name: 'Alice ' }; const MALLORY: UpstreamUser = { sub: 'mallory@upstream', name: 'Mallory' }; const testbed = adminTestbed(); @@ -27,12 +27,13 @@ afterEach(() => { describe('signing in to the admin pages', () => { it('sends a visitor with no session to sign in, then back to the page they asked for', async () => { - const browser = new Browser(app, ISSUER); + const browser = new Browser(app, ADMIN, ISSUER); const first = await browser.get('/admin/?from=test'); expect(first.statusCode).toBe(303); - const authorize = new URL(first.headers.location as string, ISSUER); - expect(authorize.pathname).toBe('/oauth/authorize'); + const authorize = new URL(first.headers.location as string); + expect(`${authorize.origin}${authorize.pathname}`).toBe(`${ISSUER}/oauth/authorize`); + expect(authorize.searchParams.get('redirect_uri')).toBe(`${ADMIN}/admin/callback`); const page = await signIn(browser, '/admin/?from=test', ALICE); expect(browser.url).toBe('/admin/?from=test'); @@ -41,18 +42,19 @@ describe('signing in to the admin pages', () => { }); it('lets in a user already signed in at our provider without visiting the upstream provider', async () => { - const browser = new Browser(app, ISSUER); + const browser = new Browser(app, ADMIN, ISSUER); const { url } = await authorizationRequest({ client_id: 'oni', redirect_uri: 'http://localhost:5173/callback', scope: 'openid' }); - await signIn(browser, url, ALICE); + await signIn(browser, `${ISSUER}${url}`, ALICE); const page = await browser.follow('/admin/'); + expect(page.headers.location).toBeUndefined(); expect(page.statusCode).toBe(200); expect(page.body).toContain('Alice <Admin>'); }); it('shows a user who is not an admin their sub and a way to sign out', async () => { - const browser = new Browser(app, ISSUER); + const browser = new Browser(app, ADMIN, ISSUER); const page = await signIn(browser, '/admin/', MALLORY); @@ -62,7 +64,7 @@ describe('signing in to the admin pages', () => { }); it('sends a session older than 8 hours to sign in again', async () => { - const browser = new Browser(app, ISSUER); + const browser = new Browser(app, ADMIN, ISSUER); await signIn(browser, '/admin/', ALICE); vi.useFakeTimers({ toFake: ['Date'] }); @@ -76,7 +78,7 @@ describe('signing in to the admin pages', () => { }); it('treats a tampered session cookie as no session', async () => { - const browser = new Browser(app, ISSUER); + const browser = new Browser(app, ADMIN, ISSUER); await signIn(browser, '/admin/', ALICE); const cookie = browser.cookie('admin_session') ?? ''; browser.setCookie('admin_session', `${cookie[0] === 'A' ? 'B' : 'A'}${cookie.slice(1)}`); @@ -88,7 +90,7 @@ describe('signing in to the admin pages', () => { }); it('refuses a callback whose state does not match the sign-in', async () => { - const browser = new Browser(app, ISSUER); + const browser = new Browser(app, ADMIN, ISSUER); const toUpstream = await browser.follow('/admin/'); let toAdmin = new URL(testbed.upstream.signIn(toUpstream.headers.location as string, ALICE)); while (toAdmin.pathname !== '/admin/callback') { @@ -104,7 +106,7 @@ describe('signing in to the admin pages', () => { }); it('signs out of the admin pages and our provider', async () => { - const browser = new Browser(app, ISSUER); + const browser = new Browser(app, ADMIN, ISSUER); await signIn(browser, '/admin/', ALICE); const signedOut = await browser.post('/admin/sign-out', {}); @@ -113,29 +115,38 @@ describe('signing in to the admin pages', () => { const endSession = new URL(signedOut.headers.location as string); expect(`${endSession.origin}${endSession.pathname}`).toBe(`${ISSUER}/oauth/session/end`); expect(endSession.searchParams.get('id_token_hint')).toEqual(expect.any(String)); - expect(endSession.searchParams.get('post_logout_redirect_uri')).toBe(`${ISSUER}/admin/`); + expect(endSession.searchParams.get('post_logout_redirect_uri')).toBe(`${ADMIN}/admin/`); expect(browser.cookie('admin_session')).toBeUndefined(); const confirm = await browser.get(endSession.toString()); const xsrf = confirm.body.match(/name="xsrf" value="([^"]+)"/)?.[1] ?? ''; - const backToAdmin = await browser.post('/oauth/session/end/confirm', { xsrf, logout: 'yes' }); - expect(backToAdmin.headers.location).toBe(`${ISSUER}/admin/`); + const backToAdmin = await browser.post(`${ISSUER}/oauth/session/end/confirm`, { xsrf, logout: 'yes' }); + expect(backToAdmin.headers.location).toBe(`${ADMIN}/admin/`); const again = await browser.follow('/admin/'); expect(new URL(again.headers.location as string).origin).toBe(testbed.upstream.issuer); }); - it('refuses a POST with a missing or foreign Origin', async () => { - const browser = new Browser(app, ISSUER); + it('refuses a POST with a missing or foreign Origin, including the issuer', async () => { + const browser = new Browser(app, ADMIN, ISSUER); await signIn(browser, '/admin/', ALICE); - for (const headers of [{}, { origin: 'https://evil.example.org' }]) { + for (const headers of [{}, { origin: 'https://evil.example.org' }, { origin: ISSUER }]) { expect((await browser.post('/admin/sign-out', {}, headers)).statusCode).toBe(403); } expect((await browser.get('/admin/')).statusCode).toBe(200); }); + it('is only on the admin origin', async () => { + const api = new Browser(app, ISSUER); + for (const path of ['/admin/', '/admin/admin.css', '/admin/callback']) { + expect((await api.get(path)).statusCode).toBe(404); + } + expect((await api.get('/api/version')).statusCode).toBe(200); + expect((await new Browser(app, ADMIN).get('/api/version')).statusCode).toBe(404); + }); + it('allows only our own scripts and styles, and no framing', async () => { - const page = await new Browser(app, ISSUER).get('/admin/'); + const page = await new Browser(app, ADMIN, ISSUER).get('/admin/'); const csp = page.headers['content-security-policy'] as string; expect(csp).toContain("script-src 'self'"); @@ -144,12 +155,12 @@ describe('signing in to the admin pages', () => { }); it('serves the stylesheet to anyone, and links it from every page', async () => { - const stylesheet = await new Browser(app, ISSUER).get('/admin/admin.css'); + const stylesheet = await new Browser(app, ADMIN, ISSUER).get('/admin/admin.css'); expect(stylesheet.statusCode).toBe(200); expect(stylesheet.headers['content-type']).toMatch(/^text\/css/); const link = ''; - expect((await signIn(new Browser(app, ISSUER), '/admin/', ALICE)).body).toContain(link); - expect((await signIn(new Browser(app, ISSUER), '/admin/', MALLORY)).body).toContain(link); + expect((await signIn(new Browser(app, ADMIN, ISSUER), '/admin/', ALICE)).body).toContain(link); + expect((await signIn(new Browser(app, ADMIN, ISSUER), '/admin/', MALLORY)).body).toContain(link); }); }); diff --git a/src/admin/sign-in.ts b/src/admin/sign-in.ts index 95e1852..0fa477c 100644 --- a/src/admin/sign-in.ts +++ b/src/admin/sign-in.ts @@ -9,9 +9,9 @@ import { adminClient, adminPostLogoutRedirectUri, adminRedirectUri } from './cli export type SignInTransaction = UpstreamChecks & { returnTo: string }; /** Signs admins in at our own provider, reaching its token endpoint in-process rather than through our public URL */ -export const createSignIn = (fastify: FastifyInstance, auth: AuthConfig) => { +export const createSignIn = (fastify: FastifyInstance, auth: AuthConfig, adminOrigin: string) => { const { issuer } = auth; - const client = adminClient(auth); + const client = adminClient(auth, adminOrigin); const configuration = new Configuration( { issuer, @@ -38,7 +38,7 @@ export const createSignIn = (fastify: FastifyInstance, auth: AuthConfig) => { } return new Response(response.body, { status: response.statusCode, headers: responseHeaders }); }; - const flow = createUpstream({ configuration, redirectUri: adminRedirectUri(issuer), scope: client.scope }); + const flow = createUpstream({ configuration, redirectUri: adminRedirectUri(adminOrigin), scope: client.scope }); return { async start(returnTo: string) { @@ -53,7 +53,7 @@ export const createSignIn = (fastify: FastifyInstance, auth: AuthConfig) => { }, signOutUrl(idToken: string) { - return buildEndSessionUrl(configuration, { id_token_hint: idToken, post_logout_redirect_uri: adminPostLogoutRedirectUri(issuer) }); + return buildEndSessionUrl(configuration, { id_token_hint: idToken, post_logout_redirect_uri: adminPostLogoutRedirectUri(adminOrigin) }); }, }; }; diff --git a/src/app.ts b/src/app.ts index 3ab57ea..6a7e2a9 100644 --- a/src/app.ts +++ b/src/app.ts @@ -63,12 +63,26 @@ export function buildApp({ repository, search }: AppDeps) { }, { prefix }, ); - const { auth } = config; + const { auth, adminOrigin } = config; if (auth) { - fastify.register(oidcProvider, { auth: { ...auth, clients: [...auth.clients, adminClient(auth)] } }); - fastify.register(adminPages, { prefix: ADMIN_PATH, auth, adminUsers: config.adminUsers, repository }); + const clients = adminOrigin ? [...auth.clients, adminClient(auth, adminOrigin)] : auth.clients; + fastify.register(oidcProvider, { auth: { ...auth, clients } }); + } + if (auth && adminOrigin) { + fastify.register(adminPages, { prefix: ADMIN_PATH, auth, origin: adminOrigin, adminUsers: config.adminUsers, repository }); + // Nothing but the admin pages answers on the admin origin, so no crate file can run script there + const adminHost = new URL(adminOrigin).host; + fastify.addHook('onRequest', async (request, reply) => { + if (request.headers.host === adminHost && request.routeOptions.url && !request.routeOptions.url.startsWith(ADMIN_PATH)) { + return reply.callNotFound(); + } + }); } else { - fastify.log.warn('The admin pages are off because sign-in is off; set OIDC_UPSTREAM_ISSUER to turn them on'); + fastify.log.warn( + auth + ? 'The admin pages are off because ADMIN_ORIGIN is not set' + : 'The admin pages are off because sign-in is off; set OIDC_UPSTREAM_ISSUER to turn them on', + ); } return fastify; diff --git a/src/configuration.test.ts b/src/configuration.test.ts index 64e45a0..e356aa9 100644 --- a/src/configuration.test.ts +++ b/src/configuration.test.ts @@ -209,6 +209,34 @@ describe('loadConfig', () => { }); }); + it('reads admins from ADMIN_USERS as a comma-separated list, nobody when unset', () => { + expect(loadConfig({ ADMIN_USERS: ' alice@upstream , ,bob@upstream ' }).adminUsers).toEqual(['alice@upstream', 'bob@upstream']); + expect(loadConfig({}).adminUsers).toEqual([]); + }); + + describe('ADMIN_ORIGIN', () => { + const signIn = { ...UPSTREAM, ...KEYS, OIDC_ISSUER: 'https://api.example.org' }; + + it('defaults to admin.localhost outside production', () => { + expect(loadConfig({}).adminOrigin).toBe('http://admin.localhost:8080'); + expect(loadConfig({ NODE_ENV: 'test' }).adminOrigin).toBe('http://admin.localhost:8080'); + }); + + it('is unset by default in production, and read as an origin', () => { + expect(loadConfig({ ...PRODUCTION, ...signIn }).adminOrigin).toBeUndefined(); + expect(loadConfig({ ...PRODUCTION, ...signIn, ADMIN_ORIGIN: 'https://admin.example.org/' }).adminOrigin).toBe('https://admin.example.org'); + }); + + it('rejects an ADMIN_ORIGIN that is not an origin, or not https in production', () => { + expect(() => loadConfig({ ADMIN_ORIGIN: 'https://admin.example.org/admin' })).toThrow(/origin[\s\S]*ADMIN_ORIGIN/); + expect(() => loadConfig({ ...PRODUCTION, ...signIn, ADMIN_ORIGIN: 'http://admin.example.org' })).toThrow(/https[\s\S]*ADMIN_ORIGIN/); + }); + + it('rejects an ADMIN_ORIGIN that is the issuer’s origin', () => { + expect(() => loadConfig({ ...signIn, ADMIN_ORIGIN: 'https://api.example.org' })).toThrow(/OIDC_ISSUER[\s\S]*ADMIN_ORIGIN/); + }); + }); + it('rejects an unknown NODE_ENV', () => { expect(() => loadConfig({ NODE_ENV: 'staging' })).toThrow(); }); diff --git a/src/configuration.ts b/src/configuration.ts index 74cd6a3..b86f750 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -121,6 +121,7 @@ export function loadConfig(processEnv: Env) { OIDC_UPSTREAM_CLIENT_SECRET: z.string().optional(), OIDC_SIGNING_KEYS: signingKeys.optional(), OIDC_COOKIE_KEYS: cookieKeys.optional(), + ADMIN_ORIGIN: nodeEnv === 'production' ? origin.optional() : origin.default('http://admin.localhost:8080'), ADMIN_USERS: commaList.default([]), }) .transform((env, ctx) => { @@ -145,6 +146,10 @@ export function loadConfig(processEnv: Env) { return z.NEVER; } const { data } = required; + if (rest.ADMIN_ORIGIN === data.OIDC_ISSUER) { + ctx.addIssue({ code: 'custom', path: ['ADMIN_ORIGIN'], message: 'Must differ from OIDC_ISSUER, so the admin pages are on their own origin' }); + return z.NEVER; + } const auth = { issuer: data.OIDC_ISSUER, audience: `${data.OIDC_ISSUER}${prefix}`, @@ -185,6 +190,7 @@ export function loadConfig(processEnv: Env) { }, enrollmentUrl: env.ENROLLMENT_URL, auth: env.auth, + adminOrigin: env.ADMIN_ORIGIN, adminUsers: env.ADMIN_USERS, indexType, search: { entityIndex: env.ENTITY_INDEX }, diff --git a/test/admin.ts b/test/admin.ts index cb42260..35e3dee 100644 --- a/test/admin.ts +++ b/test/admin.ts @@ -5,6 +5,7 @@ import { startFakeUpstream, type UpstreamUser } from './fake-upstream.ts'; import { Browser } from './oidc.ts'; export const ISSUER = 'http://localhost:8080'; +export const ADMIN = 'http://admin.localhost:8080'; const UPSTREAM_CLIENT = { clientId: 'ldacapi', clientSecret: 'upstream-secret' }; export const ALICE: UpstreamUser = { sub: 'alice@upstream', name: 'Alice' }; export const PEOPLE = 'arcp://name,people'; @@ -66,7 +67,7 @@ export const adminTestbed = () => { /** Signs `user` in to the admin pages in a new browser, returning the page it lands on */ async signInAsAdmin(app: TestApp, user: UpstreamUser) { - const browser = new Browser(app, ISSUER); + const browser = new Browser(app, ADMIN, ISSUER); const toUpstream = await browser.follow('/admin/'); const page = await browser.follow(upstream.signIn(toUpstream.headers.location as string, user)); return { browser, page }; diff --git a/test/oidc.ts b/test/oidc.ts index f4d46c5..55c14c2 100644 --- a/test/oidc.ts +++ b/test/oidc.ts @@ -2,70 +2,95 @@ import type { LightMyRequestResponse } from 'fastify'; import { calculatePKCECodeChallenge, randomPKCECodeVerifier } from 'openid-client'; import type { TestApp } from './app.ts'; -/** A browser's cookies, sent with every request to the app */ +/** A browser's cookies, kept per host and sent with every request to the app at any of its origins */ export class Browser { - private readonly cookies = new Map(); + private readonly cookies = new Map>(); private readonly app: TestApp; + /** Where relative URLs go */ private readonly origin: string; + private readonly origins: Set; /** Where the last `follow` ended */ url = ''; - constructor(app: TestApp, origin: string) { + constructor(app: TestApp, origin: string, ...otherOrigins: string[]) { this.app = app; this.origin = origin; + this.origins = new Set([origin, ...otherOrigins]); } async get(url: string, app = this.app) { - const { pathname, search, host } = new URL(url, this.origin); - const response = await app.inject({ method: 'GET', url: `${pathname}${search}`, headers: { host, cookie: this.cookieHeader() } }); - this.store(response); + const target = new URL(url, this.origin); + const response = await app.inject({ + method: 'GET', + url: `${target.pathname}${target.search}`, + headers: { host: target.host, cookie: this.cookieHeader(target) }, + }); + this.store(target, response); return response; } - /** Posts `form` with the `Origin` a browser sends, unless `headers` say otherwise */ - async post(url: string, form: Record, headers: Record = { origin: this.origin }) { - const { pathname, search, host } = new URL(url, this.origin); + /** Posts `form` with the `Origin` a browser sends from a page at `url`'s origin, unless `headers` say otherwise */ + async post(url: string, form: Record, headers?: Record) { + const target = new URL(url, this.origin); const response = await this.app.inject({ method: 'POST', - url: `${pathname}${search}`, - headers: { host, cookie: this.cookieHeader(), 'content-type': 'application/x-www-form-urlencoded', ...headers }, + url: `${target.pathname}${target.search}`, + headers: { + host: target.host, + cookie: this.cookieHeader(target), + 'content-type': 'application/x-www-form-urlencoded', + ...(headers ?? { origin: target.origin }), + }, payload: new URLSearchParams(form).toString(), }); - this.store(response); + this.store(target, response); return response; } - /** Follows redirects within the app, returning the first response that leaves it */ + /** Follows redirects within the app's origins, returning the first response that leaves them */ async follow(url: string) { this.url = url; - let response = await this.get(url); + let current = new URL(url, this.origin); + let response = await this.get(current.toString()); let location = response.headers.location as string | undefined; - while (response.statusCode >= 300 && response.statusCode < 400 && location && new URL(location, this.origin).origin === this.origin) { + while (response.statusCode >= 300 && response.statusCode < 400 && location && this.origins.has(new URL(location, current).origin)) { this.url = location; - response = await this.get(location); + current = new URL(location, current); + response = await this.get(current.toString()); location = response.headers.location as string | undefined; } return response; } + /** The cookie `name` for the host relative URLs go to */ cookie(name: string) { - return this.cookies.get(name); + return this.jar(new URL(this.origin)).get(name); } setCookie(name: string, value: string) { - this.cookies.set(name, value); + this.jar(new URL(this.origin)).set(name, value); + } + + private jar({ hostname }: URL) { + let jar = this.cookies.get(hostname); + if (!jar) { + jar = new Map(); + this.cookies.set(hostname, jar); + } + return jar; } - private cookieHeader() { - return [...this.cookies].map(([name, value]) => `${name}=${encodeURIComponent(value)}`).join('; '); + private cookieHeader(url: URL) { + return [...this.jar(url)].map(([name, value]) => `${name}=${encodeURIComponent(value)}`).join('; '); } - private store(response: LightMyRequestResponse) { + private store(url: URL, response: LightMyRequestResponse) { + const jar = this.jar(url); for (const { name, value, expires, maxAge } of response.cookies as { name: string; value: string; expires?: Date; maxAge?: number }[]) { if (maxAge === 0 || (expires && expires.getTime() <= Date.now()) || !value) { - this.cookies.delete(name); + jar.delete(name); } else { - this.cookies.set(name, value); + jar.set(name, value); } } } From 0ccdb7ea4cf8d796492f3ad5c0f814f9651d43fd Mon Sep 17 00:00:00 2001 From: John Ferlito Date: Wed, 28 Oct 2026 10:09:57 +1100 Subject: [PATCH 07/10] fix: make the admin actions cover the same crates, guard and log them - An action on a crate covers it and the crates whose id follows it with a slash, for index and delete alike; an unknown crate is refused. - Every covered crate is queued before indexing starts, so the page keeps refreshing until the work is done. - Deleting is refused while a covered crate is queued or indexing, and indexing while one is being deleted. - Add Reindex all, log every action with the admin's sub, and return sign-in after a POST to the home page. - Sign-out ends on a signed-out page rather than signing straight back in. - The filter survives the page's refresh. --- README.md | 19 +-- docs/adr/0002-server-rendered-admin-pages.md | 4 +- src/admin/actions.test.ts | 125 +++++++++++++++++-- src/admin/actions.ts | 51 ++++++-- src/admin/audit.test.ts | 35 ++++++ src/admin/client.ts | 6 +- src/admin/crates.test.ts | 1 + src/admin/crates.ts | 21 +++- src/admin/layout.ts | 1 - src/admin/plugin.ts | 51 ++++---- src/admin/public/admin.js | 20 +-- src/admin/sign-in.test.ts | 30 ++++- src/ocfl.ts | 50 ++++---- src/oidc/upstream.ts | 28 ++--- src/repository.ts | 4 +- test/admin.ts | 13 +- 16 files changed, 352 insertions(+), 107 deletions(-) create mode 100644 src/admin/audit.test.ts diff --git a/README.md b/README.md index ddb3c5f..5c34eb9 100644 --- a/README.md +++ b/README.md @@ -180,13 +180,18 @@ unchanged: the API tells the two origins apart by `Host`, not by signs in gets a page saying they aren't an admin, showing the `sub` to add. Every request checks the list, so removing someone takes effect as soon as the API restarts. An admin session lasts 8 hours, and signing out of the admin -pages also ends the session at our provider. - -The crates page shows every crate's state in each index, and can index, -reindex or delete one crate's indexes, or index or delete all of them. -Indexing a crate also indexes the crates under it. A crate that is already -indexed needs reindexing, and indexing is refused while a delete is in -progress. The page reloads itself until the work finishes. +pages also ends the session at our provider, ending on a signed-out page with a +link to sign in again. + +The crates page shows every crate's state in each index. It can index, +reindex or delete one crate's indexes, or index, reindex or delete all of them. +An action on a crate covers the crate and the crates under it: those whose id +is the crate's id followed by `/`. A crate that is already indexed needs +reindexing. Indexing is refused while any crate it covers is being deleted, +deleting is refused while any crate it covers is queued or indexing, and an +action on a crate that isn't in the repository is refused. The page reloads +itself until the work finishes. Each action, accepted or refused, is logged at +`info` with the admin's `sub`. The admin pages are a client of our provider that needs no entry in `OIDC_CLIENTS`; their client secret and session keys are derived from diff --git a/docs/adr/0002-server-rendered-admin-pages.md b/docs/adr/0002-server-rendered-admin-pages.md index ed1e0d4..e7911cd 100644 --- a/docs/adr/0002-server-rendered-admin-pages.md +++ b/docs/adr/0002-server-rendered-admin-pages.md @@ -12,8 +12,8 @@ The API serves its own admin pages at `/admin/`, rendered on the server, in the - Sign-in is a server-side authorisation code flow with PKCE, state and nonce. The browser goes through the provider as usual, but the token request reaches the provider in-process, with its metadata built from configuration rather than discovery, so the API never calls its own public URL. Only the ID token is kept. - Each sign-in's state, PKCE verifier, nonce and page to return to are held in a short-lived encrypted cookie. The session is an encrypted cookie holding the `sub`, sign-in time and ID token, with an absolute lifetime of 8 hours. Both cookies are on the admin origin, scoped to `/admin`, `HttpOnly`, `Secure` on an https admin origin, and `SameSite=Lax`, because sign-in returns through a redirect chain that started at the upstream provider, where a `Strict` cookie wouldn't be sent. Their keys are derived from every key in `OIDC_COOKIE_KEYS`, so they rotate with it. - An admin is a user whose `sub` is in `ADMIN_USERS`, checked on every request. Others who sign in get a 403 page showing their `sub`. -- Signing out clears the session and ends the session at our provider, via its end-session endpoint with the ID token as `id_token_hint`. -- Every action is a POST form that redirects back to the page with a one-time flash message, so reloading doesn't repeat it. +- Signing out clears the session and ends the session at our provider, via its end-session endpoint with the ID token as `id_token_hint`, which returns to a signed-out page that needs no session. Returning to an admin page instead would sign the admin straight back in while their upstream session lasts. +- Every action is a POST form that redirects back to the page with a one-time flash message, so reloading doesn't repeat it. An action on a crate covers it and the crates under it, and each action, accepted or refused, is logged with the admin's `sub`. A POST without a session signs in and returns to the home page, as only a GET can be repeated. - Every POST must carry an `Origin` matching the admin origin. Responses carry a Content-Security-Policy allowing only same-origin scripts and styles, and no framing. - Pages are built with an `html` tagged template that escapes every interpolated value. diff --git a/src/admin/actions.test.ts b/src/admin/actions.test.ts index 497b06c..a73933f 100644 --- a/src/admin/actions.test.ts +++ b/src/admin/actions.test.ts @@ -3,13 +3,15 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { Client } from '@opensearch-project/opensearch'; -import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; -import { ALICE, adminTestbed, ocflRepository, PEOPLE, repositoryIn } from '../../test/admin.ts'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ALICE, adminTestbed, ocflRepository, PEOPLE, repositoryIn, repositoryOf } from '../../test/admin.ts'; import type { buildTestApp, TestApp, TestRepository } from '../../test/app.ts'; import type { Browser } from '../../test/oidc.ts'; -import type { Repository } from '../repository.ts'; +import { hasState, type Repository, State } from '../repository.ts'; const BUNDLED = 'arcp://name,bundled'; +const EXTRA = `${PEOPLE}/extra`; +const PEOPLE_TOO = `${PEOPLE}2`; const testbed = adminTestbed(); let storage: string; @@ -62,16 +64,23 @@ describe('index and delete actions', () => { return body.count; }; - const waitUntilIndexed = (...crateIds: string[]) => + /** Waits until no crate is queued, indexing or deleting, so an earlier action can't leak into the next */ + const waitUntilSettled = () => vi.waitFor( async () => { - for (const crateId of crateIds) { - expect(await repository.getState(crateId)).toEqual({ structural: 'indexed', search: 'indexed' }); - } + const busy = (await Array.fromAsync(repository.objects())).filter(({ state }) => hasState(state, State.QUEUED, State.INDEXING, State.DELETING)); + expect(busy).toEqual([]); }, { timeout: 10_000, interval: 100 }, ); + const waitUntilIndexed = async (...crateIds: string[]) => { + await waitUntilSettled(); + for (const crateId of crateIds) { + expect(await repository.getState(crateId)).toEqual({ structural: 'indexed', search: 'indexed' }); + } + }; + beforeAll(async () => { let config: Awaited>['config']; ({ app, repository, prisma, config } = await start(ocflRepository)); @@ -83,6 +92,8 @@ describe('index and delete actions', () => { await repository.deleteIndex(); }); + afterEach(waitUntilSettled); + it('indexes one crate', async () => { const browser = await signIn(app); @@ -130,6 +141,37 @@ describe('index and delete actions', () => { await waitUntilIndexed(BUNDLED, PEOPLE); }); + it('reindexes every crate', async () => { + const browser = await signIn(app); + await act(browser, 'index'); + await waitUntilIndexed(BUNDLED, PEOPLE); + const entities = await entityCount(PEOPLE); + + const page = await act(browser, 'index', { force: 'true' }); + + expect(flash(page.body)).toBe('Reindexing every crate has been queued.'); + await waitUntilIndexed(BUNDLED, PEOPLE); + expect(await entityCount(PEOPLE)).toBe(entities); + }); + + it('offers to reindex every crate beside indexing every crate', async () => { + const page = await (await signIn(app)).get('/admin/'); + + expect(page.body).toMatch( + /
        ]*>]*>Reindex all<\/button>/, + ); + }); + + it('shows every crate in progress, and keeps refreshing, as soon as indexing is queued', async () => { + const browser = await signIn(app); + + const page = await act(browser, 'index'); + + expect(page.body).toMatch(/ { const browser = await signIn(app); await act(browser, 'index'); @@ -184,7 +226,76 @@ describe('index and delete actions', () => { }); }); +describe('which crates an action covers', () => { + const crates = () => + repositoryOf([ + { id: PEOPLE, state: {} }, + { id: EXTRA, state: {} }, + { id: PEOPLE_TOO, state: {} }, + ]); + + it('indexes a crate and the crates under it, not crates whose id merely starts with its id', async () => { + const repository = crates(); + const browser = await signIn((await start(repository)).app); + + await act(browser, 'index', { crateId: PEOPLE }); + + expect(repository.createIndex).toHaveBeenCalledWith([PEOPLE, EXTRA], undefined, false); + }); + + it('deletes the same crates as indexing would', async () => { + const repository = crates(); + const browser = await signIn((await start(repository)).app); + + const page = await act(browser, 'delete', { crateId: PEOPLE }); + + expect(flash(page.body)).toBe(`Deleting the indexes of ${PEOPLE} and the crates under it has started.`); + expect(repository.deleteIndex.mock.calls).toEqual([[PEOPLE], [EXTRA]]); + }); + + it.each(['index', 'delete'])('refuses to %s a crate that isn’t in the repository', async (action) => { + const repository = crates(); + const browser = await signIn((await start(repository)).app); + + const page = await act(browser, action, { crateId: 'arcp://name,nobody' }); + + expect(flash(page.body)).toMatch(/^(Indexing|Deleting the indexes of) arcp:\/\/name,nobody was refused: it was not found\.$/); + expect(repository.createIndex).not.toHaveBeenCalled(); + expect(repository.deleteIndex).not.toHaveBeenCalled(); + }); +}); + describe('protecting the actions', () => { + it('refuses to index a crate while a crate under it is being deleted', async () => { + const repository = repositoryOf([ + { id: PEOPLE, state: {} }, + { id: EXTRA, state: { search: 'deleting' } }, + ]); + const browser = await signIn((await start(repository)).app); + + const page = await act(browser, 'index', { crateId: PEOPLE }); + + expect(flash(page.body)).toBe(`Indexing ${PEOPLE} was refused: deleting is in progress.`); + expect(repository.createIndex).not.toHaveBeenCalled(); + }); + + it.each([ + ['one crate', 'queued', { crateId: PEOPLE }, `Deleting the indexes of ${PEOPLE} and the crates under it`], + ['one crate', 'indexing', { crateId: PEOPLE }, `Deleting the indexes of ${PEOPLE} and the crates under it`], + ['every crate', 'indexing', {}, 'Deleting every index'], + ])('refuses to delete %s while a crate it covers is %s', async (_, state, form, asked) => { + const repository = repositoryOf([ + { id: PEOPLE, state: {} }, + { id: EXTRA, state: { structural: state } }, + ]); + const browser = await signIn((await start(repository)).app); + + const page = await act(browser, 'delete', form); + + expect(flash(page.body)).toBe(`${asked} was refused: indexing is in progress.`); + expect(repository.deleteIndex).not.toHaveBeenCalled(); + }); + it.each([ ['one crate', { crateId: PEOPLE, force: 'true' }, `Reindexing ${PEOPLE}`], ['every crate', {}, 'Indexing every crate'], diff --git a/src/admin/actions.ts b/src/admin/actions.ts index 133c84c..937bc38 100644 --- a/src/admin/actions.ts +++ b/src/admin/actions.ts @@ -1,27 +1,56 @@ import type { FastifyBaseLogger } from 'fastify'; -import { hasState, type Repository, State } from '../repository.ts'; +import { hasState, type Repository, type RepositoryObject, State } from '../repository.ts'; +import { isWithin } from './crates.ts'; /** What an action asked for and whether it was accepted, shown once on the next page */ export type Flash = { accepted: boolean; message: string }; +const refused = (asked: string, reason: string): Flash => ({ accepted: false, message: `${asked} was refused: ${reason}.` }); + +/** The crates an action on `crateId` covers, it and the crates under it, or every crate; undefined if `crateId` isn't a crate */ +const covered = async (repository: Repository, crateId: string | undefined): Promise => { + const objects = await Array.fromAsync(repository.objects()); + if (crateId === undefined) { + return objects; + } + return objects.some(({ id }) => id === crateId) ? objects.filter(({ id }) => isWithin(id, crateId)) : undefined; +}; + /** Queues indexing `crateId` and the crates under it, or every crate; `force` rebuilds indexes that already exist */ export const indexCrates = async (repository: Repository, log: FastifyBaseLogger, crateId: string | undefined, force: boolean): Promise => { const asked = `${force ? 'Reindexing' : 'Indexing'} ${crateId ?? 'every crate'}`; - const states = crateId ? [await repository.getState(crateId)] : (await Array.fromAsync(repository.objects())).map(({ state }) => state); - if (states.some((state) => hasState(state, State.DELETING))) { - return { accepted: false, message: `${asked} was refused: deleting is in progress.` }; + const crates = await covered(repository, crateId); + if (!crates) { + return refused(asked, 'it was not found'); } - if (crateId && !force && states.some((state) => hasState(state, State.INDEXED))) { - return { accepted: false, message: `${asked} was refused: it is already indexed, so reindex it instead.` }; + if (crates.some(({ state }) => hasState(state, State.DELETING))) { + return refused(asked, 'deleting is in progress'); + } + if (!force && crates.some(({ id, state }) => id === crateId && hasState(state, State.INDEXED))) { + return refused(asked, 'it is already indexed, so reindex it instead'); } repository - .createIndex(crateId ? new RegExp(`^${RegExp.escape(crateId)}`) : undefined, undefined, force) + .createIndex( + crates.map(({ id }) => id), + undefined, + force, + ) .catch((err) => log.error({ err, crateId }, 'Indexing failed')); return { accepted: true, message: `${asked} has been queued.` }; }; -/** Starts deleting the indexes of `crateId`, or every index */ -export const deleteIndexes = (repository: Repository, log: FastifyBaseLogger, crateId: string | undefined): Flash => { - repository.deleteIndex(crateId).catch((err) => log.error({ err, crateId }, 'Deleting indexes failed')); - return { accepted: true, message: `Deleting ${crateId ? `the indexes of ${crateId}` : 'every index'} has started.` }; +/** Starts deleting the indexes of `crateId` and the crates under it, or every index */ +export const deleteIndexes = async (repository: Repository, log: FastifyBaseLogger, crateId: string | undefined): Promise => { + const crates = await covered(repository, crateId); + const under = crates && crates.length > 1 ? ' and the crates under it' : ''; + const asked = `Deleting ${crateId ? `the indexes of ${crateId}${under}` : 'every index'}`; + if (!crates) { + return refused(asked, 'it was not found'); + } + if (crates.some(({ state }) => hasState(state, State.QUEUED, State.INDEXING))) { + return refused(asked, 'indexing is in progress'); + } + const deleting = crateId === undefined ? [repository.deleteIndex()] : crates.map(({ id }) => repository.deleteIndex(id)); + Promise.all(deleting).catch((err) => log.error({ err, crateId }, 'Deleting indexes failed')); + return { accepted: true, message: `${asked} has started.` }; }; diff --git a/src/admin/audit.test.ts b/src/admin/audit.test.ts new file mode 100644 index 0000000..5307779 --- /dev/null +++ b/src/admin/audit.test.ts @@ -0,0 +1,35 @@ +import { Writable } from 'node:stream'; +import { describe, expect, it, vi } from 'vitest'; +import { ALICE, adminTestbed, PEOPLE, repositoryIn } from '../../test/admin.ts'; + +const logs = vi.hoisted(() => [] as Record[]); + +vi.mock('../logger.ts', async () => { + const { pino } = await import('pino'); + const stream = new Writable({ + write(chunk, _encoding, done) { + logs.push(JSON.parse(chunk.toString())); + done(); + }, + }); + return { logger: pino({ level: 'info' }, stream) }; +}); + +const testbed = adminTestbed(); + +describe('the audit log', () => { + it('records each accepted and refused action, with who asked', async () => { + const { app } = await testbed.start({ ADMIN_USERS: ALICE.sub }, { repository: repositoryIn('indexed') }); + const { browser } = await testbed.signInAsAdmin(app, ALICE); + + await browser.post('/admin/index', { crateId: PEOPLE, force: 'true' }); + await browser.post('/admin/delete', {}); + await browser.post('/admin/index', { crateId: PEOPLE }); + + expect(logs.filter(({ action }) => action)).toEqual([ + expect.objectContaining({ level: 30, sub: ALICE.sub, action: 'index', crateId: PEOPLE, force: true, accepted: true }), + expect.objectContaining({ level: 30, sub: ALICE.sub, action: 'delete', crateId: 'all', force: false, accepted: true }), + expect.objectContaining({ level: 30, sub: ALICE.sub, action: 'index', crateId: PEOPLE, force: false, accepted: false }), + ]); + }); +}); diff --git a/src/admin/client.ts b/src/admin/client.ts index 8c93e4f..2423545 100644 --- a/src/admin/client.ts +++ b/src/admin/client.ts @@ -3,11 +3,15 @@ import type { AuthConfig } from '../configuration.ts'; export const ADMIN_PATH = '/admin'; +export const ADMIN_HOME = `${ADMIN_PATH}/`; + +export const ADMIN_SIGNED_OUT = `${ADMIN_PATH}/signed-out`; + export const derivedKey = (cookieKey: string, purpose: string) => Buffer.from(hkdfSync('sha256', cookieKey, '', `ldacapi ${purpose}`, 32)); export const adminRedirectUri = (adminOrigin: string) => `${adminOrigin}${ADMIN_PATH}/callback`; -export const adminPostLogoutRedirectUri = (adminOrigin: string) => `${adminOrigin}${ADMIN_PATH}/`; +export const adminPostLogoutRedirectUri = (adminOrigin: string) => `${adminOrigin}${ADMIN_SIGNED_OUT}`; /** The admin pages' own client of our provider, which needs no configuration */ export const adminClient = ({ cookieKeys }: AuthConfig, adminOrigin: string) => ({ diff --git a/src/admin/crates.test.ts b/src/admin/crates.test.ts index 1663aa3..bdf8366 100644 --- a/src/admin/crates.test.ts +++ b/src/admin/crates.test.ts @@ -134,6 +134,7 @@ describe('the crate tree', () => { const script = await browser.get('/admin/admin.js'); expect(script.statusCode).toBe(200); expect(script.headers['content-type']).toMatch(/javascript/); + expect(script.body).toContain('sessionStorage'); }); }); diff --git a/src/admin/crates.ts b/src/admin/crates.ts index f2213cb..5d26c74 100644 --- a/src/admin/crates.ts +++ b/src/admin/crates.ts @@ -9,12 +9,22 @@ const INDEXES = { structural: 'Structural', search: 'Search' } as const; /** How often the page reloads while an index is changing */ const REFRESH_SECONDS = 5; -/** The nearest crate whose id is a path prefix of `id`, as indexing a crate also indexes the crates under it */ -const parentOf = (id: string, crates: Map) => { +/** The ids of the crates `id` is under, nearest first, as indexing a crate also indexes the crates under it */ +function* ancestorIds(id: string) { let path = id.replace(/\/+$/, ''); for (let slash = path.lastIndexOf('/'); slash > 0; slash = path.lastIndexOf('/')) { path = path.slice(0, slash); - const parent = crates.get(path) ?? crates.get(`${path}/`); + yield path; + yield `${path}/`; + } +} + +/** Whether `id` is `crateId` or a crate under it */ +export const isWithin = (id: string, crateId: string) => id === crateId || ancestorIds(id).some((ancestor) => ancestor === crateId); + +const parentOf = (id: string, crates: Map) => { + for (const ancestor of ancestorIds(id)) { + const parent = crates.get(ancestor); if (parent) { return parent; } @@ -50,7 +60,8 @@ const actionForm = (action: 'index' | 'delete', label: string, crateId?: string, const crateActions = (crate: Crate) => { const indexed = hasState(crate.state, State.INDEXED); - return html`${actionForm('index', indexed ? 'Reindex' : 'Index', crate.id, { force: indexed })} ${actionForm('delete', 'Delete', crate.id, { confirm: `Delete the indexes of ${crate.label}?` })}`; + const confirm = `Delete the indexes of ${crate.label}${crate.children.length > 0 ? ' and the crates under it' : ''}?`; + return html`${actionForm('index', indexed ? 'Reindex' : 'Index', crate.id, { force: indexed })} ${actionForm('delete', 'Delete', crate.id, { confirm })}`; }; const renderCrates = (crates: Crate[]): Html => html`