Skip to content
Merged
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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,20 @@ The three original packages (`@wilsoon/auth-core`, `@wilsoon/auth-react`,
`@wilsoon/auth-next`) are versioned together. `@wilsoon/auth-machine` has no dependency on
them and is versioned independently, starting at 0.x.

## 3.0.1

One fix, in the Next.js middleware.

### Fixed - `@wilsoon/auth-next`

- **A fetch whose refresh fails no longer clears the session cookie.** Once the access token
was spent, every request in a page load refreshed with the same refresh token. One won the
rotation and wrote fresh tokens onto its response; any that failed expired the cookie on
theirs, and when that deletion reached the browser last it threw away a session that had just
been renewed. A fetch now gets the same redirect to `loginPath` but leaves the cookie alone;
the next navigation retries with whatever the cookie holds by then and signs out only if that
fails too. ([#4](https://github.com/Wilsoon7721/node-oidc-kit/issues/4))

## 3.0.0

The core is now standards-only. Everything specific to one identity provider moved behind a
Expand Down
60 changes: 30 additions & 30 deletions docs/docs.lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,9 @@
"pushedAt": "2026-10-05T18:29:29.717Z"
},
"getting-started/introduction": {
"hash": "d42e240331bae8a7f9b36cc6235f7313cbceb958772e679346df10e079efe4d0",
"published": "d42e240331bae8a7f9b36cc6235f7313cbceb958772e679346df10e079efe4d0",
"pushedAt": "2026-10-05T18:29:29.802Z"
"hash": "57a07ff3b57d89a8fa83db3d9e2858722f9de79a38a4b91cc55460c713b04620",
"published": "57a07ff3b57d89a8fa83db3d9e2858722f9de79a38a4b91cc55460c713b04620",
"pushedAt": "2026-10-12T20:02:59.255Z"
},
"getting-started/quick-start": {
"hash": "725f386d521f064b151d4173d0463aa13e3fd4e713a55afb048f801925c73fcc",
Expand All @@ -52,49 +52,49 @@
"pushedAt": "2026-10-05T18:29:29.518Z"
},
"flows/step-up": {
"hash": "9e727dab3f5d87d1c5fb18885a9c097911e3b273602b234a45627e332b6804d0",
"published": "9e727dab3f5d87d1c5fb18885a9c097911e3b273602b234a45627e332b6804d0",
"pushedAt": "2026-10-05T18:29:29.630Z"
"hash": "fa910d67c8049c9f013031f3b9310ba85d2885fabc8241aa66a9b1b0d5a1760b",
"published": "fa910d67c8049c9f013031f3b9310ba85d2885fabc8241aa66a9b1b0d5a1760b",
"pushedAt": "2026-10-12T20:02:59.115Z"
},
"recipes/authorization": {
"hash": "2b00df0d7298fab19fd506af637d8d10df792d62b642aab9a9c85845883b6e89",
"published": "2b00df0d7298fab19fd506af637d8d10df792d62b642aab9a9c85845883b6e89",
"pushedAt": "2026-10-05T18:29:29.978Z"
"hash": "653870a868dba90ffba06b7c2734675f8d55ce5b5156a4c546c72dc9cc47d364",
"published": "653870a868dba90ffba06b7c2734675f8d55ce5b5156a4c546c72dc9cc47d364",
"pushedAt": "2026-10-12T20:02:59.425Z"
},
"recipes/checklist": {
"hash": "566ac90e0042e5867c0ba012bced0b937ecbd38372d77940b80ac86d641be577",
"published": "566ac90e0042e5867c0ba012bced0b937ecbd38372d77940b80ac86d641be577",
"pushedAt": "2026-10-05T18:29:30.052Z"
"hash": "e371816ef6ae00318dbe64a627c75b531b49accb029dbfe548b97576de2a38a2",
"published": "e371816ef6ae00318dbe64a627c75b531b49accb029dbfe548b97576de2a38a2",
"pushedAt": "2026-10-12T20:03:00.571Z"
},
"recipes/logout": {
"hash": "c5bc579cbfc4e0e62de895a9b9d9a459d2dd3dab727413ec95d2f20660c121c2",
"published": "c5bc579cbfc4e0e62de895a9b9d9a459d2dd3dab727413ec95d2f20660c121c2",
"pushedAt": "2026-10-05T18:29:30.125Z"
"hash": "98521c0387ff0d57fadee2a0cac92ea4c9b8a7527030885df78bc9799e7114f9",
"published": "98521c0387ff0d57fadee2a0cac92ea4c9b8a7527030885df78bc9799e7114f9",
"pushedAt": "2026-10-12T20:03:03.795Z"
},
"recipes/nextjs": {
"hash": "8df990993575f02f9a795ba88bbbcc2aa403e480ce17c65725c04648cbfacc58",
"published": "8df990993575f02f9a795ba88bbbcc2aa403e480ce17c65725c04648cbfacc58",
"pushedAt": "2026-10-05T18:29:33.118Z"
"hash": "ca88e451b767ccc81d6fca56d96f00b6a96bb80d6abd99712839d947e7237f7c",
"published": "ca88e451b767ccc81d6fca56d96f00b6a96bb80d6abd99712839d947e7237f7c",
"pushedAt": "2026-10-12T20:03:03.903Z"
},
"recipes/other-frameworks": {
"hash": "408555ce3ddf84b853d767a3f664be82cd735696152d6789e394cb794f1850d6",
"published": "408555ce3ddf84b853d767a3f664be82cd735696152d6789e394cb794f1850d6",
"pushedAt": "2026-10-05T18:29:34.365Z"
"hash": "1a740fa5c078394d4b321b9736f11312ec2c46b8d954331ae300e2b89a6dbb98",
"published": "1a740fa5c078394d4b321b9736f11312ec2c46b8d954331ae300e2b89a6dbb98",
"pushedAt": "2026-10-12T20:03:04.027Z"
},
"recipes/react-spa": {
"hash": "4f362ce519b3ea05951dd5d4bed0f2500c8e542ac70d7bc264a9f064b1fd6d46",
"published": "4f362ce519b3ea05951dd5d4bed0f2500c8e542ac70d7bc264a9f064b1fd6d46",
"pushedAt": "2026-10-05T18:29:34.440Z"
"hash": "af7ba1f48b7238b66c166dd9781e36e08a7b615d468c7367ddf119c72740c0c7",
"published": "af7ba1f48b7238b66c166dd9781e36e08a7b615d468c7367ddf119c72740c0c7",
"pushedAt": "2026-10-12T20:03:04.250Z"
},
"recipes/resource-server": {
"hash": "fda53073c7c5b5f85e5b9a6f92b941c59c38c22429115f6ae249b0e685ebfe59",
"published": "fda53073c7c5b5f85e5b9a6f92b941c59c38c22429115f6ae249b0e685ebfe59",
"pushedAt": "2026-10-05T18:29:34.510Z"
"hash": "b72593a124f439ce6b073490e7bcfa654cf05346f3153918f10d0e839f854686",
"published": "b72593a124f439ce6b073490e7bcfa654cf05346f3153918f10d0e839f854686",
"pushedAt": "2026-10-12T20:03:04.590Z"
},
"recipes/troubleshooting": {
"hash": "b885068af8d4f84cbbbd293c5196d5ca3fc42311105ed3e51c010278aa6247b4",
"published": "b885068af8d4f84cbbbd293c5196d5ca3fc42311105ed3e51c010278aa6247b4",
"pushedAt": "2026-10-05T18:29:34.589Z"
"hash": "a86a40f580640b6f6d98095145e151f47cdf648be6dbc33b982c3150d84907d3",
"published": "a86a40f580640b6f6d98095145e151f47cdf648be6dbc33b982c3150d84907d3",
"pushedAt": "2026-10-12T20:03:05.315Z"
}
}
}
12 changes: 6 additions & 6 deletions docs/getting-started/introduction.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,12 @@ This grew out of running my own OIDC provider for several first-party apps. The

## The packages

| Package | What it's for | Depends on |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------ | --------------------- |
| `@wilsoon/auth-core` | The client: discovery, PKCE, code exchange, token verification, introspection, `amr` policies. Framework-agnostic. | `jose` |
| `@wilsoon/auth-react` | `<AuthProvider>` and `useAuth()` for browser session state. | core, React ≥16 |
| `@wilsoon/auth-next` | `getSession()`, `requireSession()`, `createAuthMiddleware()`, `ServerCookieStorage`. | core, react, Next ≥14 |
| `@wilsoon/auth-machine` | `client_credentials` tokens for server-to-server calls, with caching and single-flight. **0.x**, standalone. | nothing |
| Package | What it's for | Depends on |
| ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | --------------------- |
| {% package name="@wilsoon/auth-core" /%} | The client: discovery, PKCE, code exchange, token verification, introspection, `amr` policies. Framework-agnostic. | `jose` |
| {% package name="@wilsoon/auth-react" /%} | `<AuthProvider>` and `useAuth()` for browser session state. | core, React ≥16 |
| {% package name="@wilsoon/auth-next" /%} | `getSession()`, `requireSession()`, `createAuthMiddleware()`, `ServerCookieStorage`. | core, react, Next ≥14 |
| {% package name="@wilsoon/auth-machine" /%} | `client_credentials` tokens for server-to-server calls, with caching and single-flight. **0.x**, standalone. | nothing |

The first three are versioned together on a shared 3.x line. `auth-machine` has no dependency on the others and is versioned independently - see [Machine tokens](/flows/machine-tokens).

Expand Down
18 changes: 9 additions & 9 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,16 @@
"typecheck": "turbo typecheck"
},
"devDependencies": {
"@types/node": "^25.6.0",
"@typescript-eslint/eslint-plugin": "^7",
"@typescript-eslint/parser": "^7",
"@wilsoon/docs": "^1.4.0",
"eslint": "^8.0.0",
"@types/node": "^26.6.5",
"@typescript-eslint/eslint-plugin": "^8.71.1",
"@typescript-eslint/parser": "^8.71.1",
"@wilsoon/docs": "^1.5.0",
"eslint": "^10.12.0",
"eslint-plugin-deprecation": "^3.0.0",
"eslint-plugin-jsdoc": "^46.10.1",
"prettier": "^3.0.0",
"rimraf": "^5.0.0",
"eslint-plugin-jsdoc": "^65.3.0",
"prettier": "^3.8.4",
"rimraf": "^6.1.3",
"turbo": "latest",
"typescript": "^5.0.0"
"typescript": "^7.0.2"
}
}
8 changes: 4 additions & 4 deletions packages/auth-core/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@wilsoon/auth-core",
"version": "3.0.0",
"version": "3.0.1",
"description": "Framework-agnostic OpenID Connect relying party: discovery, PKCE, and token verification for browsers, Node and edge runtimes",
"keywords": [
"oidc",
Expand Down Expand Up @@ -44,9 +44,9 @@
},
"license": "MIT",
"devDependencies": {
"tsup": "^8.0.0",
"typescript": "^5.0.0",
"vitest": "^1.0.0"
"tsup": "^8.5.1",
"typescript": "^5.9.3",
"vitest": "^1.6.1"
},
"dependencies": {
"jose": "^5.10.0"
Expand Down
6 changes: 3 additions & 3 deletions packages/auth-machine/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,9 @@
},
"license": "MIT",
"devDependencies": {
"tsup": "^8.0.0",
"typescript": "^5.0.0",
"vitest": "^1.0.0"
"tsup": "^8.5.1",
"typescript": "^5.9.3",
"vitest": "^1.6.1"
},
"engines": {
"node": ">=18"
Expand Down
2 changes: 1 addition & 1 deletion packages/auth-next/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export const config = {

The middleware verifies the session, refreshes an access token that is near expiry (writing the rotated tokens back to the cookie), and clears the cookie instead of looping when a session cannot be verified. Pass `verify: false` to get 1.x behaviour - presence-of-cookie routing only, which any client can fake.

A refresh token is single-use where the provider rotates it, so a page load that sends ten requests through the middleware with one cookie must not send ten refreshes: nine of them present a token that has just been rotated away, and a provider that reads that as theft revokes the session. Refreshes therefore start `refreshThresholdSeconds` before expiry (default 300) and only on navigations, which is one request per page load. Set `refreshOn: "request"` to refresh from any request instead. A token with nothing left refreshes whatever the setting, so an app that only ever sends fetches still renews, and a refresh that fails while the access token is still valid leaves the session alone for a later request to retry.
A refresh token is single-use where the provider rotates it, so a page load that sends ten requests through the middleware with one cookie must not send ten refreshes: nine of them present a token that has just been rotated away, and a provider that reads that as theft revokes the session. Refreshes therefore start `refreshThresholdSeconds` before expiry (default 300) and only on navigations, which is one request per page load. Set `refreshOn: "request"` to refresh from any request instead. A token with nothing left refreshes whatever the setting, so an app that only ever sends fetches still renews, and a refresh that fails while the access token is still valid leaves the session alone for a later request to retry. Once the token is spent, a failed refresh signs out on a navigation but only turns a fetch away, leaving the cookie for a request beside it that may have rotated it successfully.

### 2. Accessing Session in Server Components

Expand Down
10 changes: 5 additions & 5 deletions packages/auth-next/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@wilsoon/auth-next",
"version": "3.0.0",
"version": "3.0.1",
"description": "OpenID Connect for the Next.js App Router: verified sessions, middleware and hardened cookie storage",
"keywords": [
"oidc",
Expand Down Expand Up @@ -55,10 +55,10 @@
"@wilsoon/auth-react": "workspace:*"
},
"devDependencies": {
"next": "^16.0.0",
"tsup": "^8.0.0",
"typescript": "^5.0.0",
"vitest": "^1.0.0"
"next": "^16.4.0",
"tsup": "^8.5.1",
"typescript": "^5.9.3",
"vitest": "^1.6.1"
},
"engines": {
"node": ">=18"
Expand Down
9 changes: 7 additions & 2 deletions packages/auth-next/src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ class RequestCookieStorage implements AuthStorage {
* then verify, with `enforce: "live"` ask the provider whether the session still stands, and check the `roles`/`permissions`/`amr` policy if one was given. A session that cannot be verified redirects to `loginPath` and expires the cookie, so a request never proceeds on one.
*
* Refreshes are attempted `refreshThresholdSeconds` before expiry and, by default, only on navigations - one per page load rather than one per parallel fetch, all of them presenting the same single-use refresh token.
* A refresh that fails while the access token is still valid is ignored.
* A refresh that fails while the access token is still valid is ignored. One that fails on a spent token signs out on a navigation, but only turns a fetch away, leaving the cookie for a parallel request that may have rotated it.
*
* Scope it with a `config.matcher` as usual - it runs on every matched request, and verification is not free even with the JWKS cached.
*
Expand Down Expand Up @@ -182,7 +182,12 @@ export function createAuthMiddleware(options: AuthMiddlewareOptions) {
try {
rotated = await sharedClient.refreshAccessToken(tokens.refresh_token, { persist: false });
} catch {
if (spent) return signOut(loginPath);
// A fetch is turned away but leaves the cookie alone. It may have lost the rotation
// to a request beside it, whose response is writing fresh tokens to that same
// cookie, and expiring it here would race that write and could win. The next
// navigation retries with whatever the cookie holds by then, and clears it if that
// fails too.
if (spent) return isNavigation ? signOut(loginPath) : NextResponse.redirect(redirectTo(loginPath));
}

if (rotated) {
Expand Down
31 changes: 28 additions & 3 deletions packages/auth-next/test/middleware.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,16 +208,41 @@ describe('createAuthMiddleware', () => {
expect(response.cookies.get(STORAGE_KEYS.tokens)?.value).toBe('');
});

it('signs out when the refresh is rejected', async () => {
it('signs out when the refresh is rejected on a navigation', async () => {
idp.setTokenHandler(() => ({ status: 400, body: { error: 'invalid_grant' } }));

const response = await middlewareFor()(requestWith({
const navigation = requestWith({
access_token: await idp.mintAccessToken({}, { expiresInSeconds: -5 }),
id_token: await idp.mintIdToken(),
refresh_token: 'rt-1',
}));
});
navigation.headers.set('sec-fetch-mode', 'navigate');

const response = await middlewareFor({ cookieDomain: '.wilsoon.dev' })(navigation);

expect(locationOf(response)?.pathname).toBe('/auth');
expect(response.cookies.get(STORAGE_KEYS.tokens)?.maxAge).toBe(0);
});

it('turns away a fetch whose refresh was rejected, without clearing the cookie', async () => {
/*
The fetch may have lost the rotation to a request beside it, whose response is writing
fresh tokens to the same cookie. Expiring the cookie here raced that write, and when
it landed last it threw away a session that had just been renewed.
*/
idp.setTokenHandler(() => ({ status: 400, body: { error: 'invalid_grant' } }));

const fetched = requestWith({
access_token: await idp.mintAccessToken({}, { expiresInSeconds: -5 }),
id_token: await idp.mintIdToken(),
refresh_token: 'rt-1',
});
fetched.headers.set('sec-fetch-mode', 'cors');

const response = await middlewareFor({ cookieDomain: '.wilsoon.dev' })(fetched);

expect(locationOf(response)?.pathname).toBe('/auth');
expect(response.cookies.get(STORAGE_KEYS.tokens)).toBeUndefined();
});

it('enforces a role policy', async () => {
Expand Down
14 changes: 7 additions & 7 deletions packages/auth-react/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@wilsoon/auth-react",
"version": "3.0.0",
"version": "3.0.1",
"description": "React context and hooks for OpenID Connect browser sessions",
"keywords": [
"oidc",
Expand Down Expand Up @@ -51,13 +51,13 @@
},
"devDependencies": {
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16",
"@types/react": "^19.0.0",
"@testing-library/react": "^16.3.2",
"@types/react": "^19.2.17",
"jsdom": "^30.0.1",
"react": "^19.0.0",
"tsup": "^8.0.0",
"typescript": "^5.0.0",
"vitest": "^1.0.0"
"react": "^19.2.7",
"tsup": "^8.5.1",
"typescript": "^5.9.3",
"vitest": "^1.6.1"
},
"engines": {
"node": ">=18"
Expand Down
Loading
Loading