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
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,32 @@ manager.registerGlobally()
const response = await fetch(requestUri)
```

### Restrict which origins receive credentials (recommended)

Once global `fetch` is patched, **every** request the page makes flows through
the reactive layer — including requests to third-party origins (a CDN, an image
host, an analytics beacon, a URL embedded in fetched data). Without a boundary,
any of those origins can answer `401` to trigger the authorization flow and
receive a retry carrying the user's credentials.

Pass `allowedOrigins` — your pod/storage origins — so credentials are only ever
attached to origins you trust. A `401` from any other origin is returned
untouched:

```js
import { CredentialBoundary, ReactiveFetchManager } from "@solid/reactive-authentication"

const boundary = new CredentialBoundary(["https://alice.pod.example"])
const manager = new ReactiveFetchManager([provider], { allowedOrigins: boundary })

// The boundary can be widened in place later, without disrupting the session:
boundary.add("https://shared.pod.example")
```

`allowedOrigins` also accepts a plain iterable of origins or origin predicates.
It applies to `ReactiveAuthenticationClient` too. When omitted, the previous
"upgrade every `401`" behaviour is preserved and a one-time warning is logged.

## Run the demo

To compile,
Expand Down
12 changes: 7 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,21 +26,23 @@
"url": "git+https://github.com/solid-contrib/reactive-authentication.git"
},
"scripts": {
"build": "tsc"
"build": "tsc",
"test": "vitest run"
},
"license": "MIT",
"dependencies": {
"oauth4webapi": "^3",
"dpop": "^2",
"@rdfjs/wrapper": "^0.34",
"n3": "^2"
"dpop": "^2",
"n3": "^2",
"oauth4webapi": "^3"
},
"devDependencies": {
"@rdfjs/types": "^2",
"@types/n3": "^1",
"typedoc": "^0.28.18",
"typedoc-plugin-mdn-links": "^5.1.1",
"typescript": "^6"
"typescript": "^6",
"vitest": "^4.1.10"
},
"engines": {
"node": ">=24.0.0"
Expand Down
103 changes: 103 additions & 0 deletions src/CredentialBoundary.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/**
* A value that names one or more trusted origins: an origin string or `URL`
* (reduced to its {@link https://developer.mozilla.org/en-US/docs/Web/API/URL/origin origin}),
* or a predicate that receives a request's origin and returns whether it is trusted.
*/
export type OriginMatcher = string | URL | ((origin: string) => boolean)

/**
* The set of origins to which the reactive layer may attach the user's
* credentials when retrying a request that was rejected with `401`.
*
* @remarks
* Why this exists — a credential-leak boundary.
*
* {@link ReactiveFetchManager.registerGlobally} replaces `globalThis.fetch`, so
* *every* fetch the page makes flows through the reactive layer — including
* requests to third-party origins the app never meant to authenticate to (a CDN,
* an image host, an analytics beacon, a URL embedded in fetched data). Without a
* boundary, *any* of those origins can answer `401` to trigger a
* {@link TokenProvider.upgrade} and receive a retry carrying the user's Solid
* credentials — the `Authorization` access token (and a DPoP proof) minted for
* that origin's URL. That hands the user's identity/credential to an origin they
* never chose to trust.
*
* A `CredentialBoundary` is the allow-list of origins the consumer *does* trust
* with credentials — typically their pod/storage origins (and the issuer). A
* `401` from an out-of-boundary origin is passed through untouched: no upgrade,
* no credential attachment.
*
* The boundary can be {@link add | widened} in place after construction (for
* example when a session legitimately gains access to a further storage origin),
* without disrupting any established session — the reactive layer holds no
* per-origin state to re-grant.
*
* @example
* ```ts
* const boundary = new CredentialBoundary(["https://alice.pod.example"])
* new ReactiveFetchManager([provider], {allowedOrigins: boundary}).registerGlobally()
* // later, having discovered another storage the user can reach:
* boundary.add("https://shared.pod.example")
* ```
*/
export class CredentialBoundary {
readonly #origins = new Set<string>()
readonly #predicates: Array<(origin: string) => boolean> = []

/**
* @param matchers - Origins (or origin predicates) to trust with credentials.
*/
constructor(matchers: Iterable<OriginMatcher> = []) {
this.add(...matchers)
}

/**
* Widens the boundary to trust further origins, in place. Safe to call on a
* live boundary while requests are in flight; it never narrows the boundary.
*
* @returns This boundary, for chaining.
*/
add(...matchers: OriginMatcher[]): this {
for (const matcher of matchers) {
if (typeof matcher === "function") {
this.#predicates.push(matcher)
continue
}

const origin = originOf(matcher)
if (origin !== undefined) {
this.#origins.add(origin)
}
}

return this
}

/**
* Whether `origin` is trusted with credentials. `origin` is expected to be a
* canonical origin string (as produced by {@link originOf}); a value that is
* not exactly a trusted origin and matches no predicate is not trusted.
*/
allows(origin: string): boolean {
return this.#origins.has(origin) || this.#predicates.some(predicate => predicate(origin))
}
}

/**
* Reduces a URL(-ish) value to its canonical
* {@link https://developer.mozilla.org/en-US/docs/Web/API/URL/origin origin}
* (scheme + host + port), or `undefined` when it is not a valid absolute URL.
*
* @remarks
* Comparing canonical origins — rather than raw URL prefixes — is what makes the
* boundary robust: `https://pod.example` and `https://pod.example:443/anything`
* share one origin, while `https://pod.example.attacker.test` does not, so a
* look-alike host cannot slip past a substring check.
*/
export function originOf(url: string | URL): string | undefined {
try {
return new URL(url).origin
} catch {
return undefined
}
}
52 changes: 51 additions & 1 deletion src/ReactiveAuthenticationClient.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,32 @@
import type { TokenProvider } from "./TokenProvider.js"
import { CredentialBoundary, type OriginMatcher, originOf } from "./CredentialBoundary.js"

/** Options for {@link ReactiveAuthenticationClient}. */
export interface ReactiveAuthenticationClientOptions {
/**
* The origins the client may attach the user's credentials to when retrying a
* request rejected with `401` — see {@link CredentialBoundary}. Strongly
* recommended: set this to your pod/storage origins. When omitted, every
* `401` is upgraded regardless of origin (the previous behaviour) and a
* one-time warning is logged.
*/
allowedOrigins?: CredentialBoundary | Iterable<OriginMatcher>
}

export class ReactiveAuthenticationClient {
readonly #fetch: typeof globalThis.fetch
readonly #providers: Iterable<TokenProvider>
readonly #boundary: CredentialBoundary | undefined
#warnedNoBoundary = false

constructor(fetch: typeof globalThis.fetch, providers: Iterable<TokenProvider>) {
constructor(
fetch: typeof globalThis.fetch,
providers: Iterable<TokenProvider>,
options: ReactiveAuthenticationClientOptions = {},
) {
this.#fetch = fetch
this.#providers = providers
this.#boundary = toBoundary(options.allowedOrigins)
}

async fetch(request: Request): Promise<Response> {
Expand All @@ -15,6 +35,13 @@ export class ReactiveAuthenticationClient {
return response
}

// Credential boundary: an out-of-boundary 401 is returned untouched, so
// the user's credentials are never attached to a retry against an origin
// the consumer has not trusted (see CredentialBoundary).
if (!this.#withinBoundary(request.url)) {
return response
}

const provider = await this.#findProvider(request)
if (provider === undefined) {
return response
Expand All @@ -24,6 +51,21 @@ export class ReactiveAuthenticationClient {
return this.#fetch.call(undefined, upgraded)
}

#withinBoundary(url: string): boolean {
if (this.#boundary === undefined) {
if (!this.#warnedNoBoundary) {
this.#warnedNoBoundary = true
console.warn(
"ReactiveAuthenticationClient: no `allowedOrigins` credential boundary is set, so every 401 response — including from third-party origins — triggers a credentialed retry. Pass `allowedOrigins` (your pod/storage origins) to prevent leaking the user's credentials.",
)
}
return true
}

const origin = originOf(url)
return origin !== undefined && this.#boundary.allows(origin)
}

async #findProvider(request: Request): Promise<TokenProvider | undefined> {
for (const provider of this.#providers) {
if (await provider.matches(request)) {
Expand All @@ -34,3 +76,11 @@ export class ReactiveAuthenticationClient {
return undefined
}
}

function toBoundary(input: ReactiveAuthenticationClientOptions["allowedOrigins"]): CredentialBoundary | undefined {
if (input === undefined) {
return undefined
}

return input instanceof CredentialBoundary ? input : new CredentialBoundary(input)
}
55 changes: 54 additions & 1 deletion src/ReactiveFetchManager.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,38 @@
import type { TokenProvider } from "./TokenProvider.js"
import { CredentialBoundary, type OriginMatcher, originOf } from "./CredentialBoundary.js"

/** Options for {@link ReactiveFetchManager}. */
export interface ReactiveFetchManagerOptions {
/**
* The origins the reactive layer may attach the user's credentials to when
* retrying a request rejected with `401` — see {@link CredentialBoundary}.
*
* Strongly recommended: set this to your pod/storage origins so that a `401`
* from a third-party origin cannot harvest the user's credentials. Pass a
* {@link CredentialBoundary} instance (which you can later
* {@link CredentialBoundary.add | widen}) or any iterable of
* {@link OriginMatcher}s (origins / predicates).
*
* When omitted, every `401` is upgraded regardless of origin (the previous
* behaviour) and a one-time warning is logged.
*/
allowedOrigins?: CredentialBoundary | Iterable<OriginMatcher>
}

export class ReactiveFetchManager extends EventTarget {
readonly #globalFetch: typeof globalThis.fetch
readonly #providers: Iterable<TokenProvider>
readonly #boundary: CredentialBoundary | undefined
#warnedNoBoundary = false

constructor(providers: Iterable<TokenProvider>) {
constructor(providers: Iterable<TokenProvider>, options: ReactiveFetchManagerOptions = {}) {
super()

this.#providers = providers

this.#globalFetch = globalThis.fetch

this.#boundary = toBoundary(options.allowedOrigins)
}

registerGlobally() {
Expand All @@ -27,6 +50,13 @@ export class ReactiveFetchManager extends EventTarget {
return response
}

// Credential boundary: never attach the user's credentials to a retry
// against an origin the consumer has not trusted. An out-of-boundary
// `401` is returned untouched — no provider is consulted, no upgrade runs.
if (!this.#withinBoundary(request.url)) {
return response
}

const provider = await this.#findProvider(request)
if (provider === undefined) {
return response
Expand All @@ -36,6 +66,21 @@ export class ReactiveFetchManager extends EventTarget {
return this.#globalFetch.call(undefined, upgraded)
}

#withinBoundary(url: string): boolean {
if (this.#boundary === undefined) {
if (!this.#warnedNoBoundary) {
this.#warnedNoBoundary = true
console.warn(
"ReactiveFetchManager: no `allowedOrigins` credential boundary is set, so every 401 response — including from third-party origins — triggers a credentialed retry. Pass `allowedOrigins` (your pod/storage origins) to prevent leaking the user's credentials.",
)
}
return true
}

const origin = originOf(url)
return origin !== undefined && this.#boundary.allows(origin)
}

async #findProvider(request: Request): Promise<TokenProvider | undefined> {
for (const provider of this.#providers) {
if (await provider.matches(request)) {
Expand All @@ -46,3 +91,11 @@ export class ReactiveFetchManager extends EventTarget {
return undefined
}
}

function toBoundary(input: ReactiveFetchManagerOptions["allowedOrigins"]): CredentialBoundary | undefined {
if (input === undefined) {
return undefined
}

return input instanceof CredentialBoundary ? input : new CredentialBoundary(input)
}
1 change: 1 addition & 0 deletions src/mod.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from "./AuthorizationCodeFlow.js"
export * from "./BearerTokenProvider.js"
export * from "./CredentialBoundary.js"
export * from "./DPoPTokenProvider.js"
export * from "./ReactiveFetchManager.js"
export * from "./ReactiveFetchWorkerManager.js"
Expand Down
Loading
Loading