diff --git a/package.json b/package.json index 0efe8d0..dd10791 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,8 @@ "url": "git+https://github.com/solid-contrib/reactive-authentication.git" }, "scripts": { - "build": "tsc" + "build": "tsc", + "test": "node --test --experimental-strip-types \"test/**/*.test.ts\"" }, "license": "MIT", "dependencies": { diff --git a/src/DPoPTokenProvider.ts b/src/DPoPTokenProvider.ts index 4760ae2..3b20713 100644 --- a/src/DPoPTokenProvider.ts +++ b/src/DPoPTokenProvider.ts @@ -4,6 +4,34 @@ import type { GetCodeCallback } from "./GetCodeCallback.js" import type { TokenProvider } from "./TokenProvider.js" import type { GetIssuerCallback } from "./GetIssuerCallback.js" +/** + * Whether an issuer is a loopback (localhost / 127.0.0.0/8 / ::1) `http:` origin. + * + * oauth4webapi v3 enforces HTTPS on every request by default; for a local dev + * Solid server (e.g. a Community Solid Server on `http://localhost:3000`) every + * OIDC HTTP call (discovery, dynamic client registration, the token endpoint) + * would throw `only requests to HTTPS are allowed`. We relax that enforcement + * for — and only for — loopback `http:` issuers, leaving HTTPS strictly required + * for every other (production) issuer. + * + * @see https://www.rfc-editor.org/rfc/rfc8252#section-7.3 (loopback redirect URIs) + */ +export function isLoopbackHttpIssuer(issuer: URL): boolean { + if (issuer.protocol !== "http:") { + return false + } + + const hostname = issuer.hostname.toLowerCase() + return ( + hostname === "localhost" || + hostname === "127.0.0.1" || + hostname === "[::1]" || + hostname === "::1" || + hostname.endsWith(".localhost") || + hostname.startsWith("127.") + ) +} + export class DPoPTokenProvider implements TokenProvider { readonly #getCode: GetCodeCallback readonly #callbackUri: string @@ -22,10 +50,17 @@ export class DPoPTokenProvider implements TokenProvider { async upgrade(request: Request): Promise { const issuer = await this.#getIssuer(request) - const discoveryResponse = await oauth.discoveryRequest(issuer, {signal: request.signal}) + // Allow plain-HTTP OIDC calls for (and only for) loopback dev issuers. + // oauth4webapi enforces HTTPS by default, which otherwise breaks the + // auth-code + PKCE + DPoP flow against a local server on http://localhost. + const insecureOption: { [oauth.allowInsecureRequests]?: true } = isLoopbackHttpIssuer(issuer) + ? {[oauth.allowInsecureRequests]: true} + : {} + + const discoveryResponse = await oauth.discoveryRequest(issuer, {signal: request.signal, ...insecureOption}) const authorizationServer = await oauth.processDiscoveryResponse(issuer, discoveryResponse) - const registrationResponse = await oauth.dynamicClientRegistrationRequest(authorizationServer, {redirect_uris: [this.#callbackUri]}, {signal: request.signal}) + const registrationResponse = await oauth.dynamicClientRegistrationRequest(authorizationServer, {redirect_uris: [this.#callbackUri]}, {signal: request.signal, ...insecureOption}) const clientRegistration = await oauth.processDynamicClientRegistrationResponse(registrationResponse) const [registeredRedirectUri] = clientRegistration.redirect_uris as string[] const [registeredResponseType] = clientRegistration.response_types as string[] @@ -79,7 +114,7 @@ export class DPoPTokenProvider implements TokenProvider { } } - const tokenResponse = await oauth.authorizationCodeGrantRequest(authorizationServer, clientRegistration, this.getClientAuth(authorizationServer.issuer, clientRegistration), authorizationCodeParams, this.#callbackUri, authorizationServer.code_challenge_methods_supported !== undefined ? codeVerifier : oauth.nopkce, {DPoP: dpop, signal: request.signal}) + const tokenResponse = await oauth.authorizationCodeGrantRequest(authorizationServer, clientRegistration, this.getClientAuth(authorizationServer.issuer, clientRegistration), authorizationCodeParams, this.#callbackUri, authorizationServer.code_challenge_methods_supported !== undefined ? codeVerifier : oauth.nopkce, {DPoP: dpop, signal: request.signal, ...insecureOption}) const tokenResult = await oauth.processAuthorizationCodeResponse(authorizationServer, clientRegistration, tokenResponse, {expectedNonce: this.nonceVerificationOverride(authorizationServer.issuer, nonce)}) diff --git a/test/DPoPTokenProvider.test.ts b/test/DPoPTokenProvider.test.ts new file mode 100644 index 0000000..a4cb402 --- /dev/null +++ b/test/DPoPTokenProvider.test.ts @@ -0,0 +1,47 @@ +import { test } from "node:test" +import assert from "node:assert/strict" +import { isLoopbackHttpIssuer } from "../src/DPoPTokenProvider.ts" + +// Regression test for the "loopback-issuer bug": the auth-code + PKCE + DPoP flow +// must allow plain-HTTP OIDC calls when (and only when) the issuer is a loopback +// dev server (e.g. a local Community Solid Server on http://localhost:3000). +// oauth4webapi enforces HTTPS by default, so without this relaxation the DPoP +// flow throws `only requests to HTTPS are allowed` against a local issuer. + +test("treats http loopback issuers as insecure-allowed", () => { + for (const issuer of [ + "http://localhost:3000", + "http://localhost:3000/", + "http://localhost", + "http://127.0.0.1:3000", + "http://127.0.0.1", + "http://127.5.6.7", + "http://[::1]:3000", + "http://sub.localhost:3000", + "http://LOCALHOST:3000", // case-insensitive host + ]) { + assert.equal(isLoopbackHttpIssuer(new URL(issuer)), true, issuer) + } +}) + +test("never relaxes HTTPS enforcement for https issuers", () => { + for (const issuer of [ + "https://solidcommunity.net", + "https://login.inrupt.com", + "https://localhost:3000", // https loopback is already secure; not relaxed + ]) { + assert.equal(isLoopbackHttpIssuer(new URL(issuer)), false, issuer) + } +}) + +test("does not relax HTTPS for non-loopback http issuers", () => { + for (const issuer of [ + "http://example.com", + "http://solidcommunity.net", + "http://10.0.0.1:3000", // private but not loopback + "http://192.168.1.1", + "http://notlocalhost.example", + ]) { + assert.equal(isLoopbackHttpIssuer(new URL(issuer)), false, issuer) + } +})