@starci/auth 0.2.1 → 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,9 @@
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
+
3
7
  ## 0.2.1 - 2026-11-03
4
8
 
5
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.
@@ -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";
@@ -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 {
@@ -130,6 +134,8 @@ export interface AccessClaims {
130
134
  readonly email?: string | undefined;
131
135
  /** The device session the token belongs to. */
132
136
  readonly sessionId: string;
137
+ /** The person's full name at issue time, when the account has one. */
138
+ readonly name?: string | undefined;
133
139
  }
134
140
  /** The principal domain code builds from a verified access token. */
135
141
  export interface AuthPrincipal {
@@ -137,6 +143,7 @@ export interface AuthPrincipal {
137
143
  readonly role: "authenticated";
138
144
  readonly sessionId: string;
139
145
  readonly email?: string;
146
+ readonly name?: string;
140
147
  }
141
148
  /** A freshly signed access token. */
142
149
  export interface IssuedAccessToken {
@@ -12,6 +12,8 @@ export const RACED_SUCCESSORS_MAX = 3;
12
12
  /** The log event of a replayed refresh token. */
13
13
  export const REFRESH_REUSE_EVENT = "auth.refresh-reuse-detected";
14
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 }) });
15
17
  /**
16
18
  * Device sessions: opens one at sign-in, rotates its refresh token on every refresh (a replayed token ends the session) and ends it
17
19
  * at sign-out. A token rotated within the grace window gets a successor of its own in the same session, at most
@@ -92,7 +94,7 @@ export class AuthSessions {
92
94
  throw new AuthError(AUTH_ERROR_CODES.AccountDisabled);
93
95
  }
94
96
  await this.#store.touchSession(rotation.row.sessionId, now);
95
- 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);
96
98
  }
97
99
  /**
98
100
  * Answers a fresh access token for the session of a refresh token WITHOUT rotating it, so parallel gated requests of one browser
@@ -138,17 +140,18 @@ export class AuthSessions {
138
140
  await this.#store.closeSession(row.sessionId);
139
141
  throw new AuthError(AUTH_ERROR_CODES.AccountDisabled);
140
142
  }
141
- return this.#tokensFor({ userId: user.id, email: user.email }, row.sessionId, presented, now);
143
+ return this.#tokensFor(subjectOf(user, user.email), row.sessionId, presented, now);
142
144
  }
143
145
  async #tokensFor(subject, sessionId, refreshToken, now) {
144
- 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);
145
148
  return {
146
149
  accessToken: access.token,
147
150
  accessExpiresAt: access.expiresAt.toISOString(),
148
151
  accessExpiresIn: Math.round((access.expiresAt.getTime() - now.getTime()) / MILLISECONDS),
149
152
  cookieToken: refreshToken,
150
153
  refreshMaxAgeSeconds: this.#deps.options.refreshTtlSeconds,
151
- user: { id: subject.userId, email: subject.email },
154
+ user: { id: subject.userId, email: subject.email, ...name },
152
155
  };
153
156
  }
154
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. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starci/auth",
3
- "version": "0.2.1",
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",