Skip to content

fix: allow opting in to insecure OAuth requests - #18

Draft
jeswr wants to merge 1 commit into
mainfrom
fix/loopback-issuer
Draft

fix: allow opting in to insecure OAuth requests#18
jeswr wants to merge 1 commit into
mainfrom
fix/loopback-issuer

Conversation

@jeswr

@jeswr jeswr commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Problem

oauth4webapi v3 enforces HTTPS on every request, so discoveryRequest / dynamicClientRegistrationRequest / authorizationCodeGrantRequest all throw only requests to HTTPS are allowed against an http: issuer. That blocks the auth-code + PKCE + DPoP flow against a local Community Solid Server on http://localhost:3000.

Change

Following @langsamu's review, this no longer tries to detect loopback issuers. A global switch is exposed instead and the caller decides:

import { InsecureConfiguration } from "@solid/reactive-authentication"

InsecureConfiguration.allow()

allow() is deprecated on purpose, so the security implication shows up at the call site rather than only in docs, and it logs to console.error when called. Enforcement stays on by default — the library never relaxes it on the consumer's behalf.

Applied across all three providers:

  • DPoPTokenProvider — the original bug; had no insecure handling at all.
  • BearerTokenProvider — replaces const oauthAllowInsecureRequests = true, which unconditionally disabled HTTPS enforcement for every consumer, including production. Resolves its // TODO: Configure properly for insecure localhost only.
  • ClientCredentialsTokenProvider — had no insecure handling either, so it could not reach a local issuer at all.

index.html calls InsecureConfiguration.allow(), since the demo targets a local CSS.

Notes for review

  • Behaviour change for BearerTokenProvider: insecure requests were previously always on and are now opt-in. Anyone relying on the old implicit behaviour against a local server needs the allow() call. That is the point of the change, but it is a break worth calling out.
  • Service workers are a separate realm. A static flag set on the page does not reach reactive-fetch-worker.js, so ReactiveFetchWorkerManager consumers currently have no way to opt in. Plumbing config through the worker's message channel is a bigger change — happy to do it here or in a follow-up, whichever you prefer.
  • The loopback predicate and its node:test suite are both removed, along with the test script (the tested function no longer exists, and feat: per-issuer session cache — stop re-running the full auth flow (popup included) on every 401 #11 is separately introducing vitest — leaving package.json untouched here avoids fighting over the test script).

Rebased onto current main. npm run build (tsc) clean.

Copilot AI review requested due to automatic review settings June 15, 2026 10:47

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This PR fixes local-development logins for the auth-code + PKCE + DPoP flow by relaxing oauth4webapi’s HTTPS-only enforcement only when the OIDC issuer is a loopback http: origin (e.g. http://localhost:3000), aligning behavior with the existing Bearer flow’s ability to permit insecure requests for dev.

Changes:

  • Added an isLoopbackHttpIssuer guard and threaded oauth.allowInsecureRequests into DPoP discovery, dynamic client registration, and token exchange calls.
  • Added node:test regression tests for the loopback-issuer predicate.
  • Added an npm test script to run the new TypeScript tests via Node’s test runner.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

File Description
src/DPoPTokenProvider.ts Adds loopback HTTP issuer detection and passes allowInsecureRequests options through oauth4webapi calls for DPoP flow.
test/DPoPTokenProvider.test.ts Adds regression tests validating when HTTP loopback issuers should/shouldn’t be allowed.
package.json Adds a test script to run the new node:test suite.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/DPoPTokenProvider.ts Outdated
Comment on lines +23 to +31
const hostname = issuer.hostname.toLowerCase()
return (
hostname === "localhost" ||
hostname === "127.0.0.1" ||
hostname === "[::1]" ||
hostname === "::1" ||
hostname.endsWith(".localhost") ||
hostname.startsWith("127.")
)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Resolved by removing the predicate entirely. Per @langsamu's review the PR no longer tries to work out what is loopback — InsecureConfiguration.allow() is now an explicit global opt-in, so there is no hostname parsing left to bypass.

Comment thread test/DPoPTokenProvider.test.ts Outdated
Comment on lines +38 to +44
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",
]) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

No longer applicable — the predicate it would have guarded is gone. There is no URL parsing left to regression-test; the remaining surface is a single boolean flag.

@langsamu langsamu left a comment

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 have run into this annoyance myself several times when running CSS locally.

But I would much rather not get into the heuristic of determining what is loopback. Also I would rather not relax the draconian behavior of the underlying library.

How about another design where there is a global switch that library consumers can toggle to enable insecure requestsm like

class InsecureConfiguration {
    private static allowed: boolean = false

    /** @deprecated */
    static allow(){
        console.error("Insecure requests allowed for oauth4webapi")
        this.allowed = true
    }

    static get requestOptions(): { [oauth.allowInsecureRequests]?: boolean } {
        return {[oauth.allowInsecureRequests]: this.allowed}
    }
}

which could be used like

const tokenResponse = await oauth.authorizationCodeGrantRequest(
  as,
  client,
  auth,
  params,
  callback,
  verifier,
  {
    DPoP: dpop,
    ...InsecureConfiguration.requestOptions
  }
)

We leave it to the caller to make the decision, help them with deprecation warning and error in console, don't interfere with their security stance outside their knowledge and don't get into URI parsing.

Comment thread src/DPoPTokenProvider.ts Outdated
hostname === "[::1]" ||
hostname === "::1" ||
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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yes — 127.com would have matched, as would 127.0.0.1.evil.com. Both are gone: the whole predicate is removed in favour of your InsecureConfiguration design.

Comment thread src/DPoPTokenProvider.ts Outdated
hostname === "localhost" ||
hostname === "127.0.0.1" ||
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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Correct, URL normalises ::1 to [::1], so that branch was dead code. Moot now that the predicate is deleted.

Comment thread src/DPoPTokenProvider.ts Outdated
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.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Right, 127.0.0.2 is in-block and the exact-match arm missed it — which is what pushed me to the sloppy startsWith("127."). Removed along with the rest of the heuristic.

Comment thread src/DPoPTokenProvider.ts Outdated
*
* @see https://www.rfc-editor.org/rfc/rfc8252#section-7.3 (loopback redirect URIs)
*/
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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed — nothing extra is exported now. isLoopbackHttpIssuer is deleted; the only new export is the InsecureConfiguration class itself, which is deliberately public API.

Comment thread src/DPoPTokenProvider.ts Outdated
Comment on lines +6 to +17
/**
* 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)
*/

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed. The oversized doc block is gone with the function. The new class carries a short block in the style of the rest of the codebase.

@jeswr
jeswr force-pushed the fix/loopback-issuer branch from 64c86a7 to fa03656 Compare June 15, 2026 14:26
@jeswr

jeswr commented Jun 15, 2026

Copy link
Copy Markdown
Contributor Author

Rebased fix/loopback-issuer onto current main (was CONFLICTING → now MERGEABLE). Main had since refactored DPoPTokenProvider — a string callbackUri constructor field replacing the old #getCallback, plus {signal: request.signal} threaded through every oauth.* call. I integrated those changes and re-threaded the loopback fix on top: the insecureOption (only non-empty for loopback http: issuers) is now spread alongside signal into discoveryRequest, dynamicClientRegistrationRequest, and authorizationCodeGrantRequest, and the registration/token requests use the new this.#callbackUri field. Production issuers stay HTTPS-strict; only loopback http: (localhost / *.localhost / 127.0.0.0/8 / ::1) is relaxed, mirroring how BearerTokenProvider threads oauth.allowInsecureRequests. npm run build (tsc) and npm test (the new node:test predicate regression — 3/3) both green.

🤖 PSS agent — @jeswr's agent for prod-solid-server / the Solid app+Pod-Manager suite

oauth4webapi enforces HTTPS on every request, so the auth-code + PKCE +
DPoP flow could not talk to a local Community Solid Server over plain
HTTP. Rather than guessing which issuers are loopback, expose a global
switch consumers toggle themselves.

InsecureConfiguration.allow() is deprecated on purpose so the security
implication shows up at the call site.

Also replaces the unconditional allow in BearerTokenProvider, and covers
ClientCredentialsTokenProvider, which had no way to reach a local
issuer at all.
@jeswr
jeswr force-pushed the fix/loopback-issuer branch from fa03656 to e1abd36 Compare July 29, 2026 10:37
@jeswr jeswr changed the title fix: allow http loopback issuers in the DPoP auth-code flow fix: allow opting in to insecure OAuth requests Jul 29, 2026
@jeswr

jeswr commented Jul 29, 2026

Copy link
Copy Markdown
Contributor Author

@langsamu adopted your design — thanks, it is clearly the better call. No URI parsing, no heuristics, and the library no longer overrides anyone's security stance implicitly.

Implemented as src/InsecureConfiguration.ts essentially as you sketched it, with static #allowed to match the #-private field convention used elsewhere (Mutex, the providers). Threaded through all three providers.

Two things I went slightly beyond the original scope on, both easy to drop:

  1. BearerTokenProvider had const oauthAllowInsecureRequests = true with a // TODO: Configure properly for insecure localhost only. That unconditionally disabled HTTPS enforcement for every consumer, so the switch resolves the TODO. It does mean insecure requests are now opt-in there rather than always on.
  2. ClientCredentialsTokenProvider had no insecure handling at all, so a "global" switch would otherwise have missed one of three providers.

One gap worth your view: a static flag is per-realm, so a page calling allow() does not reach reactive-fetch-worker.js. ReactiveFetchWorkerManager consumers therefore have no way to opt in today. Plumbing it through the worker's message channel is a real design decision, so I left it out — say the word and I will add it here or open a follow-up.

Also dropped the loopback predicate's node:test suite and the test script, since the code under test is gone and #11 is separately introducing vitest.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants