@poly-x/next 0.1.1 → 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/README.md +127 -23
- package/dist/index.cjs +2 -1
- package/dist/index.d.cts +2 -1
- package/dist/index.d.mts +2 -1
- package/dist/index.mjs +2 -1
- package/dist/proxy.cjs +24 -4
- package/dist/proxy.d.cts +2 -1
- package/dist/proxy.d.mts +2 -1
- package/dist/proxy.mjs +23 -5
- package/dist/server.cjs +978 -23
- package/dist/server.d.cts +270 -3
- package/dist/server.d.mts +270 -3
- package/dist/server.mjs +959 -13
- package/dist/session-cookie-AEnY4PVW.d.cts +21 -0
- package/dist/session-cookie-AEnY4PVW.d.mts +21 -0
- package/dist/session-cookie-BQD10B3D.cjs +34 -0
- package/dist/session-cookie-C23WspJZ.mjs +23 -0
- package/dist/styles.css +151 -0
- package/package.json +3 -3
- package/dist/server-session-Df9HXl_5.cjs +0 -254
- package/dist/server-session-pY-nzh5x.mjs +0 -207
package/dist/server.cjs
CHANGED
|
@@ -1,7 +1,197 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
-
const
|
|
2
|
+
const require_session_cookie = require("./session-cookie-BQD10B3D.cjs");
|
|
3
3
|
let _poly_x_core = require("@poly-x/core");
|
|
4
4
|
let next_headers = require("next/headers");
|
|
5
|
+
//#region src/cookie-codec.ts
|
|
6
|
+
/**
|
|
7
|
+
* AES-GCM sealing of the session cookie (F007 / ADR-0004). The key is derived
|
|
8
|
+
* (SHA-256) from the consumer's `POLYX_SESSION_SECRET`; AES-GCM gives
|
|
9
|
+
* confidentiality + tamper-detection together, so any corruption, truncation,
|
|
10
|
+
* or wrong-secret input decrypts to `null` rather than a partial session.
|
|
11
|
+
* Web Crypto is universal (Node ≥20 + edge runtimes), so this runs anywhere a
|
|
12
|
+
* Next route handler runs.
|
|
13
|
+
*/
|
|
14
|
+
const IV_BYTES = 12;
|
|
15
|
+
/**
|
|
16
|
+
* Browsers cap a single cookie at roughly 4096 bytes for the whole `Set-Cookie` pair —
|
|
17
|
+
* name, value and attributes together — and enforce it by DISCARDING the cookie without
|
|
18
|
+
* an error. For a session cookie that failure is invisible and badly misleading: the
|
|
19
|
+
* platform authenticates, the handler answers 200, and the user is bounced back to
|
|
20
|
+
* sign-in on the next request because the cookie was never stored.
|
|
21
|
+
*
|
|
22
|
+
* poly-x-sdk/ADR-0006 measured a live Odin session at 2628 bytes and recorded the missing
|
|
23
|
+
* guard as an open follow-up; a per-tenant flag that inflates the JWT is enough to cross
|
|
24
|
+
* the line. We fail loudly here instead.
|
|
25
|
+
*/
|
|
26
|
+
const SESSION_COOKIE_MAX_BYTES = 4096;
|
|
27
|
+
/** Attributes `buildSessionCookieOptions` always sets: `Path=/; HttpOnly; Secure; SameSite=Lax`. */
|
|
28
|
+
const COOKIE_ATTRIBUTE_BYTES = 45;
|
|
29
|
+
/** Bytes the whole `Set-Cookie` pair will occupy for this value under this cookie name. */
|
|
30
|
+
function sealedCookieByteLength(value, cookieName = "polyx_session") {
|
|
31
|
+
return new TextEncoder().encode(cookieName).length + 1 + value.length + COOKIE_ATTRIBUTE_BYTES;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Thrown when a sealed session would exceed what a browser will store. Never includes the
|
|
35
|
+
* session itself — the claims that caused the overflow are exactly the data we must not log.
|
|
36
|
+
*/
|
|
37
|
+
var SessionCookieTooLargeError = class extends _poly_x_core.PolyXError {
|
|
38
|
+
bytes;
|
|
39
|
+
limit;
|
|
40
|
+
constructor(bytes, limit, cookieName) {
|
|
41
|
+
super(`PolyX session cookie is ${bytes} bytes, over the ~${limit}-byte browser limit for "${cookieName}". The browser would discard it silently and the user would appear signed out. Reduce what the platform puts in the session — the usual cause is a tenant configured to embed full permissions in the JWT (includeFullPermissionInJwt).`, { code: "SESSION_COOKIE_TOO_LARGE" });
|
|
42
|
+
this.bytes = bytes;
|
|
43
|
+
this.limit = limit;
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
function base64UrlEncode(bytes) {
|
|
47
|
+
let binary = "";
|
|
48
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
49
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
50
|
+
}
|
|
51
|
+
function base64UrlDecode(text) {
|
|
52
|
+
const base64 = text.replace(/-/g, "+").replace(/_/g, "/");
|
|
53
|
+
const padded = base64 + "=".repeat((4 - base64.length % 4) % 4);
|
|
54
|
+
const binary = atob(padded);
|
|
55
|
+
const bytes = new Uint8Array(binary.length);
|
|
56
|
+
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
|
|
57
|
+
return bytes;
|
|
58
|
+
}
|
|
59
|
+
async function deriveKey(secret) {
|
|
60
|
+
const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(secret));
|
|
61
|
+
return crypto.subtle.importKey("raw", hash, "AES-GCM", false, ["encrypt", "decrypt"]);
|
|
62
|
+
}
|
|
63
|
+
async function sealSession(session, secret, options = {}) {
|
|
64
|
+
const key = await deriveKey(secret);
|
|
65
|
+
const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES));
|
|
66
|
+
const plaintext = new TextEncoder().encode(JSON.stringify(session));
|
|
67
|
+
const ciphertext = new Uint8Array(await crypto.subtle.encrypt({
|
|
68
|
+
name: "AES-GCM",
|
|
69
|
+
iv
|
|
70
|
+
}, key, plaintext));
|
|
71
|
+
const packed = new Uint8Array(iv.length + ciphertext.length);
|
|
72
|
+
packed.set(iv);
|
|
73
|
+
packed.set(ciphertext, iv.length);
|
|
74
|
+
const sealed = base64UrlEncode(packed);
|
|
75
|
+
const cookieName = options.cookieName ?? "polyx_session";
|
|
76
|
+
const bytes = sealedCookieByteLength(sealed, cookieName);
|
|
77
|
+
if (bytes > 4096) throw new SessionCookieTooLargeError(bytes, SESSION_COOKIE_MAX_BYTES, cookieName);
|
|
78
|
+
return sealed;
|
|
79
|
+
}
|
|
80
|
+
function nonEmptyString(value) {
|
|
81
|
+
return typeof value === "string" && value.length > 0;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Does this decrypt to something every caller can safely use?
|
|
85
|
+
*
|
|
86
|
+
* Decryption proving *authenticity* is not the same as proving *shape*. A cookie sealed with the
|
|
87
|
+
* right secret by a different `StoredSession` version is authentic and unusable — and the SDK is
|
|
88
|
+
* pre-1.0, so that shape has already grown once and will again.
|
|
89
|
+
*
|
|
90
|
+
* It matters because eight call sites reach straight through `session.session.claims`, including
|
|
91
|
+
* `toAuthContext` behind `auth()`. Handing back a malformed object turns each into a TypeError, and
|
|
92
|
+
* the failure does not self-heal: `read()` *succeeded*, so nothing clears the cookie and the person
|
|
93
|
+
* gets a 500 on every request until they clear it by hand. Reporting `null` instead routes into the
|
|
94
|
+
* signed-out path that already exists, which re-authenticates and replaces the cookie.
|
|
95
|
+
*
|
|
96
|
+
* Checked here rather than at each call site because this is the one place every read passes
|
|
97
|
+
* through — a guard at the boundary cannot be forgotten by the ninth caller.
|
|
98
|
+
*/
|
|
99
|
+
function isUsableSession(value) {
|
|
100
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
101
|
+
const { tokens, session } = value;
|
|
102
|
+
if (!tokens || typeof tokens !== "object") return false;
|
|
103
|
+
if (!nonEmptyString(tokens.accessToken)) return false;
|
|
104
|
+
if (!session || typeof session !== "object") return false;
|
|
105
|
+
const claims = session.claims;
|
|
106
|
+
if (!claims || typeof claims !== "object") return false;
|
|
107
|
+
return nonEmptyString(claims.userId);
|
|
108
|
+
}
|
|
109
|
+
async function openSession(token, secret) {
|
|
110
|
+
try {
|
|
111
|
+
const packed = base64UrlDecode(token);
|
|
112
|
+
if (packed.length <= IV_BYTES) return null;
|
|
113
|
+
const iv = packed.slice(0, IV_BYTES);
|
|
114
|
+
const ciphertext = packed.slice(IV_BYTES);
|
|
115
|
+
const key = await deriveKey(secret);
|
|
116
|
+
const plaintext = await crypto.subtle.decrypt({
|
|
117
|
+
name: "AES-GCM",
|
|
118
|
+
iv
|
|
119
|
+
}, key, ciphertext);
|
|
120
|
+
const parsed = JSON.parse(new TextDecoder().decode(plaintext));
|
|
121
|
+
return isUsableSession(parsed) ? parsed : null;
|
|
122
|
+
} catch {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
//#endregion
|
|
127
|
+
//#region src/auth-event.ts
|
|
128
|
+
/**
|
|
129
|
+
* One-shot auth events that survive a navigation (F017 / FR-CLIENT-5).
|
|
130
|
+
*
|
|
131
|
+
* Sign-in and sign-out are full-page navigations, so anything an app queues before them —
|
|
132
|
+
* a toast, an analytics call — is destroyed when the page unloads. Odin built ~80 lines of
|
|
133
|
+
* cookie plumbing to show "Logged in successfully"; this absorbs the shape.
|
|
134
|
+
*
|
|
135
|
+
* A cookie rather than a query parameter on the redirect target: the destination may
|
|
136
|
+
* redirect again before it renders (a route gate sending an unauthorised user elsewhere,
|
|
137
|
+
* for one), which drops the query and the event with it. A cookie survives the chain.
|
|
138
|
+
*
|
|
139
|
+
* It carries no secret — it exists to be read by page script, which is why it is
|
|
140
|
+
* deliberately NOT httpOnly. Never put anything in it that the browser should not see.
|
|
141
|
+
*/
|
|
142
|
+
/** The cookie the SDK publishes a one-shot auth event in. Readable by script, by design. */
|
|
143
|
+
const AUTH_EVENT_COOKIE = "polyx_auth_event";
|
|
144
|
+
/** Serialize the `Set-Cookie` value for a one-shot event. */
|
|
145
|
+
function buildAuthEventCookie(kind, secure) {
|
|
146
|
+
return `${AUTH_EVENT_COOKIE}=${kind}; Path=/; Max-Age=30; SameSite=Lax${secure ? "; Secure" : ""}`;
|
|
147
|
+
}
|
|
148
|
+
//#endregion
|
|
149
|
+
//#region src/public-origin.ts
|
|
150
|
+
/** First entry of a comma-separated proxy chain, matching how `resolveCookieSecure` reads proto. */
|
|
151
|
+
function firstHop(value) {
|
|
152
|
+
const first = value?.split(",")[0]?.trim();
|
|
153
|
+
return first && first.length > 0 ? first : void 0;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* A forwarded host must be `hostname[:port]` (or a bracketed IPv6 literal) and nothing
|
|
157
|
+
* else. Without this check a value like `http://[bad` concatenates into
|
|
158
|
+
* `https://http://[bad`, which the URL parser happily reads as host `http` and yields the
|
|
159
|
+
* nonsense origin `https://http` — a broken redirect URI produced from hostile input
|
|
160
|
+
* rather than a clean fall-back.
|
|
161
|
+
*/
|
|
162
|
+
const VALID_FORWARDED_HOST = /^(?:\[[0-9a-fA-F:.]+\]|[a-zA-Z0-9._-]+)(?::\d{1,5})?$/;
|
|
163
|
+
/** `origin` of a URL, or undefined when it will not parse. */
|
|
164
|
+
function originOf(candidate) {
|
|
165
|
+
try {
|
|
166
|
+
const url = new URL(candidate);
|
|
167
|
+
return url.origin && url.origin !== "null" ? url.origin : void 0;
|
|
168
|
+
} catch {
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Resolve the origin the browser actually reached this app on.
|
|
174
|
+
*
|
|
175
|
+
* Precedence: explicit configuration → forwarded headers (only when trusted) → the
|
|
176
|
+
* request URL. Every step falls through on malformed input rather than emitting a broken
|
|
177
|
+
* origin, because a broken `redirect_uri` fails at the platform with a message about
|
|
178
|
+
* credentials rather than configuration.
|
|
179
|
+
*/
|
|
180
|
+
function resolvePublicOrigin(request, options = {}) {
|
|
181
|
+
if (options.publicOrigin) {
|
|
182
|
+
const configured = originOf(options.publicOrigin);
|
|
183
|
+
if (configured) return configured;
|
|
184
|
+
}
|
|
185
|
+
if (options.trustForwardedOrigin) {
|
|
186
|
+
const host = firstHop(request.headers.get("x-forwarded-host"));
|
|
187
|
+
if (host && VALID_FORWARDED_HOST.test(host)) {
|
|
188
|
+
const forwarded = originOf(`${firstHop(request.headers.get("x-forwarded-proto")) ?? originOf(request.url)?.split(":")[0] ?? "https"}://${host}`);
|
|
189
|
+
if (forwarded) return forwarded;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return originOf(request.url) ?? "";
|
|
193
|
+
}
|
|
194
|
+
//#endregion
|
|
5
195
|
//#region src/config.ts
|
|
6
196
|
/** Parse a boolean-ish env var; undefined when unset/unrecognized (→ auto-derive). */
|
|
7
197
|
function parseBoolEnv(value) {
|
|
@@ -20,7 +210,10 @@ function resolveNextConfig(overrides = {}) {
|
|
|
20
210
|
secret,
|
|
21
211
|
baseUrl: overrides.baseUrl ?? process.env.POLYX_BASE_URL,
|
|
22
212
|
signInUrl: overrides.signInUrl ?? process.env.NEXT_PUBLIC_POLYX_SIGN_IN_URL,
|
|
23
|
-
cookieSecure: overrides.cookieSecure ?? parseBoolEnv(process.env.POLYX_COOKIE_SECURE)
|
|
213
|
+
cookieSecure: overrides.cookieSecure ?? parseBoolEnv(process.env.POLYX_COOKIE_SECURE),
|
|
214
|
+
publicOrigin: overrides.publicOrigin ?? process.env.POLYX_PUBLIC_ORIGIN,
|
|
215
|
+
trustForwardedOrigin: overrides.trustForwardedOrigin ?? parseBoolEnv(process.env.POLYX_TRUST_FORWARDED_ORIGIN) ?? false,
|
|
216
|
+
realtimeBaseUrl: overrides.realtimeBaseUrl ?? process.env.POLYX_REALTIME_URL
|
|
24
217
|
};
|
|
25
218
|
}
|
|
26
219
|
function buildServerAuthClient(config) {
|
|
@@ -35,6 +228,503 @@ function buildServerAuthClient(config) {
|
|
|
35
228
|
});
|
|
36
229
|
}
|
|
37
230
|
//#endregion
|
|
231
|
+
//#region src/cookie-adapter.ts
|
|
232
|
+
/**
|
|
233
|
+
* Attributes for the session cookie (ADR-0004 secure custody). `secure` is decided at
|
|
234
|
+
* write time from the actual transport: a `Secure` cookie is silently dropped by the
|
|
235
|
+
* browser over plain http, so http/localhost dev must set `secure:false` while https
|
|
236
|
+
* always sets `secure:true`. This is not a downgrade — `Secure` carries no meaning on
|
|
237
|
+
* http; setting it there just breaks the session. httpOnly/sameSite/path are unchanged.
|
|
238
|
+
*/
|
|
239
|
+
function buildSessionCookieOptions(secure) {
|
|
240
|
+
return {
|
|
241
|
+
httpOnly: true,
|
|
242
|
+
secure,
|
|
243
|
+
sameSite: "lax",
|
|
244
|
+
path: "/"
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
//#endregion
|
|
248
|
+
//#region src/server-session.ts
|
|
249
|
+
/**
|
|
250
|
+
* The framework-agnostic BFF session controller (F007): tokens sealed in an
|
|
251
|
+
* httpOnly cookie on the consumer's domain, exchanged/refreshed server-side.
|
|
252
|
+
* Composes core's AuthClient with a CookieAdapter seam; fully unit-testable.
|
|
253
|
+
*/
|
|
254
|
+
var ServerSession = class {
|
|
255
|
+
authClient;
|
|
256
|
+
secret;
|
|
257
|
+
cookies;
|
|
258
|
+
cookieName;
|
|
259
|
+
secure;
|
|
260
|
+
constructor(options) {
|
|
261
|
+
this.authClient = options.authClient;
|
|
262
|
+
this.secret = options.secret;
|
|
263
|
+
this.cookies = options.cookies;
|
|
264
|
+
this.cookieName = options.cookieName ?? "polyx_session";
|
|
265
|
+
this.secure = options.secure ?? true;
|
|
266
|
+
}
|
|
267
|
+
async establishFromCode(code, codeVerifier, redirectUri, forwardedHeaders) {
|
|
268
|
+
const stored = await this.authClient.exchangeCode({
|
|
269
|
+
code,
|
|
270
|
+
codeVerifier,
|
|
271
|
+
redirectUri,
|
|
272
|
+
forwardedHeaders
|
|
273
|
+
});
|
|
274
|
+
await this.write(stored);
|
|
275
|
+
return stored;
|
|
276
|
+
}
|
|
277
|
+
async read() {
|
|
278
|
+
const raw = this.cookies.get(this.cookieName);
|
|
279
|
+
if (!raw) return null;
|
|
280
|
+
return openSession(raw, this.secret);
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Re-seal the session with an updated **display** subset — name, email, avatar (ADR-0006).
|
|
284
|
+
*
|
|
285
|
+
* Needed because `normalizeRefresh` carries claims forward unchanged: the display claims are
|
|
286
|
+
* derived once, at login, and never again. So after a profile save the session still describes
|
|
287
|
+
* the person as they were when they signed in, and every surface reading `useUser()` shows the
|
|
288
|
+
* old name and photo until they sign out and back in (FR-PROF-5).
|
|
289
|
+
*
|
|
290
|
+
* Deliberately narrow. `userId` and `organizationId` are authorization-relevant and are **not**
|
|
291
|
+
* writable here — a profile save must never become a route to changing who the session is for,
|
|
292
|
+
* whatever the platform echoes back. Only the three display fields move.
|
|
293
|
+
*
|
|
294
|
+
* Returns `false` rather than throwing when the session cannot be re-sealed (an oversized
|
|
295
|
+
* cookie, a missing session): the caller's own operation has usually already succeeded
|
|
296
|
+
* upstream, and failing it because the *display* copy could not be updated would report a
|
|
297
|
+
* successful save as a failure.
|
|
298
|
+
*/
|
|
299
|
+
async updateDisplayClaims(patch) {
|
|
300
|
+
const current = await this.read();
|
|
301
|
+
if (!current) return false;
|
|
302
|
+
const next = {
|
|
303
|
+
...current,
|
|
304
|
+
session: {
|
|
305
|
+
...current.session,
|
|
306
|
+
claims: {
|
|
307
|
+
...current.session.claims,
|
|
308
|
+
...patch.displayName !== void 0 ? { displayName: patch.displayName } : {},
|
|
309
|
+
...patch.email !== void 0 ? { email: patch.email } : {},
|
|
310
|
+
...patch.avatarUrl !== void 0 ? { avatarUrl: patch.avatarUrl } : {}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
};
|
|
314
|
+
try {
|
|
315
|
+
await this.write(next);
|
|
316
|
+
return true;
|
|
317
|
+
} catch {
|
|
318
|
+
return false;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
/** Rotate the tokens; clears the cookie (returns null) on a terminal refresh failure. */
|
|
322
|
+
async refresh() {
|
|
323
|
+
const current = await this.read();
|
|
324
|
+
if (!current) return null;
|
|
325
|
+
try {
|
|
326
|
+
const next = await this.authClient.createRefresher()(current);
|
|
327
|
+
await this.write(next);
|
|
328
|
+
return next;
|
|
329
|
+
} catch (error) {
|
|
330
|
+
if (error instanceof _poly_x_core.SessionRevokedError || error instanceof _poly_x_core.SessionExpiredError) {
|
|
331
|
+
this.clear();
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
if (error instanceof SessionCookieTooLargeError) {
|
|
335
|
+
console.error(`[polyx] ${error.message}`);
|
|
336
|
+
this.clear();
|
|
337
|
+
return null;
|
|
338
|
+
}
|
|
339
|
+
throw error;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* End the session upstream, then locally. `device` revokes this session; `everywhere`
|
|
344
|
+
* revokes every session for the user (FR-SSO-5).
|
|
345
|
+
*
|
|
346
|
+
* Order is load-bearing: revocation is authenticated with the access token sealed in the
|
|
347
|
+
* cookie, so the cookie must be read before it is dropped. The upstream call is
|
|
348
|
+
* best-effort — an unreachable platform must not strand the user half-signed-in — but the
|
|
349
|
+
* call itself is real: the ecosystem logout the SDK used before resolves its session from
|
|
350
|
+
* a browser cookie, which a server-to-server fetch never carries, so it silently revoked
|
|
351
|
+
* nothing and answered 200.
|
|
352
|
+
*/
|
|
353
|
+
async revoke(scope = "device") {
|
|
354
|
+
const current = await this.read();
|
|
355
|
+
if (current) try {
|
|
356
|
+
await this.authClient.revokeSession(current.tokens.accessToken, scope);
|
|
357
|
+
} catch {}
|
|
358
|
+
this.clear();
|
|
359
|
+
}
|
|
360
|
+
clear() {
|
|
361
|
+
this.cookies.delete(this.cookieName);
|
|
362
|
+
}
|
|
363
|
+
async write(session) {
|
|
364
|
+
const sealed = await sealSession(session, this.secret, { cookieName: this.cookieName });
|
|
365
|
+
this.cookies.set(this.cookieName, sealed, buildSessionCookieOptions(this.secure));
|
|
366
|
+
}
|
|
367
|
+
};
|
|
368
|
+
function toAuthContext(session) {
|
|
369
|
+
if (!session) return {
|
|
370
|
+
isSignedIn: false,
|
|
371
|
+
userId: null,
|
|
372
|
+
claims: null
|
|
373
|
+
};
|
|
374
|
+
return {
|
|
375
|
+
isSignedIn: true,
|
|
376
|
+
userId: session.session.claims.userId,
|
|
377
|
+
organizationId: session.session.claims.organizationId,
|
|
378
|
+
claims: session.session.claims
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
//#endregion
|
|
382
|
+
//#region src/session-context.ts
|
|
383
|
+
/**
|
|
384
|
+
* Session context (F015 / FR-SESS-2…6).
|
|
385
|
+
*
|
|
386
|
+
* The sealed session carries only a bounded display subset (`poly-x-sdk/ADR-0006`) —
|
|
387
|
+
* correct, because bulk authorization must not live in a cookie. But it left every app
|
|
388
|
+
* needing more than `{userId, organizationId}` to assemble it themselves, and both did:
|
|
389
|
+
* an `/api/polyx/context` route plus a hand-written profile fetch against poly-auth,
|
|
390
|
+
* roughly 170 lines each. The app was issuing platform API calls the SDK should make.
|
|
391
|
+
*
|
|
392
|
+
* The fix is that the SDK makes the same call — NOT that authorization data moves into
|
|
393
|
+
* the session. ADR-0006 is unchanged and apps keep deriving their own route gating.
|
|
394
|
+
*
|
|
395
|
+
* One deliberate improvement over the copies being absorbed: they returned `null` on any
|
|
396
|
+
* failure, which conflates "the directory was briefly unavailable" with "this user has no
|
|
397
|
+
* profile". An app cannot tell those apart, and the safe-looking reaction — sign the user
|
|
398
|
+
* out — is wrong for the first. Here the two are distinct.
|
|
399
|
+
*/
|
|
400
|
+
const SIGNED_OUT = {
|
|
401
|
+
isSignedIn: false,
|
|
402
|
+
userId: null,
|
|
403
|
+
claims: null,
|
|
404
|
+
user: null
|
|
405
|
+
};
|
|
406
|
+
/**
|
|
407
|
+
* Fetch the platform user profile with the session's own credential.
|
|
408
|
+
*
|
|
409
|
+
* `organization-id` is required, not optional: poly-auth rejects the call outright
|
|
410
|
+
* without it, and it is what scopes the returned permission to the workspace the user
|
|
411
|
+
* actually signed in to.
|
|
412
|
+
*/
|
|
413
|
+
async function fetchProfile(baseUrl, userId, accessToken, organizationId) {
|
|
414
|
+
try {
|
|
415
|
+
const response = await fetch(`${baseUrl.replace(/\/$/, "")}/v1/user/${encodeURIComponent(userId)}`, { headers: {
|
|
416
|
+
Authorization: `Bearer ${accessToken}`,
|
|
417
|
+
"organization-id": organizationId,
|
|
418
|
+
Accept: "application/json"
|
|
419
|
+
} });
|
|
420
|
+
if (response.status === 401 || response.status === 403) return {
|
|
421
|
+
user: null,
|
|
422
|
+
status: "unavailable",
|
|
423
|
+
unauthorized: true
|
|
424
|
+
};
|
|
425
|
+
if (!response.ok) return {
|
|
426
|
+
user: null,
|
|
427
|
+
status: "unavailable",
|
|
428
|
+
unauthorized: false
|
|
429
|
+
};
|
|
430
|
+
const body = await response.json();
|
|
431
|
+
return {
|
|
432
|
+
user: body.data ?? body ?? null,
|
|
433
|
+
status: "ok",
|
|
434
|
+
unauthorized: false
|
|
435
|
+
};
|
|
436
|
+
} catch {
|
|
437
|
+
return {
|
|
438
|
+
user: null,
|
|
439
|
+
status: "unavailable",
|
|
440
|
+
unauthorized: false
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
function adaptStore$1(store) {
|
|
445
|
+
return {
|
|
446
|
+
get: (name) => store.get(name)?.value,
|
|
447
|
+
set: (name, value, options) => store.set(name, value, options),
|
|
448
|
+
delete: (name) => store.delete(name)
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* Read who is signed in, including the platform profile.
|
|
453
|
+
*
|
|
454
|
+
* Safe in a Server Component: it reads the session and never writes it. The access
|
|
455
|
+
* credential is used to call the platform and is never part of the result (FR-SESS-5).
|
|
456
|
+
*/
|
|
457
|
+
async function getSessionContext(options = {}) {
|
|
458
|
+
const config = resolveNextConfig(options);
|
|
459
|
+
const current = await new ServerSession({
|
|
460
|
+
authClient: buildServerAuthClient(config),
|
|
461
|
+
secret: config.secret,
|
|
462
|
+
cookies: adaptStore$1(await (0, next_headers.cookies)())
|
|
463
|
+
}).read();
|
|
464
|
+
if (!current) return SIGNED_OUT;
|
|
465
|
+
const { claims } = current.session;
|
|
466
|
+
const base = {
|
|
467
|
+
isSignedIn: true,
|
|
468
|
+
userId: claims.userId,
|
|
469
|
+
organizationId: claims.organizationId,
|
|
470
|
+
claims,
|
|
471
|
+
user: null
|
|
472
|
+
};
|
|
473
|
+
if (options.skipProfile) return base;
|
|
474
|
+
if (!claims.organizationId) return {
|
|
475
|
+
...base,
|
|
476
|
+
profileStatus: "no_organization"
|
|
477
|
+
};
|
|
478
|
+
if (!config.baseUrl) return {
|
|
479
|
+
...base,
|
|
480
|
+
profileStatus: "unavailable"
|
|
481
|
+
};
|
|
482
|
+
const result = await fetchProfile(config.baseUrl, claims.userId, current.tokens.accessToken, claims.organizationId);
|
|
483
|
+
if (result.unauthorized) return SIGNED_OUT;
|
|
484
|
+
return {
|
|
485
|
+
...base,
|
|
486
|
+
user: result.user,
|
|
487
|
+
profileStatus: result.status
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
//#endregion
|
|
491
|
+
//#region src/handler.ts
|
|
492
|
+
/**
|
|
493
|
+
* One-declaration route mounting (F012 / FR-MOUNT-1…5).
|
|
494
|
+
*
|
|
495
|
+
* `createAuthHandlers()` returns nine handler methods and mounts nothing, so every
|
|
496
|
+
* consuming app hand-wrote its own action→handler switch plus the GET/POST split —
|
|
497
|
+
* ~100 lines, twice, identically. Worse, the published example
|
|
498
|
+
* (`export const { signin: GET } = createAuthHandlers()`) cannot dispatch by action at
|
|
499
|
+
* all: it wires exactly one action and the rest fail at runtime.
|
|
500
|
+
*
|
|
501
|
+
* This is a thin dispatcher over the existing handlers, not a replacement.
|
|
502
|
+
* `createAuthHandlers()` stays exported and unchanged for apps that already wired it.
|
|
503
|
+
*/
|
|
504
|
+
/**
|
|
505
|
+
* URL segment → handler key. The segments are kebab-case (what a developer types in a
|
|
506
|
+
* path); the handler keys are camelCase (what the object exposes). Keeping the map
|
|
507
|
+
* explicit means a new handler cannot be reached by an unintended segment spelling.
|
|
508
|
+
*/
|
|
509
|
+
const POLYX_ACTIONS = {
|
|
510
|
+
signin: "signin",
|
|
511
|
+
authenticate: "authenticate",
|
|
512
|
+
"forgot-password": "forgotPassword",
|
|
513
|
+
"reset-password": "resetPassword",
|
|
514
|
+
callback: "callback",
|
|
515
|
+
refresh: "refresh",
|
|
516
|
+
signout: "signout",
|
|
517
|
+
session: "session",
|
|
518
|
+
"subscription-credential": "subscriptionCredential",
|
|
519
|
+
"realtime-ticket": "realtimeTicket",
|
|
520
|
+
profile: "profile"
|
|
521
|
+
};
|
|
522
|
+
/**
|
|
523
|
+
* Allowed methods per action, stated rather than inferred.
|
|
524
|
+
*
|
|
525
|
+
* `signout` accepts GET as well as POST because signing out has to be a full-page
|
|
526
|
+
* navigation: a client-side redirect leaves the httpOnly cookie in place and the route
|
|
527
|
+
* gate bounces the user straight back in. Both consumers hard-code a
|
|
528
|
+
* `window.location` sign-out for exactly this reason.
|
|
529
|
+
*/
|
|
530
|
+
const POLYX_ACTION_METHODS = {
|
|
531
|
+
signin: ["GET"],
|
|
532
|
+
callback: ["GET"],
|
|
533
|
+
session: ["GET"],
|
|
534
|
+
"subscription-credential": ["GET"],
|
|
535
|
+
"realtime-ticket": ["GET"],
|
|
536
|
+
authenticate: ["POST"],
|
|
537
|
+
"forgot-password": ["POST"],
|
|
538
|
+
"reset-password": ["POST"],
|
|
539
|
+
refresh: ["POST"],
|
|
540
|
+
profile: ["GET", "POST"],
|
|
541
|
+
signout: ["GET", "POST"]
|
|
542
|
+
};
|
|
543
|
+
function jsonError$1(error, status) {
|
|
544
|
+
return Response.json({ error }, { status });
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* Resolve the action from the ROUTE PARAMS, never from the request URL.
|
|
548
|
+
*
|
|
549
|
+
* The handler must not trust a path it did not route: the framework tells us what it
|
|
550
|
+
* matched, and a URL string can disagree with that (rewrites, proxies, or a crafted
|
|
551
|
+
* request). Exactly one segment is required, so `/api/polyx/session/extra` is a 404
|
|
552
|
+
* rather than silently resolving to `session`.
|
|
553
|
+
*/
|
|
554
|
+
function resolveActionSegment(params) {
|
|
555
|
+
const values = Object.values(params);
|
|
556
|
+
if (values.length !== 1) return null;
|
|
557
|
+
const segments = values[0];
|
|
558
|
+
if (!Array.isArray(segments)) return typeof segments === "string" ? segments : null;
|
|
559
|
+
if (segments.length !== 1) return null;
|
|
560
|
+
return segments[0] ?? null;
|
|
561
|
+
}
|
|
562
|
+
function isKnownAction(segment) {
|
|
563
|
+
return Object.prototype.hasOwnProperty.call(POLYX_ACTIONS, segment);
|
|
564
|
+
}
|
|
565
|
+
/**
|
|
566
|
+
* Mount every SDK auth route from one declaration:
|
|
567
|
+
*
|
|
568
|
+
* ```ts
|
|
569
|
+
* // app/api/polyx/[...polyx]/route.ts
|
|
570
|
+
* export const { GET, POST } = createPolyXHandler();
|
|
571
|
+
* ```
|
|
572
|
+
*
|
|
573
|
+
* `handlers` is injectable for tests; production callers pass only the config.
|
|
574
|
+
*/
|
|
575
|
+
function createPolyXHandler(config = {}, handlers = createAuthHandlers(config)) {
|
|
576
|
+
async function dispatch(request, context, method) {
|
|
577
|
+
const segment = resolveActionSegment(await context.params);
|
|
578
|
+
if (segment === null || !isKnownAction(segment)) return jsonError$1("unknown_action", 404);
|
|
579
|
+
if (!POLYX_ACTION_METHODS[segment].includes(method)) return jsonError$1("method_not_allowed", 405);
|
|
580
|
+
return handlers[POLYX_ACTIONS[segment]](request);
|
|
581
|
+
}
|
|
582
|
+
return {
|
|
583
|
+
GET: (request, context) => dispatch(request, context, "GET"),
|
|
584
|
+
POST: (request, context) => dispatch(request, context, "POST")
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
//#endregion
|
|
588
|
+
//#region src/data-proxy.ts
|
|
589
|
+
/**
|
|
590
|
+
* The authenticated data path (F014 / FR-DATA-1…10).
|
|
591
|
+
*
|
|
592
|
+
* Under BFF custody the browser holds no credential, so every authenticated call to an
|
|
593
|
+
* app's own backend has to be proxied server-side. The SDK never shipped that half, even
|
|
594
|
+
* though the tech discovery that chose this custody model said it would ("proxies/
|
|
595
|
+
* decorates outbound PolyX calls"). So both live integrations wrote it themselves — and
|
|
596
|
+
* the result is byte-for-byte identical in each, ~250 lines of the subtlest code in the
|
|
597
|
+
* whole integration, sitting in two places to drift apart.
|
|
598
|
+
*
|
|
599
|
+
* This is a PORT of that proven implementation, not a rewrite: it has run in production
|
|
600
|
+
* in two apps. Two things are deliberately better here:
|
|
601
|
+
*
|
|
602
|
+
* 1. Refresh is IN-PROCESS (`refreshSession`, F013). The consumers had to `fetch` their
|
|
603
|
+
* own `/api/polyx/refresh` route and re-parse the returned `Set-Cookie` to recover
|
|
604
|
+
* the new token — a network round trip through their own edge, and a fragile parse.
|
|
605
|
+
* 2. Rotation needs no manual propagation. `refreshSession` writes through Next's cookie
|
|
606
|
+
* store, so the refreshed cookie lands on the response by itself; the consumers had
|
|
607
|
+
* to append the re-parsed `Set-Cookie` by hand.
|
|
608
|
+
*/
|
|
609
|
+
/** Never copied from the browser request onto the upstream call. */
|
|
610
|
+
const STRIP_REQUEST_HEADERS = /* @__PURE__ */ new Set([
|
|
611
|
+
"host",
|
|
612
|
+
"connection",
|
|
613
|
+
"content-length",
|
|
614
|
+
"cookie",
|
|
615
|
+
"authorization"
|
|
616
|
+
]);
|
|
617
|
+
/** Never copied from the upstream response back to the browser. */
|
|
618
|
+
const STRIP_RESPONSE_HEADERS = /* @__PURE__ */ new Set([
|
|
619
|
+
"content-encoding",
|
|
620
|
+
"content-length",
|
|
621
|
+
"transfer-encoding",
|
|
622
|
+
"connection",
|
|
623
|
+
"set-cookie"
|
|
624
|
+
]);
|
|
625
|
+
function jsonError(message, status) {
|
|
626
|
+
return Response.json({ message }, { status });
|
|
627
|
+
}
|
|
628
|
+
function trailingSlashOff(base) {
|
|
629
|
+
return base.replace(/\/$/, "");
|
|
630
|
+
}
|
|
631
|
+
/** The catch-all segments, whatever the route names its parameter. */
|
|
632
|
+
function pathSegments(params) {
|
|
633
|
+
for (const value of Object.values(params)) if (Array.isArray(value)) return value;
|
|
634
|
+
return [];
|
|
635
|
+
}
|
|
636
|
+
/**
|
|
637
|
+
* Read the sealed session's access credential. Server-side only — this value must never
|
|
638
|
+
* be handed to the browser.
|
|
639
|
+
*/
|
|
640
|
+
async function readAccessToken(store, config) {
|
|
641
|
+
return (await new ServerSession({
|
|
642
|
+
authClient: buildServerAuthClient(config),
|
|
643
|
+
secret: config.secret,
|
|
644
|
+
cookies: store
|
|
645
|
+
}).read())?.tokens.accessToken ?? null;
|
|
646
|
+
}
|
|
647
|
+
function adaptStore(store) {
|
|
648
|
+
return {
|
|
649
|
+
get: (name) => store.get(name)?.value,
|
|
650
|
+
set: (name, value, options) => store.set(name, value, options),
|
|
651
|
+
delete: (name) => store.delete(name)
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
/**
|
|
655
|
+
* Mount an authenticated proxy to one declared backend:
|
|
656
|
+
*
|
|
657
|
+
* ```ts
|
|
658
|
+
* // app/api/billing/proxy/[...path]/route.ts
|
|
659
|
+
* export const { GET, POST, PUT, PATCH, DELETE } = createDataProxy({
|
|
660
|
+
* upstream: process.env.BILLING_URL!,
|
|
661
|
+
* label: "Billing",
|
|
662
|
+
* });
|
|
663
|
+
* ```
|
|
664
|
+
*
|
|
665
|
+
* The client keeps calling a same-origin path; the credential is attached on this side of
|
|
666
|
+
* it and never reaches page scripts.
|
|
667
|
+
*/
|
|
668
|
+
function createDataProxy(options) {
|
|
669
|
+
const label = options.label ?? "PolyX";
|
|
670
|
+
async function handle(request, context) {
|
|
671
|
+
const upstreamBase = typeof options.upstream === "function" ? options.upstream() : options.upstream;
|
|
672
|
+
if (!upstreamBase) return jsonError(`${label} proxy misconfigured: no upstream URL configured.`, 500);
|
|
673
|
+
let config;
|
|
674
|
+
try {
|
|
675
|
+
config = resolveNextConfig(options);
|
|
676
|
+
} catch {
|
|
677
|
+
return jsonError(`${label} proxy misconfigured: PolyX configuration is incomplete.`, 500);
|
|
678
|
+
}
|
|
679
|
+
const accessToken = await readAccessToken(adaptStore(await (0, next_headers.cookies)()), config);
|
|
680
|
+
if (!accessToken) return jsonError("Not authenticated.", 401);
|
|
681
|
+
const incoming = new URL(request.url);
|
|
682
|
+
const target = `${trailingSlashOff(upstreamBase)}/${pathSegments(await context.params).map(encodeURIComponent).join("/")}${incoming.search}`;
|
|
683
|
+
const baseHeaders = new Headers();
|
|
684
|
+
request.headers.forEach((value, key) => {
|
|
685
|
+
if (!STRIP_REQUEST_HEADERS.has(key.toLowerCase())) baseHeaders.set(key, value);
|
|
686
|
+
});
|
|
687
|
+
const method = request.method.toUpperCase();
|
|
688
|
+
const body = method !== "GET" && method !== "HEAD" ? await request.arrayBuffer() : void 0;
|
|
689
|
+
const send = (token) => {
|
|
690
|
+
const headers = new Headers(baseHeaders);
|
|
691
|
+
headers.set("Authorization", `Bearer ${token}`);
|
|
692
|
+
return fetch(target, {
|
|
693
|
+
method,
|
|
694
|
+
headers,
|
|
695
|
+
body,
|
|
696
|
+
redirect: "manual"
|
|
697
|
+
});
|
|
698
|
+
};
|
|
699
|
+
let upstream = await send(accessToken);
|
|
700
|
+
if (upstream.status === 401) {
|
|
701
|
+
const refreshed = await refreshSession({
|
|
702
|
+
...options,
|
|
703
|
+
request
|
|
704
|
+
}).catch(() => null);
|
|
705
|
+
if (!refreshed?.isSignedIn || !refreshed.accessToken) return jsonError("Not authenticated.", 401);
|
|
706
|
+
upstream = await send(refreshed.accessToken);
|
|
707
|
+
}
|
|
708
|
+
const responseHeaders = new Headers();
|
|
709
|
+
upstream.headers.forEach((value, key) => {
|
|
710
|
+
if (!STRIP_RESPONSE_HEADERS.has(key.toLowerCase())) responseHeaders.set(key, value);
|
|
711
|
+
});
|
|
712
|
+
return new Response(upstream.body, {
|
|
713
|
+
status: upstream.status,
|
|
714
|
+
statusText: upstream.statusText,
|
|
715
|
+
headers: responseHeaders
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
return {
|
|
719
|
+
GET: handle,
|
|
720
|
+
POST: handle,
|
|
721
|
+
PUT: handle,
|
|
722
|
+
PATCH: handle,
|
|
723
|
+
DELETE: handle,
|
|
724
|
+
HEAD: handle
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
//#endregion
|
|
38
728
|
//#region src/server.ts
|
|
39
729
|
/**
|
|
40
730
|
* @poly-x/next/server — App Router server helpers (F007). `auth()` reads the
|
|
@@ -46,6 +736,25 @@ const VERIFIER_COOKIE = "polyx_pkce";
|
|
|
46
736
|
const VERIFIER_MAX_AGE = 600;
|
|
47
737
|
/** poly-auth's own floor (Joi: min 6, max 128) — reject early rather than round-trip. */
|
|
48
738
|
const MIN_PASSWORD_LENGTH = 6;
|
|
739
|
+
/**
|
|
740
|
+
* Narrow the platform's user record to what the profile surface needs.
|
|
741
|
+
*
|
|
742
|
+
* Relaying the whole record would leak whatever else poly-auth happens to return alongside it —
|
|
743
|
+
* role and permission references, internal flags, fields added by a future release nobody
|
|
744
|
+
* revisited this code for. An allow-list means new upstream fields stay server-side by default.
|
|
745
|
+
*/
|
|
746
|
+
function pickProfileFields(user) {
|
|
747
|
+
const picked = {};
|
|
748
|
+
for (const field of [
|
|
749
|
+
..._poly_x_core.PROFILE_FIELDS,
|
|
750
|
+
"profilePicture",
|
|
751
|
+
"email"
|
|
752
|
+
]) {
|
|
753
|
+
const value = user[field];
|
|
754
|
+
if (typeof value === "string") picked[field] = value;
|
|
755
|
+
}
|
|
756
|
+
return picked;
|
|
757
|
+
}
|
|
49
758
|
function adapt(store) {
|
|
50
759
|
return {
|
|
51
760
|
get: (name) => store.get(name)?.value,
|
|
@@ -148,17 +857,68 @@ function deviceContextHeadersFromBody(deviceContext) {
|
|
|
148
857
|
/** Read the current session — safe in Server Components (no cookie write). */
|
|
149
858
|
async function auth(overrides) {
|
|
150
859
|
const config = resolveNextConfig(overrides);
|
|
151
|
-
return
|
|
860
|
+
return toAuthContext(await new ServerSession({
|
|
152
861
|
authClient: buildServerAuthClient(config),
|
|
153
862
|
secret: config.secret,
|
|
154
863
|
cookies: await requestCookies()
|
|
155
864
|
}).read());
|
|
156
865
|
}
|
|
866
|
+
/**
|
|
867
|
+
* Single-flight keyed on the per-request cookie store. Next hands back the same store
|
|
868
|
+
* object for the life of a request, so concurrent callers within one request coalesce,
|
|
869
|
+
* while unrelated users in the same server process never serialise behind each other —
|
|
870
|
+
* which a module-level lock would have caused.
|
|
871
|
+
*/
|
|
872
|
+
const inFlightRefresh = /* @__PURE__ */ new WeakMap();
|
|
873
|
+
/**
|
|
874
|
+
* Refresh the current session from server code (F013 / FR-SESS-1).
|
|
875
|
+
*
|
|
876
|
+
* Before this existed there was no supported way to do it, so both live integrations
|
|
877
|
+
* did something worse: the server issued an HTTP request to its OWN
|
|
878
|
+
* `/api/polyx/refresh` route and then re-parsed the returned `Set-Cookie` header to
|
|
879
|
+
* recover the new token. That is a network round trip through the app's own edge to do
|
|
880
|
+
* something already possible in-process, and the header re-parse is fragile.
|
|
881
|
+
*
|
|
882
|
+
* Terminal-vs-transient classification lives in `ServerSession.refresh()` and is not
|
|
883
|
+
* duplicated here: a revoked family or an unstorable session signs the user out, and a
|
|
884
|
+
* transient upstream failure propagates so the caller can fail closed.
|
|
885
|
+
*/
|
|
886
|
+
async function refreshSession(options = {}) {
|
|
887
|
+
const store = await (0, next_headers.cookies)();
|
|
888
|
+
const pending = inFlightRefresh.get(store);
|
|
889
|
+
if (pending) return pending;
|
|
890
|
+
const run = (async () => {
|
|
891
|
+
const config = resolveNextConfig(options);
|
|
892
|
+
const secure = options.request ? resolveCookieSecure(options.request, config) : config.cookieSecure ?? true;
|
|
893
|
+
const next = await new ServerSession({
|
|
894
|
+
authClient: buildServerAuthClient(config),
|
|
895
|
+
secret: config.secret,
|
|
896
|
+
cookies: adapt(store),
|
|
897
|
+
secure
|
|
898
|
+
}).refresh();
|
|
899
|
+
if (!next) return {
|
|
900
|
+
isSignedIn: false,
|
|
901
|
+
accessToken: null,
|
|
902
|
+
claims: null
|
|
903
|
+
};
|
|
904
|
+
return {
|
|
905
|
+
isSignedIn: true,
|
|
906
|
+
accessToken: next.tokens.accessToken,
|
|
907
|
+
claims: next.session.claims
|
|
908
|
+
};
|
|
909
|
+
})();
|
|
910
|
+
inFlightRefresh.set(store, run);
|
|
911
|
+
try {
|
|
912
|
+
return await run;
|
|
913
|
+
} finally {
|
|
914
|
+
inFlightRefresh.delete(store);
|
|
915
|
+
}
|
|
916
|
+
}
|
|
157
917
|
function createAuthHandlers(handlersConfig = {}) {
|
|
158
918
|
const callbackPath = handlersConfig.callbackPath ?? "/api/polyx/callback";
|
|
159
919
|
const afterSignInPath = handlersConfig.afterSignInPath ?? "/";
|
|
160
920
|
function newSession(store, config, secure) {
|
|
161
|
-
return new
|
|
921
|
+
return new ServerSession({
|
|
162
922
|
authClient: buildServerAuthClient(config),
|
|
163
923
|
secret: config.secret,
|
|
164
924
|
cookies: store,
|
|
@@ -169,7 +929,7 @@ function createAuthHandlers(handlersConfig = {}) {
|
|
|
169
929
|
async signin(request) {
|
|
170
930
|
const config = resolveNextConfig(handlersConfig);
|
|
171
931
|
const secure = resolveCookieSecure(request, config);
|
|
172
|
-
const redirectUri = `${
|
|
932
|
+
const redirectUri = `${resolvePublicOrigin(request, config)}${callbackPath}`;
|
|
173
933
|
const { verifier, challenge } = await (0, _poly_x_core.generatePkce)();
|
|
174
934
|
const state = (0, _poly_x_core.randomState)();
|
|
175
935
|
(await (0, next_headers.cookies)()).set(VERIFIER_COOKIE, JSON.stringify({
|
|
@@ -193,7 +953,7 @@ function createAuthHandlers(handlersConfig = {}) {
|
|
|
193
953
|
async authenticate(request) {
|
|
194
954
|
const config = resolveNextConfig(handlersConfig);
|
|
195
955
|
const secure = resolveCookieSecure(request, config);
|
|
196
|
-
const redirectUri = `${
|
|
956
|
+
const redirectUri = `${resolvePublicOrigin(request, config)}${callbackPath}`;
|
|
197
957
|
const client = buildServerAuthClient(config);
|
|
198
958
|
let payload;
|
|
199
959
|
try {
|
|
@@ -232,7 +992,7 @@ function createAuthHandlers(handlersConfig = {}) {
|
|
|
232
992
|
...deviceContextHeaders
|
|
233
993
|
});
|
|
234
994
|
} catch (error) {
|
|
235
|
-
if (error instanceof
|
|
995
|
+
if (error instanceof SessionCookieTooLargeError) {
|
|
236
996
|
console.error(`[polyx] ${error.message}`);
|
|
237
997
|
return Response.json({
|
|
238
998
|
status: "session_too_large",
|
|
@@ -245,7 +1005,7 @@ function createAuthHandlers(handlersConfig = {}) {
|
|
|
245
1005
|
return Response.json({
|
|
246
1006
|
status: "success",
|
|
247
1007
|
redirectTo: afterSignInPath
|
|
248
|
-
});
|
|
1008
|
+
}, { headers: { "set-cookie": buildAuthEventCookie("signed-in", secure) } });
|
|
249
1009
|
}
|
|
250
1010
|
case "select_tenant": return Response.json({
|
|
251
1011
|
status: "select_tenant",
|
|
@@ -257,6 +1017,18 @@ function createAuthHandlers(handlersConfig = {}) {
|
|
|
257
1017
|
userId: outcome.userId
|
|
258
1018
|
}, { status: 403 });
|
|
259
1019
|
case "no_access": return Response.json({ status: "no_access" }, { status: 403 });
|
|
1020
|
+
case "verification_required": return Response.json({
|
|
1021
|
+
status: "verification_required",
|
|
1022
|
+
challengeId: outcome.challengeId,
|
|
1023
|
+
factorTypes: outcome.factorTypes
|
|
1024
|
+
}, { status: 401 });
|
|
1025
|
+
case "configuration_error":
|
|
1026
|
+
console.error(`[polyx] Sign-in rejected for configuration, not credentials: ${outcome.code}${outcome.message ? ` — ${outcome.message}` : ""}. redirect_uri used: ${redirectUri}. If the app is behind a proxy, set POLYX_PUBLIC_ORIGIN or enable POLYX_TRUST_FORWARDED_ORIGIN.`);
|
|
1027
|
+
return Response.json({
|
|
1028
|
+
status: "configuration_error",
|
|
1029
|
+
code: outcome.code,
|
|
1030
|
+
message: outcome.message
|
|
1031
|
+
}, { status: 500 });
|
|
260
1032
|
default: return Response.json({ status: "invalid_credentials" }, { status: 401 });
|
|
261
1033
|
}
|
|
262
1034
|
}
|
|
@@ -345,7 +1117,7 @@ function createAuthHandlers(handlersConfig = {}) {
|
|
|
345
1117
|
try {
|
|
346
1118
|
await newSession(adapt(store), config, secure).establishFromCode(code, tx.verifier, tx.redirectUri, collectForwardedDeviceHeaders(request));
|
|
347
1119
|
} catch (error) {
|
|
348
|
-
if (error instanceof
|
|
1120
|
+
if (error instanceof SessionCookieTooLargeError) {
|
|
349
1121
|
console.error(`[polyx] ${error.message}`);
|
|
350
1122
|
return Response.redirect(home, 302);
|
|
351
1123
|
}
|
|
@@ -354,10 +1126,11 @@ function createAuthHandlers(handlersConfig = {}) {
|
|
|
354
1126
|
return Response.redirect(home, 302);
|
|
355
1127
|
},
|
|
356
1128
|
async refresh(request) {
|
|
357
|
-
const
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
1129
|
+
const { isSignedIn } = await refreshSession({
|
|
1130
|
+
...handlersConfig,
|
|
1131
|
+
request
|
|
1132
|
+
});
|
|
1133
|
+
return Response.json({ isSignedIn }, { status: isSignedIn ? 200 : 401 });
|
|
361
1134
|
},
|
|
362
1135
|
/**
|
|
363
1136
|
* End the session: revoke it upstream (bearer-authenticated, using the sealed token)
|
|
@@ -374,15 +1147,188 @@ function createAuthHandlers(handlersConfig = {}) {
|
|
|
374
1147
|
const secure = resolveCookieSecure(request, config);
|
|
375
1148
|
const scope = new URL(request.url).searchParams.get("scope") === "everywhere" ? "everywhere" : "device";
|
|
376
1149
|
await newSession(adapt(await (0, next_headers.cookies)()), config, secure).revoke(scope);
|
|
377
|
-
|
|
378
|
-
|
|
1150
|
+
const event = buildAuthEventCookie("signed-out", secure);
|
|
1151
|
+
if (request.headers.get("accept")?.includes("application/json")) return Response.json({ signedOut: true }, { headers: { "set-cookie": event } });
|
|
1152
|
+
return new Response(null, {
|
|
1153
|
+
status: 302,
|
|
1154
|
+
headers: {
|
|
1155
|
+
location: resolvePublicOrigin(request, config) + "/",
|
|
1156
|
+
"set-cookie": event
|
|
1157
|
+
}
|
|
1158
|
+
});
|
|
379
1159
|
},
|
|
380
1160
|
async session() {
|
|
381
1161
|
return Response.json(await auth(handlersConfig));
|
|
382
1162
|
},
|
|
1163
|
+
/**
|
|
1164
|
+
* Exchange the sealed session for a realtime handshake ticket (F019 / FR-RT-2).
|
|
1165
|
+
*
|
|
1166
|
+
* The session credential stays on this side: the SDK calls the platform with it and
|
|
1167
|
+
* hands the browser only the ticket, which is single-use and expires in seconds.
|
|
1168
|
+
*/
|
|
1169
|
+
async realtimeTicket(request) {
|
|
1170
|
+
const config = resolveNextConfig(handlersConfig);
|
|
1171
|
+
if (!config.realtimeBaseUrl) return Response.json({
|
|
1172
|
+
status: "unavailable",
|
|
1173
|
+
message: "Set POLYX_REALTIME_URL to issue realtime tickets."
|
|
1174
|
+
}, { status: 503 });
|
|
1175
|
+
const current = await new ServerSession({
|
|
1176
|
+
authClient: buildServerAuthClient(config),
|
|
1177
|
+
secret: config.secret,
|
|
1178
|
+
cookies: await requestCookies()
|
|
1179
|
+
}).read();
|
|
1180
|
+
if (!current) return Response.json({ status: "signed_out" }, { status: 401 });
|
|
1181
|
+
const endpoint = `${config.realtimeBaseUrl.replace(/\/$/, "")}/v1/socket-ticket`;
|
|
1182
|
+
const send = (token) => fetch(endpoint, {
|
|
1183
|
+
method: "POST",
|
|
1184
|
+
headers: {
|
|
1185
|
+
Authorization: `Bearer ${token}`,
|
|
1186
|
+
Accept: "application/json"
|
|
1187
|
+
}
|
|
1188
|
+
});
|
|
1189
|
+
let organizationId = current.session.claims.organizationId;
|
|
1190
|
+
try {
|
|
1191
|
+
let upstream = await send(current.tokens.accessToken);
|
|
1192
|
+
if (upstream.status === 401) {
|
|
1193
|
+
const refreshed = await refreshSession({
|
|
1194
|
+
...handlersConfig,
|
|
1195
|
+
request
|
|
1196
|
+
}).catch(() => null);
|
|
1197
|
+
if (!refreshed?.isSignedIn || !refreshed.accessToken) return Response.json({ status: "signed_out" }, { status: 401 });
|
|
1198
|
+
organizationId = refreshed.claims?.organizationId ?? organizationId;
|
|
1199
|
+
upstream = await send(refreshed.accessToken);
|
|
1200
|
+
}
|
|
1201
|
+
if (!upstream.ok) return Response.json({ status: "unavailable" }, { status: 503 });
|
|
1202
|
+
const body = await upstream.json();
|
|
1203
|
+
if (!body.ticket) return Response.json({ status: "unavailable" }, { status: 503 });
|
|
1204
|
+
return Response.json({
|
|
1205
|
+
ticket: body.ticket,
|
|
1206
|
+
expiresInSeconds: body.expiresInSeconds,
|
|
1207
|
+
organizationId
|
|
1208
|
+
});
|
|
1209
|
+
} catch {
|
|
1210
|
+
return Response.json({ status: "unavailable" }, { status: 503 });
|
|
1211
|
+
}
|
|
1212
|
+
},
|
|
1213
|
+
async profile(request) {
|
|
1214
|
+
const config = resolveNextConfig(handlersConfig);
|
|
1215
|
+
const session = new ServerSession({
|
|
1216
|
+
authClient: buildServerAuthClient(config),
|
|
1217
|
+
secret: config.secret,
|
|
1218
|
+
cookies: await requestCookies()
|
|
1219
|
+
});
|
|
1220
|
+
const current = await session.read();
|
|
1221
|
+
if (!current) return Response.json({ status: "signed_out" }, { status: 401 });
|
|
1222
|
+
const { userId, organizationId } = current.session.claims;
|
|
1223
|
+
if (!organizationId) return Response.json({ status: "no_organization" }, { status: 409 });
|
|
1224
|
+
if (!config.baseUrl) return Response.json({ status: "configuration_error" }, { status: 503 });
|
|
1225
|
+
const target = `${config.baseUrl.replace(/\/$/, "")}/v1/user/${encodeURIComponent(userId)}`;
|
|
1226
|
+
const headers = {
|
|
1227
|
+
"organization-id": organizationId,
|
|
1228
|
+
Accept: "application/json"
|
|
1229
|
+
};
|
|
1230
|
+
let body;
|
|
1231
|
+
if (request.method !== "GET") {
|
|
1232
|
+
/**
|
|
1233
|
+
* The body is REBUILT from an allow-list rather than forwarded.
|
|
1234
|
+
*
|
|
1235
|
+
* `updateUser` upstream applies whatever recognised fields it is given — including
|
|
1236
|
+
* `email`, `password` and `role`. Relaying the browser's multipart verbatim would
|
|
1237
|
+
* therefore turn a profile form into a way to change a credential or grant a role,
|
|
1238
|
+
* skipping the verification each of those has (D25). The allow-list is the control:
|
|
1239
|
+
* anything not in it never leaves this process.
|
|
1240
|
+
*/
|
|
1241
|
+
/**
|
|
1242
|
+
* Parsed inside its own guard. `formData()` throws on a body that is not multipart or
|
|
1243
|
+
* form-encoded — the likeliest cause being an integrator posting JSON, which is the
|
|
1244
|
+
* obvious guess — and this sits before the try block that covers the upstream call, so
|
|
1245
|
+
* the rejection escaped the handler and Next answered 500. A 500 says the SDK broke;
|
|
1246
|
+
* an unreadable request is the caller's, and it belongs in nobody's alerting.
|
|
1247
|
+
*/
|
|
1248
|
+
let submitted;
|
|
1249
|
+
try {
|
|
1250
|
+
submitted = await request.formData();
|
|
1251
|
+
} catch {
|
|
1252
|
+
return Response.json({ status: "invalid_request" }, { status: 400 });
|
|
1253
|
+
}
|
|
1254
|
+
body = new FormData();
|
|
1255
|
+
for (const field of _poly_x_core.PROFILE_FIELDS) {
|
|
1256
|
+
const value = submitted.get(field);
|
|
1257
|
+
if (typeof value === "string" && value.trim() !== "") body.append(field, value.trim());
|
|
1258
|
+
}
|
|
1259
|
+
const image = submitted.get("image");
|
|
1260
|
+
if (image && typeof image !== "string") body.append("image", image);
|
|
1261
|
+
}
|
|
1262
|
+
const send = (token) => fetch(target, {
|
|
1263
|
+
method: request.method === "GET" ? "GET" : "PUT",
|
|
1264
|
+
headers: {
|
|
1265
|
+
...headers,
|
|
1266
|
+
Authorization: `Bearer ${token}`
|
|
1267
|
+
},
|
|
1268
|
+
body
|
|
1269
|
+
});
|
|
1270
|
+
let activeToken = current.tokens.accessToken;
|
|
1271
|
+
try {
|
|
1272
|
+
let upstream = await send(activeToken);
|
|
1273
|
+
if (upstream.status === 401) {
|
|
1274
|
+
const refreshed = await refreshSession({
|
|
1275
|
+
...handlersConfig,
|
|
1276
|
+
request
|
|
1277
|
+
}).catch(() => null);
|
|
1278
|
+
if (!refreshed?.isSignedIn || !refreshed.accessToken) return Response.json({ status: "signed_out" }, { status: 401 });
|
|
1279
|
+
activeToken = refreshed.accessToken;
|
|
1280
|
+
upstream = await send(activeToken);
|
|
1281
|
+
}
|
|
1282
|
+
const payload = await upstream.json().catch(() => ({}));
|
|
1283
|
+
if (!upstream.ok) return Response.json({
|
|
1284
|
+
status: "error",
|
|
1285
|
+
message: typeof payload.message === "string" ? payload.message : void 0
|
|
1286
|
+
}, { status: upstream.status });
|
|
1287
|
+
let user = payload.user ?? payload.data ?? payload;
|
|
1288
|
+
if (request.method !== "GET") {
|
|
1289
|
+
/**
|
|
1290
|
+
* Re-read after a save, because the update answers with the id ALONE.
|
|
1291
|
+
*
|
|
1292
|
+
* poly-auth returns `{message, data: {_id}}` from the update — deliberately, so a
|
|
1293
|
+
* password hash never rides on the response. So the saved document is simply not in
|
|
1294
|
+
* hand here: without this read the caller gets `{}` back and the display-claim update
|
|
1295
|
+
* below is a no-op against every field, leaving the account menu showing the name the
|
|
1296
|
+
* person just corrected (the exact thing FR-PROF-5 exists to prevent).
|
|
1297
|
+
*
|
|
1298
|
+
* The platform contract is frozen for the SDK, so the extra round trip is the answer
|
|
1299
|
+
* rather than asking poly-auth to return more.
|
|
1300
|
+
*/
|
|
1301
|
+
const reread = await fetch(target, {
|
|
1302
|
+
method: "GET",
|
|
1303
|
+
headers: {
|
|
1304
|
+
...headers,
|
|
1305
|
+
Authorization: `Bearer ${activeToken}`
|
|
1306
|
+
}
|
|
1307
|
+
}).catch(() => null);
|
|
1308
|
+
if (reread?.ok) {
|
|
1309
|
+
const body = await reread.json().catch(() => ({}));
|
|
1310
|
+
const fresh = body.user ?? body.data ?? body;
|
|
1311
|
+
if (fresh && typeof fresh === "object") user = fresh;
|
|
1312
|
+
}
|
|
1313
|
+
await session.updateDisplayClaims((0, _poly_x_core.deriveDisplayClaims)(user));
|
|
1314
|
+
}
|
|
1315
|
+
return Response.json({
|
|
1316
|
+
status: "ok",
|
|
1317
|
+
user: pickProfileFields(user)
|
|
1318
|
+
});
|
|
1319
|
+
} catch {
|
|
1320
|
+
return Response.json({ status: "unavailable" }, { status: 503 });
|
|
1321
|
+
}
|
|
1322
|
+
},
|
|
1323
|
+
/**
|
|
1324
|
+
* @deprecated Superseded by `realtimeTicket` (FR-RT-4). This hands the browser the
|
|
1325
|
+
* SESSION ACCESS TOKEN — the one place the custody guarantee is knowingly broken.
|
|
1326
|
+
* Kept working for this release so consumers migrate on their own schedule
|
|
1327
|
+
* (FR-COMPAT-3); it will be removed in a later major.
|
|
1328
|
+
*/
|
|
383
1329
|
async subscriptionCredential() {
|
|
384
1330
|
const config = resolveNextConfig(handlersConfig);
|
|
385
|
-
const current = await new
|
|
1331
|
+
const current = await new ServerSession({
|
|
386
1332
|
authClient: buildServerAuthClient(config),
|
|
387
1333
|
secret: config.secret,
|
|
388
1334
|
cookies: await requestCookies()
|
|
@@ -396,13 +1342,22 @@ function createAuthHandlers(handlersConfig = {}) {
|
|
|
396
1342
|
};
|
|
397
1343
|
}
|
|
398
1344
|
//#endregion
|
|
399
|
-
exports.
|
|
400
|
-
exports.
|
|
401
|
-
exports.
|
|
402
|
-
exports.
|
|
1345
|
+
exports.AUTH_EVENT_COOKIE = AUTH_EVENT_COOKIE;
|
|
1346
|
+
exports.DEFAULT_SESSION_COOKIE = require_session_cookie.DEFAULT_SESSION_COOKIE;
|
|
1347
|
+
exports.POLYX_ACTIONS = POLYX_ACTIONS;
|
|
1348
|
+
exports.SESSION_COOKIE_MAX_BYTES = SESSION_COOKIE_MAX_BYTES;
|
|
1349
|
+
exports.ServerSession = ServerSession;
|
|
1350
|
+
exports.SessionCookieTooLargeError = SessionCookieTooLargeError;
|
|
403
1351
|
exports.auth = auth;
|
|
1352
|
+
exports.buildAuthEventCookie = buildAuthEventCookie;
|
|
404
1353
|
exports.createAuthHandlers = createAuthHandlers;
|
|
405
|
-
exports.
|
|
1354
|
+
exports.createDataProxy = createDataProxy;
|
|
1355
|
+
exports.createPolyXHandler = createPolyXHandler;
|
|
1356
|
+
exports.getSessionContext = getSessionContext;
|
|
1357
|
+
exports.openSession = openSession;
|
|
1358
|
+
exports.readSessionCookie = require_session_cookie.readSessionCookie;
|
|
1359
|
+
exports.refreshSession = refreshSession;
|
|
406
1360
|
exports.resolveNextConfig = resolveNextConfig;
|
|
407
|
-
exports.
|
|
408
|
-
exports.
|
|
1361
|
+
exports.resolvePublicOrigin = resolvePublicOrigin;
|
|
1362
|
+
exports.sealSession = sealSession;
|
|
1363
|
+
exports.sealedCookieByteLength = sealedCookieByteLength;
|