@starci/auth 0.1.0 → 0.2.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 +4 -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/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.0 - 2026-11-03
|
|
4
|
+
|
|
5
|
+
- 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).
|
|
6
|
+
|
|
3
7
|
## 0.1.0 - 2026-11-03
|
|
4
8
|
|
|
5
9
|
The sign-in of a lite StarCi app in one package, lifted from the product that ran it in production and generalised: no product names, every setting a typed option, no environment read.
|
package/README.md
CHANGED
|
@@ -23,7 +23,7 @@ AuthModule.registerAsync({
|
|
|
23
23
|
})
|
|
24
24
|
```
|
|
25
25
|
|
|
26
|
-
The package reads no environment. The app parses its settings (BE-CONFIG-2) and passes typed options; secrets are passed as `{ reveal(): string }` holders or text. The app implements `SessionStore` over the tables of `migrations/identity-sessions.sql`, `AccountDirectory` over its
|
|
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.
|
|
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@starci/auth",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.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",
|