tina4-nodejs 3.13.103 → 3.13.104

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.
@@ -64,6 +64,12 @@ export function enforceRouteAuth(
64
64
 
65
65
  // Priority 3: Session token
66
66
  if (!resolvedToken) {
67
+ const sso = (req as any).session?.get?.("_tina4_sso") as Record<string, any> | undefined;
68
+ const identity = sso?.identity;
69
+ if (identity?.issuer && identity?.subject) {
70
+ req.user = identity;
71
+ return false;
72
+ }
67
73
  const sessionToken = (req as any).session?.get?.("token") as string | undefined;
68
74
  if (sessionToken && validToken(sessionToken)) {
69
75
  resolvedToken = sessionToken;
@@ -107,6 +107,8 @@ export { HtmlElement, htmlElement, addHtmlHelpers, Raw, SafeString } from "./htm
107
107
  export { renderErrorOverlay, isDebugMode } from "./errorOverlay.js";
108
108
  export { AI_TOOLS, isInstalled, showMenu, installSelected, installAll, generateContext } from "./ai.js";
109
109
  export type { AiTool } from "./ai.js";
110
+ export { Sso, SSO, SsoError } from "./sso.js";
111
+ export type { SsoOptions } from "./sso.js";
110
112
  export { Ai, AiError, AiConfigError, AiHTTPError, AiTimeoutError, AiParseError } from "./aiClient.js";
111
113
  export type { ChatResponse, AiMessage, AiChatOptions, AiEmbedOptions } from "./aiClient.js";
112
114
  export type { ImapMessage, ImapFullMessage, ImapAttachment } from "./messenger.js";
@@ -2000,6 +2000,11 @@ ${reset}
2000
2000
  console.log(`\n No routes directory found at ${routesDir}`);
2001
2001
  }
2002
2002
 
2003
+ // Configuration-first OIDC mounts after app discovery so canonical-path
2004
+ // collisions fail loudly rather than being overwritten.
2005
+ const { Sso } = await import("./sso.js");
2006
+ await Sso.mountConfigured(router);
2007
+
2003
2008
  // Auto-attach CSRF when TINA4_CSRF is enabled — AFTER route discovery, BEFORE
2004
2009
  // listen. OFF by default: unset means no CSRF gate; TINA4_CSRF=true/1/yes/on
2005
2010
  // attaches CsrfMiddleware globally so every write is gated (CSRF-DEC-02).
@@ -0,0 +1,285 @@
1
+ /** Provider-neutral, configuration-first OpenID Connect SSO. */
2
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
3
+ import type { Router } from "./router.js";
4
+
5
+ export class SsoError extends Error {}
6
+
7
+ type Json = Record<string, any>;
8
+ type SessionLike = {
9
+ get(key: string): unknown;
10
+ set(key: string, value: unknown): void;
11
+ delete(key: string): void;
12
+ regenerate(): string;
13
+ destroy(): void;
14
+ };
15
+
16
+ export interface SsoOptions {
17
+ issuer?: string;
18
+ clientId?: string;
19
+ clientSecret?: string;
20
+ redirectUri?: string;
21
+ scopes?: string[];
22
+ verify?: "introspection" | "jwks";
23
+ postLogoutRedirectUri?: string;
24
+ claimMap?: Record<string, string>;
25
+ timeout?: number;
26
+ }
27
+
28
+ export class Sso {
29
+ static readonly PENDING_KEY = "_tina4_sso_pending";
30
+ static readonly SESSION_KEY = "_tina4_sso";
31
+ readonly issuer: string;
32
+ readonly clientId: string;
33
+ readonly clientSecret?: string;
34
+ readonly redirectUri: string;
35
+ readonly scopes: string[];
36
+ readonly verify: "introspection" | "jwks";
37
+ readonly postLogoutRedirectUri?: string;
38
+ readonly claimMap: Record<string, string>;
39
+ readonly timeout: number;
40
+ private metadata: Json = {};
41
+ private static mountedRouters = new WeakSet<Router>();
42
+
43
+ constructor(options: SsoOptions = {}) {
44
+ this.issuer = (options.issuer ?? process.env.TINA4_SSO_ISSUER ?? "").replace(/\/$/, "");
45
+ this.clientId = options.clientId ?? process.env.TINA4_SSO_CLIENT_ID ?? "";
46
+ this.clientSecret = options.clientSecret ?? process.env.TINA4_SSO_CLIENT_SECRET;
47
+ this.redirectUri = options.redirectUri ?? process.env.TINA4_SSO_REDIRECT_URI ?? "";
48
+ this.scopes = options.scopes ?? this.jsonEnv("TINA4_SSO_SCOPES", ["openid", "profile", "email"]);
49
+ this.verify = options.verify ?? (process.env.TINA4_SSO_VERIFY as any) ?? "introspection";
50
+ this.postLogoutRedirectUri = options.postLogoutRedirectUri ?? process.env.TINA4_SSO_POST_LOGOUT_REDIRECT_URI;
51
+ this.claimMap = options.claimMap ?? this.jsonEnv("TINA4_SSO_CLAIM_MAP", {});
52
+ this.timeout = options.timeout ?? 10_000;
53
+ this.validateConfig();
54
+ }
55
+
56
+ static async fromIssuer(options: SsoOptions = {}): Promise<Sso> {
57
+ const value = new Sso(options);
58
+ await value.discover();
59
+ return value;
60
+ }
61
+
62
+ static configured(): boolean {
63
+ return ["TINA4_SSO_ISSUER", "TINA4_SSO_CLIENT_ID", "TINA4_SSO_REDIRECT_URI"]
64
+ .every((key) => Boolean(process.env[key]));
65
+ }
66
+
67
+ private jsonEnv<T>(name: string, fallback: T): T {
68
+ const raw = process.env[name];
69
+ if (!raw) return fallback;
70
+ try { return JSON.parse(raw) as T; }
71
+ catch { throw new SsoError(`${name} must be valid JSON`); }
72
+ }
73
+
74
+ private static secureUrl(value: string, name: string): void {
75
+ let url: URL;
76
+ try { url = new URL(value); }
77
+ catch { throw new SsoError(`${name} must be an absolute URL`); }
78
+ const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
79
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) {
80
+ throw new SsoError(`${name} must use HTTPS except on loopback`);
81
+ }
82
+ }
83
+
84
+ private validateConfig(): void {
85
+ if (!this.issuer || !this.clientId || !this.redirectUri) {
86
+ throw new SsoError("TINA4_SSO_ISSUER, TINA4_SSO_CLIENT_ID and TINA4_SSO_REDIRECT_URI are required");
87
+ }
88
+ Sso.secureUrl(this.issuer, "issuer");
89
+ Sso.secureUrl(this.redirectUri, "redirect URI");
90
+ if (!["introspection", "jwks"].includes(this.verify)) throw new SsoError("TINA4_SSO_VERIFY must be introspection or jwks");
91
+ if (this.verify === "jwks") throw new SsoError("jwks verification requires an installed cryptography capability");
92
+ if (this.verify === "introspection" && !this.clientSecret) throw new SsoError("introspection verification requires TINA4_SSO_CLIENT_SECRET");
93
+ if (!Array.isArray(this.scopes) || !this.scopes.includes("openid")) throw new SsoError("TINA4_SSO_SCOPES must be a list containing openid");
94
+ }
95
+
96
+ private async requestJson(url: string, form?: Json, bearer?: string, basic = false): Promise<Json> {
97
+ const headers: Record<string, string> = { Accept: "application/json" };
98
+ let body: string | undefined;
99
+ if (form) {
100
+ const parameters = new URLSearchParams();
101
+ for (const [key, value] of Object.entries(form)) parameters.set(key, String(value));
102
+ body = parameters.toString();
103
+ headers["Content-Type"] = "application/x-www-form-urlencoded";
104
+ }
105
+ if (bearer) headers.Authorization = `Bearer ${bearer}`;
106
+ if (basic) headers.Authorization = `Basic ${Buffer.from(`${this.clientId}:${this.clientSecret}`).toString("base64")}`;
107
+ const controller = new AbortController();
108
+ const timer = setTimeout(() => controller.abort(), this.timeout);
109
+ try {
110
+ const response = await fetch(url, { method: form ? "POST" : "GET", headers, body, signal: controller.signal });
111
+ if (!response.ok) throw new SsoError("OIDC provider request failed");
112
+ const result = await response.json();
113
+ if (!result || typeof result !== "object" || Array.isArray(result)) throw new SsoError("OIDC provider returned a non-object response");
114
+ return result as Json;
115
+ } catch (error) {
116
+ if (error instanceof SsoError) throw error;
117
+ throw new SsoError("OIDC provider request failed");
118
+ } finally { clearTimeout(timer); }
119
+ }
120
+
121
+ async discover(force = false): Promise<Json> {
122
+ if (Object.keys(this.metadata).length && !force) return { ...this.metadata };
123
+ const result = await this.requestJson(`${this.issuer}/.well-known/openid-configuration`);
124
+ if (result.issuer !== this.issuer) throw new SsoError("OIDC discovery issuer does not exactly match configuration");
125
+ const required = ["authorization_endpoint", "token_endpoint"];
126
+ if (this.verify === "introspection") required.push("introspection_endpoint");
127
+ for (const key of required) {
128
+ if (!result[key]) throw new SsoError(`OIDC discovery is missing ${key}`);
129
+ Sso.secureUrl(result[key], key);
130
+ }
131
+ this.metadata = result;
132
+ return { ...result };
133
+ }
134
+
135
+ static safeReturn(value?: string): string {
136
+ if (!value || !value.startsWith("/") || value.startsWith("//") || value.includes("\\")) return "/";
137
+ return [...value].some((char) => char.charCodeAt(0) < 32) ? "/" : value;
138
+ }
139
+
140
+ private session(value: any): SessionLike | null { return (value?.session ?? value) as SessionLike | null; }
141
+
142
+ async login(requestOrSession: any, returnTo = "/"): Promise<string> {
143
+ const session = this.session(requestOrSession);
144
+ if (!session) throw new SsoError("SSO login requires a Tina4 Session");
145
+ const state = randomBytes(32).toString("base64url");
146
+ const nonce = randomBytes(32).toString("base64url");
147
+ const verifier = randomBytes(64).toString("base64url");
148
+ const challenge = createHash("sha256").update(verifier).digest("base64url");
149
+ session.set(Sso.PENDING_KEY, { state, nonce, verifier, return_to: Sso.safeReturn(returnTo), created_at: Math.floor(Date.now() / 1000) });
150
+ const metadata = await this.discover();
151
+ const query = new URLSearchParams({
152
+ client_id: this.clientId, redirect_uri: this.redirectUri, response_type: "code",
153
+ scope: this.scopes.join(" "), state, nonce, code_challenge: challenge, code_challenge_method: "S256",
154
+ });
155
+ return `${metadata.authorization_endpoint}?${query}`;
156
+ }
157
+
158
+ private static equal(left: unknown, right: unknown): boolean {
159
+ const a = Buffer.from(String(left ?? "")); const b = Buffer.from(String(right ?? ""));
160
+ return a.length === b.length && timingSafeEqual(a, b);
161
+ }
162
+
163
+ private static jwtPayload(token: string): Json {
164
+ try { return JSON.parse(Buffer.from(token.split(".")[1], "base64url").toString()); }
165
+ catch { throw new SsoError("provider returned an invalid ID token"); }
166
+ }
167
+
168
+ private async introspect(accessToken: string): Promise<Json> {
169
+ const metadata = await this.discover();
170
+ const result = await this.requestJson(metadata.introspection_endpoint, { token: accessToken, token_type_hint: "access_token" }, undefined, true);
171
+ if (result.active !== true || result.iss !== this.issuer) throw new SsoError("OIDC access token is inactive or has the wrong issuer");
172
+ const audience = result.aud ?? result.client_id;
173
+ const valid = (Array.isArray(audience) ? audience.includes(this.clientId) : audience === this.clientId) || result.client_id === this.clientId;
174
+ if (!valid) throw new SsoError("OIDC token audience mismatch");
175
+ return result;
176
+ }
177
+
178
+ private claim(claims: Json, configured: string | undefined, fallback: string): any {
179
+ let value: any = claims;
180
+ for (const part of (configured ?? fallback).split(".")) value = value && typeof value === "object" ? value[part] : undefined;
181
+ return value;
182
+ }
183
+
184
+ private normalize(claims: Json): Json {
185
+ const subject = this.claim(claims, this.claimMap.subject, "sub");
186
+ const issuer = this.claim(claims, this.claimMap.issuer, "iss") ?? this.issuer;
187
+ if (!subject || issuer !== this.issuer) throw new SsoError("OIDC identity is missing a valid issuer or subject");
188
+ const roles = [...(this.claim(claims, this.claimMap.roles, "realm_access.roles") ?? []), ...(claims.resource_access?.[this.clientId]?.roles ?? [])];
189
+ const groups = this.claim(claims, this.claimMap.groups, "groups") ?? [];
190
+ return {
191
+ issuer, subject,
192
+ username: this.claim(claims, this.claimMap.username, "preferred_username") ?? null,
193
+ email: this.claim(claims, this.claimMap.email, "email") ?? null,
194
+ name: this.claim(claims, this.claimMap.name, "name") ?? null,
195
+ roles: [...new Set(roles.map(String))].sort(), groups: [...new Set(groups.map(String))].sort(),
196
+ };
197
+ }
198
+
199
+ async callback(requestOrSession: any, query?: Json): Promise<{ identity: Json; return_to: string }> {
200
+ const session = this.session(requestOrSession);
201
+ const values = query ?? requestOrSession?.query ?? {};
202
+ const pending = session?.get(Sso.PENDING_KEY) as Json | undefined;
203
+ session?.delete(Sso.PENDING_KEY);
204
+ if (!pending || !values.code || !Sso.equal(values.state, pending.state)) throw new SsoError("OIDC callback state is invalid or already consumed");
205
+ if (Math.floor(Date.now() / 1000) - Number(pending.created_at ?? 0) > 600) throw new SsoError("OIDC callback state has expired");
206
+ const metadata = await this.discover();
207
+ const tokens = await this.requestJson(metadata.token_endpoint, {
208
+ grant_type: "authorization_code", code: values.code, redirect_uri: this.redirectUri,
209
+ client_id: this.clientId, code_verifier: pending.verifier,
210
+ }, undefined, Boolean(this.clientSecret));
211
+ if (!tokens.access_token || !tokens.id_token) throw new SsoError("OIDC token response is incomplete");
212
+ if (this.verify === "jwks") throw new SsoError("JWKS verification requires an installed cryptography capability");
213
+ const claims = await this.introspect(tokens.access_token);
214
+ if (!Sso.equal(Sso.jwtPayload(tokens.id_token).nonce, pending.nonce)) throw new SsoError("OIDC ID token nonce mismatch");
215
+ if (metadata.userinfo_endpoint) Object.assign(claims, await this.requestJson(metadata.userinfo_endpoint, undefined, tokens.access_token));
216
+ const identity = this.normalize(claims);
217
+ session!.regenerate();
218
+ session!.set(Sso.SESSION_KEY, {
219
+ version: 1, identity, access_token: tokens.access_token, refresh_token: tokens.refresh_token,
220
+ id_token: tokens.id_token, expires_at: Math.floor(Date.now() / 1000) + Number(tokens.expires_in ?? 0),
221
+ });
222
+ return { identity, return_to: Sso.safeReturn(pending.return_to) };
223
+ }
224
+
225
+ identity(requestOrSession: any): Json | null {
226
+ const stored = this.session(requestOrSession)?.get(Sso.SESSION_KEY) as Json | undefined;
227
+ const identity = stored?.identity ?? null;
228
+ if (identity && requestOrSession?.session) requestOrSession.user = identity;
229
+ return identity;
230
+ }
231
+
232
+ async refresh(requestOrSession: any): Promise<Json> {
233
+ const session = this.session(requestOrSession);
234
+ const stored = session?.get(Sso.SESSION_KEY) as Json | undefined;
235
+ if (!stored?.refresh_token) { session?.delete(Sso.SESSION_KEY); throw new SsoError("OIDC session cannot be refreshed"); }
236
+ try {
237
+ const metadata = await this.discover();
238
+ const tokens = await this.requestJson(metadata.token_endpoint, {
239
+ grant_type: "refresh_token", refresh_token: stored.refresh_token, client_id: this.clientId,
240
+ }, undefined, Boolean(this.clientSecret));
241
+ const claims = await this.introspect(tokens.access_token);
242
+ if (metadata.userinfo_endpoint) Object.assign(claims, await this.requestJson(metadata.userinfo_endpoint, undefined, tokens.access_token));
243
+ const identity = this.normalize(claims);
244
+ session!.set(Sso.SESSION_KEY, { ...stored, identity, access_token: tokens.access_token,
245
+ refresh_token: tokens.refresh_token ?? stored.refresh_token, id_token: tokens.id_token ?? stored.id_token,
246
+ expires_at: Math.floor(Date.now() / 1000) + Number(tokens.expires_in ?? 0) });
247
+ return identity;
248
+ } catch (error) { session?.delete(Sso.SESSION_KEY); throw error; }
249
+ }
250
+
251
+ async logout(requestOrSession: any, returnTo = "/"): Promise<string> {
252
+ const session = this.session(requestOrSession);
253
+ const stored = session?.get(Sso.SESSION_KEY) as Json | undefined;
254
+ session?.destroy();
255
+ const endpoint = (await this.discover()).end_session_endpoint;
256
+ const target = this.postLogoutRedirectUri ?? Sso.safeReturn(returnTo);
257
+ if (!endpoint) return target;
258
+ const params = new URLSearchParams({ post_logout_redirect_uri: target, client_id: this.clientId });
259
+ if (stored?.id_token) params.set("id_token_hint", stored.id_token);
260
+ return `${endpoint}?${params}`;
261
+ }
262
+
263
+ static async mountConfigured(router: Router): Promise<boolean> {
264
+ if (Sso.mountedRouters.has(router) || !Sso.configured()) return false;
265
+ const owned = new Set(["GET /auth/login", "GET /auth/callback", "POST /auth/logout"]);
266
+ const collisions = router.getRoutes()
267
+ .map((route) => `${route.method} ${route.pattern}`)
268
+ .filter((route) => owned.has(route));
269
+ if (collisions.length) throw new SsoError(`SSO route collision: ${collisions.join(", ")}`);
270
+ const sso = await Sso.fromIssuer();
271
+ router.get("/auth/login", async (req, res) => res.redirect(await sso.login(req, (req.query as any)?.return_to ?? "/")));
272
+ router.get("/auth/callback", async (req, res) => {
273
+ try { return res.redirect((await sso.callback(req)).return_to); }
274
+ catch (error) {
275
+ const message = error instanceof SsoError ? error.message : "OIDC callback failed";
276
+ return res.error("SSO_CALLBACK_FAILED", message, 400);
277
+ }
278
+ });
279
+ router.post("/auth/logout", async (req, res) => res.redirect(await sso.logout(req, (req.query as any)?.return_to ?? "/")));
280
+ Sso.mountedRouters.add(router);
281
+ return true;
282
+ }
283
+ }
284
+
285
+ export { Sso as SSO };