Skip to content

Commit d61139f

Browse files
claude[bot]claude
andauthored
fix(client): a bearer-mode ObjectStackClient adopts the session token the three rotating auth routes hand it (#17182)
* fix(client): adopt the rotated session token on the three rotating auth routes Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8 * test(client): drive the rotating auth routes through the real better-auth pipeline Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8 * test(client): register the identity objects AuthManager's boot path reads Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8 * docs(changeset): declare the rotated-session-token adoption Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8 * build(client): resolve the suite's two new producers from source in tsc too Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8 * test(client): classify the new credential helper in the URL-conformance sweep Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8 * test(client): open the ① probe on `login`, the way the card's own probe opens The card's end-to-end probe is `login -> enable -> verifyTotp -> disable -> deleteUser`. The suite opened on the registration that had to precede that login, which is the same credential state by construction but not the same line. Spell the first step as the card spells it: a real second sign-in whose echoed token the SDK stores, asserted against the stored credential before the sequence continues. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c3756ff commit d61139f

8 files changed

Lines changed: 630 additions & 11 deletions
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
"@objectstack/client": minor
3+
---
4+
5+
feat(client): a bearer-mode `ObjectStackClient` keeps the session the server rotates it onto (#16534)
6+
7+
Three better-auth routes ROTATE the caller's session on success — they mint a new session, install it in `Set-Cookie` (and, through `bearer()`, in the `set-auth-token` response header), and DELETE the row the caller was presenting:
8+
9+
| route | where the new credential is |
10+
| --- | --- |
11+
| `auth.twoFactor.verifyTotp()` on the enrolment lane | body — `token`, and it is the LIVE one (plugin-auth's `two-factor-rotated-token-echo` repairs the vendor's stale echo) |
12+
| `auth.changePassword({ revokeOtherSessions: true })` | body — `token` |
13+
| `auth.twoFactor.disable()` | **response header only** — the body is `{ status: true }` |
14+
15+
A browser is carried across all three by its own cookie. A bearer client — this SDK's own mode — kept presenting the DELETED session's token, so its very next call answered `401 UNAUTHORIZED`. Measured against a real `AuthManager` (better-auth 1.7.2) over a real driver, driven through the real `ObjectStackClient`, `login → enable → verifyTotp → disable → deleteUser` could not run to the end without the caller re-seating `client.token` by hand between the steps.
16+
17+
The three methods now adopt the rotated credential themselves, the way `login()` already adopts the token it is handed. The `token` members stay on the wire and stay declared, so a caller that keeps its own credential store is unaffected; what changes is that it no longer has to.
18+
19+
**No public surface moves.** No new export, no new option or flag, no new key on any declared request or response type — the SDK stores a token the server already sends and this package already declares. Graded `minor` rather than `patch` because the published runtime behaviour of three methods moves for existing callers.
20+
21+
## What does NOT change, deliberately
22+
23+
The adoption is on those three routes only, never in the shared `fetch` wrapper. `set-auth-token` rides **every** response that stages a session cookie — `POST /update-user` stages one to carry the updated user without rotating anything — and it carries the SIGNED `<token>.<sig>` spelling while every JSON `token` echo carries the UNSIGNED one. A wrapper-level read would therefore rewrite the stored credential into a different spelling of the SAME session on ordinary traffic. `auth.me()`, `auth.sessions.list()`, `auth.updateUser()` and `auth.twoFactor.verifyBackupCode()` (which does not rotate — the vendor echoes the session it resolved at entry) all leave the stored credential byte-identical, and that is pinned.
24+
25+
A cookie-only deployment sends no `set-auth-token`; there is then nothing to adopt and `twoFactor.disable()` leaves the stored credential exactly as it was. `changePassword` without `revokeOtherSessions` answers `token: null` and likewise stores nothing.
26+
27+
The three TSDoc warnings that told bearer callers "this SDK does not store it" are updated in the same change.

‎packages/client/package.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@
3232
"@objectstack/metadata-core": "workspace:*",
3333
"@objectstack/metadata-protocol": "workspace:*",
3434
"@objectstack/objectql": "workspace:*",
35+
"@objectstack/platform-objects": "workspace:*",
36+
"@objectstack/plugin-auth": "workspace:*",
3537
"@objectstack/plugin-hono-server": "workspace:*",
3638
"@objectstack/rest": "workspace:*",
3739
"@objectstack/runtime": "workspace:*",

‎packages/client/src/auth-rotated-session-token.test.ts‎

Lines changed: 476 additions & 0 deletions
Large diffs are not rendered by default.

‎packages/client/src/client-url-conformance.test.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,11 @@ const NON_HTTP: Record<string, string> = {
277277
'getRoute': 'pure route-table lookup',
278278
'unwrapResponse': 'pure envelope unwrap',
279279
'isFilterAST': 'pure type predicate',
280+
// [#16534] Local credential state: it writes `this.token` from a value the
281+
// three rotating auth routes have ALREADY received, and issues nothing of its
282+
// own. Those three routes are swept on their own rows, so parking this helper
283+
// here drops no call out of coverage.
284+
'adoptRotatedSessionToken': 'local state',
280285
'environment': 'constructs a ScopedEnvironmentClient; its methods are swept separately',
281286
'setProjectId': 'local state',
282287
'getProjectId': 'local state',

‎packages/client/src/index.ts‎

Lines changed: 80 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1113,8 +1113,12 @@ export interface AuthPasswordChangeResult {
11131113
* ⚠️ SECRET — an unsigned session token. When `revokeOtherSessions: true`
11141114
* made the server rotate the caller's session this is the NEW session's
11151115
* token (every other session is gone and the cookie the caller held is
1116-
* dead); `null` otherwise. A bearer-mode caller has to store it itself —
1117-
* this SDK does not.
1116+
* dead); `null` otherwise.
1117+
*
1118+
* A bearer-mode caller no longer has to store it by hand: `changePassword`
1119+
* adopts a non-null value into the client's own credential before it
1120+
* resolves, the way `login()` adopts the token it is handed. The field is
1121+
* unchanged and still echoed, for a caller that keeps its own store.
11181122
*/
11191123
token: string | null;
11201124
/** The caller, as better-auth's session held it when the write ran. */
@@ -1153,6 +1157,11 @@ export interface AuthTwoFactorVerificationResult {
11531157
* `two-factor-rotated-token-echo` repairs the vendor's stale echo).
11541158
* Through this SDK `verifyBackupCode` cannot send `disableSession`, so
11551159
* the token is always present.
1160+
*
1161+
* `verifyTotp` adopts it into the client's own credential; `verifyBackupCode`
1162+
* does NOT, and the asymmetry is the wire fact rather than an omission —
1163+
* `/two-factor/verify-backup-code` never rotates, so what it echoes is the
1164+
* session the caller is already presenting.
11561165
*/
11571166
token: string;
11581167
/**
@@ -1438,6 +1447,18 @@ const DEFAULT_DATA_PREFIX = '/data';
14381447
*/
14391448
const DEFAULT_META_PREFIX = '/meta';
14401449

1450+
/**
1451+
* The response header better-auth's `bearer()` plugin puts a freshly installed
1452+
* session token in — the SIGNED `<token>.<sig>` form, emitted on every response
1453+
* that stages a session cookie, and added to `Access-Control-Expose-Headers` by
1454+
* the plugin itself so a cross-origin caller can read it.
1455+
*
1456+
* Read on exactly one route (`twoFactor.disable`), for the reason
1457+
* {@link ObjectStackClient.adoptRotatedSessionToken} states. Not exported: it
1458+
* names a vendor wire detail, not a capability this SDK offers.
1459+
*/
1460+
const SET_AUTH_TOKEN_HEADER = 'set-auth-token';
1461+
14411462
export class ObjectStackClient {
14421463
private baseUrl: string;
14431464
private token?: string;
@@ -4205,8 +4226,10 @@ export class ObjectStackClient {
42054226
* better-auth: POST /change-password.
42064227
* Set `revokeOtherSessions: true` to invalidate every other session
42074228
* after the change — the server then ROTATES the caller's session too and
4208-
* answers the new token in `token`; this SDK does not store it, so a
4209-
* bearer-mode caller must.
4229+
* answers the new token in `token`, and this SDK ADOPTS it (#16534), so a
4230+
* bearer-mode caller stays signed in across the change. Without
4231+
* `revokeOtherSessions` nothing rotates, the field is `null`, and the
4232+
* stored credential is left exactly as it was.
42104233
*/
42114234
changePassword: async (req: {
42124235
currentPassword: string;
@@ -4218,7 +4241,9 @@ export class ObjectStackClient {
42184241
method: 'POST',
42194242
body: JSON.stringify(req),
42204243
});
4221-
return res.json();
4244+
const result = (await res.json()) as AuthPasswordChangeResult;
4245+
this.adoptRotatedSessionToken(result?.token);
4246+
return result;
42224247
},
42234248

42244249
/**
@@ -4414,32 +4439,41 @@ export class ObjectStackClient {
44144439
* this browser for the configured trust period.
44154440
*
44164441
* On the enrolment lane the server rotates the session and answers the
4417-
* LIVE token in `token`; this SDK does not store it — a bearer-mode
4418-
* caller must, or its next call answers 401.
4442+
* LIVE token in `token`; this SDK ADOPTS it (#16534), so a bearer-mode
4443+
* caller stays signed in through enrolment instead of meeting a 401 on
4444+
* its next call. On the sign-in-challenge lane the same field carries
4445+
* the session the challenge just completed, and adopting it is how the
4446+
* SDK finishes signing in.
44194447
*/
44204448
verifyTotp: async (req: { code: string; trustDevice?: boolean }): Promise<AuthTwoFactorVerificationResult> => {
44214449
const route = this.getRoute('auth');
44224450
const res = await this.fetch(`${this.baseUrl}${route}/two-factor/verify-totp`, {
44234451
method: 'POST',
44244452
body: JSON.stringify(req),
44254453
});
4426-
return res.json();
4454+
const result = (await res.json()) as AuthTwoFactorVerificationResult;
4455+
this.adoptRotatedSessionToken(result?.token);
4456+
return result;
44274457
},
44284458

44294459
/**
44304460
* Disable 2FA for the current user. Requires the password again.
44314461
*
44324462
* ⚠️ The server ROTATES the caller's session on success and echoes only
4433-
* the receipt (the new token rides the `Set-Cookie` and the bearer
4434-
* plugin's `set-auth-token` header, neither of which this SDK reads), so
4435-
* a bearer-mode caller's stored token is dead after this call.
4463+
* the receipt — the new token rides the `Set-Cookie` and the bearer
4464+
* plugin's `set-auth-token` header. This SDK READS that header (#16534)
4465+
* and adopts the rotated session, which is the only route in the family
4466+
* where the credential is not in the body at all. A cookie-only
4467+
* deployment sends no such header; there is then nothing to adopt and
4468+
* the stored credential is left as it was.
44364469
*/
44374470
disable: async (req: { password: string }): Promise<AuthStatusReceipt> => {
44384471
const route = this.getRoute('auth');
44394472
const res = await this.fetch(`${this.baseUrl}${route}/two-factor/disable`, {
44404473
method: 'POST',
44414474
body: JSON.stringify(req),
44424475
});
4476+
this.adoptRotatedSessionToken(res.headers.get(SET_AUTH_TOKEN_HEADER));
44434477
return res.json();
44444478
},
44454479

@@ -6674,6 +6708,41 @@ export class ObjectStackClient {
66746708
return body as T;
66756709
}
66766710

6711+
/**
6712+
* Adopt a session token the server rotated this client onto mid-request.
6713+
*
6714+
* Three better-auth routes ROTATE the caller's session on success: they mint
6715+
* a new session, install it in `Set-Cookie` (and, through `bearer()`, in the
6716+
* `set-auth-token` response header), and DELETE the row the caller was
6717+
* presenting — `changePassword({ revokeOtherSessions: true })`, the enrolment
6718+
* lane of `twoFactor.verifyTotp`, and `twoFactor.disable`. A browser carries
6719+
* the cookie across on its own; a bearer caller — this SDK's own mode — kept
6720+
* presenting the DELETED session's token, so its very next call answered
6721+
* `401 UNAUTHORIZED` (#16534).
6722+
*
6723+
* ⚠️ Called from those three routes ONLY, never from the shared `fetch`
6724+
* wrapper, and the narrowness is the design rather than an implementation
6725+
* detail. `set-auth-token` rides EVERY response that stages a session cookie,
6726+
* rotation or not — `POST /update-user` stages one to carry the updated user
6727+
* — and it carries the SIGNED `<token>.<sig>` spelling while every JSON
6728+
* `token` echo carries the UNSIGNED one. A wrapper-level read would therefore
6729+
* rewrite `this.token` into a different spelling of the SAME session on
6730+
* ordinary traffic: a stored credential that churns on writes that rotated
6731+
* nothing. Storing only where the server actually rotated keeps the stored
6732+
* value equal to the credential the caller was last granted.
6733+
*
6734+
* For the same reason `verifyBackupCode` does not call this: its lane never
6735+
* rotates, so its `token` echo is the session the caller already holds.
6736+
*
6737+
* `login()` / `register()` / `refreshToken()` keep their own assignments:
6738+
* those read a normalized `{ data: { token } }` envelope this SDK builds, and
6739+
* they establish a session rather than follow a rotation.
6740+
*/
6741+
private adoptRotatedSessionToken(token: string | null | undefined): void {
6742+
if (typeof token !== 'string' || token.length === 0) return;
6743+
this.token = token;
6744+
}
6745+
66776746
private async fetch(url: string, options: RequestInit = {}): Promise<Response> {
66786747
this.logger.debug('HTTP request', {
66796748
method: options.method || 'GET',

‎packages/client/tsconfig.json‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,24 @@
3636
// the last `pnpm build` of three other packages. Each publishes a single
3737
// `"."` entry point, so one bare-name rule each — no star, per the
3838
// paragraph above.
39+
// [#16534] Same rule, same reason, for the two producers
40+
// `src/auth-rotated-session-token.test.ts` drives: `@objectstack/plugin-auth`
41+
// (the real `AuthManager`, whose three routes rotate the caller's session)
42+
// and the identity object definitions that pipeline stores its rows in.
43+
// That suite's claim is "the SDK keeps the session the SERVER just handed
44+
// it", so its verdict has to be about the server's SOURCE; through
45+
// `exports` it would be about the last `pnpm build` of two other packages,
46+
// which `check:type-source-resolution` reports as a NEW dist-resolved type
47+
// import.
48+
//
49+
// `@objectstack/platform-objects` is the one entry here spelled as a
50+
// SUBPATH: that package publishes eleven of them and this suite imports one
51+
// (`./identity`), so the rule names that subpath exactly. Still no star —
52+
// per the paragraph above, a bare-name star would fold all eleven onto a
53+
// single target and type-check green against the wrong module.
3954
"paths": {
55+
"@objectstack/plugin-auth": ["../plugins/plugin-auth/src/index.ts"],
56+
"@objectstack/platform-objects/identity": ["../platform-objects/src/identity/index.ts"],
4057
"@objectstack/metadata-core": ["../metadata-core/src/index.ts"],
4158
"@objectstack/metadata-protocol": ["../metadata-protocol/src/index.ts"],
4259
"@objectstack/rest": ["../rest/src/index.ts"],

‎packages/client/vitest.config.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,23 @@ export default defineConfig({
7878
find: /^@objectstack\/service-automation$/,
7979
replacement: path.resolve(__dirname, '../services/service-automation/src/index.ts'),
8080
},
81+
// [#16534] `auth-rotated-session-token.test.ts` drives the SDK's
82+
// credential bookkeeping against the REAL better-auth pipeline, so it
83+
// takes VALUE imports on the server that rotates the session
84+
// (`AuthManager`) and on the identity object definitions that pipeline
85+
// stores its rows in. Same reason as every entry above — a unit pin is a
86+
// verdict about the SOURCE in this checkout, and `check:test-source-alias`
87+
// dictates exactly this remedy because its `KNOWN_UNALIASED_TEST_IMPORTS`
88+
// registry is ⛔ SHRINK-ONLY. Anchored (`^…$`, array form) so neither
89+
// entry can swallow a subpath specifier and resolve it THROUGH a file.
90+
{
91+
find: /^@objectstack\/plugin-auth$/,
92+
replacement: path.resolve(__dirname, '../plugins/plugin-auth/src/index.ts'),
93+
},
94+
{
95+
find: /^@objectstack\/platform-objects\/identity$/,
96+
replacement: path.resolve(__dirname, '../platform-objects/src/identity/index.ts'),
97+
},
8198
],
8299
},
83100
});

‎pnpm-lock.yaml‎

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)