diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c5a4c858b..1ad8f64d0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -18,9 +18,13 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node-version: [20.x] + node-version: [22.x] mongodb-version: [4] steps: + - name: Remove temporary files + run: sudo rm -rf /tmp/* + - name: Remove hosted tool cache + run: sudo rm -rf /opt/hostedtoolcache - name: Git checkout uses: actions/checkout@v4 - name: Use Node.js ${{ matrix.node-version }} @@ -29,7 +33,7 @@ jobs: node-version: ${{ matrix.node-version }} cache: "npm" - name: Start MongoDB - uses: supercharge/mongodb-github-action@1.10.0 + uses: supercharge/mongodb-github-action@1.11.0 with: mongodb-version: ${{ matrix.mongodb-version }} - name: Download localisations diff --git a/helpers/mock-agent/endpoint-auth.js b/helpers/mock-agent/endpoint-auth.js index 48424424b..c5c60ce96 100644 --- a/helpers/mock-agent/endpoint-auth.js +++ b/helpers/mock-agent/endpoint-auth.js @@ -11,6 +11,17 @@ export const mockClient = () => { const origin = "https://auth-endpoint.example"; + // Client metadata + agent + .get(origin) + .intercept({ path: "/id" }) + .reply(200, { + client_id: `${origin}/id`, + client_name: "Client with metadata", + client_uri: origin, + logo_uri: `${origin}/logo.png`, + }); + // Client information (h-x-app) agent .get(origin) diff --git a/packages/endpoint-auth/lib/client.js b/packages/endpoint-auth/lib/client.js index d74eafd2d..0e6893a5a 100644 --- a/packages/endpoint-auth/lib/client.js +++ b/packages/endpoint-auth/lib/client.js @@ -1,37 +1,27 @@ import { mf2 } from "microformats-parser"; /** - * Get client information - * @param {string} client_id - Client URL - * @returns {Promise} Information about the client - * @see {@link https://indieauth.spec.indieweb.org/#client-information-discovery} + * Get client information from application Microformat + * @param {string} body - Response body + * @param {object} client - Fallback client information + * @returns {object} Client information + * @deprecated since 11 July 2024 + * @see {@link https://indieauth.spec.indieweb.org/20220212/#application-information} */ -export const getClientInformation = async (client_id) => { - let client = { - name: new URL(client_id).host, - url: client_id, - }; - - const clientResponse = await fetch(client_id); - if (!clientResponse.ok) { - return client; - } - - const body = await clientResponse.text(); - - // If response contains microformats, use available derived values - const { items } = mf2(body, { baseUrl: client_id }); +export const getApplicationInformation = (body, client) => { + const { items } = mf2(body, { baseUrl: client.url }); for (const item of items) { const { properties, type } = item; if (/^h-(?:x-)?app$/.test(type[0])) { - // If no URL property, use `client_id` + // If no URL property, use baseUrl if (!properties.url) { - properties.url = [client_id]; + properties.url = [client.url]; } - // If has URL property, only continue if matches `client_id` - if (!properties.url?.includes(client_id)) { + // Check that URL property matches `client_id`. Note that this isn’t for + // authentication, but to ensure only relevant client metadata is returned + if (!properties.url?.includes(client.url)) { continue; } @@ -48,3 +38,54 @@ export const getClientInformation = async (client_id) => { return client; }; + +/** + * Get client information from client metadata + * @param {string} body - Response body + * @param {object} client - Fallback client information + * @returns {object} Client information + * @see {@link https://indieauth.spec.indieweb.org/#client-metadata} + */ +export const getClientMetadata = (body, client) => { + const json = JSON.parse(body); + + // Client metadata MUST include `client_id` + if (!Object.hasOwn(json, "client_id")) { + throw new Error("Client metadata JSON not valid"); + } + + return { + logo: json.logo_uri, + name: json.client_name || client.name, + url: json.client_uri || client.url, + }; +}; + +/** + * Get client information + * @param {string} clientId - Client ID + * @returns {Promise} Information about the client + * @see {@link https://indieauth.spec.indieweb.org/#client-information-discovery} + */ +export const getClientInformation = async (clientId) => { + let client = { + name: new URL(clientId).host, + url: new URL(clientId).href, + }; + + const clientResponse = await fetch(clientId); + if (!clientResponse.ok) { + // Use information derived from clientId + return client; + } + + const body = await clientResponse.text(); + + try { + // Use information from client JSON metadata + return getClientMetadata(body, client); + } catch { + // Use information from client HTML microformats (deprecated) + return getApplicationInformation(body, client); + } +}; diff --git a/packages/endpoint-auth/test/unit/client.js b/packages/endpoint-auth/test/unit/client.js index ccef22327..469102820 100644 --- a/packages/endpoint-auth/test/unit/client.js +++ b/packages/endpoint-auth/test/unit/client.js @@ -6,6 +6,18 @@ import { getClientInformation } from "../../lib/client.js"; await mockAgent("endpoint-auth"); describe("endpoint-auth/lib/client", () => { + it("Gets client information (from metadata)", async () => { + const result = await getClientInformation( + "https://auth-endpoint.example/id", + ); + + assert.deepEqual(result, { + logo: "https://auth-endpoint.example/logo.png", + name: "Client with metadata", + url: "https://auth-endpoint.example", + }); + }); + it("Gets client information (has h-x-app microformat)", async () => { const result = await getClientInformation("https://auth-endpoint.example/"); diff --git a/packages/indiekit/lib/controllers/client.js b/packages/indiekit/lib/controllers/client.js new file mode 100644 index 000000000..49722beba --- /dev/null +++ b/packages/indiekit/lib/controllers/client.js @@ -0,0 +1,13 @@ +export const get = async (request, response) => { + const { name: client_name, url: client_uri } = request.app.locals.application; + const { href: client_id } = new URL("id", client_uri); + const { href: logo_uri } = new URL("assets/app-icon-512-any.png", client_uri); + + response.set("Cache-Control", "public, max-age=604800"); // 7 days + return response.type("application/json").json({ + client_id, + client_name, + client_uri, + logo_uri, + }); +}; diff --git a/packages/indiekit/lib/indieauth.js b/packages/indiekit/lib/indieauth.js index af71dc6b1..65a97ff61 100644 --- a/packages/indiekit/lib/indieauth.js +++ b/packages/indiekit/lib/indieauth.js @@ -79,14 +79,16 @@ export const IndieAuth = class { return async (request, response) => { try { const { application } = request.app.locals; - this.clientId = getCanonicalUrl(application.url); + const applicationUrl = getCanonicalUrl(application.url); + const { href: clientId } = new URL("id", applicationUrl); + const { href: callbackUrl } = new URL("session/auth", applicationUrl); - const callbackUrl = `${application.url}/session/auth`; const { redirect } = request.query; this.redirectUri = redirect ? `${callbackUrl}?redirect=${redirect}` : `${callbackUrl}`; + this.clientId = clientId; const state = generateState(this.clientId, this.iv); const authUrl = await this.getAuthUrl( application.authorizationEndpoint, diff --git a/packages/indiekit/lib/routes.js b/packages/indiekit/lib/routes.js index 61923c70c..af0743c53 100644 --- a/packages/indiekit/lib/routes.js +++ b/packages/indiekit/lib/routes.js @@ -3,6 +3,7 @@ import express from "express"; import { assetsPath } from "@indiekit/frontend"; import rateLimit from "express-rate-limit"; import * as assetsController from "./controllers/assets.js"; +import * as clientController from "./controllers/client.js"; import * as feedController from "./controllers/feed.js"; import * as homepageController from "./controllers/homepage.js"; import * as manifestController from "./controllers/manifest.js"; @@ -70,6 +71,9 @@ export const routes = (indiekitConfig) => { // Web App Manifest router.get("/app.webmanifest", manifestController.get); + // Client metadata + router.get("/id", clientController.get); + // Session router.get("/session/login", limit, sessionController.login); router.post("/session/login", limit, indieauth.login()); diff --git a/packages/indiekit/test/integration/200-client.js b/packages/indiekit/test/integration/200-client.js new file mode 100644 index 000000000..952a3fdc5 --- /dev/null +++ b/packages/indiekit/test/integration/200-client.js @@ -0,0 +1,24 @@ +import { strict as assert } from "node:assert"; +import { after, describe, it } from "node:test"; +import supertest from "supertest"; +import { testServer } from "@indiekit-test/server"; + +const server = await testServer(); +const request = supertest.agent(server); + +describe("indiekit GET /id", () => { + it("Returns client metadata", async () => { + const result = await request.get("/id"); + + assert.equal(result.status, 200); + assert.equal(result.type, "application/json"); + assert.equal(result.body.client_id.includes("/id"), true); + assert.equal(result.body.client_name, "Test configuration"); + assert.ok(result.body.client_uri); + assert.equal(result.body.logo_uri.includes("app-icon-512-any.png"), true); + }); + + after(() => { + server.close(() => process.exit(0)); + }); +});