Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions helpers/config/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,5 +71,6 @@ export const testConfig = async (options) => {
"@indiekit/endpoint-webmention-io": {
token: "abcd1234",
},
"@indiekit/endpoint-auth": options["@indiekit/endpoint-auth"],
Comment thread
paulrobertlloyd marked this conversation as resolved.
};
};
11 changes: 11 additions & 0 deletions helpers/fixtures/html/profile.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Jane Example</title>
</head>
<body class="h-card">
<img class="u-photo" src="/photo.jpg" alt="">
<h1><a class="p-name u-url" href="https://website.example">Jane Example</a></h1>
<p class="p-note">Writes about cheese sandwiches.</p>
</body>
</html>
25 changes: 25 additions & 0 deletions helpers/mock-agent/endpoint-auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
7 changes: 4 additions & 3 deletions packages/endpoint-auth/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
21 changes: 18 additions & 3 deletions packages/endpoint-auth/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -23,25 +24,34 @@ 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);
router.post("/new-password", passwordValidator, passwordController.post);

// 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);
Expand Down Expand Up @@ -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`;
}
}
}
2 changes: 1 addition & 1 deletion packages/endpoint-auth/lib/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
20 changes: 16 additions & 4 deletions packages/endpoint-auth/lib/controllers/authorization.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down Expand Up @@ -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());
},
};
});
1 change: 1 addition & 0 deletions packages/endpoint-auth/lib/controllers/metadata.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
19 changes: 15 additions & 4 deletions packages/endpoint-auth/lib/controllers/token.js
Original file line number Diff line number Diff line change
@@ -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
*
Expand All @@ -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 }) };
Expand All @@ -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());
},
};
});
44 changes: 44 additions & 0 deletions packages/endpoint-auth/lib/controllers/userinfo.js
Original file line number Diff line number Diff line change
@@ -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);
}
};
93 changes: 93 additions & 0 deletions packages/endpoint-auth/lib/profile.js
Original file line number Diff line number Diff line change
@@ -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<object>} 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<object|undefined>} 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;
};
2 changes: 1 addition & 1 deletion packages/endpoint-auth/lib/scope.js
Original file line number Diff line number Diff line change
@@ -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 },
Expand Down
Loading
Loading