@ulexid/nextjs 0.0.0-stage → 0.1.1

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 CHANGED
@@ -1,3 +1,13 @@
1
- # Temporary Holding Version
1
+ # @ulexid/nextjs
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Version `0.1.1` — [Ulexid Documentation](https://docs.ulexid.com)
4
+
5
+ Next.js App Router and Middleware helpers for [Ulexid](https://www.ulexid.com).
6
+
7
+ ```bash
8
+ npm install @ulexid/nextjs
9
+ ```
10
+
11
+ ## License
12
+
13
+ Proprietary (`UNLICENSED`). Copyright (c) Ulexid. All rights reserved.
@@ -0,0 +1,73 @@
1
+ /**
2
+ * `@ulexid/nextjs` — Next.js App Router Route Handlers & Edge Middleware for ulex.
3
+ *
4
+ * Built against standard Web `Request` / `Response` (`NextRequest` extends
5
+ * `Request`, `NextResponse` extends `Response`) so it works across Next.js 14,
6
+ * 15, and 16 in both Edge and Node.js runtimes with zero framework lock-in.
7
+ *
8
+ * Security properties:
9
+ * - PKCE `S256` state + verifier is AES-256-GCM sealed inside a short-lived
10
+ * `HttpOnly; SameSite=Lax` cookie (`__ulex_pkce`) derived from `cookieSecret`.
11
+ * - Session tokens are stored in `HttpOnly; SameSite=Lax` cookies (`__ulex_session`)
12
+ * and cryptographically verified against the tenant JWKS via `@ulexid/node`.
13
+ * - Open-redirect protection restricts `returnTo` to relative same-origin paths.
14
+ */
15
+ import { UlexClient, type FetchLike } from '@ulexid/js';
16
+ import { UlexServerClient, type VerifiedAccessToken } from '@ulexid/node';
17
+ export declare const ULEX_SESSION_COOKIE = "__ulex_session";
18
+ export declare const ULEX_PKCE_COOKIE = "__ulex_pkce";
19
+ export interface UlexNextConfig {
20
+ readonly issuer: string;
21
+ readonly clientId: string;
22
+ readonly clientSecret?: string;
23
+ /** Exact registered OAuth callback URL, e.g. `https://app.acme.com/api/auth/callback`. */
24
+ readonly redirectUri: string;
25
+ /** At least 32 characters; used to AES-256-GCM seal transient PKCE state cookies. */
26
+ readonly cookieSecret: string;
27
+ readonly scopes?: readonly string[];
28
+ readonly audience?: string | readonly string[];
29
+ readonly postLoginRedirect?: string;
30
+ readonly postLogoutRedirect?: string;
31
+ /** Defaults to `true` when `redirectUri` starts with `https://`. */
32
+ readonly secureCookies?: boolean;
33
+ readonly fetch?: FetchLike;
34
+ }
35
+ export interface UlexNextSession {
36
+ readonly accessToken: string;
37
+ readonly verified: VerifiedAccessToken;
38
+ }
39
+ export declare function parseCookieHeader(cookieHeader: string | null): Record<string, string>;
40
+ export declare function createUlexNextClients(config: UlexNextConfig): {
41
+ oauth: UlexClient;
42
+ server: UlexServerClient;
43
+ };
44
+ /**
45
+ * Verifies the `__ulex_session` cookie (or `Authorization: Bearer` header) on an
46
+ * incoming Next.js `Request` / `NextRequest` against the tenant JWKS.
47
+ */
48
+ export declare function getUlexSession(request: Request, config: UlexNextConfig, serverOverride?: UlexServerClient): Promise<UlexNextSession | null>;
49
+ /**
50
+ * Creates Next.js App Router `GET` route handlers for:
51
+ * - `/api/auth/login` (`handlers.login`)
52
+ * - `/api/auth/callback` (`handlers.callback`)
53
+ * - `/api/auth/logout` (`handlers.logout`)
54
+ * - `/api/auth/me` (`handlers.me`)
55
+ */
56
+ export declare function createAuthRouteHandlers(config: UlexNextConfig): {
57
+ login(request: Request): Promise<Response>;
58
+ callback(request: Request): Promise<Response>;
59
+ logout(request: Request): Promise<Response>;
60
+ me(request: Request): Promise<Response>;
61
+ };
62
+ export interface AuthMiddlewareOptions extends UlexNextConfig {
63
+ /** Path prefixes that do NOT require authentication (e.g. `['/', '/api/auth', '/pricing']`). */
64
+ readonly publicPaths?: readonly string[];
65
+ /** Path to redirect unauthenticated browser requests to (default: `'/api/auth/login'`). */
66
+ readonly loginPath?: string;
67
+ }
68
+ /**
69
+ * Edge-compatible middleware helper: returns `null` when the request is public or
70
+ * carries a valid ulex session, or returns a `302` redirect / `401` JSON `Response`
71
+ * when unauthenticated.
72
+ */
73
+ export declare function createUlexMiddleware(options: AuthMiddlewareOptions): (request: Request) => Promise<Response | null>;
package/dist/index.js ADDED
@@ -0,0 +1,261 @@
1
+ /**
2
+ * `@ulexid/nextjs` — Next.js App Router Route Handlers & Edge Middleware for ulex.
3
+ *
4
+ * Built against standard Web `Request` / `Response` (`NextRequest` extends
5
+ * `Request`, `NextResponse` extends `Response`) so it works across Next.js 14,
6
+ * 15, and 16 in both Edge and Node.js runtimes with zero framework lock-in.
7
+ *
8
+ * Security properties:
9
+ * - PKCE `S256` state + verifier is AES-256-GCM sealed inside a short-lived
10
+ * `HttpOnly; SameSite=Lax` cookie (`__ulex_pkce`) derived from `cookieSecret`.
11
+ * - Session tokens are stored in `HttpOnly; SameSite=Lax` cookies (`__ulex_session`)
12
+ * and cryptographically verified against the tenant JWKS via `@ulexid/node`.
13
+ * - Open-redirect protection restricts `returnTo` to relative same-origin paths.
14
+ */
15
+ import { generatePkcePair, generateState, UlexClient, } from '@ulexid/js';
16
+ import { UlexServerClient, } from '@ulexid/node';
17
+ export const ULEX_SESSION_COOKIE = '__ulex_session';
18
+ export const ULEX_PKCE_COOKIE = '__ulex_pkce';
19
+ function toBase64Url(bytes) {
20
+ return Buffer.from(bytes).toString('base64url');
21
+ }
22
+ function fromBase64Url(encoded) {
23
+ return new Uint8Array(Buffer.from(encoded, 'base64url'));
24
+ }
25
+ async function deriveAesKey(secret) {
26
+ if (secret.length < 32) {
27
+ throw new RangeError('`cookieSecret` must be at least 32 characters long.');
28
+ }
29
+ const digest = await globalThis.crypto.subtle.digest('SHA-256', new TextEncoder().encode(secret));
30
+ return globalThis.crypto.subtle.importKey('raw', digest, { name: 'AES-GCM' }, false, [
31
+ 'encrypt',
32
+ 'decrypt',
33
+ ]);
34
+ }
35
+ async function sealJson(payload, secret) {
36
+ const key = await deriveAesKey(secret);
37
+ const iv = new Uint8Array(12);
38
+ globalThis.crypto.getRandomValues(iv);
39
+ const plaintext = new TextEncoder().encode(JSON.stringify(payload));
40
+ const ciphertext = await globalThis.crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, plaintext);
41
+ return `${toBase64Url(iv)}.${toBase64Url(new Uint8Array(ciphertext))}`;
42
+ }
43
+ async function unsealJson(token, secret) {
44
+ const dot = token.indexOf('.');
45
+ if (dot <= 0)
46
+ return null;
47
+ try {
48
+ const key = await deriveAesKey(secret);
49
+ const iv = fromBase64Url(token.slice(0, dot));
50
+ const ciphertext = fromBase64Url(token.slice(dot + 1));
51
+ const plaintext = await globalThis.crypto.subtle.decrypt({ name: 'AES-GCM', iv: iv }, key, ciphertext);
52
+ return JSON.parse(new TextDecoder().decode(plaintext));
53
+ }
54
+ catch {
55
+ return null;
56
+ }
57
+ }
58
+ export function parseCookieHeader(cookieHeader) {
59
+ if (!cookieHeader)
60
+ return {};
61
+ const out = {};
62
+ for (const part of cookieHeader.split(';')) {
63
+ const eq = part.indexOf('=');
64
+ if (eq <= 0)
65
+ continue;
66
+ const k = part.slice(0, eq).trim();
67
+ const v = part.slice(eq + 1).trim();
68
+ if (k)
69
+ out[k] = decodeURIComponent(v);
70
+ }
71
+ return out;
72
+ }
73
+ function serializeCookie(name, value, opts) {
74
+ const parts = [
75
+ `${name}=${encodeURIComponent(value)}`,
76
+ `Path=${opts.path ?? '/'}`,
77
+ `Max-Age=${opts.maxAge}`,
78
+ 'HttpOnly',
79
+ 'SameSite=Lax',
80
+ ];
81
+ if (opts.secure)
82
+ parts.push('Secure');
83
+ return parts.join('; ');
84
+ }
85
+ function sanitizeRelativeReturnTo(raw, fallback) {
86
+ if (!raw)
87
+ return fallback;
88
+ // Block protocol-relative `//evil.com`, absolute URLs, and backslash tricks.
89
+ if (!raw.startsWith('/') || raw.startsWith('//') || raw.includes('\\')) {
90
+ return fallback;
91
+ }
92
+ return raw;
93
+ }
94
+ export function createUlexNextClients(config) {
95
+ const oauth = new UlexClient({
96
+ issuer: config.issuer,
97
+ clientId: config.clientId,
98
+ redirectUri: config.redirectUri,
99
+ fetch: config.fetch,
100
+ });
101
+ const server = new UlexServerClient({
102
+ issuer: config.issuer,
103
+ clientId: config.clientId,
104
+ clientSecret: config.clientSecret,
105
+ audience: config.audience,
106
+ fetch: config.fetch,
107
+ });
108
+ return { oauth, server };
109
+ }
110
+ /**
111
+ * Verifies the `__ulex_session` cookie (or `Authorization: Bearer` header) on an
112
+ * incoming Next.js `Request` / `NextRequest` against the tenant JWKS.
113
+ */
114
+ export async function getUlexSession(request, config, serverOverride) {
115
+ const authHeader = request.headers.get('authorization');
116
+ let rawToken;
117
+ if (authHeader?.toLowerCase().startsWith('bearer ')) {
118
+ rawToken = authHeader.slice(7).trim();
119
+ }
120
+ else {
121
+ const cookies = parseCookieHeader(request.headers.get('cookie'));
122
+ rawToken = cookies[ULEX_SESSION_COOKIE];
123
+ }
124
+ if (!rawToken)
125
+ return null;
126
+ const server = serverOverride ?? createUlexNextClients(config).server;
127
+ try {
128
+ const verified = await server.verifyAccessToken(rawToken);
129
+ return { accessToken: rawToken, verified };
130
+ }
131
+ catch {
132
+ return null;
133
+ }
134
+ }
135
+ /**
136
+ * Creates Next.js App Router `GET` route handlers for:
137
+ * - `/api/auth/login` (`handlers.login`)
138
+ * - `/api/auth/callback` (`handlers.callback`)
139
+ * - `/api/auth/logout` (`handlers.logout`)
140
+ * - `/api/auth/me` (`handlers.me`)
141
+ */
142
+ export function createAuthRouteHandlers(config) {
143
+ const { oauth, server } = createUlexNextClients(config);
144
+ const secure = config.secureCookies ?? config.redirectUri.startsWith('https://');
145
+ const defaultPostLogin = config.postLoginRedirect ?? '/';
146
+ return {
147
+ async login(request) {
148
+ const reqUrl = new URL(request.url);
149
+ const returnTo = sanitizeRelativeReturnTo(reqUrl.searchParams.get('returnTo'), defaultPostLogin);
150
+ const organization = reqUrl.searchParams.get('organization') ?? undefined;
151
+ const loginHint = reqUrl.searchParams.get('login_hint') ?? undefined;
152
+ const pkce = await generatePkcePair();
153
+ const state = generateState();
154
+ const authorizeUrl = oauth.buildAuthorizeUrl({
155
+ codeChallenge: pkce.challenge,
156
+ state,
157
+ redirectUri: config.redirectUri,
158
+ scopes: config.scopes ?? ['openid', 'profile', 'email'],
159
+ organization,
160
+ loginHint,
161
+ });
162
+ const sealed = await sealJson({
163
+ state,
164
+ verifier: pkce.verifier,
165
+ returnTo,
166
+ exp: Math.floor(Date.now() / 1000) + 600,
167
+ }, config.cookieSecret);
168
+ const headers = new Headers({ location: authorizeUrl });
169
+ headers.append('set-cookie', serializeCookie(ULEX_PKCE_COOKIE, sealed, { maxAge: 600, secure }));
170
+ return new Response(null, { status: 302, headers });
171
+ },
172
+ async callback(request) {
173
+ const reqUrl = new URL(request.url);
174
+ const code = reqUrl.searchParams.get('code');
175
+ const state = reqUrl.searchParams.get('state');
176
+ if (!code || !state) {
177
+ return Response.json({ error: { code: 'invalid_callback', message: 'Missing `code` or `state` query parameter.' } }, { status: 400 });
178
+ }
179
+ const cookies = parseCookieHeader(request.headers.get('cookie'));
180
+ const rawPkce = cookies[ULEX_PKCE_COOKIE];
181
+ if (!rawPkce) {
182
+ return Response.json({ error: { code: 'missing_pkce_cookie', message: 'Missing PKCE verification cookie.' } }, { status: 400 });
183
+ }
184
+ const unsealed = await unsealJson(rawPkce, config.cookieSecret);
185
+ if (!unsealed || unsealed.state !== state || unsealed.exp < Math.floor(Date.now() / 1000)) {
186
+ return Response.json({ error: { code: 'invalid_state', message: 'Expired or mismatched OAuth `state`.' } }, { status: 400 });
187
+ }
188
+ const tokenSet = await oauth.exchangeAuthorizationCode({
189
+ code,
190
+ codeVerifier: unsealed.verifier,
191
+ redirectUri: config.redirectUri,
192
+ clientSecret: config.clientSecret,
193
+ });
194
+ // Cryptographically verify the issued access token before setting session cookie.
195
+ await server.verifyAccessToken(tokenSet.access_token);
196
+ const targetUrl = new URL(sanitizeRelativeReturnTo(unsealed.returnTo, defaultPostLogin), reqUrl.origin);
197
+ const headers = new Headers({ location: targetUrl.toString() });
198
+ headers.append('set-cookie', serializeCookie(ULEX_SESSION_COOKIE, tokenSet.access_token, {
199
+ maxAge: tokenSet.expires_in,
200
+ secure,
201
+ }));
202
+ headers.append('set-cookie', serializeCookie(ULEX_PKCE_COOKIE, '', { maxAge: 0, secure }));
203
+ return new Response(null, { status: 302, headers });
204
+ },
205
+ async logout(request) {
206
+ const reqUrl = new URL(request.url);
207
+ const postLogout = config.postLogoutRedirect ?? `${reqUrl.origin}/`;
208
+ const logoutUrl = oauth.buildLogoutUrl({ postLogoutRedirectUri: postLogout });
209
+ const headers = new Headers({ location: logoutUrl });
210
+ headers.append('set-cookie', serializeCookie(ULEX_SESSION_COOKIE, '', { maxAge: 0, secure }));
211
+ headers.append('set-cookie', serializeCookie(ULEX_PKCE_COOKIE, '', { maxAge: 0, secure }));
212
+ return new Response(null, { status: 302, headers });
213
+ },
214
+ async me(request) {
215
+ const session = await getUlexSession(request, config, server);
216
+ if (!session) {
217
+ return Response.json({ authenticated: false, user: null }, { status: 401 });
218
+ }
219
+ const user = await oauth.getUserInfo(session.accessToken);
220
+ return Response.json({
221
+ authenticated: true,
222
+ sub: session.verified.sub,
223
+ orgId: session.verified.orgId ?? null,
224
+ scopes: session.verified.scopes,
225
+ user,
226
+ });
227
+ },
228
+ };
229
+ }
230
+ /**
231
+ * Edge-compatible middleware helper: returns `null` when the request is public or
232
+ * carries a valid ulex session, or returns a `302` redirect / `401` JSON `Response`
233
+ * when unauthenticated.
234
+ */
235
+ export function createUlexMiddleware(options) {
236
+ const { server } = createUlexNextClients(options);
237
+ const publicPaths = options.publicPaths ?? ['/api/auth'];
238
+ const loginPath = options.loginPath ?? '/api/auth/login';
239
+ return async function ulexMiddleware(request) {
240
+ const url = new URL(request.url);
241
+ const isPublic = publicPaths.some((prefix) => prefix === '/'
242
+ ? url.pathname === '/'
243
+ : url.pathname === prefix || url.pathname.startsWith(`${prefix}/`));
244
+ if (isPublic) {
245
+ return null;
246
+ }
247
+ const session = await getUlexSession(request, options, server);
248
+ if (session) {
249
+ return null;
250
+ }
251
+ if (url.pathname.startsWith('/api/')) {
252
+ return Response.json({ error: { code: 'unauthenticated', message: 'Authentication required.' } }, { status: 401 });
253
+ }
254
+ const loginUrl = new URL(loginPath, url.origin);
255
+ loginUrl.searchParams.set('returnTo', `${url.pathname}${url.search}`);
256
+ return new Response(null, {
257
+ status: 302,
258
+ headers: { location: loginUrl.toString() },
259
+ });
260
+ };
261
+ }
package/package.json CHANGED
@@ -1,6 +1,30 @@
1
1
  {
2
2
  "name": "@ulexid/nextjs",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "license": "UNLICENSED",
5
+ "type": "module",
6
+ "description": "Official Next.js App Router route handlers, encrypted HttpOnly PKCE/session cookies, and Edge/Server middleware for Ulexid.",
7
+ "homepage": "https://www.ulexid.com",
8
+ "engines": {
9
+ "node": ">=18.0.0"
10
+ },
11
+ "main": "./dist/index.js",
12
+ "types": "./dist/index.d.ts",
13
+ "publishConfig": {
14
+ "access": "public"
15
+ },
16
+ "exports": {
17
+ ".": {
18
+ "types": "./dist/index.d.ts",
19
+ "default": "./dist/index.js"
20
+ }
21
+ },
22
+ "files": [
23
+ "dist",
24
+ "README.md"
25
+ ],
26
+ "dependencies": {
27
+ "@ulexid/js": "0.1.1",
28
+ "@ulexid/node": "0.1.1"
29
+ }
30
+ }