-
Notifications
You must be signed in to change notification settings - Fork 1
fix: allow http loopback issuers in the DPoP auth-code flow #18
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
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Private utility should not be exported. |
||
| if (issuer.protocol !== "http:") { | ||
| return false | ||
| } | ||
|
|
||
| const hostname = issuer.hostname.toLowerCase() | ||
| return ( | ||
| hostname === "localhost" || | ||
| hostname === "127.0.0.1" || | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What about |
||
| hostname === "[::1]" || | ||
| hostname === "::1" || | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think |
||
| hostname.endsWith(".localhost") || | ||
| hostname.startsWith("127.") | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Like |
||
| ) | ||
|
Comment on lines
+24
to
+32
|
||
| } | ||
|
|
||
| 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<Request> { | ||
| 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)}) | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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", | ||
| ]) { | ||
|
Comment on lines
+38
to
+44
|
||
| assert.equal(isLoopbackHttpIssuer(new URL(issuer)), false, issuer) | ||
| } | ||
| }) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a private utility method. I'm not sure it should be documented like this.