diff --git a/package.json b/package.json index 5bf5a1e9cb..1174427136 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,9 @@ "testFile": "cross-env JEST_TESTS=1 jest --config=jest.config.js --verbose --runInBand --bail=1 --no-cache ", "test:pdf-annotations:harness:install": "playwright install chromium", "test:pdf-annotations:harness": "playwright test --config=projects/pdf-annotations/harness/playwright.config.ts", + "start:opds-pkce-test-server": "node projects/opds-pkce-test-server/server.mjs", + "test:opds-pkce-test-server": "node --test projects/opds-pkce-test-server/server.test.mjs", + "test:opds-pkce": "cross-env JEST_TESTS=1 jest --config=jest.config.js --verbose --runInBand --bail=1 --no-cache test/main/network/opdsPkce.test.ts", "_NOT_NEEDED_postinstall_ReactARIAComponents": "node ./scripts/adobe-spectrum-react-aria-components-patch.js", "_NOT_NEEDED_postinstall": "npm run pinCompromisedColorPackage && npm run electron-build", "_NOT_NEEDED_pinCompromisedColorPackage": "(npm ls colors || echo \"NPM LS?\") && rimraf node_modules/electron-rebuild/node_modules/colors && rimraf node_modules/dir-compare/node_modules/colors", diff --git a/projects/opds-pkce-test-server/README.md b/projects/opds-pkce-test-server/README.md new file mode 100644 index 0000000000..e90f8e7941 --- /dev/null +++ b/projects/opds-pkce-test-server/README.md @@ -0,0 +1,38 @@ +# OPDS Authorization Code with PKCE test server + +This dependency-free loopback server exercises the flow proposed in +[opds-community/drafts#100](https://github.com/opds-community/drafts/issues/100). +It is for local development only and does not authenticate real users. + +Run it from the repository root: + +```powershell +npm run start:opds-pkce-test-server +``` + +Then add this protected catalog to Thorium: + +```text +http://127.0.0.1:49152/opds/v2/catalog +``` + +The authentication document contains only the proposed flow type and its +required `authenticate` and `refresh` links. The server expects the shared OPDS +client ID `http://opds-spec.org/auth/client`, the callback `opds://authorize/`, +and PKCE `S256`. The `refresh` link targets `/token`, which handles both the +authorization-code exchange and refresh-token grant. + +Because this local server uses plain HTTP, only development and CI builds of +Thorium accept its loopback endpoints. Production builds require HTTPS. + +Use a different port with an optional argument: + +```powershell +node projects\opds-pkce-test-server\server.mjs 49153 +``` + +Run its tests with: + +```powershell +npm run test:opds-pkce-test-server +``` diff --git a/projects/opds-pkce-test-server/server.mjs b/projects/opds-pkce-test-server/server.mjs new file mode 100644 index 0000000000..7994ee1ad7 --- /dev/null +++ b/projects/opds-pkce-test-server/server.mjs @@ -0,0 +1,399 @@ +// ==LICENSE-BEGIN== +// Copyright 2026 European Digital Reading Lab. All rights reserved. +// Licensed to the Readium Foundation under one or more contributor license agreements. +// Use of this source code is governed by a BSD-style license +// that can be found in the LICENSE file exposed on Github (readium) in the project repository. +// ==LICENSE-END== + +import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; +import { createServer } from "node:http"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +export const AUTHENTICATION_TYPE = "http://opds-spec.org/auth/oauth/authorization-code-pkce"; +export const CLIENT_ID = "http://opds-spec.org/auth/client"; +export const REDIRECT_URI = "opds://authorize/"; + +const HOST = "127.0.0.1"; +const DEFAULT_PORT = 49152; +const AUTHORIZATION_CODE_TTL_MS = 2 * 60 * 1000; +const ACCESS_TOKEN_TTL_MS = 10 * 60 * 1000; +const REFRESH_TOKEN_TTL_MS = 60 * 60 * 1000; +const MAX_FORM_BODY_BYTES = 16 * 1024; +const PKCE_VERIFIER_REGEXP = /^[A-Za-z0-9\-._~]{43,128}$/; + +function randomToken() { + return randomBytes(32).toString("base64url"); +} + +export function createCodeChallenge(codeVerifier) { + return createHash("sha256").update(codeVerifier, "ascii").digest("base64url"); +} + +function constantTimeEqual(left, right) { + const leftBuffer = Buffer.from(left, "ascii"); + const rightBuffer = Buffer.from(right, "ascii"); + return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer); +} + +function escapeHtml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function sendJson(response, statusCode, value, headers = {}) { + const body = `${JSON.stringify(value, undefined, 2)}\n`; + response.writeHead(statusCode, { + "Cache-Control": "no-store", + "Content-Length": Buffer.byteLength(body), + "Content-Type": "application/json; charset=utf-8", + "X-Content-Type-Options": "nosniff", + ...headers, + }); + response.end(body); +} + +function sendOAuthError(response, statusCode, error, errorDescription) { + sendJson(response, statusCode, { + error, + error_description: errorDescription, + }); +} + +function sendHtml(response, body) { + response.writeHead(200, { + "Cache-Control": "no-store", + "Content-Length": Buffer.byteLength(body), + "Content-Security-Policy": + "default-src 'none'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'", + "Content-Type": "text/html; charset=utf-8", + "Referrer-Policy": "no-referrer", + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", + }); + response.end(body); +} + +async function readForm(request) { + let body = ""; + let byteLength = 0; + for await (const chunk of request) { + byteLength += chunk.length; + if (byteLength > MAX_FORM_BODY_BYTES) { + throw new Error("Form body is too large"); + } + body += chunk.toString("utf8"); + } + return new URLSearchParams(body); +} + +function redirectUriWithParams(params) { + const url = new URL(REDIRECT_URI); + for (const [key, value] of Object.entries(params)) { + if (value !== undefined && value !== null) { + url.searchParams.set(key, value); + } + } + return url.toString(); +} + +function authorizationRequestError(params) { + if (params.get("response_type") !== "code") { + return "response_type must be code"; + } + if (params.get("client_id") !== CLIENT_ID) { + return "client_id is missing or unsupported"; + } + if (params.get("redirect_uri") !== REDIRECT_URI) { + return "redirect_uri is missing or unsupported"; + } + if (!params.get("state")) { + return "state is required"; + } + if (params.get("code_challenge_method") !== "S256") { + return "code_challenge_method must be S256"; + } + if (!/^[A-Za-z0-9_-]{43}$/.test(params.get("code_challenge") || "")) { + return "code_challenge must be a SHA-256 base64url value"; + } + return undefined; +} + +function authorizationPage(params) { + const hiddenFields = [ + "response_type", + "client_id", + "redirect_uri", + "state", + "code_challenge", + "code_challenge_method", + ] + .map((name) => ``) + .join("\n"); + + return ` + + + + + OPDS PKCE test authorization + + +

Authorize the PKCE test client?

+

This local test server does not ask for credentials.

+
+ ${hiddenFields} + + +
+ +`; +} + +function getBearerToken(request) { + return /^Bearer\s+(.+)$/i.exec(request.headers.authorization || "")?.[1]; +} + +export function createPkceTestServer({ port = DEFAULT_PORT } = {}) { + const authorizationCodes = new Map(); + const accessTokens = new Map(); + const refreshTokens = new Map(); + let server; + + const getOrigin = () => { + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("PKCE test server is not listening"); + } + return `http://${HOST}:${address.port}`; + }; + + const authenticationDocument = () => ({ + id: `${getOrigin()}/auth`, + title: "Thorium PKCE Test Catalog", + authentication: [ + { + type: AUTHENTICATION_TYPE, + links: [ + { rel: "authenticate", href: `${getOrigin()}/authorize` }, + { rel: "refresh", href: `${getOrigin()}/token` }, + ], + }, + ], + }); + + const pruneExpiredValues = () => { + const now = Date.now(); + for (const values of [authorizationCodes, accessTokens, refreshTokens]) { + for (const [key, value] of values) { + const expiresAt = typeof value === "number" ? value : value.expiresAt; + if (expiresAt <= now) { + values.delete(key); + } + } + } + }; + + const unauthorized = (response) => { + sendJson(response, 401, authenticationDocument(), { + "Content-Type": "application/opds-authentication+json; charset=utf-8", + Link: `<${getOrigin()}/auth>; rel="http://opds-spec.org/auth/document"; type="application/opds-authentication+json"`, + "WWW-Authenticate": 'Bearer realm="Thorium PKCE Test Catalog"', + }); + }; + + const issueTokens = () => { + const accessToken = randomToken(); + const refreshToken = randomToken(); + accessTokens.set(accessToken, Date.now() + ACCESS_TOKEN_TTL_MS); + refreshTokens.set(refreshToken, Date.now() + REFRESH_TOKEN_TTL_MS); + return { + access_token: accessToken, + refresh_token: refreshToken, + token_type: "Bearer", + }; + }; + + server = createServer(async (request, response) => { + pruneExpiredValues(); + const origin = getOrigin(); + const url = new URL(request.url || "/", origin); + + try { + if (request.method === "GET" && url.pathname === "/auth") { + sendJson(response, 200, authenticationDocument(), { + "Content-Type": "application/opds-authentication+json; charset=utf-8", + }); + return; + } + + if (request.method === "GET" && url.pathname === "/authorize") { + const error = authorizationRequestError(url.searchParams); + if (error) { + sendOAuthError(response, 400, "invalid_request", error); + return; + } + sendHtml(response, authorizationPage(url.searchParams)); + return; + } + + if (request.method === "POST" && url.pathname === "/authorize") { + const params = await readForm(request); + const error = authorizationRequestError(params); + if (error) { + sendOAuthError(response, 400, "invalid_request", error); + return; + } + + if (params.get("decision") !== "approve") { + response.writeHead(303, { + "Cache-Control": "no-store", + Location: redirectUriWithParams({ + error: "access_denied", + error_description: "The test user denied the authorization request.", + state: params.get("state"), + }), + }); + response.end(); + return; + } + + const code = randomToken(); + authorizationCodes.set(code, { + challenge: params.get("code_challenge"), + expiresAt: Date.now() + AUTHORIZATION_CODE_TTL_MS, + }); + response.writeHead(303, { + "Cache-Control": "no-store", + Location: redirectUriWithParams({ code, state: params.get("state") }), + }); + response.end(); + return; + } + + if (request.method === "POST" && url.pathname === "/token") { + const params = await readForm(request); + if (params.get("client_secret")) { + sendOAuthError(response, 401, "invalid_client", "The shared OPDS client does not use a secret."); + return; + } + + if (params.get("grant_type") === "authorization_code") { + const code = params.get("code") || ""; + const record = authorizationCodes.get(code); + authorizationCodes.delete(code); + if (!record || record.expiresAt <= Date.now()) { + sendOAuthError(response, 400, "invalid_grant", "The authorization code is invalid or expired."); + return; + } + if (params.get("client_id") !== CLIENT_ID || params.get("redirect_uri") !== REDIRECT_URI) { + sendOAuthError(response, 400, "invalid_grant", "The client_id or redirect_uri does not match."); + return; + } + const verifier = params.get("code_verifier") || ""; + if ( + !PKCE_VERIFIER_REGEXP.test(verifier) || + !constantTimeEqual(createCodeChallenge(verifier), record.challenge) + ) { + sendOAuthError(response, 400, "invalid_grant", "PKCE verification failed."); + return; + } + sendJson(response, 200, issueTokens()); + return; + } + + if (params.get("grant_type") === "refresh_token") { + const refreshToken = params.get("refresh_token") || ""; + const expiresAt = refreshTokens.get(refreshToken); + if (!expiresAt || expiresAt <= Date.now() || params.get("client_id") !== CLIENT_ID) { + refreshTokens.delete(refreshToken); + sendOAuthError(response, 400, "invalid_grant", "The refresh token is invalid or expired."); + return; + } + const accessToken = randomToken(); + accessTokens.set(accessToken, Date.now() + ACCESS_TOKEN_TTL_MS); + sendJson(response, 200, { + access_token: accessToken, + token_type: "Bearer", + }); + return; + } + + sendOAuthError(response, 400, "unsupported_grant_type", "Use authorization_code or refresh_token."); + return; + } + + if (request.method === "GET" && url.pathname === "/opds/v2/catalog") { + const token = getBearerToken(request); + const expiresAt = token ? accessTokens.get(token) : undefined; + if (!expiresAt || expiresAt <= Date.now()) { + if (token) { + accessTokens.delete(token); + } + unauthorized(response); + return; + } + sendJson( + response, + 200, + { + metadata: { title: "Thorium PKCE Test Catalog" }, + links: [{ rel: "self", href: `${origin}/opds/v2/catalog`, type: "application/opds+json" }], + }, + { + "Content-Type": "application/opds+json; charset=utf-8", + }, + ); + return; + } + + sendJson(response, 404, { error: "not_found" }); + } catch (error) { + if (!response.headersSent) { + sendOAuthError( + response, + 400, + "invalid_request", + error instanceof Error ? error.message : String(error), + ); + } else { + response.destroy(error instanceof Error ? error : new Error(String(error))); + } + } + }); + + return { + listen: () => + new Promise((resolveListen, rejectListen) => { + server.once("error", rejectListen); + server.listen(port, HOST, () => { + server.off("error", rejectListen); + resolveListen({ + close: () => + new Promise((resolveClose, rejectClose) => { + server.close((error) => (error ? rejectClose(error) : resolveClose())); + }), + origin: getOrigin(), + }); + }); + }), + }; +} + +export async function startPkceTestServer(options) { + return createPkceTestServer(options).listen(); +} + +const directRun = process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url)); +if (directRun) { + const port = process.argv[2] === undefined ? DEFAULT_PORT : Number.parseInt(process.argv[2], 10); + if (!Number.isInteger(port) || port < 0 || port > 65535) { + throw new Error("The port must be an integer between 0 and 65535."); + } + const app = await startPkceTestServer({ port }); + process.stdout.write(`OPDS PKCE test server: ${app.origin}/opds/v2/catalog\n`); +} diff --git a/projects/opds-pkce-test-server/server.test.mjs b/projects/opds-pkce-test-server/server.test.mjs new file mode 100644 index 0000000000..49a7858c65 --- /dev/null +++ b/projects/opds-pkce-test-server/server.test.mjs @@ -0,0 +1,165 @@ +// ==LICENSE-BEGIN== +// Copyright 2026 European Digital Reading Lab. All rights reserved. +// Licensed to the Readium Foundation under one or more contributor license agreements. +// Use of this source code is governed by a BSD-style license +// that can be found in the LICENSE file exposed on Github (readium) in the project repository. +// ==LICENSE-END== + +import assert from "node:assert/strict"; +import { randomBytes } from "node:crypto"; +import { after, before, test } from "node:test"; + +import { AUTHENTICATION_TYPE, CLIENT_ID, REDIRECT_URI, createCodeChallenge, startPkceTestServer } from "./server.mjs"; + +let app; + +before(async () => { + app = await startPkceTestServer({ port: 0 }); +}); + +after(async () => { + await app.close(); +}); + +function newPkceTransaction() { + const verifier = randomBytes(32).toString("base64url"); + return { + challenge: createCodeChallenge(verifier), + state: randomBytes(32).toString("base64url"), + verifier, + }; +} + +function authorizationParams(transaction) { + return new URLSearchParams({ + response_type: "code", + client_id: CLIENT_ID, + redirect_uri: REDIRECT_URI, + code_challenge: transaction.challenge, + code_challenge_method: "S256", + state: transaction.state, + }); +} + +async function authorize(transaction, decision = "approve") { + const params = authorizationParams(transaction); + const pageResponse = await fetch(`${app.origin}/authorize?${params}`); + assert.equal(pageResponse.status, 200); + assert.match(await pageResponse.text(), /Authorize the PKCE test client/); + + params.set("decision", decision); + const response = await fetch(`${app.origin}/authorize`, { + body: params, + method: "POST", + redirect: "manual", + }); + assert.equal(response.status, 303); + return new URL(response.headers.get("location")); +} + +function exchangeCode(code, verifier, extra = {}) { + return fetch(`${app.origin}/token`, { + body: new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: REDIRECT_URI, + client_id: CLIENT_ID, + code_verifier: verifier, + ...extra, + }), + method: "POST", + }); +} + +test("advertises only the proposed OPDS PKCE fields", async () => { + const response = await fetch(`${app.origin}/opds/v2/catalog`); + assert.equal(response.status, 401); + assert.match(response.headers.get("content-type"), /^application\/opds-authentication\+json/); + + const document = await response.json(); + assert.deepEqual(Object.keys(document.authentication[0]).sort(), ["links", "type"]); + assert.equal(document.authentication[0].type, AUTHENTICATION_TYPE); + assert.deepEqual(document.authentication[0].links, [ + { rel: "authenticate", href: `${app.origin}/authorize` }, + { rel: "refresh", href: `${app.origin}/token` }, + ]); + + for (const removedPath of ["/.well-known/oauth-authorization-server", "/health", "/publication.txt"]) { + assert.equal((await fetch(`${app.origin}${removedPath}`)).status, 404); + } +}); + +test("requires the fixed client, redirect URI, and S256", async () => { + const transaction = newPkceTransaction(); + const params = authorizationParams(transaction); + + params.set("client_id", "other-client"); + assert.equal((await fetch(`${app.origin}/authorize?${params}`)).status, 400); + params.set("client_id", CLIENT_ID); + + params.set("redirect_uri", "https://attacker.example/callback"); + assert.equal((await fetch(`${app.origin}/authorize?${params}`)).status, 400); + params.set("redirect_uri", REDIRECT_URI); + + params.set("code_challenge_method", "plain"); + assert.equal((await fetch(`${app.origin}/authorize?${params}`)).status, 400); +}); + +test("returns denial with the original state", async () => { + const transaction = newPkceTransaction(); + const callback = await authorize(transaction, "deny"); + assert.equal(callback.protocol, "opds:"); + assert.equal(callback.searchParams.get("error"), "access_denied"); + assert.equal(callback.searchParams.get("state"), transaction.state); + assert.equal(callback.searchParams.has("iss"), false); +}); + +test("invalidates a code after failed PKCE verification", async () => { + const transaction = newPkceTransaction(); + const code = (await authorize(transaction)).searchParams.get("code"); + assert.ok(code); + + const wrongVerifier = randomBytes(32).toString("base64url"); + assert.equal((await exchangeCode(code, wrongVerifier)).status, 400); + assert.equal((await exchangeCode(code, transaction.verifier)).status, 400); +}); + +test("exchanges a code, protects the catalog, and refreshes at the same endpoint", async () => { + const transaction = newPkceTransaction(); + const callback = await authorize(transaction); + const code = callback.searchParams.get("code"); + assert.ok(code); + assert.equal(callback.searchParams.get("state"), transaction.state); + assert.deepEqual([...callback.searchParams.keys()].sort(), ["code", "state"]); + + const secretResponse = await exchangeCode(code, transaction.verifier, { client_secret: "secret" }); + assert.equal(secretResponse.status, 401); + + const retryTransaction = newPkceTransaction(); + const retryCode = (await authorize(retryTransaction)).searchParams.get("code"); + assert.ok(retryCode); + const tokenResponse = await exchangeCode(retryCode, retryTransaction.verifier); + assert.equal(tokenResponse.status, 200); + assert.match(tokenResponse.headers.get("cache-control"), /no-store/); + const token = await tokenResponse.json(); + assert.ok(token.access_token); + assert.ok(token.refresh_token); + assert.equal(token.token_type, "Bearer"); + + const catalogResponse = await fetch(`${app.origin}/opds/v2/catalog`, { + headers: { Authorization: `Bearer ${token.access_token}` }, + }); + assert.equal(catalogResponse.status, 200); + assert.match(catalogResponse.headers.get("content-type"), /^application\/opds\+json/); + + const refreshResponse = await fetch(`${app.origin}/token`, { + body: new URLSearchParams({ + grant_type: "refresh_token", + client_id: CLIENT_ID, + refresh_token: token.refresh_token, + }), + method: "POST", + }); + assert.equal(refreshResponse.status, 200); + assert.ok((await refreshResponse.json()).access_token); +}); diff --git a/src/main/network/http.ts b/src/main/network/http.ts index 63e51f3945..55dd0be2b1 100644 --- a/src/main/network/http.ts +++ b/src/main/network/http.ts @@ -36,6 +36,7 @@ import { IHttpGetResult, THttpGetCallback, THttpOptions, THttpResponse, } from "readium-desktop/common/utils/http"; import { decryptPersist, encryptPersist } from "readium-desktop/main/fs/persistCrypto"; +import { createOpdsPkceRefreshTokenRequest } from "readium-desktop/main/network/opdsPkce"; import { tryCatch, tryCatchSync } from "readium-desktop/utils/tryCatch"; import { diMainGet, opdsAuthFilePath } from "../di"; @@ -92,6 +93,7 @@ export interface IOpdsAuthenticationToken { opdsAuthenticationUrl?: string; // application/opds-authentication+json refreshUrl?: string; authenticateUrl?: string; + pkce?: boolean; accessToken?: string; refreshToken?: string; tokenType?: string; @@ -681,12 +683,16 @@ const httpGetUnauthorizedRefresh = options.headers = options.headers instanceof Headers ? options.headers : new Headers(options.headers || {}); - (options.headers as Headers).set("Content-Type", "application/json"); - - options.body = JSON.stringify({ - refresh_token: refreshToken, - grant_type: "refresh_token", - }); + if (auth.pkce) { + (options.headers as Headers).set("Content-Type", "application/x-www-form-urlencoded"); + options.body = createOpdsPkceRefreshTokenRequest(refreshToken); + } else { + (options.headers as Headers).set("Content-Type", "application/json"); + options.body = JSON.stringify({ + refresh_token: refreshToken, + grant_type: "refresh_token", + }); + } const httpPostResponse = await httpPost(refreshUrl, options); if (httpPostResponse.isSuccess) { diff --git a/src/main/network/opdsPkce.ts b/src/main/network/opdsPkce.ts new file mode 100644 index 0000000000..c98483da25 --- /dev/null +++ b/src/main/network/opdsPkce.ts @@ -0,0 +1,187 @@ +// ==LICENSE-BEGIN== +// Copyright 2026 European Digital Reading Lab. All rights reserved. +// Licensed to the Readium Foundation under one or more contributor license agreements. +// Use of this source code is governed by a BSD-style license +// that can be found in the LICENSE file exposed on Github (readium) in the project repository. +// ==LICENSE-END== + +import { createHash, randomBytes } from "node:crypto"; + +export const OPDS_AUTHORIZATION_CODE_PKCE_TYPE = + "http://opds-spec.org/auth/oauth/authorization-code-pkce"; +export const OPDS_OAUTH_CLIENT_ID = "http://opds-spec.org/auth/client"; +export const OPDS_OAUTH_REDIRECT_URI = "opds://authorize/"; + +const PKCE_TRANSACTION_MAX_AGE_MS = 5 * 60 * 1000; +const PKCE_VERIFIER_REGEXP = /^[A-Za-z0-9\-._~]{43,128}$/; + +function assertSecureOAuthEndpoint(value: string, name: string, allowInsecureLoopback: boolean): URL { + const url = new URL(value); + const isLoopbackHttp = url.protocol === "http:" && + (url.hostname === "localhost" || url.hostname === "[::1]" || /^127(?:\.\d{1,3}){3}$/.test(url.hostname)); + if (url.protocol !== "https:" && !(allowInsecureLoopback && isLoopbackHttp)) { + const exception = allowInsecureLoopback ? ", except on a loopback address" : ""; + throw new Error(`The PKCE ${name} must use HTTPS${exception}.`); + } + return url; +} + +export interface IOpdsPkceConfiguration { + allowInsecureLoopback?: boolean; + authorizationUrl: string; + tokenUrl: string; +} + +export interface IOpdsPkceTransaction extends IOpdsPkceConfiguration { + authorizationRequestUrl: string; + clientId: typeof OPDS_OAUTH_CLIENT_ID; + codeVerifier: string; + createdAt: number; + redirectUri: typeof OPDS_OAUTH_REDIRECT_URI; + state: string; +} + +export interface IOpdsPkceCallback { + code?: string; + error?: string; + error_description?: string; + state?: string; +} + +export interface IOpdsPkceTokenResponse { + accessToken: string; + refreshToken?: string; + tokenType: string; +} + +export type TOpdsPkceTokenPost = (url: string, body: string) => Promise; + +export function createOpdsPkceCodeChallenge(codeVerifier: string): string { + if (!PKCE_VERIFIER_REGEXP.test(codeVerifier)) { + throw new Error("The PKCE code verifier must contain 43 to 128 RFC 7636 unreserved characters."); + } + + return createHash("sha256") + .update(codeVerifier, "ascii") + .digest("base64url"); +} + +export function createOpdsPkceTransaction( + configuration: IOpdsPkceConfiguration, + createdAt = Date.now(), +): IOpdsPkceTransaction { + if (!configuration.authorizationUrl || !configuration.tokenUrl) { + throw new Error("The PKCE authenticate and refresh links are required."); + } + + const authorizationUrl = assertSecureOAuthEndpoint( + configuration.authorizationUrl, + "authorization endpoint", + !!configuration.allowInsecureLoopback, + ); + assertSecureOAuthEndpoint( + configuration.tokenUrl, + "token endpoint", + !!configuration.allowInsecureLoopback, + ); + + const codeVerifier = randomBytes(32).toString("base64url"); + const state = randomBytes(32).toString("base64url"); + authorizationUrl.searchParams.set("response_type", "code"); + authorizationUrl.searchParams.set("client_id", OPDS_OAUTH_CLIENT_ID); + authorizationUrl.searchParams.set("redirect_uri", OPDS_OAUTH_REDIRECT_URI); + authorizationUrl.searchParams.set("code_challenge", createOpdsPkceCodeChallenge(codeVerifier)); + authorizationUrl.searchParams.set("code_challenge_method", "S256"); + authorizationUrl.searchParams.set("state", state); + + return { + ...configuration, + authorizationRequestUrl: authorizationUrl.toString(), + clientId: OPDS_OAUTH_CLIENT_ID, + codeVerifier, + createdAt, + redirectUri: OPDS_OAUTH_REDIRECT_URI, + state, + }; +} + +export function validateOpdsPkceCallback( + callback: IOpdsPkceCallback, + transaction: IOpdsPkceTransaction, + now = Date.now(), +): string { + if (now - transaction.createdAt > PKCE_TRANSACTION_MAX_AGE_MS) { + throw new Error("The PKCE authorization transaction has expired."); + } + if (!callback.state || callback.state !== transaction.state) { + throw new Error("The OAuth callback state does not match the PKCE transaction."); + } + if (callback.error) { + const description = callback.error_description ? `: ${callback.error_description}` : ""; + throw new Error(`OAuth authorization failed (${callback.error})${description}`); + } + if (!callback.code) { + throw new Error("The OAuth callback does not contain an authorization code."); + } + + return callback.code; +} + +export function createOpdsPkceTokenRequest( + transaction: IOpdsPkceTransaction, + authorizationCode: string, +): string { + return new URLSearchParams({ + grant_type: "authorization_code", + code: authorizationCode, + redirect_uri: transaction.redirectUri, + client_id: transaction.clientId, + code_verifier: transaction.codeVerifier, + }).toString(); +} + +export function createOpdsPkceRefreshTokenRequest(refreshToken: string, clientId = OPDS_OAUTH_CLIENT_ID): string { + return new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: clientId, + }).toString(); +} + +export function parseOpdsPkceTokenResponse(value: unknown): IOpdsPkceTokenResponse { + if (!value || typeof value !== "object") { + throw new Error("The OAuth token endpoint returned an invalid response."); + } + + const response = value as Record; + if (typeof response.error === "string") { + const description = typeof response.error_description === "string" + ? `: ${response.error_description}` + : ""; + throw new Error(`OAuth token exchange failed (${response.error})${description}`); + } + if (typeof response.access_token !== "string" || !response.access_token) { + throw new Error("The OAuth token endpoint did not return an access_token."); + } + + return { + accessToken: response.access_token, + refreshToken: typeof response.refresh_token === "string" ? response.refresh_token : undefined, + tokenType: typeof response.token_type === "string" && response.token_type + ? response.token_type + : "Bearer", + }; +} + +export async function exchangeOpdsPkceAuthorizationCode( + transaction: IOpdsPkceTransaction, + callback: IOpdsPkceCallback, + postToken: TOpdsPkceTokenPost, +): Promise { + const authorizationCode = validateOpdsPkceCallback(callback, transaction); + const response = await postToken( + transaction.tokenUrl, + createOpdsPkceTokenRequest(transaction, authorizationCode), + ); + return parseOpdsPkceTokenResponse(response); +} diff --git a/src/main/redux/sagas/auth.ts b/src/main/redux/sagas/auth.ts index f0873b72eb..70c521911b 100644 --- a/src/main/redux/sagas/auth.ts +++ b/src/main/redux/sagas/auth.ts @@ -39,6 +39,12 @@ import { IOpdsAuthenticationToken, wipeAuthenticationTokenStorage, } from "readium-desktop/main/network/http"; import { ContentType } from "readium-desktop/utils/contentType"; +import { + IOpdsPkceTransaction, + OPDS_AUTHORIZATION_CODE_PKCE_TYPE, + createOpdsPkceTransaction, + exchangeOpdsPkceAuthorizationCode, +} from "readium-desktop/main/network/opdsPkce"; import { tryCatch, tryCatchSync } from "readium-desktop/utils/tryCatch"; // eslint-disable-next-line local-rules/typed-redux-saga-use-typed-effects import { all, call, cancel, delay, join, put, race, spawn } from "redux-saga/effects"; @@ -85,8 +91,10 @@ debug("_"); type TLinkType = "refresh" | "authenticate"; type TLabelName = "login" | "password"; type TDigestInfo = "realm" | "nonce" | "qop" | "algorithm"; -type TAuthName = "id" | "access_token" | "refresh_token" | "token_type"; -type TAuthenticationType = "http://opds-spec.org/auth/oauth/password" +type TAuthName = "id" | "access_token" | "refresh_token" | "token_type" + | "code" | "state" | "error" | "error_description"; +type TAuthenticationType = typeof OPDS_AUTHORIZATION_CODE_PKCE_TYPE + | "http://opds-spec.org/auth/oauth/password" | "http://opds-spec.org/auth/oauth/password/apiapp" | "http://opds-spec.org/auth/oauth/implicit" | "http://opds-spec.org/auth/basic" @@ -95,6 +103,7 @@ type TAuthenticationType = "http://opds-spec.org/auth/oauth/password" | "http://librarysimplified.org/authtype/SAML-2.0"; const AUTHENTICATION_TYPE: TAuthenticationType[] = [ + OPDS_AUTHORIZATION_CODE_PKCE_TYPE, "http://opds-spec.org/auth/oauth/password", "http://opds-spec.org/auth/oauth/password/apiapp", "http://opds-spec.org/auth/oauth/implicit", @@ -132,7 +141,23 @@ const opdsAuthFlow = } debug("authentication doc parsed", authParsed); - const browserUrl = getHtmlAuthenticationUrl(authParsed); + let pkceTransaction: IOpdsPkceTransaction | undefined; + if (authParsed.authenticationType === OPDS_AUTHORIZATION_CODE_PKCE_TYPE) { + pkceTransaction = tryCatchSync( + () => createOpdsPkceTransaction({ + allowInsecureLoopback: ENABLE_DEV_TOOLS, + authorizationUrl: authParsed.links?.authenticate?.url || "", + tokenUrl: authParsed.links?.refresh?.url || "", + }), + filename_, + ); + if (!pkceTransaction) { + debug("invalid OPDS PKCE authenticate or refresh link"); + return; + } + } + + const browserUrl = getHtmlAuthenticationUrl(authParsed, pkceTransaction); if (!browserUrl) { debug("no valid authentication html url"); return; @@ -143,8 +168,9 @@ const opdsAuthFlow = id: authParsed?.id || undefined, opdsAuthenticationUrl: baseUrl, tokenType: "Bearer", - refreshUrl: authParsed?.links?.refresh?.url || undefined, - authenticateUrl: authParsed?.links?.authenticate?.url || undefined, + refreshUrl: pkceTransaction?.tokenUrl || authParsed?.links?.refresh?.url || undefined, + authenticateUrl: pkceTransaction?.authorizationUrl || authParsed?.links?.authenticate?.url || undefined, + pkce: !!pkceTransaction, }; debug("authentication credential config", authCredentials); yield* callTyped(httpSetAuthenticationToken, authCredentials); @@ -155,7 +181,7 @@ const opdsAuthFlow = // yield delay(1000); return { - request: parseRequestFromCustomProtocol(parsedRequest.request), + request: parseRequestFromCustomProtocol(parsedRequest.request, authParsed.authenticationType), callback: parsedRequest.callback, }; }); @@ -236,6 +262,7 @@ const opdsAuthFlow = opdsCustomProtocolRequestParsed, authCredentials, authParsed.authenticationType, + pkceTransaction, ); callback({ @@ -245,6 +272,10 @@ const opdsAuthFlow = if (err instanceof Error) { debug("OPDS auth err", err.message); + if (authParsed.authenticationType === OPDS_AUTHORIZATION_CODE_PKCE_TYPE) { + yield put(authActions.cancel.build()); + } + return; } else { yield put(historyActions.refresh.build()); @@ -386,6 +417,7 @@ async function opdsSetAuthCredentials( opdsCustomProtocolRequestParsed: IParseRequestFromCustomProtocol, authCredentials: IOpdsAuthenticationToken, authenticationType: TAuthenticationType, + pkceTransaction?: IOpdsPkceTransaction, ): Promise<[undefined, Error]> { if (!opdsCustomProtocolRequestParsed) { @@ -538,6 +570,46 @@ async function opdsSetAuthCredentials( if (method === "GET") { + if (authenticationType === OPDS_AUTHORIZATION_CODE_PKCE_TYPE) { + if (!pkceTransaction) { + return [, new Error("missing PKCE authentication transaction")]; + } + + try { + const tokenResponse = await exchangeOpdsPkceAuthorizationCode( + pkceTransaction, + data, + async (tokenUrl, body) => { + const headers = new Headers(); + headers.set("Accept", "application/json"); + headers.set("Content-Type", ContentType.FormUrlEncoded); + const response = await httpPost(tokenUrl, { + body, + headers, + }); + const responseJson = await response.response?.json(); + if (!response.isSuccess) { + throw new Error(`OAuth token endpoint failed with HTTP ${response.statusCode || 0}`); + } + return responseJson; + }, + ); + const tokenType = tokenResponse.tokenType.charAt(0).toUpperCase() + + tokenResponse.tokenType.slice(1); + await httpSetAuthenticationToken({ + ...authCredentials, + accessToken: tokenResponse.accessToken, + pkce: true, + refreshToken: tokenResponse.refreshToken, + refreshUrl: pkceTransaction.tokenUrl, + tokenType, + }); + return [, undefined]; + } catch (error) { + return [, error instanceof Error ? error : new Error(String(error))]; + } + } + const newCredentials = { ...authCredentials, id: data.id || searchParams?.get("id") || authCredentials.id || undefined, @@ -571,9 +643,13 @@ const _implicitAuthData = { authenticationDocumentId: "", nonce: "" }; const setAndGetImplicitNonceForImplicitAuthentication = () => (_implicitAuthData.nonce = nanoid(16), _implicitAuthData.nonce); const getImplicitAuthData = () => _implicitAuthData; -function getHtmlAuthenticationUrl(auth: IOPDSAuthDocParsed) { +function getHtmlAuthenticationUrl(auth: IOPDSAuthDocParsed, pkceTransaction?: IOpdsPkceTransaction) { let browserUrl: string; switch (auth.authenticationType) { + case OPDS_AUTHORIZATION_CODE_PKCE_TYPE: { + browserUrl = pkceTransaction?.authorizationRequestUrl || ""; + break; + } case "http://opds-spec.org/auth/oauth/implicit": { try { if (!auth.links?.authenticate?.url) { @@ -718,12 +794,15 @@ function opdsAuthDocConverter(doc: OPDSAuthenticationDoc, baseUrl: string): IOPD } const authentication = doc.Authentication.find((v) => AUTHENTICATION_TYPE.includes(v.Type as any)); + if (!authentication) { + debug("OPDS Authentication Document does not contain a supported authentication type."); + return undefined; + } const links = Array.isArray(authentication.Links) ? authentication.Links.reduce((pv, cv) => { - const rel = (cv.Rel || []) - .reduce((pvRel, cvRel) => pvRel || LINK_TYPE.find((v) => v === cvRel) || "", "") as TLinkType; + const rel = (cv.Rel || []).find((rel) => LINK_TYPE.find((v) => v === rel)); if ( rel @@ -1043,8 +1122,8 @@ interface IParseRequestFromCustomProtocol { [key in T & string]?: string; }; } -function parseRequestFromCustomProtocol(req: Electron.ProtocolRequest) - : IParseRequestFromCustomProtocol | undefined { +function parseRequestFromCustomProtocol(req: Electron.ProtocolRequest, authenticationType: TAuthenticationType) + : IParseRequestFromCustomProtocol | undefined { debug("########"); debug("opds:// request:", req); @@ -1145,26 +1224,30 @@ function parseRequestFromCustomProtocol(req: Electron.ProtocolRequest) // query component of the Redirection URI, unless a different Response Mode was specified. if (data.error) { debug("OAuth Error Response", "error:", { error: data.error, error_description: data.error_description }); - return undefined; + if (authenticationType !== OPDS_AUTHORIZATION_CODE_PKCE_TYPE) { + return undefined; + } } - const implicitAuthData = getImplicitAuthData(); + if (authenticationType === "http://opds-spec.org/auth/oauth/implicit") { + const implicitAuthData = getImplicitAuthData(); - if (data.id && implicitAuthData.authenticationDocumentId && data.id !== implicitAuthData.authenticationDocumentId) { - debug("OAuth 2.0 implicit grant flow ID mismatch!", "expected (auth doc):", implicitAuthData.authenticationDocumentId, "actual (URL query param):", data.id); - return undefined; - // see https://github.com/edrlab/thorium-reader/pull/2510 - } else { - debug("OAuth 2.0 implicit grant flow ID match or missing (URL query param and/or auth doc) ==> pass.", "expected (auth doc):", implicitAuthData.authenticationDocumentId, "actual (URL query param):", data.id); - } + if (data.id && implicitAuthData.authenticationDocumentId && data.id !== implicitAuthData.authenticationDocumentId) { + debug("OAuth 2.0 implicit grant flow ID mismatch!", "expected (auth doc):", implicitAuthData.authenticationDocumentId, "actual (URL query param):", data.id); + return undefined; + // see https://github.com/edrlab/thorium-reader/pull/2510 + } else { + debug("OAuth 2.0 implicit grant flow ID match or missing (URL query param and/or auth doc) ==> pass.", "expected (auth doc):", implicitAuthData.authenticationDocumentId, "actual (URL query param):", data.id); + } - if (data.state && implicitAuthData.nonce && data.state !== implicitAuthData.nonce) { - debug("OAuth 2.0 implicit grant flow NONCE mismatch!", "expected (auth doc):", implicitAuthData.nonce, "actual (URL query param):", data.state); - return undefined; - // https://auth0.com/docs/secure/attack-protection/state-parameters - // https://github.com/edrlab/thorium-reader/issues/2506 - } else { - debug("OAuth 2.0 implicit grant flow NONCE match or missing (URL query param and/or auth doc) ==> pass.", "expected (auth doc):", implicitAuthData.nonce, "actual (URL query param):", data.state); + if (data.state && implicitAuthData.nonce && data.state !== implicitAuthData.nonce) { + debug("OAuth 2.0 implicit grant flow NONCE mismatch!", "expected (auth doc):", implicitAuthData.nonce, "actual (URL query param):", data.state); + return undefined; + // https://auth0.com/docs/secure/attack-protection/state-parameters + // https://github.com/edrlab/thorium-reader/issues/2506 + } else { + debug("OAuth 2.0 implicit grant flow NONCE match or missing (URL query param and/or auth doc) ==> pass.", "expected (auth doc):", implicitAuthData.nonce, "actual (URL query param):", data.state); + } } return { diff --git a/test/main/network/opdsPkce.test.ts b/test/main/network/opdsPkce.test.ts new file mode 100644 index 0000000000..80d68973fb --- /dev/null +++ b/test/main/network/opdsPkce.test.ts @@ -0,0 +1,255 @@ +// ==LICENSE-BEGIN== +// Copyright 2026 European Digital Reading Lab. All rights reserved. +// Licensed to the Readium Foundation under one or more contributor license agreements. +// Use of this source code is governed by a BSD-style license +// that can be found in the LICENSE file exposed on Github (readium) in the project repository. +// ==LICENSE-END== + +import { ChildProcess, spawn } from "node:child_process"; +import { once } from "node:events"; +import { resolve } from "node:path"; + +import { describe, expect, test } from "@jest/globals"; + +import { + IOpdsPkceCallback, + OPDS_AUTHORIZATION_CODE_PKCE_TYPE, + OPDS_OAUTH_CLIENT_ID, + OPDS_OAUTH_REDIRECT_URI, + createOpdsPkceCodeChallenge, + createOpdsPkceRefreshTokenRequest, + createOpdsPkceTokenRequest, + createOpdsPkceTransaction, + exchangeOpdsPkceAuthorizationCode, + parseOpdsPkceTokenResponse, + validateOpdsPkceCallback, +} from "readium-desktop/main/network/opdsPkce"; +import { OPDSAuthenticationDoc } from "@r2-opds-js/opds/opds2/opds2-authentication-doc"; +import { TaJsonDeserialize } from "@r2-lcp-js/serializable"; + +describe("OPDS Authorization Code with PKCE", () => { + test("generates the RFC 7636 S256 reference challenge", () => { + const verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; + expect(createOpdsPkceCodeChallenge(verifier)).toBe("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"); + }); + + test("creates exactly the proposed shared-client authorization request", () => { + const transaction = createOpdsPkceTransaction( + { + authorizationUrl: "https://login.example/authorize", + tokenUrl: "https://login.example/token", + }, + 1000, + ); + const url = new URL(transaction.authorizationRequestUrl); + + expect(transaction.codeVerifier).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(transaction.state).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(Object.fromEntries(url.searchParams)).toEqual({ + response_type: "code", + client_id: OPDS_OAUTH_CLIENT_ID, + redirect_uri: OPDS_OAUTH_REDIRECT_URI, + code_challenge: createOpdsPkceCodeChallenge(transaction.codeVerifier), + code_challenge_method: "S256", + state: transaction.state, + }); + expect(transaction.authorizationRequestUrl).not.toContain(transaction.codeVerifier); + expect(() => + createOpdsPkceTransaction({ + authorizationUrl: "http://login.example/authorize", + tokenUrl: "http://login.example/token", + }), + ).toThrow(/HTTPS/); + expect(() => + createOpdsPkceTransaction({ + authorizationUrl: "http://127.0.0.1/authorize", + tokenUrl: "http://127.0.0.1/token", + }), + ).toThrow(/HTTPS/); + expect(() => + createOpdsPkceTransaction({ + allowInsecureLoopback: true, + authorizationUrl: "http://127.0.0.1/authorize", + tokenUrl: "http://127.0.0.1/token", + }), + ).not.toThrow(); + expect(() => + createOpdsPkceTransaction({ + authorizationUrl: "https://login.example/authorize", + tokenUrl: "", + }), + ).toThrow(/links are required/); + }); + + test("validates callback state, errors, code, and transaction age", () => { + const transaction = createOpdsPkceTransaction( + { + authorizationUrl: "https://login.example/authorize", + tokenUrl: "https://login.example/token", + }, + 1000, + ); + const validCallback: IOpdsPkceCallback = { + code: "authorization-code", + state: transaction.state, + }; + + expect(validateOpdsPkceCallback(validCallback, transaction, 2000)).toBe("authorization-code"); + expect(() => validateOpdsPkceCallback({ ...validCallback, state: "wrong" }, transaction, 2000)).toThrow( + /state/, + ); + expect(() => validateOpdsPkceCallback({ state: transaction.state }, transaction, 2000)).toThrow(/code/); + expect(() => + validateOpdsPkceCallback( + { + error: "access_denied", + state: transaction.state, + }, + transaction, + 2000, + ), + ).toThrow(/access_denied/); + expect(() => validateOpdsPkceCallback(validCallback, transaction, 5 * 60 * 1000 + 1001)).toThrow(/expired/); + }); + + test("builds the proposed token request and parses the token response", () => { + const transaction = createOpdsPkceTransaction({ + authorizationUrl: "https://login.example/authorize", + tokenUrl: "https://login.example/token", + }); + const request = new URLSearchParams(createOpdsPkceTokenRequest(transaction, "code-value")); + + expect(Object.fromEntries(request)).toEqual({ + grant_type: "authorization_code", + code: "code-value", + redirect_uri: OPDS_OAUTH_REDIRECT_URI, + client_id: OPDS_OAUTH_CLIENT_ID, + code_verifier: transaction.codeVerifier, + }); + expect(request.has("client_secret")).toBe(false); + expect( + parseOpdsPkceTokenResponse({ + access_token: "access-token", + refresh_token: "refresh-token", + token_type: "bearer", + expires_in: 600, + }), + ).toEqual({ + accessToken: "access-token", + refreshToken: "refresh-token", + tokenType: "bearer", + }); + expect(Object.fromEntries(new URLSearchParams(createOpdsPkceRefreshTokenRequest("refresh-token")))).toEqual({ + grant_type: "refresh_token", + refresh_token: "refresh-token", + client_id: OPDS_OAUTH_CLIENT_ID, + }); + expect(() => parseOpdsPkceTokenResponse({ error: "invalid_grant" })).toThrow(/invalid_grant/); + }); + + test("completes the minimal flow against the local test server", async () => { + const serverPath = resolve(__dirname, "../../../projects/opds-pkce-test-server/server.mjs"); + const server = spawn(process.execPath, [serverPath, "0"], { + stdio: ["ignore", "pipe", "pipe"], + }); + + try { + const origin = await waitForServerOrigin(server); + const unauthorizedResponse = await fetch(`${origin}/opds/v2/catalog`); + expect(unauthorizedResponse.status).toBe(401); + const authenticationDocument = (await unauthorizedResponse.json()) as { + id: string; + authentication: Array<{ + type: string; + links: Array<{ rel: string; href: string }>; + }>; + }; + const parsedDocument = TaJsonDeserialize(authenticationDocument, OPDSAuthenticationDoc); + expect(parsedDocument.Authentication[0].Type).toBe(OPDS_AUTHORIZATION_CODE_PKCE_TYPE); + expect(Object.keys(authenticationDocument.authentication[0]).sort()).toEqual(["links", "type"]); + + const authentication = authenticationDocument.authentication[0]; + const authorizationUrl = authentication.links.find((link) => link.rel === "authenticate")?.href; + const tokenUrl = authentication.links.find((link) => link.rel === "refresh")?.href; + expect(authorizationUrl).toBeDefined(); + expect(tokenUrl).toBeDefined(); + + const transaction = createOpdsPkceTransaction({ + allowInsecureLoopback: true, + authorizationUrl: authorizationUrl || "", + tokenUrl: tokenUrl || "", + }); + expect((await fetch(transaction.authorizationRequestUrl)).status).toBe(200); + + const authorizationParams = new URL(transaction.authorizationRequestUrl).searchParams; + authorizationParams.set("decision", "approve"); + const authorizationResponse = await fetch(`${origin}/authorize`, { + body: authorizationParams, + method: "POST", + redirect: "manual", + }); + expect(authorizationResponse.status).toBe(303); + const callbackUrl = new URL(authorizationResponse.headers.get("location")); + expect([...callbackUrl.searchParams.keys()].sort()).toEqual(["code", "state"]); + + const callback = Object.fromEntries(callbackUrl.searchParams) as IOpdsPkceCallback; + const token = await exchangeOpdsPkceAuthorizationCode(transaction, callback, async (url, body) => { + const response = await fetch(url, { + body, + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + method: "POST", + }); + return response.json(); + }); + expect(token.accessToken).toBeTruthy(); + expect(token.refreshToken).toBeTruthy(); + + const catalogResponse = await fetch(`${origin}/opds/v2/catalog`, { + headers: { Authorization: `Bearer ${token.accessToken}` }, + }); + expect(catalogResponse.status).toBe(200); + } finally { + await stopServer(server); + } + }, 15000); +}); + +function waitForServerOrigin(server: ChildProcess): Promise { + return new Promise((resolveOrigin, rejectOrigin) => { + let stdout = ""; + let stderr = ""; + const timeout = setTimeout(() => { + rejectOrigin(new Error(`Timed out waiting for the OPDS PKCE test server. ${stderr}`)); + }, 5000); + + server.stderr?.on("data", (chunk) => { + stderr += chunk.toString(); + }); + server.stdout?.on("data", (chunk) => { + stdout += chunk.toString(); + const match = /OPDS PKCE test server: (http:\/\/127\.0\.0\.1:\d+)\//.exec(stdout); + if (match) { + clearTimeout(timeout); + resolveOrigin(match[1]); + } + }); + server.once("error", (error) => { + clearTimeout(timeout); + rejectOrigin(error); + }); + server.once("exit", (code) => { + if (!stdout.includes("OPDS PKCE test server:")) { + clearTimeout(timeout); + rejectOrigin(new Error(`OPDS PKCE test server exited with ${code}. ${stderr}`)); + } + }); + }); +} + +async function stopServer(server: ChildProcess): Promise { + if (server.exitCode !== null || server.signalCode !== null) { + return; + } + server.kill(); + await once(server, "exit"); +}