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 @@ + + +
+
+ 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