@baseworks/auth 0.2.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/src/session.ts ADDED
@@ -0,0 +1,538 @@
1
+ // Next.js OIDC session management — PKCE flow, cookie helpers, logout.
2
+ // Requires: next, next/headers, next/server (peer deps).
3
+
4
+ import { cookies } from "next/headers";
5
+ import type { NextRequest } from "next/server";
6
+ import { NextResponse } from "next/server";
7
+ import { getAuthPublicUrl, normalizeUrlLike } from "./url-helpers";
8
+
9
+ type DiscoveryDocument = {
10
+ authorization_endpoint: string;
11
+ end_session_endpoint?: string;
12
+ issuer: string;
13
+ jwks_uri: string;
14
+ token_endpoint: string;
15
+ };
16
+
17
+ type JwtPayload = {
18
+ aud?: string | string[];
19
+ email?: string;
20
+ exp?: number;
21
+ iss?: string;
22
+ name?: string;
23
+ nonce?: string;
24
+ picture?: string;
25
+ sub?: string;
26
+ };
27
+
28
+ type TokenResponse = {
29
+ access_token?: string;
30
+ error?: string;
31
+ error_description?: string;
32
+ expires_in?: number;
33
+ id_token?: string;
34
+ refresh_token?: string;
35
+ token_type?: string;
36
+ };
37
+
38
+ type OidcTransaction = {
39
+ codeVerifier: string;
40
+ nonce: string;
41
+ returnTo: string;
42
+ };
43
+
44
+ export type OidcSession = {
45
+ audience?: string | string[];
46
+ email?: string;
47
+ expiresAt: number;
48
+ isAuthenticated: boolean;
49
+ issuer?: string;
50
+ name?: string;
51
+ pictureUrl?: string;
52
+ subject?: string;
53
+ tokenIdentifier?: string;
54
+ };
55
+
56
+ export const oidcCookies = {
57
+ codeVerifier: "oidc_code_verifier",
58
+ state: "oidc_state",
59
+ nonce: "oidc_nonce",
60
+ returnTo: "oidc_return_to",
61
+ idToken: "oidc_id_token",
62
+ accessToken: "oidc_access_token",
63
+ refreshToken: "oidc_refresh_token",
64
+ expiresAt: "oidc_expires_at",
65
+ session: "oidc_session",
66
+ } as const;
67
+
68
+ function getIssuer() {
69
+ return (process.env.OIDC_ISSUER ?? "https://nesskey.com").replace(/\/+$/, "");
70
+ }
71
+
72
+ function getClientId() {
73
+ const clientId = process.env.OIDC_CLIENT_ID;
74
+ if (!clientId) throw new Error("OIDC_CLIENT_ID is required.");
75
+ return clientId;
76
+ }
77
+
78
+ function getOptionalClientSecret() {
79
+ return process.env.OIDC_CLIENT_SECRET;
80
+ }
81
+
82
+ function getCookieDomain() {
83
+ const domain = process.env.OIDC_COOKIE_DOMAIN?.trim();
84
+ return domain || undefined;
85
+ }
86
+
87
+ function getScope() {
88
+ return process.env.OIDC_SCOPE ?? "openid profile email offline_access";
89
+ }
90
+
91
+ function getPublicAuthBasePath() {
92
+ return normalizeUrlLike(process.env.OIDC_PUBLIC_BASE_PATH ?? getAuthPublicUrl() ?? "/auth");
93
+ }
94
+
95
+ function getRedirectUriOverride() {
96
+ return process.env.OIDC_REDIRECT_URI ?? null;
97
+ }
98
+
99
+ function getDefaultSignedOutRedirect() {
100
+ return process.env.OIDC_DEFAULT_REDIRECT ?? "/";
101
+ }
102
+
103
+ function getPublicSiteUrlFromEnv(request?: NextRequest) {
104
+ const explicit =
105
+ process.env.APP_PUBLIC_URL ??
106
+ process.env.NEXT_PUBLIC_SITE_URL ??
107
+ process.env.OIDC_PUBLIC_SITE_URL;
108
+ if (explicit) return normalizeUrlLike(explicit);
109
+ if (request) return normalizeUrlLike(request.nextUrl.origin);
110
+ return null;
111
+ }
112
+
113
+ function resolvePublicRedirect(target: string, request: NextRequest) {
114
+ if (/^https?:\/\//.test(target)) return new URL(target);
115
+ const publicSiteUrl = getPublicSiteUrlFromEnv(request);
116
+ if (publicSiteUrl) return new URL(target, publicSiteUrl);
117
+ return new URL(target, request.url);
118
+ }
119
+
120
+ function base64UrlEncode(input: ArrayBuffer | Uint8Array | string) {
121
+ const buffer =
122
+ typeof input === "string"
123
+ ? Buffer.from(input, "utf8")
124
+ : Buffer.from(input instanceof Uint8Array ? input : new Uint8Array(input));
125
+ return buffer.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
126
+ }
127
+
128
+ function base64UrlDecode(input: string) {
129
+ const padded = input.replace(/-/g, "+").replace(/_/g, "/");
130
+ const remainder = padded.length % 4;
131
+ const normalized = remainder === 0 ? padded : `${padded}${"=".repeat(4 - remainder)}`;
132
+ return Buffer.from(normalized, "base64").toString("utf8");
133
+ }
134
+
135
+ function sha256(input: string) {
136
+ return crypto.subtle.digest("SHA-256", new TextEncoder().encode(input));
137
+ }
138
+
139
+ function randomString() {
140
+ return base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)));
141
+ }
142
+
143
+ async function createPkcePair() {
144
+ const verifier = randomString();
145
+ const challenge = base64UrlEncode(await sha256(verifier));
146
+ return { challenge, verifier };
147
+ }
148
+
149
+ async function discoverOidc() {
150
+ const issuer = getIssuer();
151
+ const response = await fetch(`${issuer}/.well-known/openid-configuration`);
152
+ if (!response.ok) throw new Error(`Failed to load OIDC discovery document from ${issuer}.`);
153
+ return (await response.json()) as DiscoveryDocument;
154
+ }
155
+
156
+ function parseJwtPayload(token: string): JwtPayload {
157
+ const [, payload = ""] = token.split(".");
158
+ return JSON.parse(base64UrlDecode(payload)) as JwtPayload;
159
+ }
160
+
161
+ function encodeSessionCookie(session: OidcSession) {
162
+ return base64UrlEncode(JSON.stringify(session));
163
+ }
164
+
165
+ function parseSessionCookie(value: string): OidcSession | null {
166
+ try {
167
+ const session = JSON.parse(base64UrlDecode(value)) as OidcSession;
168
+ return session.isAuthenticated ? session : null;
169
+ } catch {
170
+ return null;
171
+ }
172
+ }
173
+
174
+ function encodeTransactionCookie(transaction: OidcTransaction) {
175
+ return base64UrlEncode(JSON.stringify(transaction));
176
+ }
177
+
178
+ function parseTransactionCookie(value: string): OidcTransaction | null {
179
+ try {
180
+ const transaction = JSON.parse(base64UrlDecode(value)) as OidcTransaction;
181
+ if (!transaction.codeVerifier || !transaction.returnTo) return null;
182
+ return transaction;
183
+ } catch {
184
+ return null;
185
+ }
186
+ }
187
+
188
+ function getTransactionCookieName(state: string) {
189
+ return `oidc_tx_${state}`;
190
+ }
191
+
192
+ function getCookieOptions(request: NextRequest) {
193
+ return {
194
+ domain: getCookieDomain(),
195
+ httpOnly: true,
196
+ path: "/",
197
+ sameSite: "lax" as const,
198
+ secure: request.nextUrl.protocol === "https:",
199
+ };
200
+ }
201
+
202
+ function expireCookie(response: NextResponse, request: NextRequest, name: string) {
203
+ response.cookies.set(name, "", { ...getCookieOptions(request), expires: new Date(0) });
204
+ }
205
+
206
+ function setTransactionCookie(
207
+ response: NextResponse,
208
+ request: NextRequest,
209
+ state: string,
210
+ transaction: OidcTransaction,
211
+ ) {
212
+ response.cookies.set(
213
+ getTransactionCookieName(state),
214
+ encodeTransactionCookie(transaction),
215
+ { ...getCookieOptions(request), expires: new Date(Date.now() + 10 * 60 * 1_000) },
216
+ );
217
+ }
218
+
219
+ function getRedirectUri(request: NextRequest) {
220
+ const override = getRedirectUriOverride();
221
+ if (override) return override;
222
+ return new URL(
223
+ `${getPublicAuthBasePath()}/oidc/callback`,
224
+ getPublicSiteUrlFromEnv(request) ?? request.nextUrl.origin,
225
+ ).toString();
226
+ }
227
+
228
+ function appendClientAuthentication(
229
+ headers: Headers,
230
+ params: URLSearchParams,
231
+ clientId: string,
232
+ clientSecret?: string,
233
+ ) {
234
+ if (clientSecret) {
235
+ const basic = Buffer.from(`${clientId}:${clientSecret}`).toString("base64");
236
+ headers.set("authorization", `Basic ${basic}`);
237
+ return;
238
+ }
239
+ params.set("client_id", clientId);
240
+ }
241
+
242
+ async function exchangeAuthorizationCode(args: {
243
+ clientId: string;
244
+ clientSecret?: string;
245
+ code: string;
246
+ codeVerifier: string;
247
+ redirectUri: string;
248
+ tokenEndpoint: string;
249
+ }) {
250
+ const params = new URLSearchParams({
251
+ code: args.code,
252
+ code_verifier: args.codeVerifier,
253
+ grant_type: "authorization_code",
254
+ redirect_uri: args.redirectUri,
255
+ });
256
+ const reqHeaders = new Headers({ "content-type": "application/x-www-form-urlencoded" });
257
+ appendClientAuthentication(reqHeaders, params, args.clientId, args.clientSecret);
258
+ const response = await fetch(args.tokenEndpoint, {
259
+ body: params.toString(),
260
+ headers: reqHeaders,
261
+ method: "POST",
262
+ });
263
+ const json = (await response.json()) as TokenResponse;
264
+ if (!response.ok || !json.id_token) {
265
+ throw new Error(json.error_description ?? "OIDC code exchange failed.");
266
+ }
267
+ return json;
268
+ }
269
+
270
+ async function refreshIdToken(args: {
271
+ clientId: string;
272
+ clientSecret?: string;
273
+ refreshToken: string;
274
+ tokenEndpoint: string;
275
+ }) {
276
+ const params = new URLSearchParams({
277
+ grant_type: "refresh_token",
278
+ refresh_token: args.refreshToken,
279
+ });
280
+ const reqHeaders = new Headers({ "content-type": "application/x-www-form-urlencoded" });
281
+ appendClientAuthentication(reqHeaders, params, args.clientId, args.clientSecret);
282
+ const response = await fetch(args.tokenEndpoint, {
283
+ body: params.toString(),
284
+ headers: reqHeaders,
285
+ method: "POST",
286
+ });
287
+ const json = (await response.json()) as TokenResponse;
288
+ if (!response.ok || !json.id_token) {
289
+ throw new Error(json.error_description ?? "OIDC token refresh failed.");
290
+ }
291
+ return json;
292
+ }
293
+
294
+ function sessionFromPayload(payload: JwtPayload): OidcSession {
295
+ return {
296
+ audience: payload.aud,
297
+ email: payload.email,
298
+ expiresAt: payload.exp ?? 0,
299
+ isAuthenticated: true,
300
+ issuer: payload.iss,
301
+ name: payload.name,
302
+ pictureUrl: payload.picture,
303
+ subject: payload.sub,
304
+ tokenIdentifier:
305
+ payload.sub && payload.iss ? `${payload.sub}|${payload.iss}` : undefined,
306
+ };
307
+ }
308
+
309
+ function applyTokenCookies(response: NextResponse, request: NextRequest, tokens: TokenResponse) {
310
+ const options = getCookieOptions(request);
311
+ const payload = parseJwtPayload(tokens.id_token!);
312
+ const expiresAt =
313
+ payload.exp ??
314
+ (tokens.expires_in ? Math.floor(Date.now() / 1_000) + tokens.expires_in : undefined);
315
+ if (!expiresAt) throw new Error("The OIDC ID token did not include an expiry.");
316
+
317
+ response.cookies.set(oidcCookies.idToken, tokens.id_token!, {
318
+ ...options,
319
+ expires: new Date(expiresAt * 1_000),
320
+ });
321
+ response.cookies.set(oidcCookies.expiresAt, String(expiresAt), {
322
+ ...options,
323
+ expires: new Date(expiresAt * 1_000),
324
+ });
325
+ response.cookies.set(
326
+ oidcCookies.session,
327
+ encodeSessionCookie(sessionFromPayload({ ...payload, exp: expiresAt })),
328
+ { ...options, expires: new Date(expiresAt * 1_000) },
329
+ );
330
+ if (tokens.access_token) {
331
+ response.cookies.set(oidcCookies.accessToken, tokens.access_token, {
332
+ ...options,
333
+ expires: new Date(expiresAt * 1_000),
334
+ });
335
+ }
336
+ if (tokens.refresh_token) {
337
+ response.cookies.set(oidcCookies.refreshToken, tokens.refresh_token, options);
338
+ }
339
+ }
340
+
341
+ function clearTransientCookies(response: NextResponse, request: NextRequest) {
342
+ expireCookie(response, request, oidcCookies.codeVerifier);
343
+ expireCookie(response, request, oidcCookies.state);
344
+ expireCookie(response, request, oidcCookies.nonce);
345
+ }
346
+
347
+ export async function buildAuthorizationRedirect(request: NextRequest) {
348
+ const discovery = await discoverOidc();
349
+ const clientId = getClientId();
350
+ const { challenge, verifier } = await createPkcePair();
351
+ const nonce = randomString();
352
+ const state = randomString();
353
+ const redirectUri = getRedirectUri(request);
354
+ const returnTo =
355
+ request.nextUrl.searchParams.get("redirectTo") ?? getDefaultSignedOutRedirect();
356
+
357
+ const url = new URL(discovery.authorization_endpoint);
358
+ url.searchParams.set("client_id", clientId);
359
+ url.searchParams.set("code_challenge", challenge);
360
+ url.searchParams.set("code_challenge_method", "S256");
361
+ url.searchParams.set("nonce", nonce);
362
+ url.searchParams.set("redirect_uri", redirectUri);
363
+ url.searchParams.set("response_type", "code");
364
+ url.searchParams.set("scope", getScope());
365
+ url.searchParams.set("state", state);
366
+
367
+ const response = NextResponse.redirect(url);
368
+ const options = getCookieOptions(request);
369
+ setTransactionCookie(response, request, state, { codeVerifier: verifier, nonce, returnTo });
370
+ response.cookies.set(oidcCookies.codeVerifier, verifier, options);
371
+ response.cookies.set(oidcCookies.state, state, options);
372
+ response.cookies.set(oidcCookies.nonce, nonce, options);
373
+ response.cookies.set(oidcCookies.returnTo, returnTo, options);
374
+ return response;
375
+ }
376
+
377
+ export async function handleAuthorizationCallback(request: NextRequest) {
378
+ const cookieStore = await cookies();
379
+ const code = request.nextUrl.searchParams.get("code");
380
+ const state = request.nextUrl.searchParams.get("state");
381
+ const transaction = state
382
+ ? parseTransactionCookie(cookieStore.get(getTransactionCookieName(state))?.value ?? "")
383
+ : null;
384
+ const savedState = cookieStore.get(oidcCookies.state)?.value;
385
+ const codeVerifier =
386
+ transaction?.codeVerifier ?? cookieStore.get(oidcCookies.codeVerifier)?.value;
387
+ const savedNonce = transaction?.nonce ?? cookieStore.get(oidcCookies.nonce)?.value;
388
+ const returnTo =
389
+ transaction?.returnTo ??
390
+ cookieStore.get(oidcCookies.returnTo)?.value ??
391
+ getDefaultSignedOutRedirect();
392
+
393
+ if (
394
+ !code ||
395
+ !state ||
396
+ !codeVerifier ||
397
+ (!transaction && (!savedState || state !== savedState))
398
+ ) {
399
+ return NextResponse.redirect(
400
+ resolvePublicRedirect(`${getPublicAuthBasePath()}?error=oidc_state`, request),
401
+ );
402
+ }
403
+
404
+ try {
405
+ const discovery = await discoverOidc();
406
+ const tokens = await exchangeAuthorizationCode({
407
+ clientId: getClientId(),
408
+ clientSecret: getOptionalClientSecret(),
409
+ code,
410
+ codeVerifier,
411
+ redirectUri: getRedirectUri(request),
412
+ tokenEndpoint: discovery.token_endpoint,
413
+ });
414
+ const payload = parseJwtPayload(tokens.id_token!);
415
+ if (savedNonce && payload.nonce && payload.nonce !== savedNonce) {
416
+ throw new Error("OIDC nonce mismatch.");
417
+ }
418
+ const response = NextResponse.redirect(resolvePublicRedirect(returnTo, request));
419
+ applyTokenCookies(response, request, tokens);
420
+ clearTransientCookies(response, request);
421
+ expireCookie(response, request, oidcCookies.returnTo);
422
+ expireCookie(response, request, getTransactionCookieName(state));
423
+ return response;
424
+ } catch {
425
+ return NextResponse.redirect(
426
+ resolvePublicRedirect(`${getPublicAuthBasePath()}?error=oidc_callback`, request),
427
+ );
428
+ }
429
+ }
430
+
431
+ export async function getSessionFromCookies(): Promise<OidcSession> {
432
+ const cookieStore = await cookies();
433
+ const idToken = cookieStore.get(oidcCookies.idToken)?.value;
434
+ if (idToken) {
435
+ try {
436
+ return sessionFromPayload(parseJwtPayload(idToken));
437
+ } catch {
438
+ // fall through to compact session cookie
439
+ }
440
+ }
441
+ const sessionCookie = cookieStore.get(oidcCookies.session)?.value;
442
+ const session = sessionCookie ? parseSessionCookie(sessionCookie) : null;
443
+ return session ?? { expiresAt: 0, isAuthenticated: false };
444
+ }
445
+
446
+ export async function getServerIdToken() {
447
+ const cookieStore = await cookies();
448
+ return cookieStore.get(oidcCookies.idToken)?.value ?? null;
449
+ }
450
+
451
+ export async function hasServerOidcSession() {
452
+ const cookieStore = await cookies();
453
+ return Boolean(
454
+ cookieStore.get(oidcCookies.session)?.value ??
455
+ cookieStore.get(oidcCookies.idToken)?.value ??
456
+ cookieStore.get(oidcCookies.accessToken)?.value,
457
+ );
458
+ }
459
+
460
+ export async function getServerAccessToken() {
461
+ const cookieStore = await cookies();
462
+ const accessToken = cookieStore.get(oidcCookies.accessToken)?.value;
463
+ if (accessToken) return accessToken;
464
+
465
+ const refreshToken = cookieStore.get(oidcCookies.refreshToken)?.value;
466
+ if (!refreshToken) return null;
467
+
468
+ try {
469
+ const discovery = await discoverOidc();
470
+ const tokens = await refreshIdToken({
471
+ clientId: getClientId(),
472
+ clientSecret: getOptionalClientSecret(),
473
+ refreshToken,
474
+ tokenEndpoint: discovery.token_endpoint,
475
+ });
476
+ return tokens.access_token ?? null;
477
+ } catch {
478
+ return null;
479
+ }
480
+ }
481
+
482
+ export async function buildSessionResponse(_request?: NextRequest) {
483
+ const cookieStore = await cookies();
484
+ const idToken = cookieStore.get(oidcCookies.idToken)?.value;
485
+ const expiresAt = Number(cookieStore.get(oidcCookies.expiresAt)?.value ?? "0");
486
+ const now = Math.floor(Date.now() / 1_000);
487
+
488
+ if (!idToken || expiresAt <= now) {
489
+ return NextResponse.json({ expiresAt: 0, isAuthenticated: false } satisfies OidcSession, {
490
+ status: 401,
491
+ });
492
+ }
493
+ return NextResponse.json(sessionFromPayload({ ...parseJwtPayload(idToken), exp: expiresAt }));
494
+ }
495
+
496
+ export async function buildTokenResponse(request: NextRequest) {
497
+ const cookieStore = await cookies();
498
+ const idToken = cookieStore.get(oidcCookies.idToken)?.value;
499
+ const expiresAt = Number(cookieStore.get(oidcCookies.expiresAt)?.value ?? "0");
500
+ const now = Math.floor(Date.now() / 1_000);
501
+
502
+ if (!idToken || expiresAt <= now) {
503
+ const response = NextResponse.json({ token: null }, { status: 401 });
504
+ expireCookie(response, request, oidcCookies.idToken);
505
+ expireCookie(response, request, oidcCookies.refreshToken);
506
+ expireCookie(response, request, oidcCookies.expiresAt);
507
+ return response;
508
+ }
509
+ return NextResponse.json({ token: idToken });
510
+ }
511
+
512
+ export async function buildLogoutResponse(request: NextRequest) {
513
+ const redirectTo =
514
+ request.nextUrl.searchParams.get("redirectTo") ?? getDefaultSignedOutRedirect();
515
+ const postLogoutUrl = resolvePublicRedirect(redirectTo, request).toString();
516
+
517
+ const cookieStore = await cookies();
518
+ const idToken = cookieStore.get(oidcCookies.idToken)?.value;
519
+
520
+ const clearResponse = NextResponse.redirect(postLogoutUrl);
521
+ Object.values(oidcCookies).forEach((name) => expireCookie(clearResponse, request, name));
522
+
523
+ try {
524
+ const discovery = await discoverOidc();
525
+ if (discovery.end_session_endpoint) {
526
+ const endSessionUrl = new URL(discovery.end_session_endpoint);
527
+ endSessionUrl.searchParams.set("post_logout_redirect_uri", postLogoutUrl);
528
+ if (idToken) endSessionUrl.searchParams.set("id_token_hint", idToken);
529
+ return NextResponse.redirect(endSessionUrl.toString(), {
530
+ headers: clearResponse.headers,
531
+ });
532
+ }
533
+ } catch {
534
+ // Discovery failed — local-only logout.
535
+ }
536
+
537
+ return clearResponse;
538
+ }
package/src/token.ts ADDED
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Token hashing utilities — no libsodium dependency, Web Crypto only.
3
+ * Runtime-agnostic: Workers, Node 20+, browser.
4
+ */
5
+
6
+ /** HMAC-SHA-256 hash with optional pepper. Use for API key storage at rest. */
7
+ export async function hashToken(token: string, pepper?: string): Promise<string> {
8
+ const input = pepper ? `${pepper}:${token}` : token;
9
+ const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input));
10
+ return Array.from(new Uint8Array(buf), (b) => b.toString(16).padStart(2, '0')).join('');
11
+ }
12
+
13
+ export function looksLikeJwt(token: string): boolean {
14
+ return token.split('.').length === 3;
15
+ }
16
+
17
+ export function stripBearer(header: string | null | undefined): string | null {
18
+ if (!header?.startsWith('Bearer ')) return null;
19
+ const token = header.slice(7).trim();
20
+ return token || null;
21
+ }
@@ -0,0 +1,66 @@
1
+ export function normalizeUrlLike(value: string) {
2
+ if (value === "/") return "";
3
+ return value.replace(/\/+$/, "");
4
+ }
5
+
6
+ function joinPublicUrl(base: string, path = "") {
7
+ const normalizedBase = normalizeUrlLike(base);
8
+ const normalizedPath = path.startsWith("/") ? path : `/${path}`;
9
+ if (!normalizedBase) return normalizedPath;
10
+ if (/^https?:\/\//.test(normalizedBase)) {
11
+ return new URL(normalizedPath, `${normalizedBase}/`).toString();
12
+ }
13
+ return `${normalizedBase}${normalizedPath}`;
14
+ }
15
+
16
+ export function getAuthPublicUrl() {
17
+ return normalizeUrlLike(process.env.NEXT_PUBLIC_AUTH_URL ?? "/auth");
18
+ }
19
+
20
+ export function getAccountPublicUrl() {
21
+ return normalizeUrlLike(process.env.NEXT_PUBLIC_ACCOUNT_URL ?? "/account");
22
+ }
23
+
24
+ export function getAppBasePath(defaultPath = "") {
25
+ return normalizeUrlLike(
26
+ process.env.NEXT_PUBLIC_APP_BASE_PATH ?? process.env.APP_BASE_PATH ?? defaultPath,
27
+ );
28
+ }
29
+
30
+ export function getPublicSiteUrl() {
31
+ return normalizeUrlLike(process.env.APP_PUBLIC_URL ?? process.env.NEXT_PUBLIC_SITE_URL ?? "");
32
+ }
33
+
34
+ export function buildPublicUrl(pathOrUrl: string, fallbackOrigin?: string) {
35
+ if (/^https?:\/\//.test(pathOrUrl)) return pathOrUrl;
36
+ const origin = getPublicSiteUrl() || fallbackOrigin;
37
+ if (!origin) return pathOrUrl;
38
+ return new URL(pathOrUrl, `${normalizeUrlLike(origin)}/`).toString();
39
+ }
40
+
41
+ function buildAuthUrl(path: string, redirectTo: string, origin?: string) {
42
+ const target = joinPublicUrl(getAuthPublicUrl(), path);
43
+ const targetIsAbsolute = /^https?:\/\//.test(target);
44
+ const fallbackOrigin = "http://localhost";
45
+ const url = new URL(target, origin ?? fallbackOrigin);
46
+ url.searchParams.set("redirectTo", redirectTo);
47
+ const href = url.toString();
48
+ if (origin || targetIsAbsolute) return href;
49
+ return href.replace(fallbackOrigin, "");
50
+ }
51
+
52
+ export function buildAuthLoginUrl(redirectTo: string, origin?: string) {
53
+ return buildAuthUrl("/login", redirectTo, origin);
54
+ }
55
+
56
+ export function buildAuthLogoutUrl(redirectTo: string, origin?: string) {
57
+ return buildAuthUrl(
58
+ "/logout",
59
+ process.env.NEXT_PUBLIC_SIGN_OUT_REDIRECT_URL ?? redirectTo,
60
+ origin,
61
+ );
62
+ }
63
+
64
+ export function buildAccountProfileUrl() {
65
+ return joinPublicUrl(getAccountPublicUrl(), "/profile");
66
+ }
package/src/zitadel.ts ADDED
@@ -0,0 +1,56 @@
1
+ import { createRemoteJWKSet, jwtVerify } from 'jose';
2
+
3
+ export interface ZitadelConfig {
4
+ issuer: string;
5
+ audience?: string;
6
+ }
7
+
8
+ export interface ZitadelClaims {
9
+ sub: string;
10
+ email?: string;
11
+ preferred_username?: string;
12
+ name?: string;
13
+ picture?: string;
14
+ exp?: number;
15
+ iss?: string;
16
+ aud?: string | string[];
17
+ }
18
+
19
+ // Matches OidcIdentity in @baseworks/account — intentionally compatible.
20
+ export interface ZitadelIdentity {
21
+ subject: string;
22
+ issuer: string; // normalised, no trailing slash
23
+ email: string;
24
+ name: string;
25
+ picture?: string;
26
+ }
27
+
28
+ function withoutTrailingSlash(v: string): string {
29
+ return v.replace(/\/+$/, '');
30
+ }
31
+
32
+ /**
33
+ * Verify a Zitadel JWT and return the identity.
34
+ * Returns null on any verification failure — never throws to the caller.
35
+ */
36
+ export async function verifyZitadelToken(
37
+ token: string,
38
+ config: ZitadelConfig,
39
+ ): Promise<ZitadelIdentity | null> {
40
+ const issuer = withoutTrailingSlash(config.issuer);
41
+ const jwks = createRemoteJWKSet(new URL(`${issuer}/oauth/v2/keys`));
42
+ const opts = config.audience ? { issuer, audience: config.audience } : { issuer };
43
+
44
+ const result = await jwtVerify(token, jwks, opts).catch(() => null);
45
+ if (!result) return null;
46
+
47
+ const payload = result.payload as ZitadelClaims;
48
+ const subject = String(payload.sub ?? '');
49
+ if (!subject) return null;
50
+
51
+ const email = String(payload.email ?? payload.preferred_username ?? `${subject}@zitadel.local`);
52
+ const name = String(payload.name ?? payload.preferred_username ?? email);
53
+ const picture = payload.picture ? String(payload.picture) : undefined;
54
+
55
+ return { subject, issuer, email, name, picture };
56
+ }