diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c6a0b7..c7ad4f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/packages/auth-core/package.json b/packages/auth-core/package.json index bf584dd..90119a0 100644 --- a/packages/auth-core/package.json +++ b/packages/auth-core/package.json @@ -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", diff --git a/packages/auth-next/README.md b/packages/auth-next/README.md index 0afca59..e114bff 100644 --- a/packages/auth-next/README.md +++ b/packages/auth-next/README.md @@ -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 diff --git a/packages/auth-next/package.json b/packages/auth-next/package.json index fd66189..bd6383f 100644 --- a/packages/auth-next/package.json +++ b/packages/auth-next/package.json @@ -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", diff --git a/packages/auth-next/src/middleware.ts b/packages/auth-next/src/middleware.ts index 2d864d0..f73552d 100644 --- a/packages/auth-next/src/middleware.ts +++ b/packages/auth-next/src/middleware.ts @@ -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. * @@ -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) { diff --git a/packages/auth-next/test/middleware.test.ts b/packages/auth-next/test/middleware.test.ts index abd1752..1d7f2a4 100644 --- a/packages/auth-next/test/middleware.test.ts +++ b/packages/auth-next/test/middleware.test.ts @@ -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 () => { diff --git a/packages/auth-react/package.json b/packages/auth-react/package.json index f74ab2a..2e51b9d 100644 --- a/packages/auth-react/package.json +++ b/packages/auth-react/package.json @@ -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",