@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 +9 -0
- package/README.md +1 -1
- package/dist/client/auth-client.d.ts +4 -6
- package/dist/client/auth-client.js +24 -57
- package/dist/client/index.d.ts +2 -0
- package/dist/client/index.js +1 -0
- package/dist/client/session.d.ts +57 -0
- package/dist/client/session.js +68 -0
- 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,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
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
70
|
-
|
|
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
|
-
|
|
75
|
+
store.signedOut();
|
|
79
76
|
if (had)
|
|
80
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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:
|
|
221
|
-
getServerSnapshot:
|
|
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
|
};
|
package/dist/client/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/client/index.js
CHANGED
|
@@ -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
|
+
};
|
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.1
|
|
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",
|