@starci/auth 0.1.0 → 0.2.1

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 CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.1 - 2026-11-03
4
+
5
+ - Fixed: a refresh replayed inside the grace window no longer signs the person out 30 seconds later. When a rotation's response never reached the browser (a navigation or an abort cut it), the next load presented the spent token, was answered an access token with no cookie (`cookieKept`) and the first load after the grace was reuse. Now `AuthSessions.refresh` gives such a replay a successor of its own in the same session and `AuthDoors.refresh` sets it as the cookie, at most `RACED_SUCCESSORS_MAX` (3) times per rotated token (`raced_count`); one more replay is reuse and ends the session, which revokes every token of it, as sign-out does. `SessionTokens.cookieKept` is removed; the session door never touched the cookie.
6
+ - Changed: `RefreshTokenRecord` carries `racedCount`, `SessionTransaction` gains `markTokenRaced(tokenId, racedCount)`, and `insertToken` takes neither `usedAt` nor `racedCount`; `MemorySessionStore` implements them. `migrations/identity-sessions.sql` holds `raced_count integer not null default 0`, and `migrations/identity-refresh-raced-successors.sql` adds it to a baseline rendered from 0.2.0. Specs: the raced successor refreshes once, the sibling works, a replay after the grace ends the session, the cap, reuse revokes every sibling, and two parallel 401s renew with one refresh.
7
+
8
+ ## 0.2.0 - 2026-11-03
9
+
10
+ - Added: `createSessionStore` and `createSingleFlight` in `@starci/auth/client`, the provider-agnostic session snapshot (snapshot, listeners, watchers, the first answer of a subscription) and the refresh-race guard (many requests answered 401 together refresh once, the others await it), each explicitly typed; `createAuthClient` is built on them and behaves as before. `@starci/auth-oidc/client` imports them instead of keeping a copy. Specs: `src/client/session.spec.ts` (one test per race case).
11
+
3
12
  ## 0.1.0 - 2026-11-03
4
13
 
5
14
  The sign-in of a lite StarCi app in one package, lifted from the product that ran it in production and generalised: no product names, every setting a typed option, no environment read.
package/README.md CHANGED
@@ -23,7 +23,7 @@ AuthModule.registerAsync({
23
23
  })
24
24
  ```
25
25
 
26
- The package reads no environment. The app parses its settings (BE-CONFIG-2) and passes typed options; secrets are passed as `{ reveal(): string }` holders or text. The app implements `SessionStore` over the tables of `migrations/identity-sessions.sql`, `AccountDirectory` over its user table, `RateCounter` over its shared counter, and `AuthAuthenticator` (the password and Google decisions) using `AuthPasswords` and `SignInBudget.counted` from the module.
26
+ The package reads no environment. The app parses its settings (BE-CONFIG-2) and passes typed options; secrets are passed as `{ reveal(): string }` holders or text. The app implements `SessionStore` over the tables of `migrations/identity-sessions.sql` (an app whose baseline holds the 0.2.0 tables adds `migrations/identity-refresh-raced-successors.sql`), `AccountDirectory` over its account doors (in a StarCi lite app, the managed `persistence/account-doors.port.ts` over the SECURITY DEFINER `be_auth_*` functions of the baseline), `RateCounter` over its shared counter, and `AuthAuthenticator` (the password and Google decisions) using `AuthPasswords` and `SignInBudget.counted` from the module.
27
27
 
28
28
  Key rotation: list the new key first with its private key, keep the old key with its public key only, deploy, and drop the old key once its access tokens have expired. Every listed key verifies and is published by `AuthSessions.jwks()`.
29
29
 
@@ -1,19 +1,17 @@
1
1
  import type { AccessGrant, AuthDoorName, AuthOutcome, SessionUser } from "../contracts/index.js";
2
2
  import type { StatusCodes } from "./outcome.js";
3
+ import type { SessionEvent, SessionListener, SessionSnapshot } from "./session.js";
3
4
  /** What a door answered. A refusal is an answer, not a throw: the status says what happened. */
4
5
  export interface AuthDoorAnswer {
5
6
  readonly status: number;
6
7
  readonly body: unknown;
7
8
  }
8
9
  /** The event a listener hears. */
9
- export type AuthEvent = "INITIAL_SESSION" | "SIGNED_IN" | "SIGNED_OUT" | "TOKEN_REFRESHED";
10
+ export type AuthEvent = SessionEvent;
10
11
  /** Called on every sign-in, sign-out and token refresh with the id of the person the session belongs to (null when signed out). */
11
- export type AuthListener = (event: AuthEvent, userId: string | null) => void;
12
+ export type AuthListener = SessionListener;
12
13
  /** What a view reads: loading until the first answer, then signed in or out. The object changes only when the session does. */
13
- export interface AuthSnapshot {
14
- readonly status: "loading" | "signed-in" | "signed-out";
15
- readonly user: SessionUser | null;
16
- }
14
+ export type AuthSnapshot = SessionSnapshot<SessionUser>;
17
15
  /** The options of the client. */
18
16
  export interface AuthClientOptions {
19
17
  /** The API origin the doors live on. */
@@ -1,6 +1,6 @@
1
1
  import { AUTH_DOORS } from "../contracts/index.js";
2
2
  import { authFailure, authOk, failureOfStatus, isSignedOutStatus, isTransientStatus } from "./outcome.js";
3
- const LOADING = { status: "loading", user: null };
3
+ import { createSessionStore, createSingleFlight } from "./session.js";
4
4
  const isGrant = (value) => typeof value === "object" &&
5
5
  value !== null &&
6
6
  "accessToken" in value &&
@@ -46,28 +46,25 @@ export const createAuthClient = (options) => {
46
46
  let expiresAt = 0;
47
47
  let announced = false;
48
48
  let user = null;
49
- let inFlight;
50
- let snapshot = LOADING;
51
- const listeners = new Set();
52
- const watchers = new Set();
53
- const setSnapshot = (next) => {
54
- if (snapshot.status === next.status && snapshot.user?.id === next.user?.id && snapshot.user?.email === next.user?.email)
55
- return;
56
- snapshot = next;
57
- for (const watcher of watchers)
58
- watcher();
59
- };
60
- const notify = (event) => {
61
- for (const listener of listeners)
62
- listener(event, user?.id ?? null);
63
- };
49
+ // The snapshot, the listeners and the watchers are the session store shared with every provider's client (./session.ts); the first
50
+ // answer of a subscription reads this client's facts at call time.
51
+ const store = createSessionStore({
52
+ announced: () => announced,
53
+ announce: () => {
54
+ announced = true;
55
+ },
56
+ held: () => token !== undefined,
57
+ userId: () => user?.id ?? null,
58
+ renew: () => renew(),
59
+ refreshing: () => flight.running(),
60
+ });
64
61
  const keep = (grant, event = "TOKEN_REFRESHED") => {
65
62
  token = grant.accessToken;
66
63
  expiresAt = now() + grant.expiresIn * MILLISECONDS;
67
64
  announced = true;
68
65
  user = userOf(grant);
69
- setSnapshot({ status: "signed-in", user });
70
- notify(event);
66
+ store.signedIn(user);
67
+ store.emit(event, user?.id ?? null);
71
68
  };
72
69
  const drop = () => {
73
70
  const had = token !== undefined;
@@ -75,9 +72,9 @@ export const createAuthClient = (options) => {
75
72
  expiresAt = 0;
76
73
  announced = true;
77
74
  user = null;
78
- setSnapshot({ status: "signed-out", user: null });
75
+ store.signedOut();
79
76
  if (had)
80
- notify("SIGNED_OUT");
77
+ store.emit("SIGNED_OUT", null);
81
78
  };
82
79
  const door = async (name, body, cookie) => {
83
80
  const get = name === "session" || name === "jwks";
@@ -121,12 +118,9 @@ export const createAuthClient = (options) => {
121
118
  return attempt < attemptsMax ? retry() : undefined;
122
119
  }
123
120
  };
124
- const renew = () => {
125
- inFlight ??= requestRefresh().finally(() => {
126
- inFlight = undefined;
127
- });
128
- return inFlight;
129
- };
121
+ // Many requests answered 401 together refresh once: the others await the one in flight.
122
+ const flight = createSingleFlight(() => requestRefresh());
123
+ const renew = () => flight.run();
130
124
  const accessToken = async () => token !== undefined && expiresAt - refreshAheadMs > now() ? token : renew();
131
125
  const outcomeOfAnswer = (answer, fallbacks) => {
132
126
  if (answer.status >= 200 && answer.status < 300)
@@ -161,33 +155,6 @@ export const createAuthClient = (options) => {
161
155
  }
162
156
  drop();
163
157
  };
164
- const subscribe = (listener) => {
165
- listeners.add(listener);
166
- if (announced) {
167
- listener("INITIAL_SESSION", user?.id ?? null);
168
- }
169
- else {
170
- void renew().then(() => {
171
- if (!announced)
172
- announced = true;
173
- if (token === undefined) {
174
- setSnapshot({ status: "signed-out", user: null });
175
- listener("INITIAL_SESSION", null);
176
- }
177
- });
178
- }
179
- return () => {
180
- listeners.delete(listener);
181
- };
182
- };
183
- const watch = (onChange) => {
184
- watchers.add(onChange);
185
- if (!announced && inFlight === undefined)
186
- void renew().then(() => undefined);
187
- return () => {
188
- watchers.delete(onChange);
189
- };
190
- };
191
158
  const checkSession = async (cookie) => {
192
159
  if (cookie === null || cookie === "")
193
160
  return authFailure("refused", "no-session");
@@ -216,10 +183,10 @@ export const createAuthClient = (options) => {
216
183
  },
217
184
  keep,
218
185
  drop,
219
- subscribe,
220
- getSnapshot: () => snapshot,
221
- getServerSnapshot: () => LOADING,
222
- watch,
186
+ subscribe: store.subscribe,
187
+ getSnapshot: store.getSnapshot,
188
+ getServerSnapshot: store.getServerSnapshot,
189
+ watch: store.watch,
223
190
  checkSession,
224
191
  };
225
192
  };
@@ -4,3 +4,5 @@ export { authFailure, authOk, failureOfStatus, isSignedOutStatus, isTransientSta
4
4
  export type { StatusCodes } from "./outcome.js";
5
5
  export { EMAIL_PATTERN, NAME_MIN_LENGTH, PASSWORD_MIN_LENGTH, RESEND_COOLDOWN_SECONDS, authErrorKey, isLinkInvalid, passwordStrength, strengthKey, } from "./shared.js";
6
6
  export type { AuthErrorKey, Strength } from "./shared.js";
7
+ export { createSessionStore, createSingleFlight } from "./session.js";
8
+ export type { SessionEvent, SessionListener, SessionPerson, SessionSnapshot, SessionSource, SessionStore, SingleFlight } from "./session.js";
@@ -1,3 +1,4 @@
1
1
  export { createAuthClient } from "./auth-client.js";
2
2
  export { authFailure, authOk, failureOfStatus, isSignedOutStatus, isTransientStatus, kindOfStatus } from "./outcome.js";
3
3
  export { EMAIL_PATTERN, NAME_MIN_LENGTH, PASSWORD_MIN_LENGTH, RESEND_COOLDOWN_SECONDS, authErrorKey, isLinkInvalid, passwordStrength, strengthKey, } from "./shared.js";
4
+ export { createSessionStore, createSingleFlight } from "./session.js";
@@ -0,0 +1,57 @@
1
+ /** The events a session listener hears. */
2
+ export type SessionEvent = "INITIAL_SESSION" | "SIGNED_IN" | "SIGNED_OUT" | "TOKEN_REFRESHED";
3
+ /** The person of a session as a view reads it. */
4
+ export interface SessionPerson {
5
+ readonly id: string;
6
+ readonly email: string;
7
+ }
8
+ /** What a view reads: loading until the first answer, then signed in or out. The object changes only when the session does. */
9
+ export interface SessionSnapshot<User extends SessionPerson> {
10
+ readonly status: "loading" | "signed-in" | "signed-out";
11
+ readonly user: User | null;
12
+ }
13
+ /** Called on every sign-in, sign-out and token refresh with the id of the person (null when signed out). */
14
+ export type SessionListener = (event: SessionEvent, userId: string | null) => void;
15
+ /** What the store asks of the client that owns it: the facts the first answer of a subscription depends on. */
16
+ export interface SessionSource {
17
+ /** Whether the client has answered once (a refresh, a sign-in or a sign-out happened). */
18
+ announced(): boolean;
19
+ /** Records that the first answer was given. */
20
+ announce(): void;
21
+ /** Whether a token is held now. */
22
+ held(): boolean;
23
+ /** The id of the signed-in person, or null. */
24
+ userId(): string | null;
25
+ /** Starts (or joins) a refresh. */
26
+ renew(): Promise<unknown>;
27
+ /** Whether a refresh is running. */
28
+ refreshing(): boolean;
29
+ }
30
+ /** The snapshot, the listeners and the watchers of one client, shared by every provider's client. */
31
+ export interface SessionStore<User extends SessionPerson> {
32
+ /** The current snapshot (stable between changes). */
33
+ getSnapshot: () => SessionSnapshot<User>;
34
+ /** The snapshot for server rendering: always loading. */
35
+ getServerSnapshot: () => SessionSnapshot<User>;
36
+ /** Publishes the snapshot of a signed-in person; watchers hear it only when status, id or email changed. */
37
+ signedIn: (user: User | null) => void;
38
+ /** Publishes the signed-out snapshot. */
39
+ signedOut: () => void;
40
+ /** Tells every listener an event with the id of the person it concerns. */
41
+ emit: (event: SessionEvent, userId: string | null) => void;
42
+ /** Listens to events; the first answer comes from a refresh when the client has not answered yet. Answers the function that stops listening. */
43
+ subscribe: (listener: SessionListener) => () => void;
44
+ /** Calls `onChange` when the snapshot changes (the shape `useSyncExternalStore` takes); starts the first refresh when none ran. */
45
+ watch: (onChange: () => void) => () => void;
46
+ }
47
+ /** Creates the session store of a client. `source` is read at call time, never captured. */
48
+ export declare const createSessionStore: <User extends SessionPerson>(source: SessionSource) => SessionStore<User>;
49
+ /** One refresh at a time: every caller that arrives while one runs awaits the same promise. */
50
+ export interface SingleFlight<T> {
51
+ /** Starts `task` when none runs, else joins the running one; the slot frees itself when the run settles, success or failure. */
52
+ run(): Promise<T>;
53
+ /** Whether a run is in progress. */
54
+ running(): boolean;
55
+ }
56
+ /** Guards `task` so that overlapping calls (many requests answered 401 together) share one run. */
57
+ export declare const createSingleFlight: <T>(task: () => Promise<T>) => SingleFlight<T>;
@@ -0,0 +1,68 @@
1
+ /** Creates the session store of a client. `source` is read at call time, never captured. */
2
+ export const createSessionStore = (source) => {
3
+ const loading = { status: "loading", user: null };
4
+ let snapshot = loading;
5
+ const listeners = new Set();
6
+ const watchers = new Set();
7
+ const set = (next) => {
8
+ if (snapshot.status === next.status && snapshot.user?.id === next.user?.id && snapshot.user?.email === next.user?.email)
9
+ return;
10
+ snapshot = next;
11
+ for (const watcher of watchers)
12
+ watcher();
13
+ };
14
+ return {
15
+ getSnapshot: () => snapshot,
16
+ getServerSnapshot: () => loading,
17
+ signedIn: (user) => {
18
+ set({ status: "signed-in", user });
19
+ },
20
+ signedOut: () => {
21
+ set({ status: "signed-out", user: null });
22
+ },
23
+ emit: (event, userId) => {
24
+ for (const listener of listeners)
25
+ listener(event, userId);
26
+ },
27
+ subscribe: (listener) => {
28
+ listeners.add(listener);
29
+ if (source.announced()) {
30
+ listener("INITIAL_SESSION", source.userId());
31
+ }
32
+ else {
33
+ void source.renew().then(() => {
34
+ if (!source.announced())
35
+ source.announce();
36
+ if (!source.held()) {
37
+ set({ status: "signed-out", user: null });
38
+ listener("INITIAL_SESSION", null);
39
+ }
40
+ });
41
+ }
42
+ return () => {
43
+ listeners.delete(listener);
44
+ };
45
+ },
46
+ watch: (onChange) => {
47
+ watchers.add(onChange);
48
+ if (!source.announced() && !source.refreshing())
49
+ void source.renew().then(() => undefined);
50
+ return () => {
51
+ watchers.delete(onChange);
52
+ };
53
+ },
54
+ };
55
+ };
56
+ /** Guards `task` so that overlapping calls (many requests answered 401 together) share one run. */
57
+ export const createSingleFlight = (task) => {
58
+ let inFlight;
59
+ return {
60
+ run: () => {
61
+ inFlight ??= task().finally(() => {
62
+ inFlight = undefined;
63
+ });
64
+ return inFlight;
65
+ },
66
+ running: () => inFlight !== undefined,
67
+ };
68
+ };
@@ -20,14 +20,14 @@ export interface DoorsDeps {
20
20
  }
21
21
  /**
22
22
  * The cookie doors of sign-in over the flows, the sessions and the cookie: what a controller calls. The refresh token only goes
23
- * into the cookie; a refresh that cannot be used clears it, one a sibling request just rotated leaves it as the sibling wrote it.
23
+ * into the cookie; every refresh rotates and sets its successor (a racing one included), and a refresh that cannot be used clears it.
24
24
  */
25
25
  export declare class AuthDoors {
26
26
  #private;
27
27
  constructor(deps: DoorsDeps);
28
28
  /** Email and password through the local strategy; the refresh token goes into the cookie. */
29
29
  login(exchange: HttpExchange): Promise<AccessGrant>;
30
- /** Rotates the cookie token and answers a new access token; a cookie that cannot be used is cleared and the refusal rethrown. */
30
+ /** Rotates the cookie token and sets the successor (every refresh rotates, a racing one included); a cookie that cannot be used is cleared and the refusal rethrown. */
31
31
  refresh(exchange: HttpExchange): Promise<AccessGrant>;
32
32
  /** Answers a fresh access token for the cookie session without rotating the cookie. */
33
33
  session(exchange: HttpExchange): Promise<AccessGrant>;
@@ -11,7 +11,7 @@ export const accessOf = (tokens) => ({
11
11
  });
12
12
  /**
13
13
  * The cookie doors of sign-in over the flows, the sessions and the cookie: what a controller calls. The refresh token only goes
14
- * into the cookie; a refresh that cannot be used clears it, one a sibling request just rotated leaves it as the sibling wrote it.
14
+ * into the cookie; every refresh rotates and sets its successor (a racing one included), and a refresh that cannot be used clears it.
15
15
  */
16
16
  export class AuthDoors {
17
17
  #deps;
@@ -25,12 +25,11 @@ export class AuthDoors {
25
25
  this.#deps.cookie.issue(exchange.res, tokens);
26
26
  return accessOf(tokens);
27
27
  }
28
- /** Rotates the cookie token and answers a new access token; a cookie that cannot be used is cleared and the refusal rethrown. */
28
+ /** Rotates the cookie token and sets the successor (every refresh rotates, a racing one included); a cookie that cannot be used is cleared and the refusal rethrown. */
29
29
  async refresh(exchange) {
30
30
  try {
31
31
  const tokens = await this.#deps.sessions.refresh(this.#deps.cookie.read(exchange.req));
32
- if (tokens.cookieKept !== true)
33
- this.#deps.cookie.issue(exchange.res, tokens);
32
+ this.#deps.cookie.issue(exchange.res, tokens);
34
33
  return accessOf(tokens);
35
34
  }
36
35
  catch (cause) {
@@ -49,6 +49,8 @@ export interface RefreshTokenRecord {
49
49
  readonly expiresAt: Date;
50
50
  /** When the token was rotated away, or null while it is the live one. */
51
51
  readonly usedAt: Date | null;
52
+ /** How many successors replays of this token obtained within the grace window after its rotation (bounded). */
53
+ readonly racedCount: number;
52
54
  }
53
55
  /** A device session to open. */
54
56
  export interface NewSession {
@@ -63,11 +65,13 @@ export interface SessionTransaction {
63
65
  /** Opens a device session. */
64
66
  openSession(session: NewSession): Promise<void>;
65
67
  /** Stores a live refresh token. */
66
- insertToken(token: Omit<RefreshTokenRecord, "usedAt">): Promise<void>;
68
+ insertToken(token: Omit<RefreshTokenRecord, "usedAt" | "racedCount">): Promise<void>;
67
69
  /** The token with this digest, locked for update (a pessimistic write lock), or null. */
68
70
  findTokenForUpdate(tokenHash: string): Promise<RefreshTokenRecord | null>;
69
71
  /** Marks a token as rotated away at `at`. */
70
72
  markTokenUsed(tokenId: string, at: Date): Promise<void>;
73
+ /** Records how many successors replays of a rotated token obtained within the grace window. */
74
+ markTokenRaced(tokenId: string, racedCount: number): Promise<void>;
71
75
  /** Deletes the tokens of an account that expired before `before`. */
72
76
  deleteExpiredTokens(userId: string, before: Date): Promise<void>;
73
77
  }
@@ -79,7 +83,7 @@ export interface SessionStore {
79
83
  findToken(tokenHash: string): Promise<RefreshTokenRecord | null>;
80
84
  /** Marks a session refreshed. */
81
85
  touchSession(sessionId: string, now: Date): Promise<void>;
82
- /** Ends one session (and with it every token of it). */
86
+ /** Ends one session and revokes every token of it (logout, reuse, a gone or disabled account). */
83
87
  closeSession(sessionId: string): Promise<void>;
84
88
  /** Ends every session of an account but one. */
85
89
  closeOtherSessions(userId: string, keepSessionId: string): Promise<void>;
@@ -115,8 +119,6 @@ export interface SessionTokens {
115
119
  readonly accessExpiresIn: number;
116
120
  /** The opaque token the refresh cookie carries. */
117
121
  readonly cookieToken: string;
118
- /** True when the answer did not rotate the token (a racing request, or the non-rotating session door): the caller leaves the cookie as it is. */
119
- readonly cookieKept?: true;
120
122
  readonly refreshMaxAgeSeconds: number;
121
123
  readonly user: SessionUser;
122
124
  }
@@ -2,8 +2,14 @@ import type { JwksDocument } from "../contracts/index.js";
2
2
  import type { AuthOptions } from "./options.js";
3
3
  import type { AuthClock, AuthIds, AuthLogger, AuthSubject, DeviceMeta, SessionTokens } from "./ports.js";
4
4
  import type { AuthTokens } from "./tokens.js";
5
- /** A rotated token presented again this soon after its rotation is a racing tab, not a theft. */
5
+ /**
6
+ * A rotated token presented again this soon after its rotation is a racing request or a browser that never received the successor (the
7
+ * response was cut by a navigation): it gets a successor of its own, set as the cookie, so the browser never keeps a spent token. Later,
8
+ * it is a replay and ends the session.
9
+ */
6
10
  export declare const DEFAULT_REUSE_GRACE_MS = 30000;
11
+ /** How many successors one rotated token may obtain within the grace window; one more is a replay and ends the session. */
12
+ export declare const RACED_SUCCESSORS_MAX = 3;
7
13
  /** The log event of a replayed refresh token. */
8
14
  export declare const REFRESH_REUSE_EVENT = "auth.refresh-reuse-detected";
9
15
  /** What the sessions need from the module. */
@@ -16,8 +22,8 @@ export interface SessionsDeps {
16
22
  }
17
23
  /**
18
24
  * Device sessions: opens one at sign-in, rotates its refresh token on every refresh (a replayed token ends the session) and ends it
19
- * at sign-out. A token rotated within the grace window is a racing request: it is answered with an access token only, the cookie
20
- * is left as the winner wrote it.
25
+ * at sign-out. A token rotated within the grace window gets a successor of its own in the same session, at most
26
+ * {@link RACED_SUCCESSORS_MAX} times.
21
27
  */
22
28
  export declare class AuthSessions {
23
29
  #private;
@@ -26,8 +32,9 @@ export declare class AuthSessions {
26
32
  start(subject: AuthSubject, device: DeviceMeta): Promise<SessionTokens>;
27
33
  /**
28
34
  * Exchanges a refresh token for a new pair; the presented token is spent. Throws when it is unknown, expired, or rotated long
29
- * ago (reuse ends the session). A token a sibling request rotated within the grace window answers an access token marked
30
- * `cookieKept`, so the caller leaves the cookie alone: the sibling already wrote the successor.
35
+ * ago (reuse ends the session). A token rotated within the grace window gets a successor of its own in the same session (its
36
+ * cookie is set: the response of the first rotation may never have reached the browser), at most {@link RACED_SUCCESSORS_MAX}
37
+ * times; one more replay is reuse.
31
38
  */
32
39
  refresh(presented: string): Promise<SessionTokens>;
33
40
  /**
@@ -1,15 +1,21 @@
1
1
  import { AUTH_ERROR_CODES } from "../contracts/index.js";
2
2
  import { AuthError } from "./errors.js";
3
3
  import { digestOf } from "./primitives.js";
4
- /** A rotated token presented again this soon after its rotation is a racing tab, not a theft. */
4
+ /**
5
+ * A rotated token presented again this soon after its rotation is a racing request or a browser that never received the successor (the
6
+ * response was cut by a navigation): it gets a successor of its own, set as the cookie, so the browser never keeps a spent token. Later,
7
+ * it is a replay and ends the session.
8
+ */
5
9
  export const DEFAULT_REUSE_GRACE_MS = 30_000;
10
+ /** How many successors one rotated token may obtain within the grace window; one more is a replay and ends the session. */
11
+ export const RACED_SUCCESSORS_MAX = 3;
6
12
  /** The log event of a replayed refresh token. */
7
13
  export const REFRESH_REUSE_EVENT = "auth.refresh-reuse-detected";
8
14
  const MILLISECONDS = 1000;
9
15
  /**
10
16
  * Device sessions: opens one at sign-in, rotates its refresh token on every refresh (a replayed token ends the session) and ends it
11
- * at sign-out. A token rotated within the grace window is a racing request: it is answered with an access token only, the cookie
12
- * is left as the winner wrote it.
17
+ * at sign-out. A token rotated within the grace window gets a successor of its own in the same session, at most
18
+ * {@link RACED_SUCCESSORS_MAX} times.
13
19
  */
14
20
  export class AuthSessions {
15
21
  #deps;
@@ -41,8 +47,9 @@ export class AuthSessions {
41
47
  }
42
48
  /**
43
49
  * Exchanges a refresh token for a new pair; the presented token is spent. Throws when it is unknown, expired, or rotated long
44
- * ago (reuse ends the session). A token a sibling request rotated within the grace window answers an access token marked
45
- * `cookieKept`, so the caller leaves the cookie alone: the sibling already wrote the successor.
50
+ * ago (reuse ends the session). A token rotated within the grace window gets a successor of its own in the same session (its
51
+ * cookie is set: the response of the first rotation may never have reached the browser), at most {@link RACED_SUCCESSORS_MAX}
52
+ * times; one more replay is reuse.
46
53
  */
47
54
  async refresh(presented) {
48
55
  if (presented === "")
@@ -53,12 +60,15 @@ export class AuthSessions {
53
60
  const row = await tx.findTokenForUpdate(digestOf(presented));
54
61
  if (row === null || row.expiresAt <= now)
55
62
  return { kind: "invalid" };
56
- if (row.usedAt !== null) {
57
- return now.getTime() - row.usedAt.getTime() <= this.#graceMs
58
- ? { kind: "raced", row }
59
- : { kind: "reused", sessionId: row.sessionId };
63
+ if (row.usedAt === null) {
64
+ await tx.markTokenUsed(row.id, now);
65
+ }
66
+ else {
67
+ const raced = now.getTime() - row.usedAt.getTime() <= this.#graceMs;
68
+ if (!raced || row.racedCount >= RACED_SUCCESSORS_MAX)
69
+ return { kind: "reused", sessionId: row.sessionId };
70
+ await tx.markTokenRaced(row.id, row.racedCount + 1);
60
71
  }
61
- await tx.markTokenUsed(row.id, now);
62
72
  await tx.insertToken({
63
73
  id: this.#deps.ids.next(),
64
74
  sessionId: row.sessionId,
@@ -76,8 +86,6 @@ export class AuthSessions {
76
86
  }
77
87
  if (rotation.kind === "invalid")
78
88
  throw new AuthError(AUTH_ERROR_CODES.RefreshInvalid);
79
- if (rotation.kind === "raced")
80
- return this.#accessOf(rotation.row, presented, now);
81
89
  const user = await this.#deps.options.accounts.find(rotation.row.userId);
82
90
  if (user === undefined || user.disabled || user.email === null) {
83
91
  await this.#store.closeSession(rotation.row.sessionId);
@@ -130,8 +138,7 @@ export class AuthSessions {
130
138
  await this.#store.closeSession(row.sessionId);
131
139
  throw new AuthError(AUTH_ERROR_CODES.AccountDisabled);
132
140
  }
133
- const tokens = await this.#tokensFor({ userId: user.id, email: user.email }, row.sessionId, presented, now);
134
- return { ...tokens, cookieKept: true };
141
+ return this.#tokensFor({ userId: user.id, email: user.email }, row.sessionId, presented, now);
135
142
  }
136
143
  async #tokensFor(subject, sessionId, refreshToken, now) {
137
144
  const access = await this.#deps.tokens.sign({ sub: subject.userId, email: subject.email, sessionId }, now);
@@ -45,7 +45,7 @@ export class MemorySessionStore {
45
45
  return Promise.resolve();
46
46
  },
47
47
  insertToken: (token) => {
48
- this.tokens.set(token.id, { ...token, usedAt: null });
48
+ this.tokens.set(token.id, { ...token, usedAt: null, racedCount: 0 });
49
49
  return Promise.resolve();
50
50
  },
51
51
  findTokenForUpdate: (tokenHash) => Promise.resolve(this.#byHash(tokenHash)),
@@ -55,6 +55,12 @@ export class MemorySessionStore {
55
55
  this.tokens.set(tokenId, { ...row, usedAt: at });
56
56
  return Promise.resolve();
57
57
  },
58
+ markTokenRaced: (tokenId, racedCount) => {
59
+ const row = this.tokens.get(tokenId);
60
+ if (row !== undefined)
61
+ this.tokens.set(tokenId, { ...row, racedCount });
62
+ return Promise.resolve();
63
+ },
58
64
  deleteExpiredTokens: (userId, before) => {
59
65
  for (const [id, row] of this.tokens)
60
66
  if (row.userId === userId && row.expiresAt < before)
@@ -0,0 +1,7 @@
1
+ -- identity-refresh-raced-successors: forward-only, for an app whose baseline holds identity-sessions.sql of @starci/auth 0.2.0.
2
+ -- A refresh token presented again within the grace window after its rotation (a racing request, or a browser whose earlier Set-Cookie
3
+ -- never arrived) gets a successor of its own, set as the cookie, instead of an access token that leaves the browser on a spent token.
4
+ -- raced_count bounds how many such successors one rotated token may obtain; beyond it the replay is reuse and ends the session.
5
+ -- Expand only: one column with a default, nothing existing altered. A baseline rendered from @starci/auth 0.2.1 already holds it.
6
+
7
+ alter table public.identity_refresh_tokens add column raced_count integer not null default 0;
@@ -1,5 +1,6 @@
1
1
  -- The device sessions and rotating refresh tokens of @starci/auth. A token is stored by its sha256 only; a rotated token stays,
2
- -- marked used, so a replay is detected (a replay inside the grace window is a racing tab, an older one ends the session).
2
+ -- marked used, so a replay is detected: a replay inside the grace window gets a successor of its own (raced_count bounds how many),
3
+ -- an older one ends the session and every token of it.
3
4
  -- identity_attempts counts the sign-in attempts of one address in a window every replica shares (the budget of failures).
4
5
  -- The app owns auth.users (db/bootstrap.sql) and the role app_be.
5
6
 
@@ -22,6 +23,7 @@ create table public.identity_refresh_tokens (
22
23
  created_at timestamptz not null default now(),
23
24
  expires_at timestamptz not null,
24
25
  used_at timestamptz,
26
+ raced_count integer not null default 0,
25
27
  constraint identity_refresh_tokens_hash_key unique (token_hash)
26
28
  );
27
29
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starci/auth",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "The sign-in of a StarCi lite app in one package: a NestJS module (EdDSA access tokens with a multi-key JWKS, bcrypt passwords, Google OAuth with state and PKCE, rotating refresh tokens with a reuse grace window and reuse detection, a failures-only sign-in limit, the refresh cookie), the door contracts, a front-end auth client (access token in memory, one shared refresh, signed out only on 401 or 403) with React bindings, and test doubles.",
5
5
  "keywords": [
6
6
  "auth",