@circle-rd/auth-ai 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.
Files changed (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +323 -2
  3. package/dist/ai.d.ts +32 -0
  4. package/dist/ai.js +65 -0
  5. package/dist/browser/client.d.ts +39 -0
  6. package/dist/browser/client.js +88 -0
  7. package/dist/browser/index.d.ts +10 -0
  8. package/dist/browser/index.js +5 -0
  9. package/dist/browser/store.d.ts +43 -0
  10. package/dist/browser/store.js +99 -0
  11. package/dist/claims.d.ts +60 -0
  12. package/dist/claims.js +68 -0
  13. package/dist/client.d.ts +54 -0
  14. package/dist/client.js +204 -0
  15. package/dist/config.d.ts +51 -0
  16. package/dist/config.js +80 -0
  17. package/dist/errors.d.ts +82 -0
  18. package/dist/errors.js +87 -0
  19. package/dist/fastify/index.d.ts +21 -0
  20. package/dist/fastify/index.js +88 -0
  21. package/dist/gateway-errors.d.ts +14 -0
  22. package/dist/gateway-errors.js +48 -0
  23. package/dist/gateway.d.ts +72 -0
  24. package/dist/gateway.js +115 -0
  25. package/dist/grants.d.ts +72 -0
  26. package/dist/grants.js +157 -0
  27. package/dist/http.d.ts +30 -0
  28. package/dist/http.js +63 -0
  29. package/dist/index.d.ts +20 -0
  30. package/dist/index.js +10 -0
  31. package/dist/introspection.d.ts +13 -0
  32. package/dist/introspection.js +12 -0
  33. package/dist/jwt.d.ts +16 -0
  34. package/dist/jwt.js +35 -0
  35. package/dist/oauth-errors.d.ts +9 -0
  36. package/dist/oauth-errors.js +50 -0
  37. package/dist/provider.d.ts +8 -0
  38. package/dist/provider.js +86 -0
  39. package/dist/react/index.d.ts +44 -0
  40. package/dist/react/index.js +88 -0
  41. package/dist/server/backchannel-logout.d.ts +47 -0
  42. package/dist/server/backchannel-logout.js +105 -0
  43. package/dist/server/bearer.d.ts +30 -0
  44. package/dist/server/bearer.js +58 -0
  45. package/dist/server/billing.d.ts +78 -0
  46. package/dist/server/billing.js +83 -0
  47. package/dist/server/consumption.d.ts +40 -0
  48. package/dist/server/consumption.js +79 -0
  49. package/dist/server/index.d.ts +11 -0
  50. package/dist/server/index.js +6 -0
  51. package/dist/server/machine-api.d.ts +17 -0
  52. package/dist/server/machine-api.js +31 -0
  53. package/dist/server/machine-tokens.d.ts +13 -0
  54. package/dist/server/machine-tokens.js +49 -0
  55. package/dist/server/protected-resource.d.ts +27 -0
  56. package/dist/server/protected-resource.js +37 -0
  57. package/dist/session-user.d.ts +21 -0
  58. package/dist/session-user.js +35 -0
  59. package/dist/types.d.ts +50 -0
  60. package/dist/types.js +1 -0
  61. package/dist/vue/index.d.ts +42 -0
  62. package/dist/vue/index.js +82 -0
  63. package/dist/wallet.d.ts +182 -0
  64. package/dist/wallet.js +184 -0
  65. package/dist/web/ai-routes.d.ts +8 -0
  66. package/dist/web/ai-routes.js +38 -0
  67. package/dist/web/cookies.d.ts +11 -0
  68. package/dist/web/cookies.js +46 -0
  69. package/dist/web/handlers.d.ts +47 -0
  70. package/dist/web/handlers.js +290 -0
  71. package/dist/web/http.d.ts +24 -0
  72. package/dist/web/http.js +85 -0
  73. package/dist/web/index.d.ts +7 -0
  74. package/dist/web/index.js +4 -0
  75. package/dist/web/seal.d.ts +11 -0
  76. package/dist/web/seal.js +39 -0
  77. package/dist/web/sessions.d.ts +43 -0
  78. package/dist/web/sessions.js +39 -0
  79. package/package.json +127 -4
@@ -0,0 +1,38 @@
1
+ import { z } from "zod";
2
+ import { ERR } from "../errors.js";
3
+ import { jsonResponse } from "./http.js";
4
+ const statsQuerySchema = z.object({
5
+ days: z.coerce.number().int().min(1).max(90).optional(),
6
+ });
7
+ /**
8
+ * Wallet and model reads for the application's own frontend, which holds no
9
+ * token: the backend calls auth-service and the gateway on the user's behalf.
10
+ */
11
+ export function createAiRoutes(ai, requireSession) {
12
+ return {
13
+ "/wallet": async (request) => {
14
+ const { userId } = await requireSession(request);
15
+ const { wallet } = ai.forUser(userId);
16
+ return jsonResponse({
17
+ ...(await wallet.getAccount()),
18
+ topupUrl: wallet.topupUrl(),
19
+ });
20
+ },
21
+ "/wallet/stats": async (request) => {
22
+ const { userId } = await requireSession(request);
23
+ const query = statsQuerySchema.safeParse(Object.fromEntries(new URL(request.url).searchParams));
24
+ if (!query.success)
25
+ throw ERR.REQ_001();
26
+ const { days } = query.data;
27
+ return jsonResponse(await ai
28
+ .forUser(userId)
29
+ .wallet.getStats(days === undefined ? {} : { days }));
30
+ },
31
+ "/models": async (request) => {
32
+ const { userId } = await requireSession(request);
33
+ return jsonResponse({
34
+ models: await ai.forUser(userId).gateway.listModels(),
35
+ });
36
+ },
37
+ };
38
+ }
@@ -0,0 +1,11 @@
1
+ export interface CookieOptions {
2
+ /** Seconds; 0 expires the cookie at once. */
3
+ readonly maxAgeSeconds: number;
4
+ /** Adds `Secure` and the `__Host-` prefix, which pins the cookie to this host. */
5
+ readonly secure: boolean;
6
+ }
7
+ /** Name of a cookie of the SDK: `__Host-` needs `Secure`, so it only applies over https. */
8
+ export declare function cookieName(base: string, secure: boolean): string;
9
+ export declare function parseCookies(header: string | null): Map<string, string>;
10
+ export declare function serializeCookie(name: string, value: string, options: CookieOptions): string;
11
+ export declare function clearCookie(name: string, secure: boolean): string;
@@ -0,0 +1,46 @@
1
+ import { ERR } from "../errors.js";
2
+ // Token characters only: the values handled here are base64url ids and compact JWEs.
3
+ const COOKIE_VALUE = /^[A-Za-z0-9._~-]+$/;
4
+ const COOKIE_NAME = /^[A-Za-z0-9_-]+$/;
5
+ /** Name of a cookie of the SDK: `__Host-` needs `Secure`, so it only applies over https. */
6
+ export function cookieName(base, secure) {
7
+ return secure ? `__Host-${base}` : base;
8
+ }
9
+ export function parseCookies(header) {
10
+ const cookies = new Map();
11
+ if (!header)
12
+ return cookies;
13
+ for (const part of header.split(";")) {
14
+ const separator = part.indexOf("=");
15
+ if (separator === -1)
16
+ continue;
17
+ const name = part.slice(0, separator).trim();
18
+ const value = part.slice(separator + 1).trim();
19
+ // The first occurrence wins, as it is the one scoped most specifically.
20
+ if (!cookies.has(name))
21
+ cookies.set(name, value);
22
+ }
23
+ return cookies;
24
+ }
25
+ export function serializeCookie(name, value, options) {
26
+ if (!COOKIE_NAME.test(name.replace(/^__Host-/, ""))) {
27
+ throw ERR.CFG_001("Invalid cookie name");
28
+ }
29
+ if (value !== "" && !COOKIE_VALUE.test(value)) {
30
+ throw ERR.CFG_001("Invalid cookie value");
31
+ }
32
+ const attributes = [
33
+ `${name}=${value}`,
34
+ "Path=/",
35
+ "HttpOnly",
36
+ // Lax still sends the cookie on the top-level GET that returns from the provider.
37
+ "SameSite=Lax",
38
+ `Max-Age=${Math.max(0, Math.floor(options.maxAgeSeconds))}`,
39
+ ];
40
+ if (options.secure)
41
+ attributes.push("Secure");
42
+ return attributes.join("; ");
43
+ }
44
+ export function clearCookie(name, secure) {
45
+ return serializeCookie(name, "", { maxAgeSeconds: 0, secure });
46
+ }
@@ -0,0 +1,47 @@
1
+ import type { AiClient } from "../ai.js";
2
+ import type { AuthClient } from "../client.js";
3
+ import type { GrantManager } from "../grants.js";
4
+ import type { ReplayStore } from "../server/backchannel-logout.js";
5
+ import type { Session, SessionStore } from "./sessions.js";
6
+ export interface AuthHandlersOptions {
7
+ readonly auth: AuthClient;
8
+ readonly grants: GrantManager;
9
+ readonly sessions: SessionStore;
10
+ /** At least 32 random characters: seals the sign-in cookie. Keep it out of source control. */
11
+ readonly secret: string;
12
+ /** Path prefix of the routes; `redirectUri` must be `<origin><basePath>/callback`. */
13
+ readonly basePath?: string;
14
+ /** Where users land after sign-in or sign-out without a target. */
15
+ readonly defaultRedirect?: string;
16
+ /** Absolute lifetime of a session. */
17
+ readonly sessionTtlSeconds?: number;
18
+ /** How often claims (roles, features…) are renewed from the provider. */
19
+ readonly claimsRenewSeconds?: number;
20
+ /** Refuse an ID token without `sid`: no back-channel logout could ever reach it. */
21
+ readonly requireProviderSession?: boolean;
22
+ /** Redirect target of a failed sign-in, with `?error=<code>`; JSON when unset. */
23
+ readonly errorRedirect?: string;
24
+ /** Adds `/wallet`, `/wallet/stats` and `/models` for the frontend, read with the user's tokens. */
25
+ readonly ai?: AiClient;
26
+ readonly replayStore?: ReplayStore;
27
+ /** Called after sessions end (sign-out or back-channel logout): close sockets, drop caches. */
28
+ readonly onSessionsEnded?: (sessions: readonly Session[]) => Promise<void> | void;
29
+ /** Receives unexpected failures and the ones the response must not detail. */
30
+ readonly onError?: (error: unknown) => void;
31
+ }
32
+ export interface AuthHandlers {
33
+ /** Serves the routes below `basePath`; null for any other path. */
34
+ handle(request: Request): Promise<Response | null>;
35
+ /** The signed-in session of a request, with claims renewed when due; null when signed out. */
36
+ getSession(request: Request): Promise<Session | null>;
37
+ /** Like `getSession`, throwing SES_001 when signed out. */
38
+ requireSession(request: Request): Promise<Session>;
39
+ /** Origin the SDK builds URLs on: that of `redirectUri`, never a request header. */
40
+ readonly origin: string;
41
+ readonly basePath: string;
42
+ }
43
+ /**
44
+ * Backend-for-frontend sign-in: the browser holds an opaque session cookie and
45
+ * never sees a token. Mount `handle` on every request below `basePath`.
46
+ */
47
+ export declare function createAuthHandlers(options: AuthHandlersOptions): Promise<AuthHandlers>;
@@ -0,0 +1,290 @@
1
+ import { z } from "zod";
2
+ import { AuthAiError, ERR } from "../errors.js";
3
+ import { toSessionUser } from "../session-user.js";
4
+ import { MemoryReplayStore, createBackchannelLogoutHandler, } from "../server/backchannel-logout.js";
5
+ import { clearCookie, cookieName, parseCookies, serializeCookie, } from "./cookies.js";
6
+ import { errorResponse, isCrossSite, jsonResponse, redirectResponse, safeRedirectPath, } from "./http.js";
7
+ import { createAiRoutes } from "./ai-routes.js";
8
+ import { deriveSealKey, seal, unseal } from "./seal.js";
9
+ const HANDSHAKE_TTL_SECONDS = 600;
10
+ const DEFAULT_SESSION_TTL_SECONDS = 8 * 3600;
11
+ const DEFAULT_CLAIMS_RENEW_SECONDS = 900;
12
+ const PROMPTS = [
13
+ "login",
14
+ "consent",
15
+ "select_account",
16
+ "create",
17
+ "none",
18
+ ];
19
+ const isPrompt = (value) => PROMPTS.some((prompt) => prompt === value);
20
+ const handshakeSchema = z.object({
21
+ state: z.string(),
22
+ nonce: z.string(),
23
+ codeVerifier: z.string(),
24
+ next: z.string(),
25
+ });
26
+ function base64Url(bytes) {
27
+ return btoa(String.fromCharCode(...bytes))
28
+ .replaceAll("+", "-")
29
+ .replaceAll("/", "_")
30
+ .replaceAll("=", "");
31
+ }
32
+ async function sha256Base64Url(value) {
33
+ return base64Url(new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value))));
34
+ }
35
+ const randomId = () => base64Url(crypto.getRandomValues(new Uint8Array(32)));
36
+ const nowSeconds = () => Math.floor(Date.now() / 1000);
37
+ /**
38
+ * Backend-for-frontend sign-in: the browser holds an opaque session cookie and
39
+ * never sees a token. Mount `handle` on every request below `basePath`.
40
+ */
41
+ export async function createAuthHandlers(options) {
42
+ const { auth, grants, sessions } = options;
43
+ const basePath = options.basePath ?? "/auth";
44
+ const defaultRedirect = options.defaultRedirect ?? "/";
45
+ const sessionTtl = options.sessionTtlSeconds ?? DEFAULT_SESSION_TTL_SECONDS;
46
+ const renewSeconds = options.claimsRenewSeconds ?? DEFAULT_CLAIMS_RENEW_SECONDS;
47
+ const requireProviderSession = options.requireProviderSession ?? true;
48
+ const replayStore = options.replayStore ?? new MemoryReplayStore();
49
+ if (!/^\/[A-Za-z0-9/_-]*$/.test(basePath) || basePath.endsWith("/")) {
50
+ throw ERR.CFG_001("basePath must look like /auth, with no trailing slash");
51
+ }
52
+ const redirect = new URL(auth.config.redirectUri);
53
+ if (redirect.pathname !== `${basePath}/callback`) {
54
+ throw ERR.CFG_001(`redirectUri must end with ${basePath}/callback`);
55
+ }
56
+ const origin = redirect.origin;
57
+ const secure = redirect.protocol === "https:";
58
+ const key = await deriveSealKey(options.secret);
59
+ const sessionCookie = cookieName("aai_session", secure);
60
+ const loginCookie = cookieName("aai_login", secure);
61
+ const withCookies = (...cookies) => {
62
+ const headers = new Headers();
63
+ for (const cookie of cookies)
64
+ headers.append("set-cookie", cookie);
65
+ return headers;
66
+ };
67
+ async function readSession(request) {
68
+ const cookie = parseCookies(request.headers.get("cookie")).get(sessionCookie);
69
+ if (!cookie)
70
+ return null;
71
+ return (await sessions.get(await sha256Base64Url(cookie))) ?? null;
72
+ }
73
+ async function renewClaims(session) {
74
+ let tokens;
75
+ try {
76
+ tokens = await grants.refreshTokens(session.userId);
77
+ }
78
+ catch (err) {
79
+ if (err instanceof AuthAiError && err.code === "TOK_002") {
80
+ await endSessions([session]);
81
+ return null;
82
+ }
83
+ throw err;
84
+ }
85
+ if (!tokens.idToken) {
86
+ throw ERR.TOK_001("The provider returned no ID token on refresh");
87
+ }
88
+ const claims = await auth.verifyIdToken(tokens.idToken);
89
+ if (claims.sub !== session.userId) {
90
+ await endSessions([session]);
91
+ throw ERR.TOK_001("The refreshed ID token belongs to another user");
92
+ }
93
+ const renewed = {
94
+ ...session,
95
+ claims,
96
+ idToken: tokens.idToken,
97
+ renewAt: Math.min(nowSeconds() + renewSeconds, session.expiresAt),
98
+ };
99
+ await sessions.update(renewed);
100
+ return renewed;
101
+ }
102
+ // Two simultaneous requests of one session share a single renewal.
103
+ const renewing = new Map();
104
+ async function getSession(request) {
105
+ const session = await readSession(request);
106
+ if (!session || nowSeconds() < session.renewAt)
107
+ return session;
108
+ let pending = renewing.get(session.id);
109
+ if (!pending) {
110
+ pending = renewClaims(session).finally(() => renewing.delete(session.id));
111
+ renewing.set(session.id, pending);
112
+ }
113
+ return pending;
114
+ }
115
+ async function requireSession(request) {
116
+ const session = await getSession(request);
117
+ if (!session)
118
+ throw ERR.SES_001();
119
+ return session;
120
+ }
121
+ /** Ends sessions, and revokes the grant of every user left with none. */
122
+ async function endSessions(ended) {
123
+ for (const session of ended)
124
+ await sessions.delete(session.id);
125
+ await afterSessionsRemoved(ended);
126
+ }
127
+ async function afterSessionsRemoved(ended) {
128
+ for (const userId of new Set(ended.map((session) => session.userId))) {
129
+ if ((await sessions.countByUser(userId)) > 0)
130
+ continue;
131
+ try {
132
+ await grants.revoke(userId);
133
+ }
134
+ catch (err) {
135
+ // The grant is already gone locally: the provider will expire it.
136
+ options.onError?.(err);
137
+ }
138
+ }
139
+ if (ended.length > 0)
140
+ await options.onSessionsEnded?.(ended);
141
+ }
142
+ async function login(request) {
143
+ const url = new URL(request.url);
144
+ const prompt = url.searchParams.get("prompt");
145
+ const { url: authorizationUrl, handshake } = await auth.startLogin(isPrompt(prompt) ? { prompt } : {});
146
+ const sealed = await seal(key, {
147
+ ...handshake,
148
+ next: safeRedirectPath(url.searchParams.get("next"), defaultRedirect),
149
+ }, HANDSHAKE_TTL_SECONDS);
150
+ return redirectResponse(authorizationUrl, 302, withCookies(serializeCookie(loginCookie, sealed, {
151
+ maxAgeSeconds: HANDSHAKE_TTL_SECONDS,
152
+ secure,
153
+ })));
154
+ }
155
+ async function createSession(claims, idToken, hasRefreshToken) {
156
+ const now = nowSeconds();
157
+ const expiresAt = hasRefreshToken
158
+ ? now + sessionTtl
159
+ : Math.min(now + sessionTtl, claims.exp);
160
+ const cookie = randomId();
161
+ const session = {
162
+ id: await sha256Base64Url(cookie),
163
+ userId: claims.sub,
164
+ sessionId: claims.sid,
165
+ claims,
166
+ idToken,
167
+ createdAt: now,
168
+ expiresAt,
169
+ renewAt: hasRefreshToken
170
+ ? Math.min(now + renewSeconds, expiresAt)
171
+ : expiresAt,
172
+ };
173
+ await sessions.create(session);
174
+ return { session, cookie };
175
+ }
176
+ async function callback(request) {
177
+ const clear = clearCookie(loginCookie, secure);
178
+ try {
179
+ const sealed = parseCookies(request.headers.get("cookie")).get(loginCookie);
180
+ if (!sealed)
181
+ throw ERR.SES_002();
182
+ const { next, ...handshake } = await unseal(key, sealed, handshakeSchema);
183
+ // Rebuilt on the registered URI: the request's own host is not trusted.
184
+ const callbackUrl = new URL(auth.config.redirectUri);
185
+ callbackUrl.search = new URL(request.url).search;
186
+ const result = await auth.handleCallback(callbackUrl, handshake);
187
+ if (requireProviderSession && !result.sessionId) {
188
+ throw ERR.SES_003("The ID token carries no sid: enable end session or a back-channel logout URI on the application");
189
+ }
190
+ const { refreshToken } = result.tokens;
191
+ if (refreshToken)
192
+ await grants.save(result.claims.sub, refreshToken);
193
+ const { cookie } = await createSession(result.claims, result.tokens.idToken, refreshToken !== undefined);
194
+ return redirectResponse(next, 303, withCookies(clear, serializeCookie(sessionCookie, cookie, {
195
+ maxAgeSeconds: sessionTtl,
196
+ secure,
197
+ })));
198
+ }
199
+ catch (err) {
200
+ // Whatever failed (state, code, client credentials, claims), the browser
201
+ // learns one thing: the sign-in failed. Telling a rejected client secret
202
+ // from a forged state would help whoever probes the callback; the cause
203
+ // reaches the application's logs.
204
+ options.onError?.(err);
205
+ const failure = ERR.SES_005(undefined, { cause: err });
206
+ if (options.errorRedirect) {
207
+ return redirectResponse(`${options.errorRedirect}?error=${failure.code}`, 303, withCookies(clear));
208
+ }
209
+ return errorResponse(failure, undefined, withCookies(clear));
210
+ }
211
+ }
212
+ async function me(request) {
213
+ const session = await getSession(request);
214
+ if (!session)
215
+ return errorResponse(ERR.SES_001(), options.onError);
216
+ return jsonResponse({
217
+ user: toSessionUser(session.claims),
218
+ expiresAt: session.expiresAt,
219
+ });
220
+ }
221
+ async function logout(request) {
222
+ if (isCrossSite(request, origin)) {
223
+ return errorResponse(ERR.SES_004(), options.onError);
224
+ }
225
+ const session = await readSession(request);
226
+ let logoutUrl = null;
227
+ if (session) {
228
+ try {
229
+ logoutUrl = await auth.logoutUrl({ idTokenHint: session.idToken });
230
+ }
231
+ catch (err) {
232
+ // A provider without end-session still lets the local session end.
233
+ options.onError?.(err);
234
+ }
235
+ await endSessions([session]);
236
+ }
237
+ const headers = withCookies(clearCookie(sessionCookie, secure));
238
+ if (request.headers.get("accept")?.includes("application/json")) {
239
+ return jsonResponse({ logoutUrl }, 200, headers);
240
+ }
241
+ return redirectResponse(logoutUrl ?? defaultRedirect, 303, headers);
242
+ }
243
+ const backchannelLogout = createBackchannelLogoutHandler({
244
+ auth,
245
+ replayStore,
246
+ ...(options.onError ? { onError: options.onError } : {}),
247
+ onLogout: async ({ sid }) => {
248
+ await afterSessionsRemoved(await sessions.deleteByProviderSession(sid));
249
+ },
250
+ });
251
+ const routes = {
252
+ "/login": { method: "GET", run: login },
253
+ "/callback": { method: "GET", run: callback },
254
+ "/me": { method: "GET", run: me },
255
+ "/logout": { method: "POST", run: logout },
256
+ "/backchannel-logout": { method: "POST", run: backchannelLogout },
257
+ };
258
+ if (options.ai) {
259
+ const aiRoutes = createAiRoutes(options.ai, requireSession);
260
+ for (const [path, run] of Object.entries(aiRoutes)) {
261
+ routes[path] = { method: "GET", run };
262
+ }
263
+ }
264
+ return {
265
+ origin,
266
+ basePath,
267
+ getSession,
268
+ requireSession,
269
+ async handle(request) {
270
+ const { pathname } = new URL(request.url);
271
+ if (!pathname.startsWith(`${basePath}/`))
272
+ return null;
273
+ const route = routes[pathname.slice(basePath.length)];
274
+ if (!route)
275
+ return null;
276
+ if (request.method !== route.method) {
277
+ return new Response(null, {
278
+ status: 405,
279
+ headers: { allow: route.method, "cache-control": "no-store" },
280
+ });
281
+ }
282
+ try {
283
+ return await route.run(request);
284
+ }
285
+ catch (err) {
286
+ return errorResponse(err, options.onError);
287
+ }
288
+ },
289
+ };
290
+ }
@@ -0,0 +1,24 @@
1
+ export declare const NO_STORE: {
2
+ readonly "cache-control": "no-store";
3
+ };
4
+ export declare function jsonResponse(body: unknown, status?: number, headers?: Headers): Response;
5
+ export declare function redirectResponse(location: string, status: 302 | 303, headers?: Headers): Response;
6
+ /**
7
+ * Serialises a failure as the same `{ error: { code, message } }` envelope
8
+ * auth-service uses, with the code and a generic message only: the detailed
9
+ * message and the details stay in the error, for the application's logs. Any
10
+ * other failure is reported to `onError` and answered generically.
11
+ */
12
+ export declare function errorResponse(err: unknown, onError: ((error: unknown) => void) | undefined, headers?: Headers): Response;
13
+ /**
14
+ * A redirect target taken from a request, reduced to a same-origin path: an
15
+ * absolute URL, a protocol-relative one (`//host`) or anything odd falls back
16
+ * to `fallback`, so a login link cannot bounce users to another site.
17
+ */
18
+ export declare function safeRedirectPath(candidate: string | null | undefined, fallback: string): string;
19
+ /**
20
+ * Refuses a state-changing request that a browser sent from another site.
21
+ * Requests without `Origin` and `Sec-Fetch-Site` (non-browser clients) carry
22
+ * no ambient cookie to abuse and pass.
23
+ */
24
+ export declare function isCrossSite(request: Request, expectedOrigin: string): boolean;
@@ -0,0 +1,85 @@
1
+ import { AuthAiError, ERR } from "../errors.js";
2
+ const STATUS_BY_CODE = {
3
+ REQ_001: 400,
4
+ SES_001: 401,
5
+ SES_002: 400,
6
+ SES_004: 403,
7
+ SES_005: 403,
8
+ TOK_001: 401,
9
+ TOK_002: 401,
10
+ TOK_003: 401,
11
+ TOK_004: 403,
12
+ OIDC_002: 400,
13
+ OIDC_001: 502,
14
+ OIDC_003: 502,
15
+ OIDC_004: 501,
16
+ API_001: 502,
17
+ API_002: 502,
18
+ GW_001: 402,
19
+ GW_002: 429,
20
+ GW_003: 403,
21
+ GW_004: 503,
22
+ GW_005: 502,
23
+ };
24
+ export const NO_STORE = { "cache-control": "no-store" };
25
+ // Wallet amounts are bigint micro-euros: they travel as integer strings.
26
+ const bigintToString = (_key, value) => typeof value === "bigint" ? value.toString() : value;
27
+ export function jsonResponse(body, status = 200, headers = new Headers()) {
28
+ headers.set("content-type", "application/json");
29
+ headers.set("cache-control", "no-store");
30
+ return new Response(JSON.stringify(body, bigintToString), {
31
+ status,
32
+ headers,
33
+ });
34
+ }
35
+ export function redirectResponse(location, status, headers = new Headers()) {
36
+ headers.set("location", location);
37
+ headers.set("cache-control", "no-store");
38
+ return new Response(null, { status, headers });
39
+ }
40
+ /**
41
+ * Serialises a failure as the same `{ error: { code, message } }` envelope
42
+ * auth-service uses, with the code and a generic message only: the detailed
43
+ * message and the details stay in the error, for the application's logs. Any
44
+ * other failure is reported to `onError` and answered generically.
45
+ */
46
+ export function errorResponse(err, onError, headers) {
47
+ if (err instanceof AuthAiError) {
48
+ return jsonResponse({ error: err.toPublicJSON() }, STATUS_BY_CODE[err.code] ?? 500, headers);
49
+ }
50
+ onError?.(err);
51
+ return jsonResponse({ error: ERR.SRV_001().toPublicJSON() }, 500, headers);
52
+ }
53
+ // Control characters and backslashes make browsers read a "path" as another host.
54
+ const hasUnsafeCharacter = (value) => [...value].some((char) => {
55
+ const code = char.charCodeAt(0);
56
+ return char === "\\" || code <= 0x1f || code === 0x7f;
57
+ });
58
+ /**
59
+ * A redirect target taken from a request, reduced to a same-origin path: an
60
+ * absolute URL, a protocol-relative one (`//host`) or anything odd falls back
61
+ * to `fallback`, so a login link cannot bounce users to another site.
62
+ */
63
+ export function safeRedirectPath(candidate, fallback) {
64
+ if (!candidate?.startsWith("/") || candidate.startsWith("//")) {
65
+ return fallback;
66
+ }
67
+ if (hasUnsafeCharacter(candidate))
68
+ return fallback;
69
+ const probe = new URL(candidate, "https://placeholder.invalid");
70
+ if (probe.origin !== "https://placeholder.invalid")
71
+ return fallback;
72
+ return `${probe.pathname}${probe.search}${probe.hash}`;
73
+ }
74
+ /**
75
+ * Refuses a state-changing request that a browser sent from another site.
76
+ * Requests without `Origin` and `Sec-Fetch-Site` (non-browser clients) carry
77
+ * no ambient cookie to abuse and pass.
78
+ */
79
+ export function isCrossSite(request, expectedOrigin) {
80
+ const origin = request.headers.get("origin");
81
+ if (origin !== null)
82
+ return origin !== expectedOrigin;
83
+ const site = request.headers.get("sec-fetch-site");
84
+ return site !== null && site !== "same-origin" && site !== "none";
85
+ }
@@ -0,0 +1,7 @@
1
+ export { createAuthHandlers } from "./handlers.js";
2
+ export type { AuthHandlers, AuthHandlersOptions } from "./handlers.js";
3
+ export { MemorySessionStore } from "./sessions.js";
4
+ export type { Session, SessionStore } from "./sessions.js";
5
+ export { safeRedirectPath } from "./http.js";
6
+ export { sessionUserSchema, toSessionUser } from "../session-user.js";
7
+ export type { SessionUser } from "../session-user.js";
@@ -0,0 +1,4 @@
1
+ export { createAuthHandlers } from "./handlers.js";
2
+ export { MemorySessionStore } from "./sessions.js";
3
+ export { safeRedirectPath } from "./http.js";
4
+ export { sessionUserSchema, toSessionUser } from "../session-user.js";
@@ -0,0 +1,11 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * Derives the AES-256 key sealing the short-lived cookies from the
4
+ * application's secret. The secret must be long and random (for example 32
5
+ * bytes of base64): it is never stored or logged.
6
+ */
7
+ export declare function deriveSealKey(secret: string): Promise<Uint8Array>;
8
+ /** Encrypts and authenticates a JSON value into a compact token that expires. */
9
+ export declare function seal(key: Uint8Array, data: unknown, ttlSeconds: number): Promise<string>;
10
+ /** The value of `seal`, validated by `schema`; SES_002 for any tampered or expired token. */
11
+ export declare function unseal<T>(key: Uint8Array, token: string, schema: z.ZodType<T>): Promise<T>;
@@ -0,0 +1,39 @@
1
+ import { EncryptJWT, jwtDecrypt } from "jose";
2
+ import { z } from "zod";
3
+ import { ERR } from "../errors.js";
4
+ const MIN_SECRET_LENGTH = 32;
5
+ /**
6
+ * Derives the AES-256 key sealing the short-lived cookies from the
7
+ * application's secret. The secret must be long and random (for example 32
8
+ * bytes of base64): it is never stored or logged.
9
+ */
10
+ export async function deriveSealKey(secret) {
11
+ if (secret.length < MIN_SECRET_LENGTH) {
12
+ throw ERR.CFG_001(`The secret must be at least ${MIN_SECRET_LENGTH} characters long`);
13
+ }
14
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(`auth-ai:seal:${secret}`));
15
+ return new Uint8Array(digest);
16
+ }
17
+ const envelopeSchema = z.object({ data: z.unknown() });
18
+ /** Encrypts and authenticates a JSON value into a compact token that expires. */
19
+ export async function seal(key, data, ttlSeconds) {
20
+ return new EncryptJWT({ data })
21
+ .setProtectedHeader({ alg: "dir", enc: "A256GCM" })
22
+ .setIssuedAt()
23
+ .setExpirationTime(`${ttlSeconds}s`)
24
+ .encrypt(key);
25
+ }
26
+ /** The value of `seal`, validated by `schema`; SES_002 for any tampered or expired token. */
27
+ export async function unseal(key, token, schema) {
28
+ try {
29
+ const { payload } = await jwtDecrypt(token, key, {
30
+ keyManagementAlgorithms: ["dir"],
31
+ contentEncryptionAlgorithms: ["A256GCM"],
32
+ });
33
+ const envelope = envelopeSchema.parse(payload);
34
+ return schema.parse(envelope.data);
35
+ }
36
+ catch (err) {
37
+ throw ERR.SES_002(undefined, { cause: err });
38
+ }
39
+ }
@@ -0,0 +1,43 @@
1
+ import type { IdTokenClaims } from "../claims.js";
2
+ /** A signed-in browser session, as kept by the application's backend. */
3
+ export interface Session {
4
+ /** SHA-256 of the cookie value: a leaked store cannot be replayed as cookies. */
5
+ readonly id: string;
6
+ /** The provider's subject: also the key of the user's grant. */
7
+ readonly userId: string;
8
+ /** The provider session, which a back-channel logout names. */
9
+ readonly sessionId: string | undefined;
10
+ readonly claims: IdTokenClaims;
11
+ /** Hint for the provider's logout endpoint. */
12
+ readonly idToken: string;
13
+ /** Unix seconds. */
14
+ readonly createdAt: number;
15
+ /** Absolute end of the session, in unix seconds. */
16
+ readonly expiresAt: number;
17
+ /** When the claims are next renewed from the provider, in unix seconds. */
18
+ readonly renewAt: number;
19
+ }
20
+ /**
21
+ * Where sessions live. Implement it on the database of the application; it
22
+ * must survive restarts and be shared by every replica. Records are keyed by
23
+ * `Session.id`, and `sessionId` and `userId` must be indexed.
24
+ */
25
+ export interface SessionStore {
26
+ create(session: Session): Promise<void>;
27
+ /** Returns undefined for an unknown or expired session. */
28
+ get(id: string): Promise<Session | undefined>;
29
+ update(session: Session): Promise<void>;
30
+ delete(id: string): Promise<void>;
31
+ /** Removes every session of a provider session and returns what was removed. */
32
+ deleteByProviderSession(sessionId: string): Promise<readonly Session[]>;
33
+ countByUser(userId: string): Promise<number>;
34
+ }
35
+ export declare class MemorySessionStore implements SessionStore {
36
+ #private;
37
+ create(session: Session): Promise<void>;
38
+ get(id: string): Promise<Session | undefined>;
39
+ update(session: Session): Promise<void>;
40
+ delete(id: string): Promise<void>;
41
+ deleteByProviderSession(sessionId: string): Promise<readonly Session[]>;
42
+ countByUser(userId: string): Promise<number>;
43
+ }