diff --git a/helpers/config/index.js b/helpers/config/index.js index 9599eb0a1..608e549eb 100644 --- a/helpers/config/index.js +++ b/helpers/config/index.js @@ -71,5 +71,6 @@ export const testConfig = async (options) => { "@indiekit/endpoint-webmention-io": { token: "abcd1234", }, + "@indiekit/endpoint-auth": options["@indiekit/endpoint-auth"], }; }; diff --git a/helpers/fixtures/html/profile.html b/helpers/fixtures/html/profile.html new file mode 100644 index 000000000..2a1fd6be5 --- /dev/null +++ b/helpers/fixtures/html/profile.html @@ -0,0 +1,11 @@ + + + + Jane Example + + + +

Jane Example

+

Writes about cheese sandwiches.

+ + diff --git a/helpers/mock-agent/endpoint-auth.js b/helpers/mock-agent/endpoint-auth.js index 6147f45ef..398495985 100644 --- a/helpers/mock-agent/endpoint-auth.js +++ b/helpers/mock-agent/endpoint-auth.js @@ -112,6 +112,31 @@ export const mockClient = () => { }) .persist(); + // Profile information (h-card on the user’s site) + agent + .get("https://website.example") + .intercept({ path: "/" }) + .reply(200, getFixture("html/profile.html"), { + headers: { "content-type": "text/html" }, + }) + .persist(); + + // Profile information (no h-card on the user’s site) + agent + .get("https://no-hcard.example") + .intercept({ path: "/" }) + .reply(200, getFixture("html/page.html"), { + headers: { "content-type": "text/html" }, + }) + .persist(); + + // Profile information (Not Found) + agent + .get("https://profile-404.example") + .intercept({ path: "/" }) + .reply(404) + .persist(); + // Profile URL response agent .get(origin) diff --git a/packages/endpoint-auth/README.md b/packages/endpoint-auth/README.md index dbbdc9a88..89971029e 100644 --- a/packages/endpoint-auth/README.md +++ b/packages/endpoint-auth/README.md @@ -28,6 +28,7 @@ You will also need to set the following environment variables: ## Options -| Option | Type | Description | -| :---------- | :------- | :--------------------------------------------------------------- | -| `mountPath` | `string` | Path to authorization endpoint. _Optional_, defaults to `/auth`. | +| Option | Type | Description | +| :---------- | :------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `mountPath` | `string` | Path to authorization endpoint. _Optional_, defaults to `/auth`. | +| `profile` | `object` | Profile information (`name`, `url`, `photo`) returned to clients granted the `profile` scope. _Optional_; anything not configured is discovered from the h-card on your website. | diff --git a/packages/endpoint-auth/index.js b/packages/endpoint-auth/index.js index bafaa9a76..b0a0008b4 100644 --- a/packages/endpoint-auth/index.js +++ b/packages/endpoint-auth/index.js @@ -7,6 +7,7 @@ import { introspectionController } from "./lib/controllers/introspection.js"; import { metadataController } from "./lib/controllers/metadata.js"; import { passwordController } from "./lib/controllers/password.js"; import { tokenController } from "./lib/controllers/token.js"; +import { userinfoController } from "./lib/controllers/userinfo.js"; import { codeValidator } from "./lib/middleware/code.js"; import { hasSecret } from "./lib/middleware/secret.js"; import { @@ -23,17 +24,25 @@ const router = express.Router({ caseSensitive: true, mergeParams: true }); export default class AuthorizationEndpoint { name = "IndieAuth endpoint"; + /** + * @param {object} [options] - Plug-in options + * @param {string} [options.mountPath] - Path to endpoint + * @param {object} [options.profile] - Profile information returned for the `profile` scope + */ constructor(options = {}) { this.options = { ...defaults, ...options }; this.mountPath = this.options.mountPath; } get routesPublic() { + const authorization = authorizationController(this.options); + const token = tokenController(this.options); + router.use(hasSecret); // Authorization - router.get("/", authorizationController.get, documentationController); - router.post("/", codeValidator, authorizationController.post); + router.get("/", authorization.get, documentationController); + router.post("/", codeValidator, authorization.post); router.get("/consent", consentController.get); router.post("/consent", consentValidator, consentController.post); router.get("/new-password", passwordController.get); @@ -41,7 +50,8 @@ export default class AuthorizationEndpoint { // Authentication router.get("/token", introspectionController.post); - router.post("/token", codeValidator, tokenController.post); + router.post("/token", codeValidator, token.post); + router.get("/userinfo", userinfoController(this.options)); // Verification router.post("/introspect", introspectionController.post); @@ -78,5 +88,10 @@ export default class AuthorizationEndpoint { if (!Indiekit.config.application.tokenEndpoint) { Indiekit.config.application.tokenEndpoint = `${this.mountPath}/token`; } + + // Only mount if user information endpoint not already configured + if (!Indiekit.config.application.userinfoEndpoint) { + Indiekit.config.application.userinfoEndpoint = `${this.mountPath}/userinfo`; + } } } diff --git a/packages/endpoint-auth/lib/client.js b/packages/endpoint-auth/lib/client.js index 8fe68e988..f4d61966a 100644 --- a/packages/endpoint-auth/lib/client.js +++ b/packages/endpoint-auth/lib/client.js @@ -16,7 +16,7 @@ const FETCH_TIMEOUT = 5000; * @see {@link https://indieauth.spec.indieweb.org/#client-identifier} * @see {@link https://indieauth.spec.indieweb.org/#client-information-discovery} */ -const isFetchableOrigin = (url) => { +export const isFetchableOrigin = (url) => { if (url.protocol !== "https:" && url.protocol !== "http:") { return false; } diff --git a/packages/endpoint-auth/lib/controllers/authorization.js b/packages/endpoint-auth/lib/controllers/authorization.js index de67e09ed..9b3e1d4ea 100644 --- a/packages/endpoint-auth/lib/controllers/authorization.js +++ b/packages/endpoint-auth/lib/controllers/authorization.js @@ -2,10 +2,16 @@ import { IndiekitError } from "@indiekit/error"; import { getCanonicalUrl, isSameOrigin } from "@indiekit/util"; import { getClientInformation } from "../client.js"; +import { getProfileInformation } from "../profile.js"; import { createRequestUri } from "../pushed-authorization-request.js"; import { validateRedirect } from "../redirect.js"; -export const authorizationController = { +/** + * @param {object} [options] - Plug-in options + * @param {object} [options.profile] - Configured profile information + * @returns {object} Controller + */ +export const authorizationController = (options = {}) => ({ /** * Authorization request * @@ -117,13 +123,19 @@ export const authorizationController = { * @see {@link https://indieauth.spec.indieweb.org/#profile-url-response} */ async post(request, response) { - const profileToken = { me: request.verifiedToken.me }; + const { me, scope } = request.verifiedToken; + const profileToken = { me }; + + // Include profile information if `profile` scope was granted + const profile = scope?.split(" ").includes("profile") + ? await getProfileInformation(me, options.profile) + : undefined; if (request.accepts("application/json")) { - return response.json(profileToken); + return response.json({ ...profileToken, ...(profile && { profile }) }); } response.set("content-type", "application/x-www-form-urlencoded"); return response.send(new URLSearchParams(profileToken).toString()); }, -}; +}); diff --git a/packages/endpoint-auth/lib/controllers/metadata.js b/packages/endpoint-auth/lib/controllers/metadata.js index c60498eb5..ede073ede 100644 --- a/packages/endpoint-auth/lib/controllers/metadata.js +++ b/packages/endpoint-auth/lib/controllers/metadata.js @@ -8,6 +8,7 @@ export const metadataController = (request, response) => { authorization_endpoint: application.authorizationEndpoint, introspection_endpoint: application.introspectionEndpoint, token_endpoint: application.tokenEndpoint, + userinfo_endpoint: application.userinfoEndpoint, code_challenge_methods_supported: ["S256"], response_types_supported: ["code"], scopes_supported: supportedScopes, diff --git a/packages/endpoint-auth/lib/controllers/token.js b/packages/endpoint-auth/lib/controllers/token.js index 8a3956247..4755073f6 100644 --- a/packages/endpoint-auth/lib/controllers/token.js +++ b/packages/endpoint-auth/lib/controllers/token.js @@ -1,6 +1,12 @@ +import { getProfileInformation } from "../profile.js"; import { signToken } from "../token.js"; -export const tokenController = { +/** + * @param {object} [options] - Plug-in options + * @param {object} [options.profile] - Configured profile information + * @returns {object} Controller + */ +export const tokenController = (options = {}) => ({ /** * Authorization code request * @@ -9,7 +15,7 @@ export const tokenController = { * @see {@link https://indieauth.spec.indieweb.org/#redeeming-the-authorization-code} * @see {@link https://indieauth.spec.indieweb.org/#access-token-response} */ - post(request, response) { + async post(request, response) { const { me, scope } = request.verifiedToken; const tokenData = { me, ...(scope && { scope }) }; @@ -19,11 +25,16 @@ export const tokenController = { ...tokenData, }; + // Include profile information if `profile` scope was granted + const profile = scope?.split(" ").includes("profile") + ? await getProfileInformation(me, options.profile) + : undefined; + if (request.accepts("application/json")) { - return response.json(accessToken); + return response.json({ ...accessToken, ...(profile && { profile }) }); } response.set("content-type", "application/x-www-form-urlencoded"); return response.send(new URLSearchParams(accessToken).toString()); }, -}; +}); diff --git a/packages/endpoint-auth/lib/controllers/userinfo.js b/packages/endpoint-auth/lib/controllers/userinfo.js new file mode 100644 index 000000000..5e744dcd4 --- /dev/null +++ b/packages/endpoint-auth/lib/controllers/userinfo.js @@ -0,0 +1,44 @@ +import { IndiekitError } from "@indiekit/error"; + +import { getProfileInformation } from "../profile.js"; +import { verifyToken } from "../token.js"; + +/** + * User information request + * + * Return profile information for the user an access token was issued to. + * @param {object} [options] - Plug-in options + * @param {object} [options.profile] - Configured profile information + * @returns {import("express").RequestHandler} Controller + * @see {@link https://indieauth.spec.indieweb.org/#user-information} + */ +export const userinfoController = + (options = {}) => + async (request, response, next) => { + try { + let accessToken; + try { + // Remove ‘Bearer ’ from authorization header + const token = request.headers.authorization?.trim().split(/\s+/, 2)[1]; + accessToken = verifyToken(token); + } catch { + throw IndiekitError.unauthorized( + response.locals.__("UnauthorizedError.invalidToken"), + ); + } + + const { me, scope } = accessToken; + if (!scope?.split(" ").includes("profile")) { + throw IndiekitError.insufficientScope( + response.locals.__("ForbiddenError.insufficientScope"), + { scope: "profile" }, + ); + } + + const profile = await getProfileInformation(me, options.profile); + + response.json(profile || {}); + } catch (error) { + next(error); + } + }; diff --git a/packages/endpoint-auth/lib/profile.js b/packages/endpoint-auth/lib/profile.js new file mode 100644 index 000000000..7f23a3468 --- /dev/null +++ b/packages/endpoint-auth/lib/profile.js @@ -0,0 +1,93 @@ +import { mf2 } from "microformats-parser"; + +import { isFetchableOrigin } from "./client.js"; + +const FETCH_TIMEOUT = 5000; + +/** + * Get first value of a microformats property, as a string + * @param {Array} [values] - Property values + * @returns {string|undefined} Value + */ +const getValue = (values) => { + const value = values?.[0]; + return typeof value === "object" ? value.value : value; +}; + +/** + * Discover profile information from the representative h-card on a user’s + * site, preferring an h-card whose URL is the profile URL + * @param {string} me - Profile URL + * @returns {Promise} Discovered profile information + * @see {@link https://microformats.org/wiki/representative-h-card-parsing} + */ +const discoverProfile = async (me) => { + if (!URL.canParse(me) || !isFetchableOrigin(new URL(me))) { + return {}; + } + + let body; + try { + const response = await fetch(me, { + headers: { accept: "text/html" }, + signal: AbortSignal.timeout(FETCH_TIMEOUT), + }); + + if (!response.ok) { + return {}; + } + + body = await response.text(); + } catch { + return {}; + } + + let items; + try { + ({ items } = mf2(body, { baseUrl: me })); + } catch { + return {}; + } + + const cards = items.filter((item) => item.type?.includes("h-card")); + const canonical = me.replace(/\/$/, ""); + const card = + cards.find((item) => + item.properties.url?.some((url) => getValue([url]) === canonical), + ) || cards[0]; + + if (!card) { + return {}; + } + + return { + name: getValue(card.properties.name), + url: getValue(card.properties.url) || me, + photo: getValue(card.properties.photo), + }; +}; + +/** + * Get profile information for a user, using configured values first and + * filling in anything missing from the h-card on their site + * @param {string} me - Profile URL + * @param {object} [configured] - Configured profile information + * @param {string} [configured.name] - Name + * @param {string} [configured.url] - URL + * @param {string} [configured.photo] - Photo URL + * @returns {Promise} Profile information, if any + * @see {@link https://indieauth.spec.indieweb.org/#profile-information} + */ +export const getProfileInformation = async (me, configured = {}) => { + const discovered = await discoverProfile(me); + const profile = {}; + + for (const key of ["name", "url", "photo"]) { + const value = configured[key] || discovered[key]; + if (value) { + profile[key] = value; + } + } + + return Object.keys(profile).length > 0 ? profile : undefined; +}; diff --git a/packages/endpoint-auth/lib/scope.js b/packages/endpoint-auth/lib/scope.js index 1e00210a1..d03065467 100644 --- a/packages/endpoint-auth/lib/scope.js +++ b/packages/endpoint-auth/lib/scope.js @@ -1,7 +1,7 @@ export const scopes = { // IndieAuth scopes email: { supported: false }, - profile: { supported: false }, + profile: { supported: true }, // Micropub scopes create: { supported: true }, draft: { supported: true }, diff --git a/packages/endpoint-auth/test/integration/200-authorization-profile-scope.js b/packages/endpoint-auth/test/integration/200-authorization-profile-scope.js new file mode 100644 index 000000000..36a3f8741 --- /dev/null +++ b/packages/endpoint-auth/test/integration/200-authorization-profile-scope.js @@ -0,0 +1,51 @@ +import { strict as assert } from "node:assert"; +import { after, before, describe, it } from "node:test"; + +import { mockAgent } from "@indiekit-test/mock-agent"; +import { testServer } from "@indiekit-test/server"; +import supertest from "supertest"; + +import { signToken } from "../../lib/token.js"; + +await mockAgent("endpoint-auth"); +const server = await testServer(); +const request = supertest.agent(server); + +describe("endpoint-auth POST /auth", () => { + before(async () => { + await request + .get("/auth") + .query({ client_id: "https://auth-endpoint.example" }) + .query({ redirect_uri: "https://auth-endpoint.example/redirect" }) + .query({ response_type: "code" }) + .query({ state: "12345" }); + }); + + it("Returns profile information with profile URL", async () => { + const code = signToken({ + access_token: "token", + client_id: "https://auth-endpoint.example", + me: "https://website.example", + redirect_uri: "https://auth-endpoint.example/redirect", + scope: "profile create", + token_type: "Bearer", + }); + const result = await request + .post("/auth") + .set("accept", "application/json") + .query({ client_id: "https://auth-endpoint.example" }) + .query({ code }) + .query({ grant_type: "authorization_code" }) + .query({ redirect_uri: "https://auth-endpoint.example/redirect" }); + + assert.equal(result.status, 200); + assert.equal(result.body.me, "https://website.example"); + assert.equal(result.body.profile.name, "Jane Example"); + assert.equal( + result.body.profile.photo, + "https://website.example/photo.jpg", + ); + }); + + after(() => server.close()); +}); diff --git a/packages/endpoint-auth/test/integration/200-metadata.js b/packages/endpoint-auth/test/integration/200-metadata.js index e13a2a617..d9f92d142 100644 --- a/packages/endpoint-auth/test/integration/200-metadata.js +++ b/packages/endpoint-auth/test/integration/200-metadata.js @@ -22,6 +22,7 @@ describe("endpoint-auth GET /auth/metadata", () => { assert.ok(result.scopes_supported); assert.ok(result.service_documentation); assert.ok(result.token_endpoint); + assert.ok(result.userinfo_endpoint); assert.equal(result.ui_locales_supported, "en"); }); diff --git a/packages/endpoint-auth/test/integration/200-token-grant-profile-scope.js b/packages/endpoint-auth/test/integration/200-token-grant-profile-scope.js new file mode 100644 index 000000000..346a9dea2 --- /dev/null +++ b/packages/endpoint-auth/test/integration/200-token-grant-profile-scope.js @@ -0,0 +1,51 @@ +import { strict as assert } from "node:assert"; +import { after, before, describe, it } from "node:test"; + +import { mockAgent } from "@indiekit-test/mock-agent"; +import { testServer } from "@indiekit-test/server"; +import supertest from "supertest"; + +import { signToken } from "../../lib/token.js"; + +await mockAgent("endpoint-auth"); +const server = await testServer(); +const request = supertest.agent(server); + +describe("endpoint-auth POST /auth/token", () => { + before(async () => { + await request + .get("/auth") + .query({ client_id: "https://auth-endpoint.example" }) + .query({ redirect_uri: "https://auth-endpoint.example/redirect" }) + .query({ response_type: "code" }) + .query({ state: "12345" }); + }); + + it("Returns profile information with access token", async () => { + const code = signToken({ + access_token: "token", + client_id: "https://auth-endpoint.example", + me: "https://website.example", + redirect_uri: "https://auth-endpoint.example/redirect", + scope: "profile create", + token_type: "Bearer", + }); + const result = await request + .post("/auth/token") + .set("accept", "application/json") + .query({ client_id: "https://auth-endpoint.example" }) + .query({ code }) + .query({ grant_type: "authorization_code" }) + .query({ redirect_uri: "https://auth-endpoint.example/redirect" }); + + assert.equal(result.status, 200); + assert.equal(result.body.me, "https://website.example"); + assert.equal(result.body.profile.name, "Jane Example"); + assert.equal( + result.body.profile.photo, + "https://website.example/photo.jpg", + ); + }); + + after(() => server.close()); +}); diff --git a/packages/endpoint-auth/test/integration/200-userinfo.js b/packages/endpoint-auth/test/integration/200-userinfo.js new file mode 100644 index 000000000..3223bf024 --- /dev/null +++ b/packages/endpoint-auth/test/integration/200-userinfo.js @@ -0,0 +1,33 @@ +import { strict as assert } from "node:assert"; +import { after, describe, it } from "node:test"; + +import { mockAgent } from "@indiekit-test/mock-agent"; +import { testServer } from "@indiekit-test/server"; +import { testToken } from "@indiekit-test/token"; +import supertest from "supertest"; + +await mockAgent("endpoint-auth"); +const server = await testServer({ + "@indiekit/endpoint-auth": { + profile: { name: "Jane Doe" }, + }, +}); +const request = supertest.agent(server); + +describe("endpoint-auth GET /auth/userinfo", () => { + it("Returns profile information, configured values first", async () => { + const result = await request + .get("/auth/userinfo") + .auth(testToken({ scope: "profile create" }), { type: "bearer" }) + .set("accept", "application/json"); + + assert.equal(result.status, 200); + assert.deepEqual(result.body, { + name: "Jane Doe", + url: "https://website.example", + photo: "https://website.example/photo.jpg", + }); + }); + + after(() => server.close()); +}); diff --git a/packages/endpoint-auth/test/integration/401-userinfo-invalid-token.js b/packages/endpoint-auth/test/integration/401-userinfo-invalid-token.js new file mode 100644 index 000000000..139172b55 --- /dev/null +++ b/packages/endpoint-auth/test/integration/401-userinfo-invalid-token.js @@ -0,0 +1,23 @@ +import { strict as assert } from "node:assert"; +import { after, describe, it } from "node:test"; + +import { testServer } from "@indiekit-test/server"; +import supertest from "supertest"; + +const server = await testServer(); +const request = supertest.agent(server); + +describe("endpoint-auth GET /auth/userinfo", () => { + it("Returns 401 error if token invalid", async () => { + const result = await request + .get("/auth/userinfo") + .auth("invalid", { type: "bearer" }) + .set("accept", "application/json"); + + assert.equal(result.status, 401); + assert.equal(result.body.error, "unauthorized"); + assert.match(result.body.error_description, /access token/); + }); + + after(() => server.close()); +}); diff --git a/packages/endpoint-auth/test/integration/403-userinfo-insufficient-scope.js b/packages/endpoint-auth/test/integration/403-userinfo-insufficient-scope.js new file mode 100644 index 000000000..ab6c6ae8b --- /dev/null +++ b/packages/endpoint-auth/test/integration/403-userinfo-insufficient-scope.js @@ -0,0 +1,23 @@ +import { strict as assert } from "node:assert"; +import { after, describe, it } from "node:test"; + +import { testServer } from "@indiekit-test/server"; +import { testToken } from "@indiekit-test/token"; +import supertest from "supertest"; + +const server = await testServer(); +const request = supertest.agent(server); + +describe("endpoint-auth GET /auth/userinfo", () => { + it("Returns 403 error if token has no profile scope", async () => { + const result = await request + .get("/auth/userinfo") + .auth(testToken({ scope: "create" }), { type: "bearer" }) + .set("accept", "application/json"); + + assert.equal(result.status, 403); + assert.equal(result.body.error, "insufficient_scope"); + }); + + after(() => server.close()); +}); diff --git a/packages/endpoint-auth/test/unit/profile.js b/packages/endpoint-auth/test/unit/profile.js new file mode 100644 index 000000000..551764b9a --- /dev/null +++ b/packages/endpoint-auth/test/unit/profile.js @@ -0,0 +1,49 @@ +import { strict as assert } from "node:assert"; +import { describe, it } from "node:test"; + +import { mockAgent } from "@indiekit-test/mock-agent"; + +import { getProfileInformation } from "../../lib/profile.js"; + +await mockAgent("endpoint-auth"); + +describe("endpoint-auth/lib/profile", () => { + it("Discovers profile from h-card on user’s site", async () => { + const result = await getProfileInformation("https://website.example"); + + assert.deepEqual(result, { + name: "Jane Example", + url: "https://website.example", + photo: "https://website.example/photo.jpg", + }); + }); + + it("Prefers configured values, discovering the rest", async () => { + const result = await getProfileInformation("https://website.example", { + name: "Jane Doe", + }); + + assert.equal(result.name, "Jane Doe"); + assert.equal(result.photo, "https://website.example/photo.jpg"); + }); + + it("Returns configured values if user’s site can’t be fetched", async () => { + const result = await getProfileInformation("https://profile-404.example", { + name: "Jane Doe", + }); + + assert.deepEqual(result, { name: "Jane Doe" }); + }); + + it("Returns undefined if no h-card and nothing configured", async () => { + const result = await getProfileInformation("https://no-hcard.example"); + + assert.equal(result, undefined); + }); + + it("Doesn’t fetch an address that isn’t a domain name", async () => { + const result = await getProfileInformation("http://127.0.0.1/"); + + assert.equal(result, undefined); + }); +});