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

### Pin the providers' own OIDC requests to a pristine `fetch`

The token providers perform their own network requests (discovery, dynamic client registration, the token grant) which default to `globalThis.fetch`. If the application patches the global `fetch` with an authenticating wrapper — `registerGlobally()`, or its own wrapper that single-flights concurrent requests onto one shared authentication attempt — those OIDC requests re-enter the wrapper mid-login. A single-flighting wrapper then awaits the very authentication attempt its request is serving: a circular await that hangs login before the authorization popup/redirect ever opens.

To avoid this, capture the pristine `fetch` before patching and pin the providers to it:

```js
const pristineFetch = globalThis.fetch.bind(globalThis) // capture BEFORE patching

const provider = new DPoPTokenProvider(callbackUri, getCode, getIssuer, {fetch: pristineFetch})
const manager = new ReactiveFetchManager([provider])
manager.registerGlobally()
```

## Run the demo

To compile,
Expand Down
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": "npm run build && node --test --experimental-strip-types \"test/**/*.test.ts\""
},
"license": "MIT",
"dependencies": {
Expand Down
16 changes: 12 additions & 4 deletions src/BearerTokenProvider.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
import * as oauth from "oauth4webapi"
import { GetCodeCallback } from "./GetCodeCallback.js"
import { TokenProvider } from "./TokenProvider.js"
import type { TokenProviderOptions } from "./TokenProviderOptions.js"
import { customFetchOption } from "./customFetchOption.js"

// TODO: Configure properly for insecure localhost only
const oauthAllowInsecureRequests = true

export class BearerTokenProvider implements TokenProvider {
readonly #getCode: GetCodeCallback
readonly #fetch: typeof globalThis.fetch | undefined

constructor(getCodeCallback: GetCodeCallback) {
constructor(getCodeCallback: GetCodeCallback, options?: TokenProviderOptions) {
this.#getCode = getCodeCallback
this.#fetch = options?.fetch
}

async #getIssuer(request: Request): Promise<URL> {
Expand Down Expand Up @@ -43,12 +47,16 @@ export class BearerTokenProvider implements TokenProvider {
async upgrade(request: Request): Promise<Request> {
const issuer = await this.#getIssuer(request)

const discoveryResponse = await oauth.discoveryRequest(issuer, {[oauth.allowInsecureRequests]: oauthAllowInsecureRequests})
// Pin the provider's own OIDC requests to the configured fetch (when given)
// so they never re-enter an authenticating wrapper patched over the global.
const oidcRequestOptions = {[oauth.allowInsecureRequests]: oauthAllowInsecureRequests, ...customFetchOption(this.#fetch)}

const discoveryResponse = await oauth.discoveryRequest(issuer, oidcRequestOptions)
const authorizationServer = await oauth.processDiscoveryResponse(issuer, discoveryResponse)

const callbackUri = await this.#getCallback(request)

const registrationResponse = await oauth.dynamicClientRegistrationRequest(authorizationServer, {redirect_uris: [callbackUri]}, {[oauth.allowInsecureRequests]: oauthAllowInsecureRequests})
const registrationResponse = await oauth.dynamicClientRegistrationRequest(authorizationServer, {redirect_uris: [callbackUri]}, oidcRequestOptions)
const clientRegistration = await oauth.processDynamicClientRegistrationResponse(registrationResponse)
const [registeredRedirectUri] = clientRegistration.redirect_uris as string[]
const [registeredResponseType] = clientRegistration.response_types as string[]
Expand Down Expand Up @@ -84,7 +92,7 @@ export class BearerTokenProvider implements TokenProvider {
clientAuth = authenticationMethod(clientSecret)
}

const tokenResponse = await oauth.authorizationCodeGrantRequest(authorizationServer, clientRegistration, clientAuth, authorizationCodeParams, callbackUri, codeVerifier, {[oauth.allowInsecureRequests]: oauthAllowInsecureRequests})
const tokenResponse = await oauth.authorizationCodeGrantRequest(authorizationServer, clientRegistration, clientAuth, authorizationCodeParams, callbackUri, codeVerifier, oidcRequestOptions)

// jwt nonce missing in igrant
// const tokenResult = await oauth.processAuthorizationCodeResponse(authorizationServer, clientRegistration, tokenResponse, {expectedNonce: nonce})
Expand Down
17 changes: 12 additions & 5 deletions src/ClientCredentialsTokenProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,14 @@ import * as oauth from "oauth4webapi"
import { AuthorizationServer } from "oauth4webapi"
import * as DPoP from "dpop"
import type { TokenProvider } from "./TokenProvider.js"
import type { TokenProviderOptions } from "./TokenProviderOptions.js"
import { customFetchOption } from "./customFetchOption.js"

export class ClientCredentialsTokenProvider implements TokenProvider {
constructor(private clientId: string, private clientSecret: string) {
readonly #fetch: typeof globalThis.fetch | undefined

constructor(private clientId: string, private clientSecret: string, options?: TokenProviderOptions) {
this.#fetch = options?.fetch
}

async #getIssuer(request: Request): Promise<URL> {
Expand Down Expand Up @@ -34,9 +39,11 @@ export class ClientCredentialsTokenProvider implements TokenProvider {
async upgrade(request: Request): Promise<Request> {
const issuer = await this.#getIssuer(request)

const discoveryResponse = await oauth.discoveryRequest(issuer, {
signal: request.signal
})
// Pin the provider's own OIDC requests to the configured fetch (when given)
// so they never re-enter an authenticating wrapper patched over the global.
const oidcRequestOptions = {signal: request.signal, ...customFetchOption(this.#fetch)}

const discoveryResponse = await oauth.discoveryRequest(issuer, oidcRequestOptions)
const authorizationServer = await oauth.processDiscoveryResponse(issuer, discoveryResponse)

const clientRegistration: oauth.Client = {client_id: this.clientId, client_secret: this.clientSecret}
Expand All @@ -46,7 +53,7 @@ export class ClientCredentialsTokenProvider implements TokenProvider {

const tokenResponse = await oauth.clientCredentialsGrantRequest(authorizationServer, clientRegistration, this.getClientAuth(authorizationServer, clientRegistration), {scope: "webid"}, {
DPoP: dpop,
signal: request.signal
...oidcRequestOptions
})

const tokenResult = await oauth.processClientCredentialsResponse(authorizationServer, clientRegistration, tokenResponse)
Expand Down
16 changes: 12 additions & 4 deletions src/DPoPTokenProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,20 @@ import * as DPoP from "dpop"
import type { GetCodeCallback } from "./GetCodeCallback.js"
import type { TokenProvider } from "./TokenProvider.js"
import type { GetIssuerCallback } from "./GetIssuerCallback.js"
import type { TokenProviderOptions } from "./TokenProviderOptions.js"
import { customFetchOption } from "./customFetchOption.js"

export class DPoPTokenProvider implements TokenProvider {
readonly #getCode: GetCodeCallback
readonly #callbackUri: string
readonly #getIssuer: GetIssuerCallback
readonly #fetch: typeof globalThis.fetch | undefined

constructor(callbackUri: string, getCodeCallback: GetCodeCallback, getIssuerCallback: GetIssuerCallback) {
constructor(callbackUri: string, getCodeCallback: GetCodeCallback, getIssuerCallback: GetIssuerCallback, options?: TokenProviderOptions) {
this.#getCode = getCodeCallback
this.#callbackUri = callbackUri
this.#getIssuer = getIssuerCallback
this.#fetch = options?.fetch
}

async matches(request: Request): Promise<boolean> {
Expand All @@ -22,10 +26,14 @@ 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})
// Pin the provider's own OIDC requests to the configured fetch (when given)
// so they never re-enter an authenticating wrapper patched over the global.
const oidcRequestOptions = {signal: request.signal, ...customFetchOption(this.#fetch)}

const discoveryResponse = await oauth.discoveryRequest(issuer, oidcRequestOptions)
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]}, oidcRequestOptions)
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 +87,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, ...oidcRequestOptions})

const tokenResult = await oauth.processAuthorizationCodeResponse(authorizationServer, clientRegistration, tokenResponse, {expectedNonce: this.nonceVerificationOverride(authorizationServer.issuer, nonce)})

Expand Down
23 changes: 23 additions & 0 deletions src/TokenProviderOptions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/**
* Options accepted by the token providers.
*/
export interface TokenProviderOptions {
/**
* The fetch implementation the provider uses for its own OIDC requests —
* discovery, dynamic client registration and the token grant.
*
* @remarks
* Defaults to `globalThis.fetch`, resolved at call time (oauth4webapi's
* default behaviour).
*
* Applications that patch the global fetch with an authenticating wrapper
* (`ReactiveFetchManager.registerGlobally()`, or their own wrapper that
* single-flights concurrent requests onto one shared authentication
* attempt) should pass the pristine, pre-patch fetch here. Otherwise the
* provider's own OIDC requests re-enter the wrapper mid-upgrade: a
* single-flighting wrapper then awaits the very authentication attempt
* those requests are serving — a circular await that hangs login before
* the authorization redirect/popup ever opens.
*/
fetch?: typeof globalThis.fetch
}
27 changes: 27 additions & 0 deletions src/customFetchOption.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import * as oauth from "oauth4webapi"

/**
* Adapts an optional Fetch API implementation to oauth4webapi's `customFetch`
* request option, as a spreadable options fragment.
*
* @remarks
* Returns an empty fragment when no fetch is given, preserving oauth4webapi's
* default (`globalThis.fetch`, resolved at call time).
*
* @see {@link TokenProviderOptions.fetch}
*/
export function customFetchOption(fetchImplementation: typeof globalThis.fetch | undefined): {[oauth.customFetch]?: (url: string, options: oauth.CustomFetchOptions<string, string | URLSearchParams | undefined>) => Promise<Response>} {
if (fetchImplementation === undefined) {
return {}
}

return {
[oauth.customFetch]: (url, options) => fetchImplementation(url, {
method: options.method,
headers: options.headers,
body: options.body ?? null,
redirect: options.redirect,
signal: options.signal ?? null,
}),
}
}
1 change: 1 addition & 0 deletions src/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export * from "./ClientCredentialsTokenProvider.js"
export * from "./GetCodeCallback.js"
export * from "./issuerFrom.js"
export * from "./TokenProvider.js"
export * from "./TokenProviderOptions.js"
export * from "./GetIssuerCallback.js"
export * from "./IdpPicker.js"
export * from "./WebIdPicker.js"
Expand Down
Loading
Loading