diff --git a/README.md b/README.md index 7ca0299..a773fae 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,18 @@ The pages are React components using [FiestaUI](https://github.com/Fiestaboard/F loads the UI when a person is needed, so the common case does not wait for React. - `src/prerender.tsx` and `scripts/prerender.mjs` render each page into the built HTML. +## OAuth client metadata documents + +Some providers (Todoist, Hugging Face) accept a URL as the `client_id` and fetch the app's +details from it, so nobody has to register an app +([draft-ietf-oauth-client-id-metadata-document](https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/)). +`public/clients/.json` is copied into the site unchanged and served at +`https://fiestaboard.app/auth/clients/.json`, which is also its `client_id`. + +Like the redirect URL, **these URLs must never change**: FiestaBoard plugins ship them as +their client ID. Each lists both redirect URIs and `"token_endpoint_auth_method": "none"` +(a public client using PKCE); none may ever carry a secret. + ## Development ```sh diff --git a/package.json b/package.json index aef0e56..3d368ab 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "build": "tsc --noEmit && vite build && vite build --ssr src/prerender.tsx --outDir dist-ssr && node scripts/prerender.mjs", "preview": "vite preview --port 8787", "typecheck": "tsc --noEmit", - "test": "node --test tests/board-address.test.js", + "test": "node --test tests/board-address.test.js tests/clients.test.js", "test:dist": "node --test tests/dist.test.js" }, "dependencies": { diff --git a/public/clients/huggingface.json b/public/clients/huggingface.json new file mode 100644 index 0000000..9559f08 --- /dev/null +++ b/public/clients/huggingface.json @@ -0,0 +1,10 @@ +{ + "client_id": "https://fiestaboard.app/auth/clients/huggingface.json", + "client_name": "FiestaBoard", + "client_uri": "https://fiestaboard.app/", + "redirect_uris": [ + "https://fiestaboard.app/auth/oauth/redirect", + "https://fiestaboard.app/auth/oauth/redirect.html" + ], + "token_endpoint_auth_method": "none" +} diff --git a/public/clients/todoist.json b/public/clients/todoist.json new file mode 100644 index 0000000..4a01e84 --- /dev/null +++ b/public/clients/todoist.json @@ -0,0 +1,13 @@ +{ + "client_id": "https://fiestaboard.app/auth/clients/todoist.json", + "client_name": "FiestaBoard", + "client_uri": "https://fiestaboard.app/", + "redirect_uris": [ + "https://fiestaboard.app/auth/oauth/redirect", + "https://fiestaboard.app/auth/oauth/redirect.html" + ], + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "token_endpoint_auth_method": "none", + "scope": "data:read" +} diff --git a/tests/clients.test.js b/tests/clients.test.js new file mode 100644 index 0000000..c21214d --- /dev/null +++ b/tests/clients.test.js @@ -0,0 +1,35 @@ +// OAuth client metadata documents (draft-ietf-oauth-client-id-metadata-document). +// +// A provider that supports these fetches the client_id itself: the client_id +// is the URL of the document. Vite copies public/ into dist/ unchanged, so +// public/clients/.json is served at https://fiestaboard.app/auth/clients/.json. +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { test } from "node:test"; + +const SITE = "https://fiestaboard.app/auth/"; +// Both forms are registered: FiestaBoard 9.5.0 through 9.7.x sends the .html one. +const REDIRECT_URIS = [`${SITE}oauth/redirect`, `${SITE}oauth/redirect.html`]; +const CLIENTS = ["todoist", "huggingface"]; + +const load = (name) => JSON.parse(readFileSync(resolve(import.meta.dirname, `../public/clients/${name}.json`), "utf8")); + +for (const name of CLIENTS) { + test(`${name}: client_id is the URL the document is served at`, () => { + assert.equal(load(name).client_id, `${SITE}clients/${name}.json`); + }); + + test(`${name}: a public client that returns through the relay`, () => { + const doc = load(name); + assert.equal(doc.token_endpoint_auth_method, "none"); + assert.deepEqual(doc.redirect_uris, REDIRECT_URIS); + assert.equal(typeof doc.client_name, "string"); + assert.ok(!("client_secret" in doc)); + }); + + test(`${name}: small enough for providers' fetch limits`, () => { + // Todoist refuses documents over 5 KB. + assert.ok(readFileSync(resolve(import.meta.dirname, `../public/clients/${name}.json`)).length < 5000); + }); +} diff --git a/tests/dist.test.js b/tests/dist.test.js index d162cbb..3187fc9 100644 --- a/tests/dist.test.js +++ b/tests/dist.test.js @@ -94,3 +94,11 @@ test("no script or stylesheet references another host", () => { assert.doesNotMatch(css, /@import\s+(url\()?["']?https?:/); } }); + +test("OAuth client metadata documents are published at their client_id URLs", () => { + // The client_id is this URL; moving a document breaks every sign-in that uses it. + for (const name of ["todoist", "huggingface"]) { + const doc = JSON.parse(read(`clients/${name}.json`)); + assert.equal(doc.client_id, `https://fiestaboard.app/auth/clients/${name}.json`); + } +});