@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 +63 -0
- package/dist/index.cjs +360 -0
- package/dist/index.d.cts +12 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +324 -0
- package/package.json +42 -0
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
|
+
});
|
package/dist/index.d.cts
ADDED
|
@@ -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.d.ts
ADDED
|
@@ -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
|
+
}
|