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
27 changes: 27 additions & 0 deletions .changeset/client-adopts-rotated-session-token.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
"@objectstack/client": minor
---

feat(client): a bearer-mode `ObjectStackClient` keeps the session the server rotates it onto (#16534)

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:

| route | where the new credential is |
| --- | --- |
| `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) |
| `auth.changePassword({ revokeOtherSessions: true })` | body — `token` |
| `auth.twoFactor.disable()` | **response header only** — the body is `{ status: true }` |

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.

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.

**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.

## What does NOT change, deliberately

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.

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.

The three TSDoc warnings that told bearer callers "this SDK does not store it" are updated in the same change.
2 changes: 2 additions & 0 deletions packages/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@
"@objectstack/metadata-core": "workspace:*",
"@objectstack/metadata-protocol": "workspace:*",
"@objectstack/objectql": "workspace:*",
"@objectstack/platform-objects": "workspace:*",
"@objectstack/plugin-auth": "workspace:*",
"@objectstack/plugin-hono-server": "workspace:*",
"@objectstack/rest": "workspace:*",
"@objectstack/runtime": "workspace:*",
Expand Down
476 changes: 476 additions & 0 deletions packages/client/src/auth-rotated-session-token.test.ts

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions packages/client/src/client-url-conformance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,11 @@ const NON_HTTP: Record<string, string> = {
'getRoute': 'pure route-table lookup',
'unwrapResponse': 'pure envelope unwrap',
'isFilterAST': 'pure type predicate',
// [#16534] Local credential state: it writes `this.token` from a value the
// three rotating auth routes have ALREADY received, and issues nothing of its
// own. Those three routes are swept on their own rows, so parking this helper
// here drops no call out of coverage.
'adoptRotatedSessionToken': 'local state',
'environment': 'constructs a ScopedEnvironmentClient; its methods are swept separately',
'setProjectId': 'local state',
'getProjectId': 'local state',
Expand Down
91 changes: 80 additions & 11 deletions packages/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1113,8 +1113,12 @@ export interface AuthPasswordChangeResult {
* ⚠️ SECRET — an unsigned session token. When `revokeOtherSessions: true`
* made the server rotate the caller's session this is the NEW session's
* token (every other session is gone and the cookie the caller held is
* dead); `null` otherwise. A bearer-mode caller has to store it itself —
* this SDK does not.
* dead); `null` otherwise.
*
* A bearer-mode caller no longer has to store it by hand: `changePassword`
* adopts a non-null value into the client's own credential before it
* resolves, the way `login()` adopts the token it is handed. The field is
* unchanged and still echoed, for a caller that keeps its own store.
*/
token: string | null;
/** The caller, as better-auth's session held it when the write ran. */
Expand Down Expand Up @@ -1153,6 +1157,11 @@ export interface AuthTwoFactorVerificationResult {
* `two-factor-rotated-token-echo` repairs the vendor's stale echo).
* Through this SDK `verifyBackupCode` cannot send `disableSession`, so
* the token is always present.
*
* `verifyTotp` adopts it into the client's own credential; `verifyBackupCode`
* does NOT, and the asymmetry is the wire fact rather than an omission —
* `/two-factor/verify-backup-code` never rotates, so what it echoes is the
* session the caller is already presenting.
*/
token: string;
/**
Expand Down Expand Up @@ -1438,6 +1447,18 @@ const DEFAULT_DATA_PREFIX = '/data';
*/
const DEFAULT_META_PREFIX = '/meta';

/**
* The response header better-auth's `bearer()` plugin puts a freshly installed
* session token in — the SIGNED `<token>.<sig>` form, emitted on every response
* that stages a session cookie, and added to `Access-Control-Expose-Headers` by
* the plugin itself so a cross-origin caller can read it.
*
* Read on exactly one route (`twoFactor.disable`), for the reason
* {@link ObjectStackClient.adoptRotatedSessionToken} states. Not exported: it
* names a vendor wire detail, not a capability this SDK offers.
*/
const SET_AUTH_TOKEN_HEADER = 'set-auth-token';

export class ObjectStackClient {
private baseUrl: string;
private token?: string;
Expand Down Expand Up @@ -4205,8 +4226,10 @@ export class ObjectStackClient {
* better-auth: POST /change-password.
* Set `revokeOtherSessions: true` to invalidate every other session
* after the change — the server then ROTATES the caller's session too and
* answers the new token in `token`; this SDK does not store it, so a
* bearer-mode caller must.
* answers the new token in `token`, and this SDK ADOPTS it (#16534), so a
* bearer-mode caller stays signed in across the change. Without
* `revokeOtherSessions` nothing rotates, the field is `null`, and the
* stored credential is left exactly as it was.
*/
changePassword: async (req: {
currentPassword: string;
Expand All @@ -4218,7 +4241,9 @@ export class ObjectStackClient {
method: 'POST',
body: JSON.stringify(req),
});
return res.json();
const result = (await res.json()) as AuthPasswordChangeResult;
this.adoptRotatedSessionToken(result?.token);
return result;
},

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

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

Expand Down Expand Up @@ -6674,6 +6708,41 @@ export class ObjectStackClient {
return body as T;
}

/**
* Adopt a session token the server rotated this client onto mid-request.
*
* 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 — `changePassword({ revokeOtherSessions: true })`, the enrolment
* lane of `twoFactor.verifyTotp`, and `twoFactor.disable`. A browser carries
* the cookie across on its own; a bearer caller — this SDK's own mode — kept
* presenting the DELETED session's token, so its very next call answered
* `401 UNAUTHORIZED` (#16534).
*
* ⚠️ Called from those three routes ONLY, never from the shared `fetch`
* wrapper, and the narrowness is the design rather than an implementation
* detail. `set-auth-token` rides EVERY response that stages a session cookie,
* rotation or not — `POST /update-user` stages one to carry the updated user
* — and it carries the SIGNED `<token>.<sig>` spelling while every JSON
* `token` echo carries the UNSIGNED one. A wrapper-level read would therefore
* rewrite `this.token` into a different spelling of the SAME session on
* ordinary traffic: a stored credential that churns on writes that rotated
* nothing. Storing only where the server actually rotated keeps the stored
* value equal to the credential the caller was last granted.
*
* For the same reason `verifyBackupCode` does not call this: its lane never
* rotates, so its `token` echo is the session the caller already holds.
*
* `login()` / `register()` / `refreshToken()` keep their own assignments:
* those read a normalized `{ data: { token } }` envelope this SDK builds, and
* they establish a session rather than follow a rotation.
*/
private adoptRotatedSessionToken(token: string | null | undefined): void {
if (typeof token !== 'string' || token.length === 0) return;
this.token = token;
}

private async fetch(url: string, options: RequestInit = {}): Promise<Response> {
this.logger.debug('HTTP request', {
method: options.method || 'GET',
Expand Down
17 changes: 17 additions & 0 deletions packages/client/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,24 @@
// the last `pnpm build` of three other packages. Each publishes a single
// `"."` entry point, so one bare-name rule each — no star, per the
// paragraph above.
// [#16534] Same rule, same reason, for the two producers
// `src/auth-rotated-session-token.test.ts` drives: `@objectstack/plugin-auth`
// (the real `AuthManager`, whose three routes rotate the caller's session)
// and the identity object definitions that pipeline stores its rows in.
// That suite's claim is "the SDK keeps the session the SERVER just handed
// it", so its verdict has to be about the server's SOURCE; through
// `exports` it would be about the last `pnpm build` of two other packages,
// which `check:type-source-resolution` reports as a NEW dist-resolved type
// import.
//
// `@objectstack/platform-objects` is the one entry here spelled as a
// SUBPATH: that package publishes eleven of them and this suite imports one
// (`./identity`), so the rule names that subpath exactly. Still no star —
// per the paragraph above, a bare-name star would fold all eleven onto a
// single target and type-check green against the wrong module.
"paths": {
"@objectstack/plugin-auth": ["../plugins/plugin-auth/src/index.ts"],
"@objectstack/platform-objects/identity": ["../platform-objects/src/identity/index.ts"],
"@objectstack/metadata-core": ["../metadata-core/src/index.ts"],
"@objectstack/metadata-protocol": ["../metadata-protocol/src/index.ts"],
"@objectstack/rest": ["../rest/src/index.ts"],
Expand Down
17 changes: 17 additions & 0 deletions packages/client/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,23 @@ export default defineConfig({
find: /^@objectstack\/service-automation$/,
replacement: path.resolve(__dirname, '../services/service-automation/src/index.ts'),
},
// [#16534] `auth-rotated-session-token.test.ts` drives the SDK's
// credential bookkeeping against the REAL better-auth pipeline, so it
// takes VALUE imports on the server that rotates the session
// (`AuthManager`) and on the identity object definitions that pipeline
// stores its rows in. Same reason as every entry above — a unit pin is a
// verdict about the SOURCE in this checkout, and `check:test-source-alias`
// dictates exactly this remedy because its `KNOWN_UNALIASED_TEST_IMPORTS`
// registry is ⛔ SHRINK-ONLY. Anchored (`^…$`, array form) so neither
// entry can swallow a subpath specifier and resolve it THROUGH a file.
{
find: /^@objectstack\/plugin-auth$/,
replacement: path.resolve(__dirname, '../plugins/plugin-auth/src/index.ts'),
},
{
find: /^@objectstack\/platform-objects\/identity$/,
replacement: path.resolve(__dirname, '../platform-objects/src/identity/index.ts'),
},
],
},
});
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading