@pramen/auth 0.0.50 → 0.0.52

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/dist/index.d.ts CHANGED
@@ -407,3 +407,4 @@ export interface EmailVerificationOptions {
407
407
  * later `changeEmail` invalidates it (verifyEmail rejects a token whose address no longer
408
408
  * matches). Spread `emailTokenSchema` into your schema and `.tasks` into your task map. */
409
409
  export declare function createEmailVerification(opts: EmailVerificationOptions): AuthModule;
410
+ export { createOidcAuth, oidcHandlers, OIDC_UPSERT_HANDLER, type OidcOptions } from "./oidc.js";
package/dist/index.js CHANGED
@@ -837,3 +837,5 @@ export function createEmailVerification(opts) {
837
837
  };
838
838
  return { handlers, tasks };
839
839
  }
840
+ // --- OIDC (authorization code + PKCE) ---------------------------------------
841
+ export { createOidcAuth, oidcHandlers, OIDC_UPSERT_HANDLER } from "./oidc.js";
package/dist/oidc.d.ts ADDED
@@ -0,0 +1,76 @@
1
+ import type { HandlerMap, JsonObject } from "@pramen/server";
2
+ import type { PublicRoute } from "@pramen/server/worker";
3
+ export interface OidcOptions {
4
+ /** The provider's issuer URL. `/.well-known/openid-configuration` is fetched from it, so
5
+ * the endpoints and the JWKS location are never hand-copied. */
6
+ issuer: string;
7
+ clientId: string;
8
+ /** Confidential clients only. Omit for a public client — the exchange then relies on
9
+ * PKCE alone, which is the correct configuration for a SPA. */
10
+ clientSecret?: string;
11
+ /** Must match the redirect URI registered with the provider, exactly. */
12
+ redirectUri: string;
13
+ /** Where to send the browser after a successful login. The session token arrives in the
14
+ * URL FRAGMENT (`#token=…`), which — unlike a query parameter — is never sent to a
15
+ * server, kept in server logs, or included in a `Referer` header. */
16
+ successRedirect: string;
17
+ /** Default `["openid", "email", "profile"]`. */
18
+ scopes?: readonly string[];
19
+ /** The users table. Default `auth_users` (the `authSchema` shape). */
20
+ table?: string;
21
+ /** Roles for a first-time login when `mapRoles` yields none. Default `["user"]`. */
22
+ defaultRoles?: readonly string[];
23
+ /**
24
+ * Read roles out of the ID token's claims, for providers where the IdP is authoritative:
25
+ *
26
+ * mapRoles: (c) => c["https://acme.com/roles"] as string[] // Auth0/Okta
27
+ * mapRoles: (c) => c.roles as string[] // Entra
28
+ *
29
+ * Return `undefined` to fall back to the roles stored on the user's row. Google Workspace
30
+ * ships no roles in the token, so omit this and manage them in pramen.
31
+ */
32
+ mapRoles?: (claims: JsonObject) => readonly string[] | undefined;
33
+ /**
34
+ * What identifies the account across logins.
35
+ *
36
+ * `"email"` (default) matches how the rest of `@pramen/auth` keys users, so an OIDC login
37
+ * lands on the SAME row as a magic-link or password login for that address. It is only
38
+ * honored when the provider asserts `email_verified` — an IdP that lets a user set an
39
+ * unverified address would otherwise be an account-takeover path into any existing
40
+ * email-keyed account.
41
+ *
42
+ * `"sub"` keys on the provider's opaque subject, namespaced by issuer. Immune to an email
43
+ * change, and to the above, but it will not link up with accounts created another way.
44
+ */
45
+ accountKey?: "email" | "sub";
46
+ sessionTtlSeconds?: number;
47
+ /** Where the flow is mounted. Defaults `/auth/oidc/start` and `/auth/oidc/callback`. */
48
+ startPath?: string;
49
+ callbackPath?: string;
50
+ /** How long an in-flight login may take. Default 600s. */
51
+ stateTtlSeconds?: number;
52
+ }
53
+ /**
54
+ * OIDC login for a pramen app. Spread the routes into `app.routes` (they are PRE-AUTH by
55
+ * design — a caller arriving here has no session yet):
56
+ *
57
+ * const oidc = createOidcAuth({ issuer, clientId, clientSecret, redirectUri, successRedirect });
58
+ * export const app = { schema, handlers, acl, routes: [...oidc.routes] };
59
+ *
60
+ * The browser goes to `/auth/oidc/start`, comes back to `/auth/oidc/callback`, and lands on
61
+ * `successRedirect#token=<pramen session>`.
62
+ */
63
+ export declare function createOidcAuth(opts: OidcOptions): {
64
+ routes: PublicRoute[];
65
+ };
66
+ /** The name of the privileged handler the callback route calls. A route has no `ctx.db` — it
67
+ * runs in the Worker, before any tenant DO — so the write goes through `callPrivileged`,
68
+ * exactly as the CMS's preview route does. */
69
+ export declare const OIDC_UPSERT_HANDLER = "__oidcUpsertUser";
70
+ /** Handlers for `createOidcAuth`'s routes. Spread into `app.handlers`:
71
+ *
72
+ * handlers: { ...authHandlers, ...oidcHandlers }
73
+ *
74
+ * `__oidcUpsertUser` is SYSTEM-only: `callPrivileged` reaches it from inside the Worker, and
75
+ * `auth: []` means no role satisfies it over `/rpc`, so it cannot be called from outside. */
76
+ export declare const oidcHandlers: HandlerMap;
package/dist/oidc.js ADDED
@@ -0,0 +1,319 @@
1
+ // OIDC login — authorization code + PKCE, exchanged for a PRAMEN session.
2
+ //
3
+ // pramen's core stays verify-only (BYO-IdP): `JwksStrategy` already verifies an RS256 token
4
+ // against a remote JWKS, so a deployment whose frontend already holds an IdP token needs
5
+ // nothing from this file. What was missing is the FLOW — the redirect dance that turns a
6
+ // browser with no token into a session — and the claim mapping that makes an IdP's idea of
7
+ // a user into pramen's.
8
+ //
9
+ // WHY IT MINTS A PRAMEN TOKEN rather than passing the IdP's through. The rest of the system
10
+ // is built on pramen sessions: `refreshSession` re-reads roles without a re-login, the KV
11
+ // denylist can revoke one mid-flight, and the ACL reads roles off the token. Forwarding a
12
+ // provider token would give up all three and put role resolution on the hot path of every
13
+ // request. So: the IdP proves WHO you are, once; pramen owns the session from there. Same
14
+ // shape as `createMagicLinkAuth`, which exchanges a one-time link for the same thing.
15
+ //
16
+ // WHERE ROLES COME FROM, which differs per provider and is the part that silently fails:
17
+ // - Entra puts app roles in a top-level `roles` claim,
18
+ // - Auth0/Okta put them in a NAMESPACED claim (`https://example.com/roles`),
19
+ // - Google Workspace has none in the token at all.
20
+ // So roles resolve in this order: `mapRoles(claims)` if you supply one (the IdP is
21
+ // authoritative), else the roles stored on the user's row (pramen is authoritative — the
22
+ // only workable answer for Google), else `defaultRoles` for a first login.
23
+ import { JwksStrategy, Kv, mutation } from "@pramen/server";
24
+ // The package's OWN HS256 signer — `@pramen/server`'s `signToken` mints the opaque
25
+ // file/preview token, which the request verifier does not accept as a session.
26
+ import { signToken } from "./index.js";
27
+ const b64url = (bytes) => {
28
+ const b = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
29
+ return btoa(String.fromCharCode(...b)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
30
+ };
31
+ const randomB64 = (bytes = 32) => b64url(crypto.getRandomValues(new Uint8Array(bytes)));
32
+ /** PKCE S256: the verifier stays server-side, only its hash goes to the provider, so an
33
+ * intercepted authorization code cannot be redeemed by whoever intercepted it. */
34
+ async function challengeFor(verifier) {
35
+ return b64url(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)));
36
+ }
37
+ /** Discovery, cached per issuer for the isolate's life. The document is static in practice,
38
+ * and a fetch on every login would put the provider's availability on the login path twice. */
39
+ const discoveryCache = new Map();
40
+ function discover(issuer) {
41
+ const base = issuer.replace(/\/+$/, "");
42
+ let cached = discoveryCache.get(base);
43
+ if (!cached) {
44
+ cached = (async () => {
45
+ const res = await fetch(`${base}/.well-known/openid-configuration`);
46
+ if (!res.ok)
47
+ throw new Error(`@pramen/auth: OIDC discovery failed for ${base} (HTTP ${res.status})`);
48
+ const doc = (await res.json());
49
+ for (const field of ["issuer", "authorization_endpoint", "token_endpoint", "jwks_uri"]) {
50
+ if (typeof doc[field] !== "string")
51
+ throw new Error(`@pramen/auth: OIDC discovery document from ${base} has no ${field}`);
52
+ }
53
+ // The document's own `issuer` is what ID tokens will carry, and it is what we verify
54
+ // against — a provider whose discovery URL and issuer differ (a tenant alias, say) is
55
+ // legitimate; a document claiming a DIFFERENT issuer than it was fetched from is not.
56
+ return doc;
57
+ })();
58
+ discoveryCache.set(base, cached);
59
+ void cached.catch(() => discoveryCache.delete(base)); // never cache a failure
60
+ }
61
+ return cached;
62
+ }
63
+ const sha256Hex = async (v) => [...new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(v)))].map((b) => b.toString(16).padStart(2, "0")).join("");
64
+ const BINDER_COOKIE = "pramen_oidc";
65
+ function readCookie(request, name) {
66
+ const raw = request.headers.get("cookie");
67
+ if (!raw)
68
+ return null;
69
+ for (const part of raw.split(";")) {
70
+ const [k, ...v] = part.trim().split("=");
71
+ if (k === name)
72
+ return decodeURIComponent(v.join("="));
73
+ }
74
+ return null;
75
+ }
76
+ /** `SameSite=Lax` is what makes this work at all: the callback is a TOP-LEVEL GET navigation
77
+ * from the provider's origin, which Lax allows, while a cross-site POST or subresource would
78
+ * not carry it. `Secure` is set whenever the request is https — omitted on plain-http local
79
+ * dev, where the browser would otherwise drop the cookie entirely. */
80
+ const binderCookie = (url, path, value, maxAge) => `${BINDER_COOKIE}=${encodeURIComponent(value)}; Path=${path}; Max-Age=${maxAge}; HttpOnly; SameSite=Lax${url.protocol === "https:" ? "; Secure" : ""}`;
81
+ const ESCAPES = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
82
+ /** An error page. The message is ESCAPED here rather than at the call sites: this route is
83
+ * public, pre-auth, and reachable with arbitrary query parameters, so anything interpolated
84
+ * into it is attacker-controlled until proven otherwise. Escaping centrally means a future
85
+ * caller cannot reintroduce the hole by forgetting. */
86
+ const html = (status, message) => new Response(`<!doctype html><meta charset="utf-8"><title>Sign-in</title><p>${message.replace(/[&<>"']/g, (c) => ESCAPES[c])}</p>`, { status, headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" } });
87
+ /** OAuth error codes are a constrained vocabulary (RFC 6749 §4.1.2.1). Anything outside it
88
+ * is not a provider error worth echoing — it is someone probing this endpoint. */
89
+ const safeErrorCode = (raw) => (/^[a-z_]{1,64}$/.test(raw) ? raw : "unspecified");
90
+ /**
91
+ * OIDC login for a pramen app. Spread the routes into `app.routes` (they are PRE-AUTH by
92
+ * design — a caller arriving here has no session yet):
93
+ *
94
+ * const oidc = createOidcAuth({ issuer, clientId, clientSecret, redirectUri, successRedirect });
95
+ * export const app = { schema, handlers, acl, routes: [...oidc.routes] };
96
+ *
97
+ * The browser goes to `/auth/oidc/start`, comes back to `/auth/oidc/callback`, and lands on
98
+ * `successRedirect#token=<pramen session>`.
99
+ */
100
+ export function createOidcAuth(opts) {
101
+ const scopes = opts.scopes ?? ["openid", "email", "profile"];
102
+ const table = opts.table ?? "auth_users";
103
+ const defaultRoles = opts.defaultRoles ?? ["user"];
104
+ const accountKey = opts.accountKey ?? "email";
105
+ const startPath = opts.startPath ?? "/auth/oidc/start";
106
+ const callbackPath = opts.callbackPath ?? "/auth/oidc/callback";
107
+ const stateTtl = opts.stateTtlSeconds ?? 600;
108
+ const sessionTtl = opts.sessionTtlSeconds ?? 3600;
109
+ // One verifier per issuer, so the JWKS cache and its key-rotation handling are shared
110
+ // across logins rather than rebuilt per request.
111
+ let verifier;
112
+ const idTokenVerifier = (jwksUri, issuer) => (verifier ??= new JwksStrategy(jwksUri, undefined, { requireExp: true, issuer, audience: opts.clientId }));
113
+ const start = {
114
+ method: "GET",
115
+ path: startPath,
116
+ handler: async (request, env) => {
117
+ const doc = await discover(opts.issuer);
118
+ const kv = new Kv(env.KV);
119
+ const state = randomB64();
120
+ const binder = randomB64();
121
+ const pending = {
122
+ verifier: randomB64(64),
123
+ nonce: randomB64(),
124
+ binderHash: await sha256Hex(binder),
125
+ // Where the user was going before they were bounced to sign in. Only ever a PATH:
126
+ // an absolute URL here would make this an open redirect.
127
+ returnTo: new URL(request.url).searchParams.get("returnTo") ?? undefined,
128
+ };
129
+ await kv.put(`oidc:${state}`, JSON.stringify(pending), { expirationTtl: stateTtl });
130
+ const url = new URL(doc.authorization_endpoint);
131
+ url.searchParams.set("response_type", "code");
132
+ url.searchParams.set("client_id", opts.clientId);
133
+ url.searchParams.set("redirect_uri", opts.redirectUri);
134
+ url.searchParams.set("scope", scopes.join(" "));
135
+ url.searchParams.set("state", state);
136
+ url.searchParams.set("nonce", pending.nonce);
137
+ url.searchParams.set("code_challenge", await challengeFor(pending.verifier));
138
+ url.searchParams.set("code_challenge_method", "S256");
139
+ return new Response(null, {
140
+ status: 302,
141
+ headers: {
142
+ location: url.toString(),
143
+ "cache-control": "no-store",
144
+ "set-cookie": binderCookie(new URL(request.url), callbackPath, binder, stateTtl),
145
+ },
146
+ });
147
+ },
148
+ };
149
+ const callback = {
150
+ method: "GET",
151
+ path: callbackPath,
152
+ handler: async (request, env, ctx) => {
153
+ const url = new URL(request.url);
154
+ const kv = new Kv(env.KV);
155
+ // A provider that refuses (consent declined, unauthorized client) redirects back with
156
+ // `error` rather than `code`. Surface it instead of reporting "no code".
157
+ const providerError = url.searchParams.get("error");
158
+ if (providerError)
159
+ return html(400, `Sign-in was refused by the provider (${safeErrorCode(providerError)}).`);
160
+ const state = url.searchParams.get("state");
161
+ const code = url.searchParams.get("code");
162
+ if (!state || !code)
163
+ return html(400, "Sign-in link is incomplete. Start again.");
164
+ // SINGLE USE: read and delete before anything else, so a replayed callback — or two
165
+ // tabs racing the same code — cannot both proceed.
166
+ const pending = (await kv.get(`oidc:${state}`, "json"));
167
+ await kv.delete(`oidc:${state}`);
168
+ if (!pending)
169
+ return html(400, "Sign-in expired or was already used. Start again.");
170
+ // The state must belong to THIS browser. An attacker who starts their own login holds
171
+ // a perfectly valid state+code; without this check, feeding them to a victim's browser
172
+ // signs the victim in as the attacker.
173
+ const binder = readCookie(request, BINDER_COOKIE);
174
+ if (!binder || (await sha256Hex(binder)) !== pending.binderHash) {
175
+ return html(400, "This sign-in did not start in this browser. Start again.");
176
+ }
177
+ const doc = await discover(opts.issuer);
178
+ const body = new URLSearchParams({
179
+ grant_type: "authorization_code",
180
+ code,
181
+ redirect_uri: opts.redirectUri,
182
+ client_id: opts.clientId,
183
+ code_verifier: pending.verifier,
184
+ });
185
+ if (opts.clientSecret)
186
+ body.set("client_secret", opts.clientSecret);
187
+ const tokenRes = await fetch(doc.token_endpoint, {
188
+ method: "POST",
189
+ headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
190
+ body,
191
+ });
192
+ const tokens = (await tokenRes.json().catch(() => ({})));
193
+ if (!tokenRes.ok || typeof tokens.id_token !== "string") {
194
+ console.error(`pramen/auth: OIDC token exchange failed (HTTP ${tokenRes.status}${tokens.error ? `, ${tokens.error}` : ""})`);
195
+ return html(502, "Sign-in could not be completed. Try again.");
196
+ }
197
+ // Signature, issuer, audience and expiry — then the nonce, which is what binds this
198
+ // ID token to the authorization request WE started. Without it a token minted for a
199
+ // different session of the same client would be accepted here.
200
+ const claims = await idTokenVerifier(doc.jwks_uri, doc.issuer).verify(tokens.id_token);
201
+ if (!claims)
202
+ return html(401, "Sign-in token could not be verified.");
203
+ if (claims.nonce !== pending.nonce)
204
+ return html(401, "Sign-in token does not match this sign-in attempt.");
205
+ const sub = typeof claims.sub === "string" ? claims.sub : "";
206
+ const email = typeof claims.email === "string" ? claims.email.toLowerCase() : "";
207
+ const emailVerified = claims.email_verified === true;
208
+ if (!sub)
209
+ return html(401, "Sign-in token carries no subject.");
210
+ // See `accountKey`: keying on an UNVERIFIED email would let a provider that permits
211
+ // arbitrary addresses take over an existing account. Fail rather than silently
212
+ // falling back to `sub`, which would quietly create a second account for the user.
213
+ let username;
214
+ if (accountKey === "email") {
215
+ if (!email || !emailVerified) {
216
+ return html(401, "This provider did not assert a verified email address, which this app uses to identify accounts.");
217
+ }
218
+ username = email;
219
+ }
220
+ else {
221
+ username = `${doc.issuer}#${sub}`;
222
+ }
223
+ const mapped = opts.mapRoles?.(claims);
224
+ const res = await ctx.callPrivileged({
225
+ name: OIDC_UPSERT_HANDLER,
226
+ input: { table, username, email: email || null, roles: mapped ? [...mapped] : null, defaultRoles: [...defaultRoles] },
227
+ });
228
+ const upserted = (await res.json().catch(() => ({})));
229
+ if (upserted.ok !== true || !upserted.result)
230
+ return html(500, "Sign-in could not be completed.");
231
+ // A deactivated account must not be revived by logging in through the IdP — the
232
+ // provider knows nothing about pramen's `active` flag.
233
+ if (upserted.result.active === false)
234
+ return html(403, "This account is deactivated.");
235
+ const secret = env.AUTH_SECRET;
236
+ if (typeof secret !== "string" || secret.length === 0) {
237
+ console.error("pramen/auth: OIDC callback cannot mint a session — AUTH_SECRET is not configured");
238
+ return html(500, "Sign-in could not be completed.");
239
+ }
240
+ const token = await signToken({ sub: username, roles: upserted.result.roles ?? [] }, secret, { ttlSeconds: sessionTtl });
241
+ const target = new URL(opts.successRedirect);
242
+ if (pending.returnTo?.startsWith("/") && !pending.returnTo.startsWith("//"))
243
+ target.searchParams.set("returnTo", pending.returnTo);
244
+ // FRAGMENT, not query: a fragment is never sent to a server, so the session token
245
+ // stays out of access logs, proxies and `Referer`.
246
+ target.hash = `token=${encodeURIComponent(token)}`;
247
+ return new Response(null, {
248
+ status: 302,
249
+ headers: {
250
+ location: target.toString(),
251
+ "cache-control": "no-store",
252
+ // The binder is spent with the state it protected.
253
+ "set-cookie": binderCookie(url, callbackPath, "", 0),
254
+ },
255
+ });
256
+ },
257
+ };
258
+ return { routes: [start, callback] };
259
+ }
260
+ /** The name of the privileged handler the callback route calls. A route has no `ctx.db` — it
261
+ * runs in the Worker, before any tenant DO — so the write goes through `callPrivileged`,
262
+ * exactly as the CMS's preview route does. */
263
+ export const OIDC_UPSERT_HANDLER = "__oidcUpsertUser";
264
+ /** Handlers for `createOidcAuth`'s routes. Spread into `app.handlers`:
265
+ *
266
+ * handlers: { ...authHandlers, ...oidcHandlers }
267
+ *
268
+ * `__oidcUpsertUser` is SYSTEM-only: `callPrivileged` reaches it from inside the Worker, and
269
+ * `auth: []` means no role satisfies it over `/rpc`, so it cannot be called from outside. */
270
+ export const oidcHandlers = {
271
+ [OIDC_UPSERT_HANDLER]: mutation(async (ctx, input) => {
272
+ const rows = (await ctx.db.exec(`SELECT username, roles, active FROM ${quoteIdent(input.table)} WHERE username = ? LIMIT 1`, input.username));
273
+ const existing = rows[0];
274
+ if (!existing) {
275
+ // First login. `passwordHash` is empty — the column is NOT NULL in `authSchema` and
276
+ // an empty hash never verifies, which is exactly how a magic-link user is created.
277
+ const roles = input.roles ?? input.defaultRoles;
278
+ await ctx.db.exec(`INSERT INTO ${quoteIdent(input.table)} (username, passwordHash, roles, email, emailVerified, active, createdAt) VALUES (?, ?, ?, ?, ?, ?, ?)`, input.username, "", JSON.stringify(roles), input.email,
279
+ // The IdP asserted it (the route refuses an unverified address when keying on
280
+ // email), so it is verified here in a way a self-service signup never is.
281
+ input.email ? Date.now() : null, 1, Date.now());
282
+ return { roles, active: true };
283
+ }
284
+ // Returning user. Roles the IdP asserts overwrite the stored ones — that is what
285
+ // "the IdP is authoritative" means, including a role being REMOVED there. With no
286
+ // `mapRoles`, the stored roles stand and pramen owns them.
287
+ const stored = parseRoles(existing.roles);
288
+ const roles = input.roles ?? stored;
289
+ const active = existing.active !== 0 && existing.active !== false;
290
+ if (input.roles && JSON.stringify(roles) !== JSON.stringify(stored)) {
291
+ await ctx.db.exec(`UPDATE ${quoteIdent(input.table)} SET roles = ? WHERE username = ?`, JSON.stringify(roles), input.username);
292
+ }
293
+ return { roles, active };
294
+ },
295
+ // No role can satisfy an empty allow-list, so /rpc always 403s; callPrivileged runs as
296
+ // SYSTEM and bypasses it. The handler writes roles, so it must never be callable.
297
+ { auth: [] }),
298
+ };
299
+ /** The table name comes from the app's own options, never from a request — but it is
300
+ * interpolated into SQL, so it is quoted and constrained rather than trusted by convention. */
301
+ function quoteIdent(table) {
302
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(table))
303
+ throw new Error(`@pramen/auth: invalid table name ${JSON.stringify(table)}`);
304
+ return `"${table}"`;
305
+ }
306
+ function parseRoles(v) {
307
+ if (Array.isArray(v))
308
+ return v.map(String);
309
+ if (typeof v === "string") {
310
+ try {
311
+ const parsed = JSON.parse(v);
312
+ return Array.isArray(parsed) ? parsed.map(String) : [];
313
+ }
314
+ catch {
315
+ return [];
316
+ }
317
+ }
318
+ return [];
319
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pramen/auth",
3
- "version": "0.0.50",
3
+ "version": "0.0.52",
4
4
  "description": "Optional credential→JWT login for pramen — signup/login/me + PBKDF2 hashing, issuing HS256 tokens the pramen verifier accepts.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -34,6 +34,6 @@
34
34
  "access": "public"
35
35
  },
36
36
  "dependencies": {
37
- "@pramen/server": "0.0.50"
37
+ "@pramen/server": "0.0.52"
38
38
  }
39
39
  }
package/src/index.ts CHANGED
@@ -1069,3 +1069,6 @@ export function createEmailVerification(opts: EmailVerificationOptions): AuthMod
1069
1069
 
1070
1070
  return { handlers, tasks };
1071
1071
  }
1072
+
1073
+ // --- OIDC (authorization code + PKCE) ---------------------------------------
1074
+ export { createOidcAuth, oidcHandlers, OIDC_UPSERT_HANDLER, type OidcOptions } from "./oidc.js";
package/src/oidc.ts ADDED
@@ -0,0 +1,427 @@
1
+ // OIDC login — authorization code + PKCE, exchanged for a PRAMEN session.
2
+ //
3
+ // pramen's core stays verify-only (BYO-IdP): `JwksStrategy` already verifies an RS256 token
4
+ // against a remote JWKS, so a deployment whose frontend already holds an IdP token needs
5
+ // nothing from this file. What was missing is the FLOW — the redirect dance that turns a
6
+ // browser with no token into a session — and the claim mapping that makes an IdP's idea of
7
+ // a user into pramen's.
8
+ //
9
+ // WHY IT MINTS A PRAMEN TOKEN rather than passing the IdP's through. The rest of the system
10
+ // is built on pramen sessions: `refreshSession` re-reads roles without a re-login, the KV
11
+ // denylist can revoke one mid-flight, and the ACL reads roles off the token. Forwarding a
12
+ // provider token would give up all three and put role resolution on the hot path of every
13
+ // request. So: the IdP proves WHO you are, once; pramen owns the session from there. Same
14
+ // shape as `createMagicLinkAuth`, which exchanges a one-time link for the same thing.
15
+ //
16
+ // WHERE ROLES COME FROM, which differs per provider and is the part that silently fails:
17
+ // - Entra puts app roles in a top-level `roles` claim,
18
+ // - Auth0/Okta put them in a NAMESPACED claim (`https://example.com/roles`),
19
+ // - Google Workspace has none in the token at all.
20
+ // So roles resolve in this order: `mapRoles(claims)` if you supply one (the IdP is
21
+ // authoritative), else the roles stored on the user's row (pramen is authoritative — the
22
+ // only workable answer for Google), else `defaultRoles` for a first login.
23
+
24
+ import { JwksStrategy, Kv, mutation } from "@pramen/server";
25
+ import type { EnvBag, HandlerContext, HandlerMap, JsonObject, Row } from "@pramen/server";
26
+ import type { PublicRoute, RouteContext } from "@pramen/server/worker";
27
+ // The package's OWN HS256 signer — `@pramen/server`'s `signToken` mints the opaque
28
+ // file/preview token, which the request verifier does not accept as a session.
29
+ import { signToken } from "./index.js";
30
+
31
+ /** An OpenID Provider's discovery document — the fields this flow uses. */
32
+ interface Discovery {
33
+ issuer: string;
34
+ authorization_endpoint: string;
35
+ token_endpoint: string;
36
+ jwks_uri: string;
37
+ }
38
+
39
+ export interface OidcOptions {
40
+ /** The provider's issuer URL. `/.well-known/openid-configuration` is fetched from it, so
41
+ * the endpoints and the JWKS location are never hand-copied. */
42
+ issuer: string;
43
+ clientId: string;
44
+ /** Confidential clients only. Omit for a public client — the exchange then relies on
45
+ * PKCE alone, which is the correct configuration for a SPA. */
46
+ clientSecret?: string;
47
+ /** Must match the redirect URI registered with the provider, exactly. */
48
+ redirectUri: string;
49
+ /** Where to send the browser after a successful login. The session token arrives in the
50
+ * URL FRAGMENT (`#token=…`), which — unlike a query parameter — is never sent to a
51
+ * server, kept in server logs, or included in a `Referer` header. */
52
+ successRedirect: string;
53
+ /** Default `["openid", "email", "profile"]`. */
54
+ scopes?: readonly string[];
55
+ /** The users table. Default `auth_users` (the `authSchema` shape). */
56
+ table?: string;
57
+ /** Roles for a first-time login when `mapRoles` yields none. Default `["user"]`. */
58
+ defaultRoles?: readonly string[];
59
+ /**
60
+ * Read roles out of the ID token's claims, for providers where the IdP is authoritative:
61
+ *
62
+ * mapRoles: (c) => c["https://acme.com/roles"] as string[] // Auth0/Okta
63
+ * mapRoles: (c) => c.roles as string[] // Entra
64
+ *
65
+ * Return `undefined` to fall back to the roles stored on the user's row. Google Workspace
66
+ * ships no roles in the token, so omit this and manage them in pramen.
67
+ */
68
+ mapRoles?: (claims: JsonObject) => readonly string[] | undefined;
69
+ /**
70
+ * What identifies the account across logins.
71
+ *
72
+ * `"email"` (default) matches how the rest of `@pramen/auth` keys users, so an OIDC login
73
+ * lands on the SAME row as a magic-link or password login for that address. It is only
74
+ * honored when the provider asserts `email_verified` — an IdP that lets a user set an
75
+ * unverified address would otherwise be an account-takeover path into any existing
76
+ * email-keyed account.
77
+ *
78
+ * `"sub"` keys on the provider's opaque subject, namespaced by issuer. Immune to an email
79
+ * change, and to the above, but it will not link up with accounts created another way.
80
+ */
81
+ accountKey?: "email" | "sub";
82
+ sessionTtlSeconds?: number;
83
+ /** Where the flow is mounted. Defaults `/auth/oidc/start` and `/auth/oidc/callback`. */
84
+ startPath?: string;
85
+ callbackPath?: string;
86
+ /** How long an in-flight login may take. Default 600s. */
87
+ stateTtlSeconds?: number;
88
+ }
89
+
90
+ const b64url = (bytes: ArrayBuffer | Uint8Array): string => {
91
+ const b = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
92
+ return btoa(String.fromCharCode(...b)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
93
+ };
94
+
95
+ const randomB64 = (bytes = 32): string => b64url(crypto.getRandomValues(new Uint8Array(bytes)));
96
+
97
+ /** PKCE S256: the verifier stays server-side, only its hash goes to the provider, so an
98
+ * intercepted authorization code cannot be redeemed by whoever intercepted it. */
99
+ async function challengeFor(verifier: string): Promise<string> {
100
+ return b64url(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)));
101
+ }
102
+
103
+ /** Discovery, cached per issuer for the isolate's life. The document is static in practice,
104
+ * and a fetch on every login would put the provider's availability on the login path twice. */
105
+ const discoveryCache = new Map<string, Promise<Discovery>>();
106
+ function discover(issuer: string): Promise<Discovery> {
107
+ const base = issuer.replace(/\/+$/, "");
108
+ let cached = discoveryCache.get(base);
109
+ if (!cached) {
110
+ cached = (async () => {
111
+ const res = await fetch(`${base}/.well-known/openid-configuration`);
112
+ if (!res.ok) throw new Error(`@pramen/auth: OIDC discovery failed for ${base} (HTTP ${res.status})`);
113
+ const doc = (await res.json()) as Partial<Discovery>;
114
+ for (const field of ["issuer", "authorization_endpoint", "token_endpoint", "jwks_uri"] as const) {
115
+ if (typeof doc[field] !== "string") throw new Error(`@pramen/auth: OIDC discovery document from ${base} has no ${field}`);
116
+ }
117
+ // The document's own `issuer` is what ID tokens will carry, and it is what we verify
118
+ // against — a provider whose discovery URL and issuer differ (a tenant alias, say) is
119
+ // legitimate; a document claiming a DIFFERENT issuer than it was fetched from is not.
120
+ return doc as Discovery;
121
+ })();
122
+ discoveryCache.set(base, cached);
123
+ void cached.catch(() => discoveryCache.delete(base)); // never cache a failure
124
+ }
125
+ return cached;
126
+ }
127
+
128
+ /** One in-flight login. Held in KV under a random key so the browser carries only the
129
+ * lookup key, never the verifier. */
130
+ interface PendingLogin {
131
+ verifier: string;
132
+ nonce: string;
133
+ returnTo?: string;
134
+ /** SHA-256 of the binder cookie handed to the browser that STARTED this login.
135
+ *
136
+ * Without it, `state` is just a random string an attacker can obtain by starting a login
137
+ * of their own: they then trick the victim's browser into loading the callback with their
138
+ * code+state, and the victim is silently signed in AS THE ATTACKER — everything the victim
139
+ * subsequently writes lands in the attacker's account. Requiring the cookie means the
140
+ * callback only completes in the browser the flow began in. */
141
+ binderHash: string;
142
+ }
143
+
144
+ const sha256Hex = async (v: string): Promise<string> =>
145
+ [...new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(v)))].map((b) => b.toString(16).padStart(2, "0")).join("");
146
+
147
+ const BINDER_COOKIE = "pramen_oidc";
148
+
149
+ function readCookie(request: Request, name: string): string | null {
150
+ const raw = request.headers.get("cookie");
151
+ if (!raw) return null;
152
+ for (const part of raw.split(";")) {
153
+ const [k, ...v] = part.trim().split("=");
154
+ if (k === name) return decodeURIComponent(v.join("="));
155
+ }
156
+ return null;
157
+ }
158
+
159
+ /** `SameSite=Lax` is what makes this work at all: the callback is a TOP-LEVEL GET navigation
160
+ * from the provider's origin, which Lax allows, while a cross-site POST or subresource would
161
+ * not carry it. `Secure` is set whenever the request is https — omitted on plain-http local
162
+ * dev, where the browser would otherwise drop the cookie entirely. */
163
+ const binderCookie = (url: URL, path: string, value: string, maxAge: number): string =>
164
+ `${BINDER_COOKIE}=${encodeURIComponent(value)}; Path=${path}; Max-Age=${maxAge}; HttpOnly; SameSite=Lax${url.protocol === "https:" ? "; Secure" : ""}`;
165
+
166
+ const ESCAPES: Record<string, string> = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
167
+
168
+ /** An error page. The message is ESCAPED here rather than at the call sites: this route is
169
+ * public, pre-auth, and reachable with arbitrary query parameters, so anything interpolated
170
+ * into it is attacker-controlled until proven otherwise. Escaping centrally means a future
171
+ * caller cannot reintroduce the hole by forgetting. */
172
+ const html = (status: number, message: string): Response =>
173
+ new Response(
174
+ `<!doctype html><meta charset="utf-8"><title>Sign-in</title><p>${message.replace(/[&<>"']/g, (c) => ESCAPES[c]!)}</p>`,
175
+ { status, headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" } },
176
+ );
177
+
178
+ /** OAuth error codes are a constrained vocabulary (RFC 6749 §4.1.2.1). Anything outside it
179
+ * is not a provider error worth echoing — it is someone probing this endpoint. */
180
+ const safeErrorCode = (raw: string): string => (/^[a-z_]{1,64}$/.test(raw) ? raw : "unspecified");
181
+
182
+ /**
183
+ * OIDC login for a pramen app. Spread the routes into `app.routes` (they are PRE-AUTH by
184
+ * design — a caller arriving here has no session yet):
185
+ *
186
+ * const oidc = createOidcAuth({ issuer, clientId, clientSecret, redirectUri, successRedirect });
187
+ * export const app = { schema, handlers, acl, routes: [...oidc.routes] };
188
+ *
189
+ * The browser goes to `/auth/oidc/start`, comes back to `/auth/oidc/callback`, and lands on
190
+ * `successRedirect#token=<pramen session>`.
191
+ */
192
+ export function createOidcAuth(opts: OidcOptions): { routes: PublicRoute[] } {
193
+ const scopes = opts.scopes ?? ["openid", "email", "profile"];
194
+ const table = opts.table ?? "auth_users";
195
+ const defaultRoles = opts.defaultRoles ?? ["user"];
196
+ const accountKey = opts.accountKey ?? "email";
197
+ const startPath = opts.startPath ?? "/auth/oidc/start";
198
+ const callbackPath = opts.callbackPath ?? "/auth/oidc/callback";
199
+ const stateTtl = opts.stateTtlSeconds ?? 600;
200
+ const sessionTtl = opts.sessionTtlSeconds ?? 3600;
201
+
202
+ // One verifier per issuer, so the JWKS cache and its key-rotation handling are shared
203
+ // across logins rather than rebuilt per request.
204
+ let verifier: JwksStrategy | undefined;
205
+ const idTokenVerifier = (jwksUri: string, issuer: string): JwksStrategy =>
206
+ (verifier ??= new JwksStrategy(jwksUri, undefined, { requireExp: true, issuer, audience: opts.clientId }));
207
+
208
+ const start: PublicRoute = {
209
+ method: "GET",
210
+ path: startPath,
211
+ handler: async (request: Request, env: EnvBag) => {
212
+ const doc = await discover(opts.issuer);
213
+ const kv = new Kv((env as EnvBag & { KV: KVNamespace }).KV);
214
+ const state = randomB64();
215
+ const binder = randomB64();
216
+ const pending: PendingLogin = {
217
+ verifier: randomB64(64),
218
+ nonce: randomB64(),
219
+ binderHash: await sha256Hex(binder),
220
+ // Where the user was going before they were bounced to sign in. Only ever a PATH:
221
+ // an absolute URL here would make this an open redirect.
222
+ returnTo: new URL(request.url).searchParams.get("returnTo") ?? undefined,
223
+ };
224
+ await kv.put(`oidc:${state}`, JSON.stringify(pending), { expirationTtl: stateTtl });
225
+
226
+ const url = new URL(doc.authorization_endpoint);
227
+ url.searchParams.set("response_type", "code");
228
+ url.searchParams.set("client_id", opts.clientId);
229
+ url.searchParams.set("redirect_uri", opts.redirectUri);
230
+ url.searchParams.set("scope", scopes.join(" "));
231
+ url.searchParams.set("state", state);
232
+ url.searchParams.set("nonce", pending.nonce);
233
+ url.searchParams.set("code_challenge", await challengeFor(pending.verifier));
234
+ url.searchParams.set("code_challenge_method", "S256");
235
+ return new Response(null, {
236
+ status: 302,
237
+ headers: {
238
+ location: url.toString(),
239
+ "cache-control": "no-store",
240
+ "set-cookie": binderCookie(new URL(request.url), callbackPath, binder, stateTtl),
241
+ },
242
+ });
243
+ },
244
+ };
245
+
246
+ const callback: PublicRoute = {
247
+ method: "GET",
248
+ path: callbackPath,
249
+ handler: async (request: Request, env: EnvBag, ctx: RouteContext) => {
250
+ const url = new URL(request.url);
251
+ const kv = new Kv((env as EnvBag & { KV: KVNamespace }).KV);
252
+
253
+ // A provider that refuses (consent declined, unauthorized client) redirects back with
254
+ // `error` rather than `code`. Surface it instead of reporting "no code".
255
+ const providerError = url.searchParams.get("error");
256
+ if (providerError) return html(400, `Sign-in was refused by the provider (${safeErrorCode(providerError)}).`);
257
+
258
+ const state = url.searchParams.get("state");
259
+ const code = url.searchParams.get("code");
260
+ if (!state || !code) return html(400, "Sign-in link is incomplete. Start again.");
261
+
262
+ // SINGLE USE: read and delete before anything else, so a replayed callback — or two
263
+ // tabs racing the same code — cannot both proceed.
264
+ const pending = (await kv.get(`oidc:${state}`, "json")) as PendingLogin | null;
265
+ await kv.delete(`oidc:${state}`);
266
+ if (!pending) return html(400, "Sign-in expired or was already used. Start again.");
267
+
268
+ // The state must belong to THIS browser. An attacker who starts their own login holds
269
+ // a perfectly valid state+code; without this check, feeding them to a victim's browser
270
+ // signs the victim in as the attacker.
271
+ const binder = readCookie(request, BINDER_COOKIE);
272
+ if (!binder || (await sha256Hex(binder)) !== pending.binderHash) {
273
+ return html(400, "This sign-in did not start in this browser. Start again.");
274
+ }
275
+
276
+ const doc = await discover(opts.issuer);
277
+ const body = new URLSearchParams({
278
+ grant_type: "authorization_code",
279
+ code,
280
+ redirect_uri: opts.redirectUri,
281
+ client_id: opts.clientId,
282
+ code_verifier: pending.verifier,
283
+ });
284
+ if (opts.clientSecret) body.set("client_secret", opts.clientSecret);
285
+ const tokenRes = await fetch(doc.token_endpoint, {
286
+ method: "POST",
287
+ headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
288
+ body,
289
+ });
290
+ const tokens = (await tokenRes.json().catch(() => ({}))) as { id_token?: string; error?: string };
291
+ if (!tokenRes.ok || typeof tokens.id_token !== "string") {
292
+ console.error(`pramen/auth: OIDC token exchange failed (HTTP ${tokenRes.status}${tokens.error ? `, ${tokens.error}` : ""})`);
293
+ return html(502, "Sign-in could not be completed. Try again.");
294
+ }
295
+
296
+ // Signature, issuer, audience and expiry — then the nonce, which is what binds this
297
+ // ID token to the authorization request WE started. Without it a token minted for a
298
+ // different session of the same client would be accepted here.
299
+ const claims = await idTokenVerifier(doc.jwks_uri, doc.issuer).verify(tokens.id_token);
300
+ if (!claims) return html(401, "Sign-in token could not be verified.");
301
+ if (claims.nonce !== pending.nonce) return html(401, "Sign-in token does not match this sign-in attempt.");
302
+
303
+ const sub = typeof claims.sub === "string" ? claims.sub : "";
304
+ const email = typeof claims.email === "string" ? claims.email.toLowerCase() : "";
305
+ const emailVerified = claims.email_verified === true;
306
+ if (!sub) return html(401, "Sign-in token carries no subject.");
307
+
308
+ // See `accountKey`: keying on an UNVERIFIED email would let a provider that permits
309
+ // arbitrary addresses take over an existing account. Fail rather than silently
310
+ // falling back to `sub`, which would quietly create a second account for the user.
311
+ let username: string;
312
+ if (accountKey === "email") {
313
+ if (!email || !emailVerified) {
314
+ return html(401, "This provider did not assert a verified email address, which this app uses to identify accounts.");
315
+ }
316
+ username = email;
317
+ } else {
318
+ username = `${doc.issuer}#${sub}`;
319
+ }
320
+
321
+ const mapped = opts.mapRoles?.(claims);
322
+ const res = await ctx.callPrivileged({
323
+ name: OIDC_UPSERT_HANDLER,
324
+ input: { table, username, email: email || null, roles: mapped ? [...mapped] : null, defaultRoles: [...defaultRoles] },
325
+ });
326
+ const upserted = (await res.json().catch(() => ({}))) as { ok?: boolean; result?: { roles?: string[]; active?: boolean } };
327
+ if (upserted.ok !== true || !upserted.result) return html(500, "Sign-in could not be completed.");
328
+ // A deactivated account must not be revived by logging in through the IdP — the
329
+ // provider knows nothing about pramen's `active` flag.
330
+ if (upserted.result.active === false) return html(403, "This account is deactivated.");
331
+
332
+ const secret = (env as EnvBag).AUTH_SECRET;
333
+ if (typeof secret !== "string" || secret.length === 0) {
334
+ console.error("pramen/auth: OIDC callback cannot mint a session — AUTH_SECRET is not configured");
335
+ return html(500, "Sign-in could not be completed.");
336
+ }
337
+ const token = await signToken({ sub: username, roles: upserted.result.roles ?? [] }, secret, { ttlSeconds: sessionTtl });
338
+ const target = new URL(opts.successRedirect);
339
+ if (pending.returnTo?.startsWith("/") && !pending.returnTo.startsWith("//")) target.searchParams.set("returnTo", pending.returnTo);
340
+ // FRAGMENT, not query: a fragment is never sent to a server, so the session token
341
+ // stays out of access logs, proxies and `Referer`.
342
+ target.hash = `token=${encodeURIComponent(token)}`;
343
+ return new Response(null, {
344
+ status: 302,
345
+ headers: {
346
+ location: target.toString(),
347
+ "cache-control": "no-store",
348
+ // The binder is spent with the state it protected.
349
+ "set-cookie": binderCookie(url, callbackPath, "", 0),
350
+ },
351
+ });
352
+ },
353
+ };
354
+
355
+ return { routes: [start, callback] };
356
+ }
357
+
358
+ /** The name of the privileged handler the callback route calls. A route has no `ctx.db` — it
359
+ * runs in the Worker, before any tenant DO — so the write goes through `callPrivileged`,
360
+ * exactly as the CMS's preview route does. */
361
+ export const OIDC_UPSERT_HANDLER = "__oidcUpsertUser";
362
+
363
+ /** Handlers for `createOidcAuth`'s routes. Spread into `app.handlers`:
364
+ *
365
+ * handlers: { ...authHandlers, ...oidcHandlers }
366
+ *
367
+ * `__oidcUpsertUser` is SYSTEM-only: `callPrivileged` reaches it from inside the Worker, and
368
+ * `auth: []` means no role satisfies it over `/rpc`, so it cannot be called from outside. */
369
+ export const oidcHandlers: HandlerMap = {
370
+ [OIDC_UPSERT_HANDLER]: mutation(
371
+ async (ctx: HandlerContext, input: { table: string; username: string; email: string | null; roles: string[] | null; defaultRoles: string[] }) => {
372
+ const rows = (await ctx.db.exec(`SELECT username, roles, active FROM ${quoteIdent(input.table)} WHERE username = ? LIMIT 1`, input.username)) as Row[];
373
+ const existing = rows[0];
374
+ if (!existing) {
375
+ // First login. `passwordHash` is empty — the column is NOT NULL in `authSchema` and
376
+ // an empty hash never verifies, which is exactly how a magic-link user is created.
377
+ const roles = input.roles ?? input.defaultRoles;
378
+ await ctx.db.exec(
379
+ `INSERT INTO ${quoteIdent(input.table)} (username, passwordHash, roles, email, emailVerified, active, createdAt) VALUES (?, ?, ?, ?, ?, ?, ?)`,
380
+ input.username,
381
+ "",
382
+ JSON.stringify(roles),
383
+ input.email,
384
+ // The IdP asserted it (the route refuses an unverified address when keying on
385
+ // email), so it is verified here in a way a self-service signup never is.
386
+ input.email ? Date.now() : null,
387
+ 1,
388
+ Date.now(),
389
+ );
390
+ return { roles, active: true };
391
+ }
392
+ // Returning user. Roles the IdP asserts overwrite the stored ones — that is what
393
+ // "the IdP is authoritative" means, including a role being REMOVED there. With no
394
+ // `mapRoles`, the stored roles stand and pramen owns them.
395
+ const stored = parseRoles(existing.roles);
396
+ const roles = input.roles ?? stored;
397
+ const active = existing.active !== 0 && existing.active !== false;
398
+ if (input.roles && JSON.stringify(roles) !== JSON.stringify(stored)) {
399
+ await ctx.db.exec(`UPDATE ${quoteIdent(input.table)} SET roles = ? WHERE username = ?`, JSON.stringify(roles), input.username);
400
+ }
401
+ return { roles, active };
402
+ },
403
+ // No role can satisfy an empty allow-list, so /rpc always 403s; callPrivileged runs as
404
+ // SYSTEM and bypasses it. The handler writes roles, so it must never be callable.
405
+ { auth: [] },
406
+ ),
407
+ };
408
+
409
+ /** The table name comes from the app's own options, never from a request — but it is
410
+ * interpolated into SQL, so it is quoted and constrained rather than trusted by convention. */
411
+ function quoteIdent(table: string): string {
412
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(table)) throw new Error(`@pramen/auth: invalid table name ${JSON.stringify(table)}`);
413
+ return `"${table}"`;
414
+ }
415
+
416
+ function parseRoles(v: unknown): string[] {
417
+ if (Array.isArray(v)) return v.map(String);
418
+ if (typeof v === "string") {
419
+ try {
420
+ const parsed = JSON.parse(v) as unknown;
421
+ return Array.isArray(parsed) ? parsed.map(String) : [];
422
+ } catch {
423
+ return [];
424
+ }
425
+ }
426
+ return [];
427
+ }