@starci/auth 0.2.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 +5 -0
- package/README.md +1 -1
- package/dist/server/doors.d.ts +2 -2
- package/dist/server/doors.js +3 -4
- package/dist/server/ports.d.ts +6 -4
- package/dist/server/sessions.d.ts +12 -5
- package/dist/server/sessions.js +21 -14
- package/dist/testing/index.js +7 -1
- package/migrations/identity-refresh-raced-successors.sql +7 -0
- package/migrations/identity-sessions.sql +3 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
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
|
+
|
|
3
8
|
## 0.2.0 - 2026-11-03
|
|
4
9
|
|
|
5
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).
|
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
|
|
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
|
|
package/dist/server/doors.d.ts
CHANGED
|
@@ -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;
|
|
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
|
|
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>;
|
package/dist/server/doors.js
CHANGED
|
@@ -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;
|
|
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
|
|
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
|
-
|
|
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) {
|
package/dist/server/ports.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
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
|
|
20
|
-
*
|
|
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
|
|
30
|
-
*
|
|
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
|
/**
|
package/dist/server/sessions.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
12
|
-
*
|
|
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
|
|
45
|
-
*
|
|
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
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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);
|
package/dist/testing/index.js
CHANGED
|
@@ -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
|
|
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.
|
|
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",
|