@starci/auth 0.0.0-stage → 0.1.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 +12 -0
- package/LICENSE +21 -0
- package/README.md +38 -2
- package/dist/client/auth-client.d.ts +70 -0
- package/dist/client/auth-client.js +225 -0
- package/dist/client/index.d.ts +6 -0
- package/dist/client/index.js +3 -0
- package/dist/client/outcome.d.ts +15 -0
- package/dist/client/outcome.js +22 -0
- package/dist/client/shared.d.ts +20 -0
- package/dist/client/shared.js +42 -0
- package/dist/contracts/index.d.ts +111 -0
- package/dist/contracts/index.js +71 -0
- package/dist/react/index.d.ts +21 -0
- package/dist/react/index.js +42 -0
- package/dist/server/auth.module.d.ts +29 -0
- package/dist/server/auth.module.js +85 -0
- package/dist/server/auth.tokens.d.ts +24 -0
- package/dist/server/auth.tokens.js +24 -0
- package/dist/server/budget.d.ts +21 -0
- package/dist/server/budget.js +38 -0
- package/dist/server/cookie.d.ts +19 -0
- package/dist/server/cookie.js +54 -0
- package/dist/server/doors.d.ts +44 -0
- package/dist/server/doors.js +72 -0
- package/dist/server/errors.d.ts +24 -0
- package/dist/server/errors.js +18 -0
- package/dist/server/flows.d.ts +21 -0
- package/dist/server/flows.js +66 -0
- package/dist/server/google-state.d.ts +33 -0
- package/dist/server/google-state.js +73 -0
- package/dist/server/http.d.ts +27 -0
- package/dist/server/http.js +1 -0
- package/dist/server/index.d.ts +24 -0
- package/dist/server/index.js +15 -0
- package/dist/server/options.d.ts +81 -0
- package/dist/server/options.js +1 -0
- package/dist/server/passwords.d.ts +11 -0
- package/dist/server/passwords.js +22 -0
- package/dist/server/ports.d.ts +145 -0
- package/dist/server/ports.js +1 -0
- package/dist/server/primitives.d.ts +36 -0
- package/dist/server/primitives.js +67 -0
- package/dist/server/runtime.d.ts +5 -0
- package/dist/server/runtime.js +5 -0
- package/dist/server/sessions.d.ts +45 -0
- package/dist/server/sessions.js +153 -0
- package/dist/server/strategies.d.ts +29 -0
- package/dist/server/strategies.js +55 -0
- package/dist/server/tokens.d.ts +26 -0
- package/dist/server/tokens.js +130 -0
- package/dist/server/verifier.d.ts +20 -0
- package/dist/server/verifier.js +23 -0
- package/dist/testing/index.d.ts +52 -0
- package/dist/testing/index.js +158 -0
- package/migrations/identity-sessions.sql +58 -0
- package/package.json +126 -4
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 - 2026-11-03
|
|
4
|
+
|
|
5
|
+
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.
|
|
6
|
+
|
|
7
|
+
- Added: `@starci/auth/server`, the NestJS module `AuthModule.register` / `registerAsync`. `AuthTokens` signs and verifies EdDSA access tokens (the algorithm pinned, issuer, audience, `role` and `session_id` claims, a `kid` from the key or its RFC 7638 thumbprint) over a key set: one signing key and any number of retired keys, all verified and all published by `jwks()`, so a key rotates without signing anybody out. `AuthSessions` opens device sessions and rotates the refresh token on every refresh with reuse detection: a spent token replayed inside the grace window (30 seconds) is a racing tab and is answered with an access token and no rotation (`cookieKept`), an older replay ends the session. `SignInBudget` counts sign-in failures only (a right password clears its address). `RefreshCookie` writes the httpOnly, Secure, SameSite cookie with a configurable name, domain, path and legacy paths. `AuthFlows` runs the passport local and Google strategies (state and PKCE in a signed cookie, a same-site landing path only). `AuthDoors` is what a controller calls. `AuthPasswords` is the one owner of bcrypt. `AuthPrincipalVerifier` implements `PrincipalVerifier`, the interface the identity domain injects.
|
|
8
|
+
- Added: ports the app implements over its own tables: `SessionStore`, `AccountDirectory`, `RateCounter`, `AuthAuthenticator`.
|
|
9
|
+
- Added: `migrations/identity-sessions.sql`, the session tables (`identity_sessions`, `identity_refresh_tokens`) and the sign-in attempt counter (`identity_attempts`) the starter renders.
|
|
10
|
+
- Added: `@starci/auth/contracts`, the door paths, wire codes, claim names and answer shapes both sides read.
|
|
11
|
+
- Added: `@starci/auth/client`, `createAuthClient`: the access token in memory only, one shared refresh for overlapping callers with refresh-ahead, retries of 429, 5xx and a lost connection with backoff, signed out only on 401 or 403, an event listener and a stable snapshot, the server-side `checkSession`. `@starci/auth/client/react` holds `AuthProvider` (a context that draws nothing), `useAuthSession`, `useAuthClient`, `useWhenSignedIn` and `useCooldown`.
|
|
12
|
+
- Added: `@starci/auth/testing`, test keys, a fake clock, in-memory stores and `testAuthOptions`.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 starci183
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,39 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @starci/auth
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The sign-in of a lite StarCi app: one package, four entries.
|
|
4
|
+
|
|
5
|
+
| entry | holds |
|
|
6
|
+
|---|---|
|
|
7
|
+
| `@starci/auth/server` | the NestJS module (`AuthModule.register` / `registerAsync`), the token signer and verifier (EdDSA, multi-key JWKS), rotating sessions, the sign-in budget, the refresh cookie, the passport flows and the doors that run them |
|
|
8
|
+
| `@starci/auth/client` | `createAuthClient`: access token in memory, one shared refresh, signed out only on 401 or 403 |
|
|
9
|
+
| `@starci/auth/client/react` | `AuthProvider`, `useAuthSession`, `useAuthClient`, `useWhenSignedIn`, `useCooldown` (context and hooks over the client; no visual) |
|
|
10
|
+
| `@starci/auth/contracts` | door paths, wire codes, claim names and answer shapes both sides read |
|
|
11
|
+
| `@starci/auth/testing` | test keys, a fake clock, in-memory stores, `testAuthOptions` |
|
|
12
|
+
|
|
13
|
+
An app reaches the package only through `integrations/passport` (back end) and `modules/auth/client.ts` (front end); the lint refuses `passport*`, `jose`, `bcryptjs` and `@nestjs/jwt` in an app.
|
|
14
|
+
|
|
15
|
+
## Back end
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
AuthModule.registerAsync({
|
|
19
|
+
isGlobal: true,
|
|
20
|
+
authenticator: IDENTITY_AUTHENTICATOR, // the token of the app's AuthAuthenticator, resolved from a global module
|
|
21
|
+
inject: [SESSION_STORE, ACCOUNT_DIRECTORY, RATE_LIMIT_STORE],
|
|
22
|
+
useFactory: (sessions, accounts, rate) => ({ ...parsedOptions, sessions, accounts, rate }),
|
|
23
|
+
})
|
|
24
|
+
```
|
|
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 user table, `RateCounter` over its shared counter, and `AuthAuthenticator` (the password and Google decisions) using `AuthPasswords` and `SignInBudget.counted` from the module.
|
|
27
|
+
|
|
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
|
+
|
|
30
|
+
## Front end
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
// modules/auth/client.ts: the one instance
|
|
34
|
+
export const auth = createAuthClient({ apiOrigin: () => apiOrigin() })
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Tests
|
|
38
|
+
|
|
39
|
+
`npm test` typechecks, lints and runs the `node:test` specs under 90% line coverage.
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { AccessGrant, AuthDoorName, AuthOutcome, SessionUser } from "../contracts/index.js";
|
|
2
|
+
import type { StatusCodes } from "./outcome.js";
|
|
3
|
+
/** What a door answered. A refusal is an answer, not a throw: the status says what happened. */
|
|
4
|
+
export interface AuthDoorAnswer {
|
|
5
|
+
readonly status: number;
|
|
6
|
+
readonly body: unknown;
|
|
7
|
+
}
|
|
8
|
+
/** The event a listener hears. */
|
|
9
|
+
export type AuthEvent = "INITIAL_SESSION" | "SIGNED_IN" | "SIGNED_OUT" | "TOKEN_REFRESHED";
|
|
10
|
+
/** 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 = (event: AuthEvent, userId: string | null) => void;
|
|
12
|
+
/** What a view reads: loading until the first answer, then signed in or out. The object changes only when the session does. */
|
|
13
|
+
export interface AuthSnapshot {
|
|
14
|
+
readonly status: "loading" | "signed-in" | "signed-out";
|
|
15
|
+
readonly user: SessionUser | null;
|
|
16
|
+
}
|
|
17
|
+
/** The options of the client. */
|
|
18
|
+
export interface AuthClientOptions {
|
|
19
|
+
/** The API origin the doors live on. */
|
|
20
|
+
readonly apiOrigin: string | (() => string);
|
|
21
|
+
/** The fetch to use (the global one when absent). */
|
|
22
|
+
readonly fetch?: typeof fetch | undefined;
|
|
23
|
+
/** Refresh this long before the token really expires, so a request never carries a dying one (15 seconds). */
|
|
24
|
+
readonly refreshAheadMs?: number | undefined;
|
|
25
|
+
/** Tries a refresh makes before it gives up on a busy or unreachable back end (4). */
|
|
26
|
+
readonly retryAttempts?: number | undefined;
|
|
27
|
+
/** The base of the linear backoff between tries, in milliseconds (400). */
|
|
28
|
+
readonly retryBaseMs?: number | undefined;
|
|
29
|
+
/** The clock in milliseconds (`Date.now`). */
|
|
30
|
+
readonly now?: (() => number) | undefined;
|
|
31
|
+
/** The pause between tries (a timer). */
|
|
32
|
+
readonly sleep?: ((ms: number) => Promise<void>) | undefined;
|
|
33
|
+
}
|
|
34
|
+
/** The front-end client of the auth doors: the access token lives in this closure's memory only, never in storage or a cookie. */
|
|
35
|
+
export interface AuthClient {
|
|
36
|
+
/** Calls a door with the credentials of the browser; a transport failure rejects, any answer resolves. `cookie` is the header a server forwards. */
|
|
37
|
+
door(name: AuthDoorName, body?: object, cookie?: string): Promise<AuthDoorAnswer>;
|
|
38
|
+
/** Signs in with email and password and keeps the grant; a refusal is an outcome. */
|
|
39
|
+
signIn(body: object): Promise<AuthOutcome<SessionUser>>;
|
|
40
|
+
/** Calls a door that returns no grant (signup, resend, confirm, forgot, reset) and maps its answer to an outcome. */
|
|
41
|
+
send(name: AuthDoorName, body: object, fallbacks?: StatusCodes): Promise<AuthOutcome<unknown>>;
|
|
42
|
+
/** Ends the session of the cookie and forgets the token; the token is forgotten even when the door cannot be reached. */
|
|
43
|
+
signOut(): Promise<void>;
|
|
44
|
+
/** Refreshes through the httpOnly cookie. Overlapping calls share one refresh. `undefined` when it cannot be refreshed. */
|
|
45
|
+
renew(): Promise<string | undefined>;
|
|
46
|
+
/** The access token of the current session, refreshed when missing or about to expire; `undefined` when signed out. */
|
|
47
|
+
accessToken(): Promise<string | undefined>;
|
|
48
|
+
/** The held access token without any request. */
|
|
49
|
+
peek(): string | undefined;
|
|
50
|
+
/** The signed-in person (id and email), or null when signed out. */
|
|
51
|
+
user(): Promise<SessionUser | null>;
|
|
52
|
+
/** The `Authorization` header of the current session (an empty object when signed out). */
|
|
53
|
+
headers(): Promise<Record<string, string>>;
|
|
54
|
+
/** Keeps a grant a door answered (an OAuth landing, a sign-in the app ran itself) and announces it. */
|
|
55
|
+
keep(grant: AccessGrant, event?: AuthEvent): void;
|
|
56
|
+
/** Forgets the token (sign-out, or a refresh the back end refused). */
|
|
57
|
+
drop(): void;
|
|
58
|
+
/** Listens to auth events; the first answer comes from a refresh. Answers the function that stops listening. */
|
|
59
|
+
subscribe(listener: AuthListener): () => void;
|
|
60
|
+
/** The current snapshot (stable between changes). */
|
|
61
|
+
getSnapshot: () => AuthSnapshot;
|
|
62
|
+
/** The snapshot for server rendering: always loading. */
|
|
63
|
+
getServerSnapshot: () => AuthSnapshot;
|
|
64
|
+
/** Calls `onChange` when the snapshot changes (the shape `useSyncExternalStore` takes). */
|
|
65
|
+
watch: (onChange: () => void) => () => void;
|
|
66
|
+
/** Asks the back end whether the given `Cookie` header holds a live session (server side); the door does not rotate the cookie. */
|
|
67
|
+
checkSession(cookie: string | null): Promise<AuthOutcome<true>>;
|
|
68
|
+
}
|
|
69
|
+
/** Creates the one auth client of a front end (one instance, like the event stream). */
|
|
70
|
+
export declare const createAuthClient: (options: AuthClientOptions) => AuthClient;
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { AUTH_DOORS } from "../contracts/index.js";
|
|
2
|
+
import { authFailure, authOk, failureOfStatus, isSignedOutStatus, isTransientStatus } from "./outcome.js";
|
|
3
|
+
const LOADING = { status: "loading", user: null };
|
|
4
|
+
const isGrant = (value) => typeof value === "object" &&
|
|
5
|
+
value !== null &&
|
|
6
|
+
"accessToken" in value &&
|
|
7
|
+
typeof value.accessToken === "string" &&
|
|
8
|
+
"expiresIn" in value &&
|
|
9
|
+
typeof value.expiresIn === "number";
|
|
10
|
+
const fieldOf = (value, key) => typeof value === "object" && value !== null ? Object.entries(value).find(([name]) => name === key)?.[1] : undefined;
|
|
11
|
+
const claimsOf = (token) => {
|
|
12
|
+
const payload = token.split(".")[1];
|
|
13
|
+
if (payload === undefined)
|
|
14
|
+
return {};
|
|
15
|
+
try {
|
|
16
|
+
const parsed = JSON.parse(atob(payload.replaceAll("-", "+").replaceAll("_", "/")));
|
|
17
|
+
return typeof parsed === "object" && parsed !== null ? parsed : {};
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return {};
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
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;
|
|
28
|
+
};
|
|
29
|
+
const MILLISECONDS = 1000;
|
|
30
|
+
const timerPause = (ms) => new Promise((resolve) => {
|
|
31
|
+
const timer = setTimeout(() => {
|
|
32
|
+
clearTimeout(timer);
|
|
33
|
+
resolve();
|
|
34
|
+
}, ms);
|
|
35
|
+
});
|
|
36
|
+
/** Creates the one auth client of a front end (one instance, like the event stream). */
|
|
37
|
+
export const createAuthClient = (options) => {
|
|
38
|
+
const fetcher = options.fetch ?? ((input, init) => fetch(input, init));
|
|
39
|
+
const refreshAheadMs = options.refreshAheadMs ?? 15_000;
|
|
40
|
+
const attemptsMax = options.retryAttempts ?? 4;
|
|
41
|
+
const retryBaseMs = options.retryBaseMs ?? 400;
|
|
42
|
+
const now = options.now ?? Date.now;
|
|
43
|
+
const pause = options.sleep ?? timerPause;
|
|
44
|
+
const originOf = () => (typeof options.apiOrigin === "string" ? options.apiOrigin : options.apiOrigin());
|
|
45
|
+
let token;
|
|
46
|
+
let expiresAt = 0;
|
|
47
|
+
let announced = false;
|
|
48
|
+
let user = null;
|
|
49
|
+
let inFlight;
|
|
50
|
+
let snapshot = LOADING;
|
|
51
|
+
const listeners = new Set();
|
|
52
|
+
const watchers = new Set();
|
|
53
|
+
const setSnapshot = (next) => {
|
|
54
|
+
if (snapshot.status === next.status && snapshot.user?.id === next.user?.id && snapshot.user?.email === next.user?.email)
|
|
55
|
+
return;
|
|
56
|
+
snapshot = next;
|
|
57
|
+
for (const watcher of watchers)
|
|
58
|
+
watcher();
|
|
59
|
+
};
|
|
60
|
+
const notify = (event) => {
|
|
61
|
+
for (const listener of listeners)
|
|
62
|
+
listener(event, user?.id ?? null);
|
|
63
|
+
};
|
|
64
|
+
const keep = (grant, event = "TOKEN_REFRESHED") => {
|
|
65
|
+
token = grant.accessToken;
|
|
66
|
+
expiresAt = now() + grant.expiresIn * MILLISECONDS;
|
|
67
|
+
announced = true;
|
|
68
|
+
user = userOf(grant);
|
|
69
|
+
setSnapshot({ status: "signed-in", user });
|
|
70
|
+
notify(event);
|
|
71
|
+
};
|
|
72
|
+
const drop = () => {
|
|
73
|
+
const had = token !== undefined;
|
|
74
|
+
token = undefined;
|
|
75
|
+
expiresAt = 0;
|
|
76
|
+
announced = true;
|
|
77
|
+
user = null;
|
|
78
|
+
setSnapshot({ status: "signed-out", user: null });
|
|
79
|
+
if (had)
|
|
80
|
+
notify("SIGNED_OUT");
|
|
81
|
+
};
|
|
82
|
+
const door = async (name, body, cookie) => {
|
|
83
|
+
const get = name === "session" || name === "jwks";
|
|
84
|
+
const response = await fetcher(`${originOf()}${AUTH_DOORS[name]}`, {
|
|
85
|
+
method: get ? "GET" : "POST",
|
|
86
|
+
credentials: "include",
|
|
87
|
+
headers: {
|
|
88
|
+
...(get ? {} : { "content-type": "application/json" }),
|
|
89
|
+
...(cookie === undefined ? {} : { cookie }),
|
|
90
|
+
},
|
|
91
|
+
...(get ? {} : { body: JSON.stringify(body ?? {}) }),
|
|
92
|
+
});
|
|
93
|
+
const parsed = await response.json().catch(() => undefined);
|
|
94
|
+
return { status: response.status, body: parsed };
|
|
95
|
+
};
|
|
96
|
+
const refreshEvent = () => {
|
|
97
|
+
if (!announced)
|
|
98
|
+
return "INITIAL_SESSION";
|
|
99
|
+
return token === undefined ? "SIGNED_IN" : "TOKEN_REFRESHED";
|
|
100
|
+
};
|
|
101
|
+
const requestRefresh = async (attempt = 1) => {
|
|
102
|
+
const retry = async () => {
|
|
103
|
+
await pause(retryBaseMs * attempt);
|
|
104
|
+
return requestRefresh(attempt + 1);
|
|
105
|
+
};
|
|
106
|
+
try {
|
|
107
|
+
const answer = await door("refresh");
|
|
108
|
+
if (answer.status < 200 || answer.status >= 300) {
|
|
109
|
+
if (isSignedOutStatus(answer.status))
|
|
110
|
+
drop();
|
|
111
|
+
else if (isTransientStatus(answer.status) && attempt < attemptsMax)
|
|
112
|
+
return await retry();
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
if (!isGrant(answer.body))
|
|
116
|
+
return undefined;
|
|
117
|
+
keep(answer.body, refreshEvent());
|
|
118
|
+
return answer.body.accessToken;
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return attempt < attemptsMax ? retry() : undefined;
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
const renew = () => {
|
|
125
|
+
inFlight ??= requestRefresh().finally(() => {
|
|
126
|
+
inFlight = undefined;
|
|
127
|
+
});
|
|
128
|
+
return inFlight;
|
|
129
|
+
};
|
|
130
|
+
const accessToken = async () => token !== undefined && expiresAt - refreshAheadMs > now() ? token : renew();
|
|
131
|
+
const outcomeOfAnswer = (answer, fallbacks) => {
|
|
132
|
+
if (answer.status >= 200 && answer.status < 300)
|
|
133
|
+
return authOk(answer.body);
|
|
134
|
+
const code = fieldOf(answer.body, "code");
|
|
135
|
+
return failureOfStatus(answer.status, typeof code === "string" ? code : undefined, fallbacks);
|
|
136
|
+
};
|
|
137
|
+
const send = async (name, body, fallbacks) => {
|
|
138
|
+
try {
|
|
139
|
+
return outcomeOfAnswer(await door(name, body), fallbacks);
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return authFailure("unavailable", "transport");
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
const signIn = async (body) => {
|
|
146
|
+
const outcome = await send("login", body);
|
|
147
|
+
if (!outcome.success)
|
|
148
|
+
return outcome;
|
|
149
|
+
if (!isGrant(outcome.data))
|
|
150
|
+
return authFailure("unavailable", "grant");
|
|
151
|
+
keep(outcome.data, "SIGNED_IN");
|
|
152
|
+
const person = userOf(outcome.data);
|
|
153
|
+
return person === null ? authFailure("unavailable", "grant") : authOk(person);
|
|
154
|
+
};
|
|
155
|
+
const signOut = async () => {
|
|
156
|
+
try {
|
|
157
|
+
await door("logout");
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
// The token is forgotten below even when the door cannot be reached; the cookie then lapses with its session.
|
|
161
|
+
}
|
|
162
|
+
drop();
|
|
163
|
+
};
|
|
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
|
+
const checkSession = async (cookie) => {
|
|
192
|
+
if (cookie === null || cookie === "")
|
|
193
|
+
return authFailure("refused", "no-session");
|
|
194
|
+
try {
|
|
195
|
+
const answer = await door("session", undefined, cookie);
|
|
196
|
+
if (answer.status >= 200 && answer.status < 300)
|
|
197
|
+
return authOk(true);
|
|
198
|
+
return authFailure(isSignedOutStatus(answer.status) ? "refused" : "unavailable", String(answer.status));
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
return authFailure("unavailable", "transport");
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
return {
|
|
205
|
+
door,
|
|
206
|
+
signIn,
|
|
207
|
+
send,
|
|
208
|
+
signOut,
|
|
209
|
+
renew,
|
|
210
|
+
accessToken,
|
|
211
|
+
peek: () => token,
|
|
212
|
+
user: async () => ((await accessToken()) === undefined ? null : user),
|
|
213
|
+
headers: async () => {
|
|
214
|
+
const held = await accessToken();
|
|
215
|
+
return held === undefined ? {} : { authorization: `Bearer ${held}` };
|
|
216
|
+
},
|
|
217
|
+
keep,
|
|
218
|
+
drop,
|
|
219
|
+
subscribe,
|
|
220
|
+
getSnapshot: () => snapshot,
|
|
221
|
+
getServerSnapshot: () => LOADING,
|
|
222
|
+
watch,
|
|
223
|
+
checkSession,
|
|
224
|
+
};
|
|
225
|
+
};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { createAuthClient } from "./auth-client.js";
|
|
2
|
+
export type { AuthClient, AuthClientOptions, AuthDoorAnswer, AuthEvent, AuthListener, AuthSnapshot } from "./auth-client.js";
|
|
3
|
+
export { authFailure, authOk, failureOfStatus, isSignedOutStatus, isTransientStatus, kindOfStatus } from "./outcome.js";
|
|
4
|
+
export type { StatusCodes } from "./outcome.js";
|
|
5
|
+
export { EMAIL_PATTERN, NAME_MIN_LENGTH, PASSWORD_MIN_LENGTH, RESEND_COOLDOWN_SECONDS, authErrorKey, isLinkInvalid, passwordStrength, strengthKey, } from "./shared.js";
|
|
6
|
+
export type { AuthErrorKey, Strength } from "./shared.js";
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { createAuthClient } from "./auth-client.js";
|
|
2
|
+
export { authFailure, authOk, failureOfStatus, isSignedOutStatus, isTransientStatus, kindOfStatus } from "./outcome.js";
|
|
3
|
+
export { EMAIL_PATTERN, NAME_MIN_LENGTH, PASSWORD_MIN_LENGTH, RESEND_COOLDOWN_SECONDS, authErrorKey, isLinkInvalid, passwordStrength, strengthKey, } from "./shared.js";
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { AuthFailureKind, AuthOutcome } from "../contracts/index.js";
|
|
2
|
+
/** Wraps a value as a successful outcome. */
|
|
3
|
+
export declare const authOk: <T>(value: T) => AuthOutcome<T>;
|
|
4
|
+
/** Builds an expected failure: the kind as the message, the wire code as the error. */
|
|
5
|
+
export declare const authFailure: <T = never>(kind: AuthFailureKind, code: string) => AuthOutcome<T>;
|
|
6
|
+
/** True when a status of a refresh or session door means the person is signed out. Only 401 and 403 do: a 429, a 5xx or a lost connection is a passing failure that never ends a session. */
|
|
7
|
+
export declare const isSignedOutStatus: (status: number) => boolean;
|
|
8
|
+
/** True when a status is a passing failure (too many requests, a server error) that a later attempt can clear. */
|
|
9
|
+
export declare const isTransientStatus: (status: number) => boolean;
|
|
10
|
+
/** The failure kind an HTTP status of the auth doors stands for. */
|
|
11
|
+
export declare const kindOfStatus: (status: number) => AuthFailureKind;
|
|
12
|
+
/** Codes a status falls back to when the body names none, so the friendly copy can pick its sentence. */
|
|
13
|
+
export type StatusCodes = Readonly<Record<number, string>>;
|
|
14
|
+
/** Maps a non-ok answer of an auth door (status plus the optional `code` of its JSON body) into a failed outcome. */
|
|
15
|
+
export declare const failureOfStatus: <T = never>(status: number, code: string | undefined, fallbacks?: StatusCodes) => AuthOutcome<T>;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Wraps a value as a successful outcome. */
|
|
2
|
+
export const authOk = (value) => ({ success: true, message: "ok", data: value });
|
|
3
|
+
/** Builds an expected failure: the kind as the message, the wire code as the error. */
|
|
4
|
+
export const authFailure = (kind, code) => ({
|
|
5
|
+
success: false,
|
|
6
|
+
message: kind,
|
|
7
|
+
error: code,
|
|
8
|
+
});
|
|
9
|
+
/** True when a status of a refresh or session door means the person is signed out. Only 401 and 403 do: a 429, a 5xx or a lost connection is a passing failure that never ends a session. */
|
|
10
|
+
export const isSignedOutStatus = (status) => status === 401 || status === 403;
|
|
11
|
+
/** True when a status is a passing failure (too many requests, a server error) that a later attempt can clear. */
|
|
12
|
+
export const isTransientStatus = (status) => status === 429 || status >= 500;
|
|
13
|
+
/** The failure kind an HTTP status of the auth doors stands for. */
|
|
14
|
+
export const kindOfStatus = (status) => {
|
|
15
|
+
if (isSignedOutStatus(status))
|
|
16
|
+
return "refused";
|
|
17
|
+
if (status === 404)
|
|
18
|
+
return "not-found";
|
|
19
|
+
return status === 400 || status === 409 || status === 410 || status === 422 ? "invalid" : "unavailable";
|
|
20
|
+
};
|
|
21
|
+
/** Maps a non-ok answer of an auth door (status plus the optional `code` of its JSON body) into a failed outcome. */
|
|
22
|
+
export const failureOfStatus = (status, code, fallbacks = {}) => authFailure(kindOfStatus(status), code ?? fallbacks[status] ?? String(status));
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Shortest display name a sign-up accepts. */
|
|
2
|
+
export declare const NAME_MIN_LENGTH = 2;
|
|
3
|
+
/** Shortest password the forms accept (the doors enforce the same bound). */
|
|
4
|
+
export declare const PASSWORD_MIN_LENGTH: 8;
|
|
5
|
+
/** Seconds before a confirmation or reset mail may be requested again. */
|
|
6
|
+
export declare const RESEND_COOLDOWN_SECONDS = 60;
|
|
7
|
+
/** One address: a local part, one `@`, a domain with a dot. */
|
|
8
|
+
export declare const EMAIL_PATTERN: RegExp;
|
|
9
|
+
/** How strong a password looks. */
|
|
10
|
+
export type Strength = "weak" | "ok" | "strong";
|
|
11
|
+
/** Rough strength hint for the sign-up and reset forms (length plus character variety). */
|
|
12
|
+
export declare const passwordStrength: (value: string) => Strength;
|
|
13
|
+
/** The catalog key of the strength sentence. */
|
|
14
|
+
export declare const strengthKey: (value: Strength) => "strengthWeak" | "strengthOk" | "strengthStrong";
|
|
15
|
+
/** Catalog keys of the friendly error sentences. */
|
|
16
|
+
export type AuthErrorKey = "errInvalidCredentials" | "errEmailNotConfirmed" | "errTooMany" | "errWeakPassword" | "errLinkInvalid" | "errGeneric";
|
|
17
|
+
/** Maps the code of a failed outcome (a wire code, or an HTTP status when the body named none) to its catalog key. */
|
|
18
|
+
export declare const authErrorKey: (code: string) => AuthErrorKey;
|
|
19
|
+
/** Whether a failure code means the recovery link is no longer usable. */
|
|
20
|
+
export declare const isLinkInvalid: (code: string) => boolean;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { AUTH_ERROR_CODES, PASSWORD_BOUNDS } from "../contracts/index.js";
|
|
2
|
+
/** Shortest display name a sign-up accepts. */
|
|
3
|
+
export const NAME_MIN_LENGTH = 2;
|
|
4
|
+
/** Shortest password the forms accept (the doors enforce the same bound). */
|
|
5
|
+
export const PASSWORD_MIN_LENGTH = PASSWORD_BOUNDS.min;
|
|
6
|
+
/** Seconds before a confirmation or reset mail may be requested again. */
|
|
7
|
+
export const RESEND_COOLDOWN_SECONDS = 60;
|
|
8
|
+
/** One address: a local part, one `@`, a domain with a dot. */
|
|
9
|
+
export const EMAIL_PATTERN = /^[^\s@]+@[^\s@][^\s@.]*\.[^\s@]+$/u;
|
|
10
|
+
/** Rough strength hint for the sign-up and reset forms (length plus character variety). */
|
|
11
|
+
export const passwordStrength = (value) => {
|
|
12
|
+
const variety = [/[a-z]/u, /[A-Z]/u, /\d/u, /[^A-Za-z0-9]/u].filter((pattern) => pattern.test(value)).length;
|
|
13
|
+
if (value.length < PASSWORD_MIN_LENGTH || variety < 2)
|
|
14
|
+
return "weak";
|
|
15
|
+
return value.length >= 10 && variety >= 3 ? "strong" : "ok";
|
|
16
|
+
};
|
|
17
|
+
/** The catalog key of the strength sentence. */
|
|
18
|
+
export const strengthKey = (value) => {
|
|
19
|
+
if (value === "weak")
|
|
20
|
+
return "strengthWeak";
|
|
21
|
+
return value === "ok" ? "strengthOk" : "strengthStrong";
|
|
22
|
+
};
|
|
23
|
+
/** Maps the code of a failed outcome (a wire code, or an HTTP status when the body named none) to its catalog key. */
|
|
24
|
+
export const authErrorKey = (code) => {
|
|
25
|
+
switch (code) {
|
|
26
|
+
case AUTH_ERROR_CODES.InvalidCredentials:
|
|
27
|
+
return "errInvalidCredentials";
|
|
28
|
+
case AUTH_ERROR_CODES.EmailNotConfirmed:
|
|
29
|
+
return "errEmailNotConfirmed";
|
|
30
|
+
case AUTH_ERROR_CODES.TooManyAttempts:
|
|
31
|
+
case "429":
|
|
32
|
+
return "errTooMany";
|
|
33
|
+
case AUTH_ERROR_CODES.PasswordInvalid:
|
|
34
|
+
return "errWeakPassword";
|
|
35
|
+
case AUTH_ERROR_CODES.ResetTokenInvalid:
|
|
36
|
+
return "errLinkInvalid";
|
|
37
|
+
default:
|
|
38
|
+
return "errGeneric";
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
/** Whether a failure code means the recovery link is no longer usable. */
|
|
42
|
+
export const isLinkInvalid = (code) => authErrorKey(code) === "errLinkInvalid";
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The contract both sides of the sign-in share: the door paths, the wire codes, the claim names and the shapes of the answers.
|
|
3
|
+
* The server controller and the front-end client read these constants, so a door can never drift between them.
|
|
4
|
+
*/
|
|
5
|
+
/** The path prefix of every auth door. */
|
|
6
|
+
export declare const AUTH_PREFIX = "auth";
|
|
7
|
+
/** The REST doors of sign-in, relative to the API origin. */
|
|
8
|
+
export declare const AUTH_DOORS: {
|
|
9
|
+
readonly login: "/auth/login";
|
|
10
|
+
readonly signup: "/auth/signup";
|
|
11
|
+
readonly resend: "/auth/resend";
|
|
12
|
+
readonly confirm: "/auth/confirm";
|
|
13
|
+
readonly refresh: "/auth/refresh";
|
|
14
|
+
readonly session: "/auth/session";
|
|
15
|
+
readonly logout: "/auth/logout";
|
|
16
|
+
readonly forgot: "/auth/forgot";
|
|
17
|
+
readonly reset: "/auth/reset";
|
|
18
|
+
readonly google: "/auth/google";
|
|
19
|
+
readonly googleCallback: "/auth/google/callback";
|
|
20
|
+
readonly jwks: "/auth/jwks";
|
|
21
|
+
};
|
|
22
|
+
/** The name of one door. */
|
|
23
|
+
export type AuthDoorName = keyof typeof AUTH_DOORS;
|
|
24
|
+
/** The `role` claim every access token carries; any other role is refused. */
|
|
25
|
+
export declare const AUTH_ROLE = "authenticated";
|
|
26
|
+
/** The audience every access token names. */
|
|
27
|
+
export declare const AUTH_AUDIENCE = "authenticated";
|
|
28
|
+
/** The claim names of an access token beyond the registered ones. */
|
|
29
|
+
export declare const AUTH_CLAIMS: {
|
|
30
|
+
readonly role: "role";
|
|
31
|
+
readonly sessionId: "session_id";
|
|
32
|
+
readonly email: "email";
|
|
33
|
+
};
|
|
34
|
+
/** The signature algorithm of every access token: pinned, never negotiated. */
|
|
35
|
+
export declare const AUTH_ALGORITHM = "EdDSA";
|
|
36
|
+
/** The codes the doors answer on the wire; the front end maps them to its sentences. */
|
|
37
|
+
export declare const AUTH_ERROR_CODES: {
|
|
38
|
+
readonly Unauthenticated: "IDENTITY_UNAUTHENTICATED";
|
|
39
|
+
readonly AccessTokenInvalid: "PASSPORT_ACCESS_TOKEN_INVALID";
|
|
40
|
+
readonly KeyInvalid: "PASSPORT_KEY_INVALID";
|
|
41
|
+
readonly MissingCredentials: "PASSPORT_MISSING_CREDENTIALS";
|
|
42
|
+
readonly GoogleStateInvalid: "PASSPORT_GOOGLE_STATE_INVALID";
|
|
43
|
+
readonly GoogleRejected: "PASSPORT_GOOGLE_REJECTED";
|
|
44
|
+
readonly GoogleUnavailable: "PASSPORT_GOOGLE_UNAVAILABLE";
|
|
45
|
+
readonly RefreshInvalid: "IDENTITY_REFRESH_INVALID";
|
|
46
|
+
readonly RefreshReused: "IDENTITY_REFRESH_REUSED";
|
|
47
|
+
readonly AccountDisabled: "IDENTITY_ACCOUNT_DISABLED";
|
|
48
|
+
readonly TooManyAttempts: "IDENTITY_TOO_MANY_ATTEMPTS";
|
|
49
|
+
readonly InvalidCredentials: "IDENTITY_INVALID_CREDENTIALS";
|
|
50
|
+
readonly EmailNotConfirmed: "IDENTITY_EMAIL_NOT_CONFIRMED";
|
|
51
|
+
readonly PasswordInvalid: "IDENTITY_PASSWORD_INVALID";
|
|
52
|
+
readonly ResetTokenInvalid: "IDENTITY_RESET_TOKEN_INVALID";
|
|
53
|
+
};
|
|
54
|
+
/** One wire code of sign-in. */
|
|
55
|
+
export type AuthErrorCode = (typeof AUTH_ERROR_CODES)[keyof typeof AUTH_ERROR_CODES];
|
|
56
|
+
/** How a code travels: the family of status the transport maps it to. */
|
|
57
|
+
export type AuthErrorKind = "unauthenticated" | "forbidden" | "invalid" | "rate-limited" | "internal" | "unavailable";
|
|
58
|
+
/** The kind of every code. */
|
|
59
|
+
export declare const AUTH_ERROR_KINDS: Readonly<Record<AuthErrorCode, AuthErrorKind>>;
|
|
60
|
+
/** The person a session belongs to. */
|
|
61
|
+
export interface SessionUser {
|
|
62
|
+
/** The user id. */
|
|
63
|
+
readonly id: string;
|
|
64
|
+
/** The account email. */
|
|
65
|
+
readonly email: string;
|
|
66
|
+
}
|
|
67
|
+
/** What a sign-in, refresh or session door answers in the body. */
|
|
68
|
+
export interface AccessGrant {
|
|
69
|
+
/** The access JWT, to be sent as a Bearer token. */
|
|
70
|
+
readonly accessToken: string;
|
|
71
|
+
/** How many seconds the access token lives. */
|
|
72
|
+
readonly expiresIn: number;
|
|
73
|
+
/** When the access token stops being accepted, ISO 8601. */
|
|
74
|
+
readonly expiresAt: string;
|
|
75
|
+
/** The signed-in person. */
|
|
76
|
+
readonly user: SessionUser;
|
|
77
|
+
}
|
|
78
|
+
/** The attempts a sign-in budget counts separately. */
|
|
79
|
+
export type AttemptKind = "login" | "reset" | "signup";
|
|
80
|
+
/** One public key of the JWKS document. */
|
|
81
|
+
export type PublicJwk = Readonly<Record<string, string>>;
|
|
82
|
+
/** The public key document served at the JWKS door. */
|
|
83
|
+
export interface JwksDocument {
|
|
84
|
+
/** The public keys access tokens verify against. */
|
|
85
|
+
readonly keys: readonly PublicJwk[];
|
|
86
|
+
}
|
|
87
|
+
/** Where the browser goes after the Google round trip. */
|
|
88
|
+
export interface GoogleRedirect {
|
|
89
|
+
/** The absolute address. */
|
|
90
|
+
readonly url: string;
|
|
91
|
+
/** The redirect status. */
|
|
92
|
+
readonly statusCode: number;
|
|
93
|
+
}
|
|
94
|
+
/** The kinds of failure a front-end call ends with, as the doors' statuses stand for them. */
|
|
95
|
+
export type AuthFailureKind = "refused" | "not-found" | "invalid" | "unavailable";
|
|
96
|
+
/** The outcome of a front-end auth call: a value, or a failure carrying its kind and the wire code. */
|
|
97
|
+
export type AuthOutcome<T> = {
|
|
98
|
+
readonly success: true;
|
|
99
|
+
readonly message: "ok";
|
|
100
|
+
readonly data: T;
|
|
101
|
+
} | {
|
|
102
|
+
readonly success: false;
|
|
103
|
+
readonly message: AuthFailureKind;
|
|
104
|
+
readonly error: string;
|
|
105
|
+
};
|
|
106
|
+
/** The facts both sides hold about a password. */
|
|
107
|
+
export declare const PASSWORD_BOUNDS: {
|
|
108
|
+
readonly min: 8;
|
|
109
|
+
readonly max: 256;
|
|
110
|
+
readonly bcryptMaxBytes: 72;
|
|
111
|
+
};
|