@pramen/auth 0.0.51 → 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 +1 -0
- package/dist/index.js +2 -0
- package/dist/oidc.d.ts +76 -0
- package/dist/oidc.js +319 -0
- package/package.json +2 -2
- package/src/index.ts +3 -0
- package/src/oidc.ts +427 -0
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
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 = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" };
|
|
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.
|
|
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.
|
|
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> = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" };
|
|
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
|
+
}
|