fix: allow opting in to insecure OAuth requests - #18
Conversation
There was a problem hiding this comment.
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
isLoopbackHttpIssuerguard and threadedoauth.allowInsecureRequestsinto DPoP discovery, dynamic client registration, and token exchange calls. - Added
node:testregression tests for the loopback-issuer predicate. - Added an
npm testscript 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.
| const hostname = issuer.hostname.toLowerCase() | ||
| return ( | ||
| hostname === "localhost" || | ||
| hostname === "127.0.0.1" || | ||
| hostname === "[::1]" || | ||
| hostname === "::1" || | ||
| hostname.endsWith(".localhost") || | ||
| hostname.startsWith("127.") | ||
| ) |
There was a problem hiding this comment.
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.
| 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", | ||
| ]) { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| hostname === "[::1]" || | ||
| hostname === "::1" || | ||
| hostname.endsWith(".localhost") || | ||
| hostname.startsWith("127.") |
There was a problem hiding this comment.
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.
| hostname === "localhost" || | ||
| hostname === "127.0.0.1" || | ||
| hostname === "[::1]" || | ||
| hostname === "::1" || |
There was a problem hiding this comment.
I think URL normalizes these to the above.
There was a problem hiding this comment.
Correct, URL normalises ::1 to [::1], so that branch was dead code. Moot now that the predicate is deleted.
| const hostname = issuer.hostname.toLowerCase() | ||
| return ( | ||
| hostname === "localhost" || | ||
| hostname === "127.0.0.1" || |
There was a problem hiding this comment.
What about 127.0.0.2? (Since the block is 127.0.0.0/8.)
There was a problem hiding this comment.
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.
| * | ||
| * @see https://www.rfc-editor.org/rfc/rfc8252#section-7.3 (loopback redirect URIs) | ||
| */ | ||
| export function isLoopbackHttpIssuer(issuer: URL): boolean { |
There was a problem hiding this comment.
Private utility should not be exported.
There was a problem hiding this comment.
Fixed — nothing extra is exported now. isLoopbackHttpIssuer is deleted; the only new export is the InsecureConfiguration class itself, which is deliberately public API.
| /** | ||
| * 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) | ||
| */ |
There was a problem hiding this comment.
This is a private utility method. I'm not sure it should be documented like this.
There was a problem hiding this comment.
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.
64c86a7 to
fa03656
Compare
|
Rebased 🤖 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.
fa03656 to
e1abd36
Compare
|
@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 Two things I went slightly beyond the original scope on, both easy to drop:
One gap worth your view: a static flag is per-realm, so a page calling Also dropped the loopback predicate's |
Problem
oauth4webapiv3 enforces HTTPS on every request, sodiscoveryRequest/dynamicClientRegistrationRequest/authorizationCodeGrantRequestall throwonly requests to HTTPS are allowedagainst anhttp:issuer. That blocks the auth-code + PKCE + DPoP flow against a local Community Solid Server onhttp://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:
allow()is deprecated on purpose, so the security implication shows up at the call site rather than only in docs, and it logs toconsole.errorwhen 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— replacesconst 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.htmlcallsInsecureConfiguration.allow(), since the demo targets a local CSS.Notes for review
BearerTokenProvider: insecure requests were previously always on and are now opt-in. Anyone relying on the old implicit behaviour against a local server needs theallow()call. That is the point of the change, but it is a break worth calling out.reactive-fetch-worker.js, soReactiveFetchWorkerManagerconsumers 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.node:testsuite are both removed, along with thetestscript (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 — leavingpackage.jsonuntouched here avoids fighting over thetestscript).Rebased onto current
main.npm run build(tsc) clean.