Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Client metadata #760

Merged
merged 3 commits into from
Aug 25, 2024
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
8 changes: 6 additions & 2 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand All @@ -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
Expand Down
11 changes: 11 additions & 0 deletions helpers/mock-agent/endpoint-auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
87 changes: 64 additions & 23 deletions packages/endpoint-auth/lib/client.js
Original file line number Diff line number Diff line change
@@ -1,37 +1,27 @@
import { mf2 } from "microformats-parser";

/**
* Get client information
* @param {string} client_id - Client URL
* @returns {Promise<object>} 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;
}

Expand All @@ -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<object>} 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);
}
};
12 changes: 12 additions & 0 deletions packages/endpoint-auth/test/unit/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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/");

Expand Down
13 changes: 13 additions & 0 deletions packages/indiekit/lib/controllers/client.js
Original file line number Diff line number Diff line change
@@ -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,
});
};
6 changes: 4 additions & 2 deletions packages/indiekit/lib/indieauth.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions packages/indiekit/lib/routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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());
Expand Down
24 changes: 24 additions & 0 deletions packages/indiekit/test/integration/200-client.js
Original file line number Diff line number Diff line change
@@ -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));
});
});
Loading