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 ` + +
+ + +This local test server does not ask for credentials.
+ + +`; +} + +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