@payez/next-mvp 4.10.0 → 4.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +31 -0
- package/dist/api-handlers/auth/refresh.d.ts +17 -58
- package/dist/api-handlers/auth/refresh.js +71 -772
- package/dist/auth/utils/token-utils.d.ts +1 -1
- package/dist/auth/utils/token-utils.js +3 -2
- package/dist/lib/api-handler.js +4 -3
- package/dist/lib/ensure-fresh-access-token.d.ts +24 -1
- package/dist/lib/ensure-fresh-access-token.js +194 -28
- package/dist/lib/idp-fetch.js +3 -2
- package/dist/lib/refresh-in-doubt.d.ts +31 -0
- package/dist/lib/refresh-in-doubt.js +81 -0
- package/dist/lib/refresh-token-validator.js +2 -1
- package/dist/lib/refresh-window.d.ts +16 -0
- package/dist/lib/refresh-window.js +19 -0
- package/dist/lib/safe-trace-id.d.ts +11 -0
- package/dist/lib/safe-trace-id.js +23 -0
- package/dist/lib/session-store.js +14 -4
- package/dist/lib/token-lifecycle.js +3 -2
- package/dist/models/SessionModel.d.ts +7 -0
- package/dist/routes/auth/refresh.d.ts +1 -15
- package/dist/server/auth.d.ts +1 -1
- package/dist/stores/authStore.js +3 -2
- package/package.json +1 -1
- package/scripts/verify-release-provenance.mjs +1 -0
- package/src/api-handlers/auth/refresh.ts +72 -812
- package/src/auth/utils/token-utils.ts +3 -2
- package/src/lib/api-handler.ts +4 -3
- package/src/lib/ensure-fresh-access-token.ts +213 -29
- package/src/lib/idp-fetch.ts +3 -2
- package/src/lib/refresh-in-doubt.ts +79 -0
- package/src/lib/refresh-token-validator.ts +2 -1
- package/src/lib/refresh-window.ts +16 -0
- package/src/lib/safe-trace-id.ts +21 -0
- package/src/lib/session-store.ts +15 -3
- package/src/lib/token-lifecycle.ts +3 -2
- package/src/models/SessionModel.ts +7 -0
- package/src/server/auth.ts +1 -1
- package/src/stores/authStore.ts +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,36 @@
|
|
|
1
1
|
# Changelog — @payez/next-mvp
|
|
2
2
|
|
|
3
|
+
## 4.11.0 (to be published under dist-tag `next` only, after 4.10.0; `latest` stays 4.8.9 until Jon says otherwise)
|
|
4
|
+
|
|
5
|
+
Carries PRs 1443, 1444, 1445, 1446, 1451, 1452, 1460 and 1461 (and the 1450 design doc): the refresh consolidation (the refresh route is now a thin wrapper over `ensureFreshAccessToken`), the one freshness window, the fetch timeout with the in-doubt marker, and the trace-id cleanup. A minor, because response codes and bodies that callers may match on changed (BREAKING below).
|
|
6
|
+
|
|
7
|
+
- **BREAKING (for callers that match on refresh error codes, wording or bodies; BAPert 76495 item 4).** Each is detailed in the entries below: (1) a Better-Auth-only session answers 401 `NO_CANONICAL_SESSION` (terminal) where it answered `NO_REFRESH_TOKEN`; (2) a pending-2FA row answers 401 `NO_TOKEN_PENDING_2FA` and is no longer refreshed; (3) new answers `504 UPSTREAM_TIMEOUT` and `401 REFRESH_OUTCOME_UNKNOWN` (a refresh whose outcome is unknown is never replayed); (4) a non-envelope IdP answer on the refresh route is `502 Non-compliant IDP envelope` (was `Upstream non-compliance`); the route's `IDP service unavailable` and `Invalid response from IDP` wording is unchanged (only `ensureFreshAccessToken`'s own result messages changed, see its entry below); (5) the `409 CONFLICT` body gains `retryable: true`; (6) **a bare 401 whose body is not an IdP envelope no longer discards the token** (only the IdP's own verdict does: a typed error with `discard_token`, or the `IDP_DISCARD_TOKEN` path; a bare 401 comes from a proxy or gateway, and DotNetPert 76454 shows every Core refresh 401 is an envelope). Also, a refresh whose result could not be stored answers the terminal `SESSION_PERSIST_FAILED` where the route used to answer a generic 500. A caller that matched the old strings or codes must be updated.
|
|
8
|
+
|
|
9
|
+
- **FIX: the caller's trace id is cleaned before it is logged (PAY-2376).** `ensureFreshAccessToken` logged `options.requestId` and the refresh route passed the `x-request-id` header into it: attacker-chosen text in the log sink (a newline forges a line, a huge value floods it). New `lib/safe-trace-id` `sanitizeTraceId` removes control characters (C0, DEL, C1, U+2028/U+2029), caps the length at 64 and turns anything that is not a non-empty string into `undefined`; both the route and E use it. Log correlation only: it was never the lock identity. Rows: `test/next-mvp-4910-trace-id-sanitize.test.cjs`.
|
|
10
|
+
- **CHANGE: the last 5-minute freshness literals are on the one refresh window (PAY-2374).** `lib/api-handler.ts` (three sites), `lib/token-lifecycle.ts` (`REFRESH_THRESHOLD_MS`), `auth/utils/token-utils.ts` (`tokenNeedsRefresh`'s default `bufferMs`) and `stores/authStore.ts` (post-rehydration freshness check) and `lib/refresh-token-validator.ts` (`checkRefreshViability`'s buffer, in seconds) now use `REFRESH_WINDOW_MS` from `lib/refresh-window`, the value the refresh route, `ensureFreshAccessToken` and `idp-fetch` already use. No value changes today (all were 5 minutes); the point is that they can no longer drift apart. Cache TTLs and the circuit breaker are not freshness and are left alone. Rows: `test/next-mvp-4910-refresh-window-literals.test.cjs`.
|
|
11
|
+
- **CHANGE: `POST /api/auth/refresh` (`api-handlers/auth/refresh`) is now a thin wrapper over `ensureFreshAccessToken` (BAPert 76449, R-over-E PR 2).** One implementation talks to the IdP, holds the refresh lock and writes the tokens; the route keeps what only an HTTP request has: resolving the session from the Better Auth cookie, the `x-session-token` ownership rejection, the `x-request-id` trace id, and the mapping of E's typed result onto the route's existing JSON bodies and status codes. Success bodies are unchanged (`{ refreshed, [reason: 'already_fresh' | 'completed_by_concurrent_request'], accessTokenExpires, hasRefreshToken }`; the access token is never in a body), as are the bodies for `UNAUTHORIZED`, `NO_REFRESH_TOKEN`, `CONFLICT` and the upstream failures. **What changes for a caller of the route:**
|
|
12
|
+
- A **Better-Auth-only session** (no canonical row) now answers 401 `NO_CANONICAL_SESSION` (terminal) instead of `NO_REFRESH_TOKEN`; and a session whose row vanished while the request waited for the lock answers 401 `UNAUTHORIZED` (no session).
|
|
13
|
+
- A row with a refresh token but **no access token** is refreshed only when it is not 2FA-pending; a **pending-2FA** row now answers `NO_TOKEN_PENDING_2FA` instead of being refreshed (refresh is never a way around 2FA).
|
|
14
|
+
- The IdP envelope rule is E's (no `meta` requirement): a response that is not an envelope is a 502 `Non-compliant IDP envelope` (was `Upstream non-compliance`), and a **bare 401 whose body is not an envelope no longer discards the token**.
|
|
15
|
+
- The `409 CONFLICT` body gains `retryable: true`; the lock identity prefix is `ensure_fresh_` (not observable). The window (5 min), the fetch timeout, the in-doubt marker, the dropped-persist answer and the `success:false` meaning are the ones already released, now in one place.
|
|
16
|
+
- Tests: the rows that broke the route's source now break `ensureFreshAccessToken`'s (the same defects, loaded through the real route); new `test/next-mvp-4910-refresh-route-wrapper.test.cjs` pins the wrapper's own mapping, the ownership rejection and the refresh-token requirement. The mutants are loaded through a per-process module hook (`test/lib/swap-dist.cjs`), never written over shipped `dist` files (the test files run in parallel processes).
|
|
17
|
+
|
|
18
|
+
- **FIX + CHANGE: `ensureFreshAccessToken` brought to what the refresh route does, so the route can become a thin wrapper over it (BAPert 76449, R-over-E PR 1; E only).**
|
|
19
|
+
- **FIX (twin of the route's 1432 fix): the write-back could put a SPENT refresh token back.** E SENT the post-lock token but its write-back fallbacks (`idpRefreshToken` when the IdP answer carries no new refresh token, `idpRefreshTokenExpires`, `bearerKeyId`, `authenticationMethods`/`authenticationLevel` and the MFA timing fields) read the PRE-lock session, and it spread the whole pre-lock row over the canonical row. If a concurrent request rotated the token while this one waited for the lock and the IdP omitted `refresh_token`, the rotated token was overwritten by the spent one (a dead stored credential), and a concurrent writer's change to any other field was overwritten too. The fallbacks now read the post-lock canonical row, the stored token is the one that was SENT, and only the refreshed fields are written (`updateSession` merges).
|
|
20
|
+
- **CHANGE: the result carries a `reason`** (`'already_fresh' | 'refreshed' | 'completed_by_concurrent_request'`), additive. `refreshed` is unchanged (it is `true` for both the last two). The route's response bodies, and `lib/token-lifecycle`'s `reason === 'already_fresh'`, are built from it.
|
|
21
|
+
- **CHANGE: a row with a refresh token but NO access token is refreshed when it is not 2FA-pending** (as the route always did); it used to answer `NO_TOKEN`. A pending-2FA row keeps `NO_TOKEN_PENDING_2FA` even with a refresh token (refresh is never a way around 2FA); no refresh token is `NO_TOKEN` as before.
|
|
22
|
+
- **CHANGE: `persisted` means the canonical row.** A token served from the Better Auth fallback record is `persisted: false` (it used to say `true`). Nothing in this package reads it.
|
|
23
|
+
- **CHANGE: `ensureFreshAccessToken`'s result messages now use the route's wording (the route's own wording did not change).** A transport failure answers `'IDP service unavailable'` (it returned the raw error text, which can carry host names and socket details) and an unparseable non-2xx answer `'Invalid response from IDP'` (it answered `'Invalid JSON from IDP'`). Callers of `ensureFreshAccessToken` that matched the old `message` text must be updated; the `code`s are unchanged.
|
|
24
|
+
- **CHANGE: the IdP envelope rule (BAPert 76455 (B)): no `meta` requirement.** `success` must be a boolean; `success:true` needs `data.access_token` (a string); `success:false` needs `error.code` (a string); `meta`, `request_id` and `time_stamp` are optional; anything else (including `success:true` on a non-2xx, or a non-2xx body that is not an envelope) is a 502 `Non-compliant IDP envelope`, retryable, and DISCARDS NOTHING. Core's four hint-carrying error returns on `POST /api/ExternalAuth/refresh` carry no `meta` (they are the answers that carry `discard_token` / `retryable`), so they are mapped by their code and flags as before. A non-2xx whose body is not an envelope used to be treated by its status alone (a bare 401 discarded the token); it no longer is. Only Core's own verdict (a typed error with `discard_token`, or the `IDP_DISCARD_TOKEN` code path) proves the grant is dead; a bare 401 comes from a proxy or gateway, not from Core's refresh decision (DotNetPert 76454: every Core refresh 401 is an envelope), so it answers the 502 with the token kept and is not in doubt (BAPert 76480).
|
|
25
|
+
- Rows: `test/next-mvp-4910-ensure-fresh-r-parity.test.cjs` (a mutant per change).
|
|
26
|
+
|
|
27
|
+
- **FIX: the IdP refresh fetch has a timeout, and a timed-out refresh is never replayed (BAPert 76397 decision 3, corrected by 76403).** Neither the refresh route nor `ensureFreshAccessToken` had a fetch timeout. Core consumes a refresh token with one atomic update and has no reuse grace, and a replay revokes EVERY newer refresh token the user holds, on every device. So a refresh whose answer never arrived is outcome-UNKNOWN: Core may have consumed the token and minted a replacement we never saw, and the store still holds the token we sent. Now: the fetch carries `AbortSignal.timeout(10 s)` (`REFRESH_FETCH_TIMEOUT_MS`, covering the body read too); a timeout answers **504 `UPSTREAM_TIMEOUT`** (`outcomeUnknown: true`, `retryable: false`, `discardToken: false`; `ensureFreshAccessToken` returns the same code with `status: 504`) and writes onto the session row `refreshInDoubtTokenHash`, a SHA-256 of the token that was sent (never the token), and `refreshInDoubtAt`. Any later attempt that would send that same token **refuses without calling the IdP**: terminal 401 **`REFRESH_OUTCOME_UNKNOWN`** (`resolution: 'REAUTHENTICATE'`, `retryable: false`, `discardToken: false`) from both implementations. One login on one session instead of a user-wide revocation. The marker is dropped by the same `updateSession`/`setSession` write that stores a DIFFERENT refresh token; a write that stores the same token keeps it. Nothing tries to resolve the doubt without a login. This also closes the old replay: a hung fetch used to hold the lock for its 60 s TTL and then the next request re-sent the same token. **What changes for you:** a refresh that used to hang now answers 504 after 10 s; consumers that switch on `code` see `UPSTREAM_TIMEOUT` and, afterwards, `REFRESH_OUTCOME_UNKNOWN` (both new). **Any transport failure is outcome-unknown too** (BAPert 76433): a connection reset after the request was written looks the same as a timeout from here. It takes the same path (504 `UPSTREAM_TIMEOUT` plus the marker) UNLESS its cause code proves the request never left: the allowlist is exactly `ECONNREFUSED`, `ENOTFOUND`, `EAI_AGAIN` (read from the error, its `cause` and an AggregateError's members; EVERY code must be on the list). Those stay 503 `UPSTREAM_SERVICE_UNAVAILABLE`, retryable, with no marker. `ECONNRESET`, `UND_ERR_SOCKET`, an error with no code and a mixed list are in doubt; a failure while reading the response body (not a JSON syntax error) is in doubt as well, and so is a **2xx whose body is empty or does not parse** (BAPert 76441: a 2xx means Core processed the grant and consumed the token, so the replacement is lost). A **non-2xx** whose body is not JSON (a gateway HTML 502/503) did not mint and stays a completed failure: 502 `UPSTREAM_SERVICE_ERROR`, no marker. A false in-doubt costs one re-login; a missed one costs a user-wide revocation. Do not add a code to the list without proof it can only occur before the request is sent. The `SessionData` type gains `refreshInDoubtTokenHash` and `refreshInDoubtAt`. Rows: `test/next-mvp-4910-refresh-timeout-in-doubt.test.cjs` (no marker write -> the replay is sent; marker never cleared -> RED; signal removed -> RED).
|
|
28
|
+
|
|
29
|
+
- **CHANGE: one freshness window, 5 minutes, for the refresh route, `ensureFreshAccessToken` and `lib/idp-fetch` (BAPert 76397 decision 1).** They disagreed: the route and `idp-fetch` used 5 minutes, `ensureFreshAccessToken` used 60 seconds, so a session could be stale to one and fresh to another and two callers rotated a token the other had just rotated. They now share `REFRESH_WINDOW_MS` (`lib/refresh-window`). Core's access-token lifetime is a fixed 15 minutes, so 5 minutes is the ruled value (lifetime / 3 if it were shorter). **What changes for you:** callers of `ensureFreshAccessToken` (`getIdpToken` in `server/auth.ts`, the OAuth resume handler) now refresh up to 4 minutes earlier than before, so slightly more IdP refreshes per token lifetime and never a staler token; the `safetyWindowMs` option still exists and still wins when passed. The route's and `idp-fetch`'s behaviour is unchanged. Row: `test/next-mvp-4910-one-refresh-window.test.cjs` (each path probed at 6 and 4 minutes left, a mutant per path).
|
|
30
|
+
- **FIX: `ensureFreshAccessToken` no longer spends a refresh token it cannot store (BAPert 76397 Finding 1).** It READ a session through the Better Auth fallback but PERSISTED only into the canonical session row (`updateSession` returns `null` when that row is absent). For a session that lives only under the Better Auth key it therefore called the IdP, which rotated and spent the refresh token, could not store the answer, and returned `SESSION_PERSIST_FAILED` with the replacement token lost. It now requires the canonical row BEFORE the IdP call: no row -> no IdP call and a typed terminal answer, 401 `NO_CANONICAL_SESSION` (`terminal`, `discardToken: false`, `retryable: false`, `resolution: 'REAUTHENTICATE'`; the Better Auth record is left alone). `server/auth.ts` forwards `result.code`, so a consumer that switches on codes sees the new one for these sessions, where it saw `SESSION_PERSIST_FAILED` after a spent token. `SESSION_PERSIST_FAILED` remains for a row that disappears after the check. Row: `test/next-mvp-4910-ensure-fresh-canonical-row.test.cjs` (with the check removed it goes RED and the token is lost); the 320072 file now makes the row vanish during the IdP call.
|
|
31
|
+
|
|
32
|
+
- **FIX: `POST /api/auth/refresh` no longer ignores a dropped persist (BAPert 76392 item 2).** `updateSession` returns `null` when the canonical session row is gone. The refresh route ignored that, so after the IdP exchange had already spent the old refresh token it answered a generic 500 'No access token after refresh'. It now answers 401 `SESSION_PERSIST_FAILED` (`discardToken: true`, `retryable: false`, `resolution: 'REAUTHENTICATE'`), the same code and meaning `ensureFreshAccessToken` has answered since 320072, and logs the drop. Row: `test/next-mvp-4910-refresh-persist-failed.test.cjs` (with the check removed the row goes RED).
|
|
33
|
+
|
|
3
34
|
## 4.10.0 (to be published under dist-tag `next` only, after 4.9.0; `latest` stays 4.8.9 until Jon says otherwise)
|
|
4
35
|
|
|
5
36
|
Carries PRs 1432, 1435 and 1438. A minor, not a patch, because a removed export breaks importers (BREAKING below).
|
|
@@ -1,37 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* REFRESH TOKEN API HANDLER - a thin HTTP wrapper over `ensureFreshAccessToken` (R-over-E, BAPert 76449; design: docs/refresh-r-over-e-delta.md)
|
|
3
3
|
*
|
|
4
|
-
* ASK BEFORE EDITING -
|
|
4
|
+
* ASK BEFORE EDITING - this is the route every browser, middleware and `internalRefresh` caller reaches.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
* -
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
6
|
+
* `lib/ensure-fresh-access-token` is the ONE implementation that talks to the IdP, holds the refresh lock and writes the tokens (post-lock
|
|
7
|
+
* token, in-doubt marker, fetch timeout, envelope rule, dropped-persist and canonical-row handling all live there). This file keeps what only
|
|
8
|
+
* an HTTP request has:
|
|
9
|
+
* - resolving the session from the request's own Better Auth cookie,
|
|
10
|
+
* - the `x-session-token` ownership rejection (below),
|
|
11
|
+
* - the `x-request-id` trace id (log correlation only; it is never the lock identity),
|
|
12
|
+
* - mapping E's typed result onto this route's existing JSON bodies and status codes.
|
|
13
|
+
* The bodies did not change except where E's rules did (see CHANGELOG): the browser, `middleware/create-middleware`, `lib/token-lifecycle`
|
|
14
|
+
* (which reads `reason === 'already_fresh'`) and `lib/idp-fetch` / `lib/api-handler` (via `internalRefresh`) depend on them.
|
|
12
15
|
*
|
|
13
16
|
* ── 246007: THE `x-session-token` FALLBACK IS GONE. IT NOW HARD-DENIES. ────
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* there is no session to establish ownership of the token it names. Accepting
|
|
18
|
-
* it let a caller refresh a session they did not own.
|
|
17
|
+
* A request presenting `x-session-token` with no cookie-resolved session of its own is rejected 401 UNAUTHORIZED, because there is no
|
|
18
|
+
* session to establish ownership of the token it names. Accepting it let a caller refresh a session they did not own. This docstring
|
|
19
|
+
* ships (it is compiled into dist and the .d.ts), so it describes what the code does.
|
|
19
20
|
*
|
|
20
|
-
*
|
|
21
|
-
* rather than quietly dropped, because THIS DOCSTRING SHIPS. It is compiled
|
|
22
|
-
* into dist and into the .d.ts, so it reaches every consumer's editor as hover
|
|
23
|
-
* text. QAPert-NightHawk caught that the fixed build was still describing the
|
|
24
|
-
* removed capability as a current feature (34973) — her verifier tests code
|
|
25
|
-
* properties and reads no comments, so a correct build could have published
|
|
26
|
-
* documentation asserting the opposite of what the code does.
|
|
27
|
-
*
|
|
28
|
-
* That matters twice over: an internal caller relying on the fallback gets a
|
|
29
|
-
* silent behavioural break while the docs promise it still works, and a
|
|
30
|
-
* published package that misdescribes its own auth behaviour is the same
|
|
31
|
-
* amplifier that put other defects onto three surfaces — shipped code carrying
|
|
32
|
-
* instructions for using it wrongly.
|
|
33
|
-
*
|
|
34
|
-
* @version 2.1
|
|
21
|
+
* @version 3.0
|
|
35
22
|
*/
|
|
36
23
|
import { NextRequest, NextResponse } from 'next/server';
|
|
37
24
|
interface RefreshConfig {
|
|
@@ -57,38 +44,10 @@ interface RefreshConfig {
|
|
|
57
44
|
* });
|
|
58
45
|
* ```
|
|
59
46
|
*/
|
|
60
|
-
export declare function createRefreshHandler(config: RefreshConfig): (req: NextRequest) => Promise<NextResponse<
|
|
61
|
-
error: string;
|
|
62
|
-
code: string;
|
|
63
|
-
}> | NextResponse<{
|
|
64
|
-
error: any;
|
|
65
|
-
code: any;
|
|
66
|
-
discardToken: boolean;
|
|
67
|
-
retryable: boolean;
|
|
68
|
-
resolution: any;
|
|
69
|
-
status: number;
|
|
70
|
-
}> | NextResponse<{
|
|
71
|
-
refreshed: boolean;
|
|
72
|
-
accessTokenExpires: number;
|
|
73
|
-
hasRefreshToken: boolean;
|
|
74
|
-
}>>;
|
|
47
|
+
export declare function createRefreshHandler(config: RefreshConfig): (req: NextRequest) => Promise<NextResponse<unknown>>;
|
|
75
48
|
/**
|
|
76
49
|
* Default POST export — drop-in for `app/api/auth/refresh/route.ts`.
|
|
77
50
|
* Requires environment variables: IDP_URL, CLIENT_ID
|
|
78
51
|
*/
|
|
79
|
-
export declare const POST: (req: NextRequest) => Promise<NextResponse<
|
|
80
|
-
error: string;
|
|
81
|
-
code: string;
|
|
82
|
-
}> | NextResponse<{
|
|
83
|
-
error: any;
|
|
84
|
-
code: any;
|
|
85
|
-
discardToken: boolean;
|
|
86
|
-
retryable: boolean;
|
|
87
|
-
resolution: any;
|
|
88
|
-
status: number;
|
|
89
|
-
}> | NextResponse<{
|
|
90
|
-
refreshed: boolean;
|
|
91
|
-
accessTokenExpires: number;
|
|
92
|
-
hasRefreshToken: boolean;
|
|
93
|
-
}>>;
|
|
52
|
+
export declare const POST: (req: NextRequest) => Promise<NextResponse<unknown>>;
|
|
94
53
|
export {};
|