Skip to content
Draft
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
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
41 changes: 38 additions & 3 deletions src/DPoPTokenProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
*/
Comment on lines +7 to +18

Copy link
Copy Markdown
Collaborator

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.

export function isLoopbackHttpIssuer(issuer: URL): boolean {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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" ||

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about 127.0.0.2? (Since the block is 127.0.0.0/8.)

hostname === "[::1]" ||
hostname === "::1" ||

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think URL normalizes these to the above.

hostname.endsWith(".localhost") ||
hostname.startsWith("127.")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Like 127.com?

)
Comment on lines +24 to +32
}

export class DPoPTokenProvider implements TokenProvider {
readonly #getCode: GetCodeCallback
readonly #callbackUri: string
Expand All @@ -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[]
Expand Down Expand Up @@ -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)})

Expand Down
47 changes: 47 additions & 0 deletions test/DPoPTokenProvider.test.ts
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)
}
})