@starci/auth 0.2.0 → 0.3.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 CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0 - 2026-11-03
4
+
5
+ - Added: a session carries the person's full name when the account directory knows it: `AccountStanding.name` and `AuthSubject.name` in; `SessionUser.name`, the `name` access-token claim (`AUTH_CLAIMS.name`), `AccessClaims.name` and `AuthPrincipal.name` out. The client keeps it (from the grant, or from the token when the grant carries no user) and announces a change of it. An account without a name answers as before.
6
+
7
+ ## 0.2.1 - 2026-11-03
8
+
9
+ - 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.
10
+ - 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.
11
+
3
12
  ## 0.2.0 - 2026-11-03
4
13
 
5
14
  - 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).
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 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.
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
 
@@ -45,7 +45,7 @@ export interface AuthClient {
45
45
  accessToken(): Promise<string | undefined>;
46
46
  /** The held access token without any request. */
47
47
  peek(): string | undefined;
48
- /** The signed-in person (id and email), or null when signed out. */
48
+ /** The signed-in person (id, email and the full name when the account has one), or null when signed out. */
49
49
  user(): Promise<SessionUser | null>;
50
50
  /** The `Authorization` header of the current session (an empty object when signed out). */
51
51
  headers(): Promise<Record<string, string>>;
@@ -20,11 +20,14 @@ const claimsOf = (token) => {
20
20
  return {};
21
21
  }
22
22
  };
23
+ /** The full name of a person, when a grant or a token names one. */
24
+ const nameOf = (name) => (typeof name === "string" ? { name } : {});
23
25
  const userOf = (grant) => {
24
- if (typeof grant.user === "object" && typeof grant.user.id === "string")
25
- return { id: grant.user.id, email: typeof grant.user.email === "string" ? grant.user.email : "" };
26
- const { sub, email } = claimsOf(grant.accessToken);
27
- return typeof sub === "string" ? { id: sub, email: typeof email === "string" ? email : "" } : null;
26
+ if (typeof grant.user === "object" && typeof grant.user.id === "string") {
27
+ return { id: grant.user.id, email: typeof grant.user.email === "string" ? grant.user.email : "", ...nameOf(fieldOf(grant.user, "name")) };
28
+ }
29
+ const { sub, email, name } = claimsOf(grant.accessToken);
30
+ return typeof sub === "string" ? { id: sub, email: typeof email === "string" ? email : "", ...nameOf(name) } : null;
28
31
  };
29
32
  const MILLISECONDS = 1000;
30
33
  const timerPause = (ms) => new Promise((resolve) => {
@@ -4,6 +4,7 @@ export type SessionEvent = "INITIAL_SESSION" | "SIGNED_IN" | "SIGNED_OUT" | "TOK
4
4
  export interface SessionPerson {
5
5
  readonly id: string;
6
6
  readonly email: string;
7
+ readonly name?: string;
7
8
  }
8
9
  /** What a view reads: loading until the first answer, then signed in or out. The object changes only when the session does. */
9
10
  export interface SessionSnapshot<User extends SessionPerson> {
@@ -33,7 +34,7 @@ export interface SessionStore<User extends SessionPerson> {
33
34
  getSnapshot: () => SessionSnapshot<User>;
34
35
  /** The snapshot for server rendering: always loading. */
35
36
  getServerSnapshot: () => SessionSnapshot<User>;
36
- /** Publishes the snapshot of a signed-in person; watchers hear it only when status, id or email changed. */
37
+ /** Publishes the snapshot of a signed-in person; watchers hear it only when status, id, email or name changed. */
37
38
  signedIn: (user: User | null) => void;
38
39
  /** Publishes the signed-out snapshot. */
39
40
  signedOut: () => void;
@@ -5,7 +5,7 @@ export const createSessionStore = (source) => {
5
5
  const listeners = new Set();
6
6
  const watchers = new Set();
7
7
  const set = (next) => {
8
- if (snapshot.status === next.status && snapshot.user?.id === next.user?.id && snapshot.user?.email === next.user?.email)
8
+ if (snapshot.status === next.status && snapshot.user?.id === next.user?.id && snapshot.user?.email === next.user?.email && snapshot.user?.name === next.user?.name)
9
9
  return;
10
10
  snapshot = next;
11
11
  for (const watcher of watchers)
@@ -30,6 +30,7 @@ export declare const AUTH_CLAIMS: {
30
30
  readonly role: "role";
31
31
  readonly sessionId: "session_id";
32
32
  readonly email: "email";
33
+ readonly name: "name";
33
34
  };
34
35
  /** The signature algorithm of every access token: pinned, never negotiated. */
35
36
  export declare const AUTH_ALGORITHM = "EdDSA";
@@ -63,6 +64,8 @@ export interface SessionUser {
63
64
  readonly id: string;
64
65
  /** The account email. */
65
66
  readonly email: string;
67
+ /** The person's full name, when the account has one. */
68
+ readonly name?: string;
66
69
  }
67
70
  /** What a sign-in, refresh or session door answers in the body. */
68
71
  export interface AccessGrant {
@@ -28,6 +28,7 @@ export const AUTH_CLAIMS = {
28
28
  role: "role",
29
29
  sessionId: "session_id",
30
30
  email: "email",
31
+ name: "name",
31
32
  };
32
33
  /** The signature algorithm of every access token: pinned, never negotiated. */
33
34
  export const AUTH_ALGORITHM = "EdDSA";
@@ -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) {
@@ -5,6 +5,8 @@ export interface AuthSubject {
5
5
  readonly userId: string;
6
6
  /** The account email. */
7
7
  readonly email: string;
8
+ /** The person's full name, when the account has one. */
9
+ readonly name?: string | undefined;
8
10
  }
9
11
  /** What Google told us about the person who consented. */
10
12
  export interface GoogleProfile {
@@ -34,6 +36,8 @@ export interface AccountStanding {
34
36
  readonly email: string | null;
35
37
  /** Whether the account is disabled or gone. */
36
38
  readonly disabled: boolean;
39
+ /** The person's full name, when the account has one (the session answers and the access token carry it). */
40
+ readonly name?: string | undefined;
37
41
  }
38
42
  /** The app's user table, seen only as far as sessions need it. */
39
43
  export interface AccountDirectory {
@@ -49,6 +53,8 @@ export interface RefreshTokenRecord {
49
53
  readonly expiresAt: Date;
50
54
  /** When the token was rotated away, or null while it is the live one. */
51
55
  readonly usedAt: Date | null;
56
+ /** How many successors replays of this token obtained within the grace window after its rotation (bounded). */
57
+ readonly racedCount: number;
52
58
  }
53
59
  /** A device session to open. */
54
60
  export interface NewSession {
@@ -63,11 +69,13 @@ export interface SessionTransaction {
63
69
  /** Opens a device session. */
64
70
  openSession(session: NewSession): Promise<void>;
65
71
  /** Stores a live refresh token. */
66
- insertToken(token: Omit<RefreshTokenRecord, "usedAt">): Promise<void>;
72
+ insertToken(token: Omit<RefreshTokenRecord, "usedAt" | "racedCount">): Promise<void>;
67
73
  /** The token with this digest, locked for update (a pessimistic write lock), or null. */
68
74
  findTokenForUpdate(tokenHash: string): Promise<RefreshTokenRecord | null>;
69
75
  /** Marks a token as rotated away at `at`. */
70
76
  markTokenUsed(tokenId: string, at: Date): Promise<void>;
77
+ /** Records how many successors replays of a rotated token obtained within the grace window. */
78
+ markTokenRaced(tokenId: string, racedCount: number): Promise<void>;
71
79
  /** Deletes the tokens of an account that expired before `before`. */
72
80
  deleteExpiredTokens(userId: string, before: Date): Promise<void>;
73
81
  }
@@ -79,7 +87,7 @@ export interface SessionStore {
79
87
  findToken(tokenHash: string): Promise<RefreshTokenRecord | null>;
80
88
  /** Marks a session refreshed. */
81
89
  touchSession(sessionId: string, now: Date): Promise<void>;
82
- /** Ends one session (and with it every token of it). */
90
+ /** Ends one session and revokes every token of it (logout, reuse, a gone or disabled account). */
83
91
  closeSession(sessionId: string): Promise<void>;
84
92
  /** Ends every session of an account but one. */
85
93
  closeOtherSessions(userId: string, keepSessionId: string): Promise<void>;
@@ -115,8 +123,6 @@ export interface SessionTokens {
115
123
  readonly accessExpiresIn: number;
116
124
  /** The opaque token the refresh cookie carries. */
117
125
  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
126
  readonly refreshMaxAgeSeconds: number;
121
127
  readonly user: SessionUser;
122
128
  }
@@ -128,6 +134,8 @@ export interface AccessClaims {
128
134
  readonly email?: string | undefined;
129
135
  /** The device session the token belongs to. */
130
136
  readonly sessionId: string;
137
+ /** The person's full name at issue time, when the account has one. */
138
+ readonly name?: string | undefined;
131
139
  }
132
140
  /** The principal domain code builds from a verified access token. */
133
141
  export interface AuthPrincipal {
@@ -135,6 +143,7 @@ export interface AuthPrincipal {
135
143
  readonly role: "authenticated";
136
144
  readonly sessionId: string;
137
145
  readonly email?: string;
146
+ readonly name?: string;
138
147
  }
139
148
  /** A freshly signed access token. */
140
149
  export interface IssuedAccessToken {
@@ -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,23 @@
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;
15
+ /** The subject of a standing account: its id, its address and its full name when the directory knows one. */
16
+ const subjectOf = (user, email) => ({ userId: user.id, email, ...(user.name === undefined ? {} : { name: user.name }) });
9
17
  /**
10
18
  * 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.
19
+ * at sign-out. A token rotated within the grace window gets a successor of its own in the same session, at most
20
+ * {@link RACED_SUCCESSORS_MAX} times.
13
21
  */
14
22
  export class AuthSessions {
15
23
  #deps;
@@ -41,8 +49,9 @@ export class AuthSessions {
41
49
  }
42
50
  /**
43
51
  * 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.
52
+ * ago (reuse ends the session). A token rotated within the grace window gets a successor of its own in the same session (its
53
+ * cookie is set: the response of the first rotation may never have reached the browser), at most {@link RACED_SUCCESSORS_MAX}
54
+ * times; one more replay is reuse.
46
55
  */
47
56
  async refresh(presented) {
48
57
  if (presented === "")
@@ -53,12 +62,15 @@ export class AuthSessions {
53
62
  const row = await tx.findTokenForUpdate(digestOf(presented));
54
63
  if (row === null || row.expiresAt <= now)
55
64
  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 };
65
+ if (row.usedAt === null) {
66
+ await tx.markTokenUsed(row.id, now);
67
+ }
68
+ else {
69
+ const raced = now.getTime() - row.usedAt.getTime() <= this.#graceMs;
70
+ if (!raced || row.racedCount >= RACED_SUCCESSORS_MAX)
71
+ return { kind: "reused", sessionId: row.sessionId };
72
+ await tx.markTokenRaced(row.id, row.racedCount + 1);
60
73
  }
61
- await tx.markTokenUsed(row.id, now);
62
74
  await tx.insertToken({
63
75
  id: this.#deps.ids.next(),
64
76
  sessionId: row.sessionId,
@@ -76,15 +88,13 @@ export class AuthSessions {
76
88
  }
77
89
  if (rotation.kind === "invalid")
78
90
  throw new AuthError(AUTH_ERROR_CODES.RefreshInvalid);
79
- if (rotation.kind === "raced")
80
- return this.#accessOf(rotation.row, presented, now);
81
91
  const user = await this.#deps.options.accounts.find(rotation.row.userId);
82
92
  if (user === undefined || user.disabled || user.email === null) {
83
93
  await this.#store.closeSession(rotation.row.sessionId);
84
94
  throw new AuthError(AUTH_ERROR_CODES.AccountDisabled);
85
95
  }
86
96
  await this.#store.touchSession(rotation.row.sessionId, now);
87
- return this.#tokensFor({ userId: user.id, email: user.email }, rotation.row.sessionId, rotation.next, now);
97
+ return this.#tokensFor(subjectOf(user, user.email), rotation.row.sessionId, rotation.next, now);
88
98
  }
89
99
  /**
90
100
  * Answers a fresh access token for the session of a refresh token WITHOUT rotating it, so parallel gated requests of one browser
@@ -130,18 +140,18 @@ export class AuthSessions {
130
140
  await this.#store.closeSession(row.sessionId);
131
141
  throw new AuthError(AUTH_ERROR_CODES.AccountDisabled);
132
142
  }
133
- const tokens = await this.#tokensFor({ userId: user.id, email: user.email }, row.sessionId, presented, now);
134
- return { ...tokens, cookieKept: true };
143
+ return this.#tokensFor(subjectOf(user, user.email), row.sessionId, presented, now);
135
144
  }
136
145
  async #tokensFor(subject, sessionId, refreshToken, now) {
137
- const access = await this.#deps.tokens.sign({ sub: subject.userId, email: subject.email, sessionId }, now);
146
+ const name = subject.name === undefined ? {} : { name: subject.name };
147
+ const access = await this.#deps.tokens.sign({ sub: subject.userId, email: subject.email, sessionId, ...name }, now);
138
148
  return {
139
149
  accessToken: access.token,
140
150
  accessExpiresAt: access.expiresAt.toISOString(),
141
151
  accessExpiresIn: Math.round((access.expiresAt.getTime() - now.getTime()) / MILLISECONDS),
142
152
  cookieToken: refreshToken,
143
153
  refreshMaxAgeSeconds: this.#deps.options.refreshTtlSeconds,
144
- user: { id: subject.userId, email: subject.email },
154
+ user: { id: subject.userId, email: subject.email, ...name },
145
155
  };
146
156
  }
147
157
  #refreshExpiry(now) {
@@ -25,6 +25,7 @@ export class AuthTokens {
25
25
  [AUTH_CLAIMS.role]: AUTH_ROLE,
26
26
  [AUTH_CLAIMS.sessionId]: claims.sessionId,
27
27
  ...(claims.email === undefined ? {} : { [AUTH_CLAIMS.email]: claims.email }),
28
+ ...(claims.name === undefined ? {} : { [AUTH_CLAIMS.name]: claims.name }),
28
29
  })
29
30
  .setProtectedHeader({ alg: AUTH_ALGORITHM, kid: signer.keyId, typ: "JWT" })
30
31
  .setSubject(claims.sub)
@@ -50,7 +51,8 @@ export class AuthTokens {
50
51
  return refused(AUTH_ERROR_CODES.AccessTokenInvalid);
51
52
  }
52
53
  const email = payload[AUTH_CLAIMS.email];
53
- return ok({ sub: payload.sub, sessionId, ...(typeof email === "string" ? { email } : {}) });
54
+ const name = payload[AUTH_CLAIMS.name];
55
+ return ok({ sub: payload.sub, sessionId, ...(typeof email === "string" ? { email } : {}), ...(typeof name === "string" ? { name } : {}) });
54
56
  }
55
57
  catch (cause) {
56
58
  return refused(AUTH_ERROR_CODES.AccessTokenInvalid, { cause: String(cause) });
@@ -13,8 +13,8 @@ export class AuthPrincipalVerifier {
13
13
  const verified = await this.#tokens.verify(token, now);
14
14
  if (verified.kind === "refused")
15
15
  return refused(AUTH_ERROR_CODES.AccessTokenInvalid);
16
- const { sub, email, sessionId } = verified.value;
17
- return ok({ id: sub, role: AUTH_ROLE, sessionId, ...(email === undefined ? {} : { email }) });
16
+ const { sub, email, name, sessionId } = verified.value;
17
+ return ok({ id: sub, role: AUTH_ROLE, sessionId, ...(email === undefined ? {} : { email }), ...(name === undefined ? {} : { name }) });
18
18
  }
19
19
  }
20
20
  /** The verifier of an app that serves no user session: every access token is refused, so the default-deny guard admits only its public and credential doors. */
@@ -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.2.0",
3
+ "version": "0.3.0",
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",