@danainnovations/sonance-gate 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,63 @@
1
+ # @danainnovations/sonance-gate
2
+
3
+ Sonance Okta SSO for any web app on Vercel — one file, any framework (Next.js, Vite, Astro, SvelteKit, static HTML).
4
+
5
+ ## How it behaves
6
+
7
+ | Environment | Behavior |
8
+ |---|---|
9
+ | Vercel production | Real Okta sign-in required for every route |
10
+ | Vercel preview | Open (protect previews with Vercel Deployment Protection) |
11
+ | Local dev | Open — `/auth/me` returns a fake "Dev User" |
12
+
13
+ Sessions last 12 hours; re-auth is silent while your Okta session is alive.
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ npm install @danainnovations/sonance-gate
19
+ ```
20
+
21
+ Create `middleware.ts` at your project root (NOT inside `src/` or `app/`):
22
+
23
+ ```ts
24
+ import { createGate } from '@danainnovations/sonance-gate'
25
+
26
+ export default createGate({
27
+ // Paths reachable without sign-in. '/prefix/*' matches the whole subtree;
28
+ // a bare '/prefix' must be listed exactly (on its own) to be public.
29
+ publicPaths: ['/api/webhook/*'],
30
+ })
31
+
32
+ export const config = {
33
+ runtime: 'nodejs',
34
+ matcher: ['/((?!_next/static|_next/image|favicon.ico|assets/).*)'],
35
+ }
36
+ ```
37
+
38
+ Next.js note: on Next.js ≤15 the framework owns root `middleware.ts` — compose by calling the gate first inside your existing middleware and returning its Response when defined. On Next.js 16+ use `proxy.ts` for framework logic; `middleware.ts` belongs to the gate.
39
+
40
+ ## Environment variables (Vercel production only)
41
+
42
+ `OKTA_CLIENT_ID`, `OKTA_CLIENT_SECRET`, `OKTA_ISSUER` — pushed by the Technology team from your app's Okta registration. `SESSION_SECRET` — any random string ≥32 chars. Nothing is needed locally.
43
+
44
+ If any are missing in production the gate fails closed with a 500 naming the missing key names.
45
+
46
+ ## Showing who's signed in
47
+
48
+ The gate serves `GET /auth/me` → `{ sub, name, email }` (a fake Dev User outside production). Framework-free widget:
49
+
50
+ ```html
51
+ <div id="whoami"></div>
52
+ <script>
53
+ fetch('/auth/me').then(r => r.ok ? r.json() : null).then(user => {
54
+ if (!user) return
55
+ document.getElementById('whoami').innerHTML =
56
+ `${user.name} · <a href="/auth/signout">Sign out</a>`
57
+ })
58
+ </script>
59
+ ```
60
+
61
+ Endpoints: `/auth/signin` (optionally `?returnTo=/path`), `/auth/signout`, `/auth/me`, `/auth/callback` (Okta's redirect URI — register `https://<your-domain>/auth/callback`).
62
+
63
+ Sign-out clears the app session and forces a fresh Okta credential prompt on the next visit (your Okta org session for other apps stays alive).
package/dist/index.cjs ADDED
@@ -0,0 +1,360 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/index.ts
31
+ var index_exports = {};
32
+ __export(index_exports, {
33
+ DEV_USER: () => DEV_USER,
34
+ createGate: () => createGate
35
+ });
36
+ module.exports = __toCommonJS(index_exports);
37
+ var import_middleware = require("@vercel/functions/middleware");
38
+
39
+ // src/config.ts
40
+ function loadEnv(vars = process.env) {
41
+ const enforcing = vars.VERCEL_ENV === "production" && vars.AUTH_BYPASS !== "true";
42
+ if (!enforcing) return { mode: "open" };
43
+ const missing = [];
44
+ if (!vars.OKTA_CLIENT_ID) missing.push("OKTA_CLIENT_ID");
45
+ if (!vars.OKTA_CLIENT_SECRET) missing.push("OKTA_CLIENT_SECRET");
46
+ if (!vars.OKTA_ISSUER) missing.push("OKTA_ISSUER");
47
+ if (!vars.SESSION_SECRET) missing.push("SESSION_SECRET");
48
+ else if (vars.SESSION_SECRET.length < 32) missing.push("SESSION_SECRET (must be at least 32 chars)");
49
+ if (missing.length > 0) return { mode: "misconfigured", missing };
50
+ return {
51
+ mode: "enforce",
52
+ env: {
53
+ clientId: vars.OKTA_CLIENT_ID,
54
+ clientSecret: vars.OKTA_CLIENT_SECRET,
55
+ issuer: normalizeIssuer(vars.OKTA_ISSUER),
56
+ sessionSecret: vars.SESSION_SECRET
57
+ }
58
+ };
59
+ }
60
+ function normalizeIssuer(raw) {
61
+ const withScheme = /^https?:\/\//.test(raw) ? raw : `https://${raw}`;
62
+ return withScheme.replace(/\/+$/, "");
63
+ }
64
+
65
+ // src/cookies.ts
66
+ var SESSION_COOKIE = "__Host-sg_session";
67
+ var STATE_COOKIE = "__Host-sg_state";
68
+ var SIGNEDOUT_COOKIE = "__Host-sg_signedout";
69
+ var ATTRS = "Path=/; HttpOnly; Secure; SameSite=Lax";
70
+ function getCookie(request, name) {
71
+ const header = request.headers.get("cookie");
72
+ if (!header) return void 0;
73
+ for (const part of header.split(";")) {
74
+ const eq = part.indexOf("=");
75
+ if (eq === -1) continue;
76
+ if (part.slice(0, eq).trim() === name) return part.slice(eq + 1).trim();
77
+ }
78
+ return void 0;
79
+ }
80
+ function setCookie(name, value, maxAgeSec) {
81
+ return `${name}=${value}; ${ATTRS}; Max-Age=${maxAgeSec}`;
82
+ }
83
+ function clearCookie(name) {
84
+ return `${name}=; ${ATTRS}; Max-Age=0`;
85
+ }
86
+
87
+ // src/session.ts
88
+ var import_node_crypto = require("crypto");
89
+ var import_jose = require("jose");
90
+ var SESSION_MAX_AGE = 43200;
91
+ var STATE_MAX_AGE = 600;
92
+ function keyOf(secret) {
93
+ return new Uint8Array((0, import_node_crypto.createHash)("sha256").update(secret).digest());
94
+ }
95
+ async function seal(claims, secret, maxAgeSec) {
96
+ const now = Math.floor(Date.now() / 1e3);
97
+ return new import_jose.EncryptJWT(claims).setProtectedHeader({ alg: "dir", enc: "A256GCM" }).setIssuedAt(now).setExpirationTime(now + maxAgeSec).encrypt(keyOf(secret));
98
+ }
99
+ async function unseal(token, secret) {
100
+ try {
101
+ const { payload } = await (0, import_jose.jwtDecrypt)(token, keyOf(secret));
102
+ return payload;
103
+ } catch {
104
+ return null;
105
+ }
106
+ }
107
+
108
+ // src/oidc.ts
109
+ var import_node_crypto2 = require("crypto");
110
+ var import_jose2 = require("jose");
111
+ var discoveryCache = /* @__PURE__ */ new Map();
112
+ function discover(issuer, fetchFn = fetch) {
113
+ let cached = discoveryCache.get(issuer);
114
+ if (!cached) {
115
+ cached = (async () => {
116
+ const res = await fetchFn(`${issuer}/.well-known/openid-configuration`);
117
+ if (!res.ok) throw new Error(`OIDC discovery failed (${res.status}) for issuer`);
118
+ return await res.json();
119
+ })();
120
+ cached.catch(() => discoveryCache.delete(issuer));
121
+ discoveryCache.set(issuer, cached);
122
+ }
123
+ return cached;
124
+ }
125
+ function randomToken() {
126
+ return (0, import_node_crypto2.randomBytes)(32).toString("base64url");
127
+ }
128
+ async function pkcePair() {
129
+ const verifier = randomToken();
130
+ const challenge = (0, import_node_crypto2.createHash)("sha256").update(verifier).digest("base64url");
131
+ return { verifier, challenge };
132
+ }
133
+ function buildAuthorizeUrl(meta, p) {
134
+ const url = new URL(meta.authorization_endpoint);
135
+ url.search = new URLSearchParams({
136
+ client_id: p.clientId,
137
+ response_type: "code",
138
+ scope: "openid profile email",
139
+ redirect_uri: p.redirectUri,
140
+ state: p.state,
141
+ nonce: p.nonce,
142
+ code_challenge: p.challenge,
143
+ code_challenge_method: "S256",
144
+ ...p.prompt ? { prompt: p.prompt } : {}
145
+ }).toString();
146
+ return url.toString();
147
+ }
148
+ async function exchangeCode(meta, p, fetchFn = fetch) {
149
+ const res = await fetchFn(meta.token_endpoint, {
150
+ method: "POST",
151
+ headers: {
152
+ "content-type": "application/x-www-form-urlencoded",
153
+ authorization: `Basic ${Buffer.from(`${p.clientId}:${p.clientSecret}`).toString("base64")}`
154
+ },
155
+ body: new URLSearchParams({
156
+ grant_type: "authorization_code",
157
+ code: p.code,
158
+ redirect_uri: p.redirectUri,
159
+ code_verifier: p.verifier
160
+ }).toString()
161
+ });
162
+ if (!res.ok) throw new Error(`Token exchange failed (${res.status})`);
163
+ const json2 = await res.json();
164
+ if (!json2.id_token) throw new Error("Token exchange failed (no id_token in response)");
165
+ return { idToken: json2.id_token };
166
+ }
167
+ async function validateIdToken(idToken, p) {
168
+ const { payload } = await (0, import_jose2.jwtVerify)(idToken, p.jwks, {
169
+ issuer: p.issuer,
170
+ audience: p.clientId
171
+ });
172
+ if (payload.nonce !== p.nonce) throw new Error("ID token nonce mismatch");
173
+ return {
174
+ sub: String(payload.sub ?? ""),
175
+ name: String(payload.name ?? payload.email ?? "Unknown"),
176
+ email: String(payload.email ?? "")
177
+ };
178
+ }
179
+
180
+ // src/gate.ts
181
+ var remoteJwks;
182
+ var DEV_USER = { sub: "dev-user", name: "Dev User", email: "dev@sonance.com" };
183
+ function json(body, status = 200, headers = {}) {
184
+ return new Response(JSON.stringify(body), {
185
+ status,
186
+ headers: { "content-type": "application/json", "cache-control": "no-store", ...headers }
187
+ });
188
+ }
189
+ function redirect(location, cookies = []) {
190
+ const headers = new Headers({ location });
191
+ for (const c of cookies) headers.append("set-cookie", c);
192
+ return new Response(null, { status: 302, headers });
193
+ }
194
+ async function handleRequest(request, opts = {}, vars = process.env, deps = {}) {
195
+ const env = loadEnv(vars);
196
+ const path = new URL(request.url).pathname;
197
+ if (env.mode === "open") {
198
+ if (path === "/auth/me") return json(DEV_USER);
199
+ if (path === "/auth/signin" || path === "/auth/signout") return redirect("/");
200
+ return void 0;
201
+ }
202
+ if (env.mode === "misconfigured") {
203
+ return new Response(
204
+ `Authentication misconfigured. Missing environment variables: ${env.missing.join(", ")}`,
205
+ { status: 500, headers: { "content-type": "text/plain" } }
206
+ );
207
+ }
208
+ try {
209
+ return await enforce(request, path, env.env, opts, deps);
210
+ } catch {
211
+ return new Response("Authentication error. Please try again shortly.", {
212
+ status: 502,
213
+ headers: { "content-type": "text/plain" }
214
+ });
215
+ }
216
+ }
217
+ function sanitizeReturnTo(raw) {
218
+ if (raw && /^\/(?![/\\])/.test(raw)) return raw;
219
+ return "/";
220
+ }
221
+ function wantsHtml(request) {
222
+ if (request.headers.get("sec-fetch-dest") === "document") return true;
223
+ return (request.headers.get("accept") ?? "").includes("text/html");
224
+ }
225
+ function isPublic(path, publicPaths = []) {
226
+ for (const pub of publicPaths) {
227
+ if (pub.endsWith("/*")) {
228
+ if (path.startsWith(pub.slice(0, -1))) return true;
229
+ } else if (path === pub) {
230
+ return true;
231
+ }
232
+ }
233
+ return false;
234
+ }
235
+ async function startSignin(url, returnTo, env, deps, forceLogin = false) {
236
+ const meta = await discover(env.issuer, deps.fetchFn);
237
+ const { verifier, challenge } = await pkcePair();
238
+ const payload = {
239
+ state: randomToken(),
240
+ verifier,
241
+ nonce: randomToken(),
242
+ returnTo: sanitizeReturnTo(returnTo)
243
+ };
244
+ const stateCookie = await seal({ ...payload }, env.sessionSecret, STATE_MAX_AGE);
245
+ const authorizeUrl = buildAuthorizeUrl(meta, {
246
+ clientId: env.clientId,
247
+ redirectUri: `${url.origin}/auth/callback`,
248
+ state: payload.state,
249
+ nonce: payload.nonce,
250
+ challenge,
251
+ // After an explicit sign-out, silent Okta re-auth would sign the user
252
+ // straight back in; prompt=login forces a fresh credential prompt.
253
+ prompt: forceLogin ? "login" : void 0
254
+ });
255
+ return redirect(authorizeUrl, [setCookie(STATE_COOKIE, stateCookie, STATE_MAX_AGE)]);
256
+ }
257
+ async function enforce(request, path, env, opts, deps) {
258
+ const url = new URL(request.url);
259
+ const session = await (async () => {
260
+ const raw = getCookie(request, SESSION_COOKIE);
261
+ return raw ? unseal(raw, env.sessionSecret) : null;
262
+ })();
263
+ const signedOut = getCookie(request, SIGNEDOUT_COOKIE) === "1";
264
+ if (path === "/auth/signin") {
265
+ return startSignin(url, sanitizeReturnTo(url.searchParams.get("returnTo")), env, deps, signedOut);
266
+ }
267
+ if (path === "/auth/callback") {
268
+ return handleCallback(request, url, env, deps);
269
+ }
270
+ if (path === "/auth/signout") {
271
+ return redirect("/", [
272
+ clearCookie(SESSION_COOKIE),
273
+ setCookie(SIGNEDOUT_COOKIE, "1", 60 * 60 * 24 * 30)
274
+ ]);
275
+ }
276
+ if (path === "/auth/me") {
277
+ if (!session) return json({ error: "unauthenticated" }, 401);
278
+ const { sub, name, email } = session;
279
+ return json({ sub, name, email });
280
+ }
281
+ if (isPublic(path, opts.publicPaths)) return void 0;
282
+ if (session) return void 0;
283
+ if (wantsHtml(request)) {
284
+ return startSignin(url, url.pathname + url.search, env, deps, signedOut);
285
+ }
286
+ return json({ error: "unauthenticated", signin: "/auth/signin" }, 401);
287
+ }
288
+ function errorPage(status, title, detail) {
289
+ const html = `<!doctype html><meta charset="utf-8"><title>${title}</title>
290
+ <body style="font-family:system-ui;max-width:32rem;margin:4rem auto;padding:0 1rem">
291
+ <h1 style="font-size:1.25rem">${title}</h1><p>${detail}</p>
292
+ <p><a href="/auth/signin">Try signing in again</a></p></body>`;
293
+ return new Response(html, { status, headers: { "content-type": "text/html; charset=utf-8" } });
294
+ }
295
+ async function handleCallback(request, url, env, deps) {
296
+ const oktaError = url.searchParams.get("error");
297
+ if (oktaError === "access_denied") {
298
+ return errorPage(
299
+ 403,
300
+ "Access denied",
301
+ "You are not assigned to this application in Okta. Ask the Technology team for access."
302
+ );
303
+ }
304
+ if (oktaError) {
305
+ const safeError = /^[a-z0-9_]{1,40}$/i.test(oktaError) ? oktaError : "unknown";
306
+ return errorPage(502, "Sign-in failed", `Okta returned an error (${safeError}).`);
307
+ }
308
+ const rawState = getCookie(request, STATE_COOKIE);
309
+ const st = rawState ? await unseal(rawState, env.sessionSecret) : null;
310
+ const code = url.searchParams.get("code");
311
+ if (!st || !code || url.searchParams.get("state") !== st.state) {
312
+ return errorPage(
313
+ 400,
314
+ "Sign-in expired",
315
+ "The sign-in attempt is invalid or expired. Please try again."
316
+ );
317
+ }
318
+ try {
319
+ const meta = await discover(env.issuer, deps.fetchFn);
320
+ const { idToken } = await exchangeCode(meta, {
321
+ clientId: env.clientId,
322
+ clientSecret: env.clientSecret,
323
+ code,
324
+ redirectUri: `${url.origin}/auth/callback`,
325
+ verifier: st.verifier
326
+ }, deps.fetchFn);
327
+ const { createRemoteJWKSet } = await import("jose");
328
+ const jwks = deps.jwks ?? (remoteJwks ??= createRemoteJWKSet(new URL(meta.jwks_uri)));
329
+ const claims = await validateIdToken(idToken, {
330
+ issuer: env.issuer,
331
+ clientId: env.clientId,
332
+ nonce: st.nonce,
333
+ jwks
334
+ });
335
+ const sessionToken = await seal({ ...claims }, env.sessionSecret, SESSION_MAX_AGE);
336
+ return redirect(st.returnTo, [
337
+ setCookie(SESSION_COOKIE, sessionToken, SESSION_MAX_AGE),
338
+ clearCookie(STATE_COOKIE),
339
+ clearCookie(SIGNEDOUT_COOKIE)
340
+ ]);
341
+ } catch {
342
+ return errorPage(
343
+ 502,
344
+ "Sign-in failed",
345
+ "Could not complete sign-in with Okta. Try again; if it persists, contact the Technology team."
346
+ );
347
+ }
348
+ }
349
+
350
+ // src/index.ts
351
+ function createGate(opts = {}) {
352
+ return async (request) => {
353
+ return await handleRequest(request, opts) ?? (0, import_middleware.next)();
354
+ };
355
+ }
356
+ // Annotate the CommonJS export names for ESM import in node:
357
+ 0 && (module.exports = {
358
+ DEV_USER,
359
+ createGate
360
+ });
@@ -0,0 +1,12 @@
1
+ interface GateOptions {
2
+ publicPaths?: string[];
3
+ }
4
+ declare const DEV_USER: {
5
+ sub: string;
6
+ name: string;
7
+ email: string;
8
+ };
9
+
10
+ declare function createGate(opts?: GateOptions): (request: Request) => Promise<Response>;
11
+
12
+ export { DEV_USER, type GateOptions, createGate };
@@ -0,0 +1,12 @@
1
+ interface GateOptions {
2
+ publicPaths?: string[];
3
+ }
4
+ declare const DEV_USER: {
5
+ sub: string;
6
+ name: string;
7
+ email: string;
8
+ };
9
+
10
+ declare function createGate(opts?: GateOptions): (request: Request) => Promise<Response>;
11
+
12
+ export { DEV_USER, type GateOptions, createGate };
package/dist/index.js ADDED
@@ -0,0 +1,324 @@
1
+ // src/index.ts
2
+ import { next } from "@vercel/functions/middleware";
3
+
4
+ // src/config.ts
5
+ function loadEnv(vars = process.env) {
6
+ const enforcing = vars.VERCEL_ENV === "production" && vars.AUTH_BYPASS !== "true";
7
+ if (!enforcing) return { mode: "open" };
8
+ const missing = [];
9
+ if (!vars.OKTA_CLIENT_ID) missing.push("OKTA_CLIENT_ID");
10
+ if (!vars.OKTA_CLIENT_SECRET) missing.push("OKTA_CLIENT_SECRET");
11
+ if (!vars.OKTA_ISSUER) missing.push("OKTA_ISSUER");
12
+ if (!vars.SESSION_SECRET) missing.push("SESSION_SECRET");
13
+ else if (vars.SESSION_SECRET.length < 32) missing.push("SESSION_SECRET (must be at least 32 chars)");
14
+ if (missing.length > 0) return { mode: "misconfigured", missing };
15
+ return {
16
+ mode: "enforce",
17
+ env: {
18
+ clientId: vars.OKTA_CLIENT_ID,
19
+ clientSecret: vars.OKTA_CLIENT_SECRET,
20
+ issuer: normalizeIssuer(vars.OKTA_ISSUER),
21
+ sessionSecret: vars.SESSION_SECRET
22
+ }
23
+ };
24
+ }
25
+ function normalizeIssuer(raw) {
26
+ const withScheme = /^https?:\/\//.test(raw) ? raw : `https://${raw}`;
27
+ return withScheme.replace(/\/+$/, "");
28
+ }
29
+
30
+ // src/cookies.ts
31
+ var SESSION_COOKIE = "__Host-sg_session";
32
+ var STATE_COOKIE = "__Host-sg_state";
33
+ var SIGNEDOUT_COOKIE = "__Host-sg_signedout";
34
+ var ATTRS = "Path=/; HttpOnly; Secure; SameSite=Lax";
35
+ function getCookie(request, name) {
36
+ const header = request.headers.get("cookie");
37
+ if (!header) return void 0;
38
+ for (const part of header.split(";")) {
39
+ const eq = part.indexOf("=");
40
+ if (eq === -1) continue;
41
+ if (part.slice(0, eq).trim() === name) return part.slice(eq + 1).trim();
42
+ }
43
+ return void 0;
44
+ }
45
+ function setCookie(name, value, maxAgeSec) {
46
+ return `${name}=${value}; ${ATTRS}; Max-Age=${maxAgeSec}`;
47
+ }
48
+ function clearCookie(name) {
49
+ return `${name}=; ${ATTRS}; Max-Age=0`;
50
+ }
51
+
52
+ // src/session.ts
53
+ import { createHash } from "crypto";
54
+ import { EncryptJWT, jwtDecrypt } from "jose";
55
+ var SESSION_MAX_AGE = 43200;
56
+ var STATE_MAX_AGE = 600;
57
+ function keyOf(secret) {
58
+ return new Uint8Array(createHash("sha256").update(secret).digest());
59
+ }
60
+ async function seal(claims, secret, maxAgeSec) {
61
+ const now = Math.floor(Date.now() / 1e3);
62
+ return new EncryptJWT(claims).setProtectedHeader({ alg: "dir", enc: "A256GCM" }).setIssuedAt(now).setExpirationTime(now + maxAgeSec).encrypt(keyOf(secret));
63
+ }
64
+ async function unseal(token, secret) {
65
+ try {
66
+ const { payload } = await jwtDecrypt(token, keyOf(secret));
67
+ return payload;
68
+ } catch {
69
+ return null;
70
+ }
71
+ }
72
+
73
+ // src/oidc.ts
74
+ import { createHash as createHash2, randomBytes } from "crypto";
75
+ import { jwtVerify } from "jose";
76
+ var discoveryCache = /* @__PURE__ */ new Map();
77
+ function discover(issuer, fetchFn = fetch) {
78
+ let cached = discoveryCache.get(issuer);
79
+ if (!cached) {
80
+ cached = (async () => {
81
+ const res = await fetchFn(`${issuer}/.well-known/openid-configuration`);
82
+ if (!res.ok) throw new Error(`OIDC discovery failed (${res.status}) for issuer`);
83
+ return await res.json();
84
+ })();
85
+ cached.catch(() => discoveryCache.delete(issuer));
86
+ discoveryCache.set(issuer, cached);
87
+ }
88
+ return cached;
89
+ }
90
+ function randomToken() {
91
+ return randomBytes(32).toString("base64url");
92
+ }
93
+ async function pkcePair() {
94
+ const verifier = randomToken();
95
+ const challenge = createHash2("sha256").update(verifier).digest("base64url");
96
+ return { verifier, challenge };
97
+ }
98
+ function buildAuthorizeUrl(meta, p) {
99
+ const url = new URL(meta.authorization_endpoint);
100
+ url.search = new URLSearchParams({
101
+ client_id: p.clientId,
102
+ response_type: "code",
103
+ scope: "openid profile email",
104
+ redirect_uri: p.redirectUri,
105
+ state: p.state,
106
+ nonce: p.nonce,
107
+ code_challenge: p.challenge,
108
+ code_challenge_method: "S256",
109
+ ...p.prompt ? { prompt: p.prompt } : {}
110
+ }).toString();
111
+ return url.toString();
112
+ }
113
+ async function exchangeCode(meta, p, fetchFn = fetch) {
114
+ const res = await fetchFn(meta.token_endpoint, {
115
+ method: "POST",
116
+ headers: {
117
+ "content-type": "application/x-www-form-urlencoded",
118
+ authorization: `Basic ${Buffer.from(`${p.clientId}:${p.clientSecret}`).toString("base64")}`
119
+ },
120
+ body: new URLSearchParams({
121
+ grant_type: "authorization_code",
122
+ code: p.code,
123
+ redirect_uri: p.redirectUri,
124
+ code_verifier: p.verifier
125
+ }).toString()
126
+ });
127
+ if (!res.ok) throw new Error(`Token exchange failed (${res.status})`);
128
+ const json2 = await res.json();
129
+ if (!json2.id_token) throw new Error("Token exchange failed (no id_token in response)");
130
+ return { idToken: json2.id_token };
131
+ }
132
+ async function validateIdToken(idToken, p) {
133
+ const { payload } = await jwtVerify(idToken, p.jwks, {
134
+ issuer: p.issuer,
135
+ audience: p.clientId
136
+ });
137
+ if (payload.nonce !== p.nonce) throw new Error("ID token nonce mismatch");
138
+ return {
139
+ sub: String(payload.sub ?? ""),
140
+ name: String(payload.name ?? payload.email ?? "Unknown"),
141
+ email: String(payload.email ?? "")
142
+ };
143
+ }
144
+
145
+ // src/gate.ts
146
+ var remoteJwks;
147
+ var DEV_USER = { sub: "dev-user", name: "Dev User", email: "dev@sonance.com" };
148
+ function json(body, status = 200, headers = {}) {
149
+ return new Response(JSON.stringify(body), {
150
+ status,
151
+ headers: { "content-type": "application/json", "cache-control": "no-store", ...headers }
152
+ });
153
+ }
154
+ function redirect(location, cookies = []) {
155
+ const headers = new Headers({ location });
156
+ for (const c of cookies) headers.append("set-cookie", c);
157
+ return new Response(null, { status: 302, headers });
158
+ }
159
+ async function handleRequest(request, opts = {}, vars = process.env, deps = {}) {
160
+ const env = loadEnv(vars);
161
+ const path = new URL(request.url).pathname;
162
+ if (env.mode === "open") {
163
+ if (path === "/auth/me") return json(DEV_USER);
164
+ if (path === "/auth/signin" || path === "/auth/signout") return redirect("/");
165
+ return void 0;
166
+ }
167
+ if (env.mode === "misconfigured") {
168
+ return new Response(
169
+ `Authentication misconfigured. Missing environment variables: ${env.missing.join(", ")}`,
170
+ { status: 500, headers: { "content-type": "text/plain" } }
171
+ );
172
+ }
173
+ try {
174
+ return await enforce(request, path, env.env, opts, deps);
175
+ } catch {
176
+ return new Response("Authentication error. Please try again shortly.", {
177
+ status: 502,
178
+ headers: { "content-type": "text/plain" }
179
+ });
180
+ }
181
+ }
182
+ function sanitizeReturnTo(raw) {
183
+ if (raw && /^\/(?![/\\])/.test(raw)) return raw;
184
+ return "/";
185
+ }
186
+ function wantsHtml(request) {
187
+ if (request.headers.get("sec-fetch-dest") === "document") return true;
188
+ return (request.headers.get("accept") ?? "").includes("text/html");
189
+ }
190
+ function isPublic(path, publicPaths = []) {
191
+ for (const pub of publicPaths) {
192
+ if (pub.endsWith("/*")) {
193
+ if (path.startsWith(pub.slice(0, -1))) return true;
194
+ } else if (path === pub) {
195
+ return true;
196
+ }
197
+ }
198
+ return false;
199
+ }
200
+ async function startSignin(url, returnTo, env, deps, forceLogin = false) {
201
+ const meta = await discover(env.issuer, deps.fetchFn);
202
+ const { verifier, challenge } = await pkcePair();
203
+ const payload = {
204
+ state: randomToken(),
205
+ verifier,
206
+ nonce: randomToken(),
207
+ returnTo: sanitizeReturnTo(returnTo)
208
+ };
209
+ const stateCookie = await seal({ ...payload }, env.sessionSecret, STATE_MAX_AGE);
210
+ const authorizeUrl = buildAuthorizeUrl(meta, {
211
+ clientId: env.clientId,
212
+ redirectUri: `${url.origin}/auth/callback`,
213
+ state: payload.state,
214
+ nonce: payload.nonce,
215
+ challenge,
216
+ // After an explicit sign-out, silent Okta re-auth would sign the user
217
+ // straight back in; prompt=login forces a fresh credential prompt.
218
+ prompt: forceLogin ? "login" : void 0
219
+ });
220
+ return redirect(authorizeUrl, [setCookie(STATE_COOKIE, stateCookie, STATE_MAX_AGE)]);
221
+ }
222
+ async function enforce(request, path, env, opts, deps) {
223
+ const url = new URL(request.url);
224
+ const session = await (async () => {
225
+ const raw = getCookie(request, SESSION_COOKIE);
226
+ return raw ? unseal(raw, env.sessionSecret) : null;
227
+ })();
228
+ const signedOut = getCookie(request, SIGNEDOUT_COOKIE) === "1";
229
+ if (path === "/auth/signin") {
230
+ return startSignin(url, sanitizeReturnTo(url.searchParams.get("returnTo")), env, deps, signedOut);
231
+ }
232
+ if (path === "/auth/callback") {
233
+ return handleCallback(request, url, env, deps);
234
+ }
235
+ if (path === "/auth/signout") {
236
+ return redirect("/", [
237
+ clearCookie(SESSION_COOKIE),
238
+ setCookie(SIGNEDOUT_COOKIE, "1", 60 * 60 * 24 * 30)
239
+ ]);
240
+ }
241
+ if (path === "/auth/me") {
242
+ if (!session) return json({ error: "unauthenticated" }, 401);
243
+ const { sub, name, email } = session;
244
+ return json({ sub, name, email });
245
+ }
246
+ if (isPublic(path, opts.publicPaths)) return void 0;
247
+ if (session) return void 0;
248
+ if (wantsHtml(request)) {
249
+ return startSignin(url, url.pathname + url.search, env, deps, signedOut);
250
+ }
251
+ return json({ error: "unauthenticated", signin: "/auth/signin" }, 401);
252
+ }
253
+ function errorPage(status, title, detail) {
254
+ const html = `<!doctype html><meta charset="utf-8"><title>${title}</title>
255
+ <body style="font-family:system-ui;max-width:32rem;margin:4rem auto;padding:0 1rem">
256
+ <h1 style="font-size:1.25rem">${title}</h1><p>${detail}</p>
257
+ <p><a href="/auth/signin">Try signing in again</a></p></body>`;
258
+ return new Response(html, { status, headers: { "content-type": "text/html; charset=utf-8" } });
259
+ }
260
+ async function handleCallback(request, url, env, deps) {
261
+ const oktaError = url.searchParams.get("error");
262
+ if (oktaError === "access_denied") {
263
+ return errorPage(
264
+ 403,
265
+ "Access denied",
266
+ "You are not assigned to this application in Okta. Ask the Technology team for access."
267
+ );
268
+ }
269
+ if (oktaError) {
270
+ const safeError = /^[a-z0-9_]{1,40}$/i.test(oktaError) ? oktaError : "unknown";
271
+ return errorPage(502, "Sign-in failed", `Okta returned an error (${safeError}).`);
272
+ }
273
+ const rawState = getCookie(request, STATE_COOKIE);
274
+ const st = rawState ? await unseal(rawState, env.sessionSecret) : null;
275
+ const code = url.searchParams.get("code");
276
+ if (!st || !code || url.searchParams.get("state") !== st.state) {
277
+ return errorPage(
278
+ 400,
279
+ "Sign-in expired",
280
+ "The sign-in attempt is invalid or expired. Please try again."
281
+ );
282
+ }
283
+ try {
284
+ const meta = await discover(env.issuer, deps.fetchFn);
285
+ const { idToken } = await exchangeCode(meta, {
286
+ clientId: env.clientId,
287
+ clientSecret: env.clientSecret,
288
+ code,
289
+ redirectUri: `${url.origin}/auth/callback`,
290
+ verifier: st.verifier
291
+ }, deps.fetchFn);
292
+ const { createRemoteJWKSet } = await import("jose");
293
+ const jwks = deps.jwks ?? (remoteJwks ??= createRemoteJWKSet(new URL(meta.jwks_uri)));
294
+ const claims = await validateIdToken(idToken, {
295
+ issuer: env.issuer,
296
+ clientId: env.clientId,
297
+ nonce: st.nonce,
298
+ jwks
299
+ });
300
+ const sessionToken = await seal({ ...claims }, env.sessionSecret, SESSION_MAX_AGE);
301
+ return redirect(st.returnTo, [
302
+ setCookie(SESSION_COOKIE, sessionToken, SESSION_MAX_AGE),
303
+ clearCookie(STATE_COOKIE),
304
+ clearCookie(SIGNEDOUT_COOKIE)
305
+ ]);
306
+ } catch {
307
+ return errorPage(
308
+ 502,
309
+ "Sign-in failed",
310
+ "Could not complete sign-in with Okta. Try again; if it persists, contact the Technology team."
311
+ );
312
+ }
313
+ }
314
+
315
+ // src/index.ts
316
+ function createGate(opts = {}) {
317
+ return async (request) => {
318
+ return await handleRequest(request, opts) ?? next();
319
+ };
320
+ }
321
+ export {
322
+ DEV_USER,
323
+ createGate
324
+ };
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@danainnovations/sonance-gate",
3
+ "version": "0.1.0",
4
+ "description": "Framework-agnostic Sonance Okta SSO gate for Vercel apps (Routing Middleware)",
5
+ "type": "module",
6
+ "main": "./dist/index.cjs",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js",
12
+ "require": "./dist/index.cjs"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist"
17
+ ],
18
+ "publishConfig": {
19
+ "access": "public"
20
+ },
21
+ "engines": {
22
+ "node": ">=20"
23
+ },
24
+ "scripts": {
25
+ "build": "tsup",
26
+ "test": "vitest run",
27
+ "test:watch": "vitest",
28
+ "typecheck": "tsc --noEmit",
29
+ "prepublishOnly": "npm run build && npm test"
30
+ },
31
+ "dependencies": {
32
+ "@vercel/functions": "^3.7.5",
33
+ "jose": "^5.9.6"
34
+ },
35
+ "devDependencies": {
36
+ "@types/node": "^26.1.0",
37
+ "tsup": "^8.3.5",
38
+ "typescript": "^5.7.2",
39
+ "vitest": "^2.1.8"
40
+ },
41
+ "license": "MIT"
42
+ }