@poly-x/next 0.1.1 → 0.3.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 +166 -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 +1085 -22
- package/dist/server.d.cts +294 -3
- package/dist/server.d.mts +294 -3
- package/dist/server.mjs +1066 -12
- 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
|
@@ -1,207 +0,0 @@
|
|
|
1
|
-
import { PolyXError, SessionExpiredError, SessionRevokedError } from "@poly-x/core";
|
|
2
|
-
//#region src/cookie-codec.ts
|
|
3
|
-
/**
|
|
4
|
-
* AES-GCM sealing of the session cookie (F007 / ADR-0004). The key is derived
|
|
5
|
-
* (SHA-256) from the consumer's `POLYX_SESSION_SECRET`; AES-GCM gives
|
|
6
|
-
* confidentiality + tamper-detection together, so any corruption, truncation,
|
|
7
|
-
* or wrong-secret input decrypts to `null` rather than a partial session.
|
|
8
|
-
* Web Crypto is universal (Node ≥20 + edge runtimes), so this runs anywhere a
|
|
9
|
-
* Next route handler runs.
|
|
10
|
-
*/
|
|
11
|
-
const IV_BYTES = 12;
|
|
12
|
-
/**
|
|
13
|
-
* Browsers cap a single cookie at roughly 4096 bytes for the whole `Set-Cookie` pair —
|
|
14
|
-
* name, value and attributes together — and enforce it by DISCARDING the cookie without
|
|
15
|
-
* an error. For a session cookie that failure is invisible and badly misleading: the
|
|
16
|
-
* platform authenticates, the handler answers 200, and the user is bounced back to
|
|
17
|
-
* sign-in on the next request because the cookie was never stored.
|
|
18
|
-
*
|
|
19
|
-
* poly-x-sdk/ADR-0006 measured a live Odin session at 2628 bytes and recorded the missing
|
|
20
|
-
* guard as an open follow-up; a per-tenant flag that inflates the JWT is enough to cross
|
|
21
|
-
* the line. We fail loudly here instead.
|
|
22
|
-
*/
|
|
23
|
-
const SESSION_COOKIE_MAX_BYTES = 4096;
|
|
24
|
-
/** Attributes `buildSessionCookieOptions` always sets: `Path=/; HttpOnly; Secure; SameSite=Lax`. */
|
|
25
|
-
const COOKIE_ATTRIBUTE_BYTES = 45;
|
|
26
|
-
/** Bytes the whole `Set-Cookie` pair will occupy for this value under this cookie name. */
|
|
27
|
-
function sealedCookieByteLength(value, cookieName = "polyx_session") {
|
|
28
|
-
return new TextEncoder().encode(cookieName).length + 1 + value.length + COOKIE_ATTRIBUTE_BYTES;
|
|
29
|
-
}
|
|
30
|
-
/**
|
|
31
|
-
* Thrown when a sealed session would exceed what a browser will store. Never includes the
|
|
32
|
-
* session itself — the claims that caused the overflow are exactly the data we must not log.
|
|
33
|
-
*/
|
|
34
|
-
var SessionCookieTooLargeError = class extends PolyXError {
|
|
35
|
-
bytes;
|
|
36
|
-
limit;
|
|
37
|
-
constructor(bytes, limit, cookieName) {
|
|
38
|
-
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" });
|
|
39
|
-
this.bytes = bytes;
|
|
40
|
-
this.limit = limit;
|
|
41
|
-
}
|
|
42
|
-
};
|
|
43
|
-
function base64UrlEncode(bytes) {
|
|
44
|
-
let binary = "";
|
|
45
|
-
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
46
|
-
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
47
|
-
}
|
|
48
|
-
function base64UrlDecode(text) {
|
|
49
|
-
const base64 = text.replace(/-/g, "+").replace(/_/g, "/");
|
|
50
|
-
const padded = base64 + "=".repeat((4 - base64.length % 4) % 4);
|
|
51
|
-
const binary = atob(padded);
|
|
52
|
-
const bytes = new Uint8Array(binary.length);
|
|
53
|
-
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
|
|
54
|
-
return bytes;
|
|
55
|
-
}
|
|
56
|
-
async function deriveKey(secret) {
|
|
57
|
-
const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(secret));
|
|
58
|
-
return crypto.subtle.importKey("raw", hash, "AES-GCM", false, ["encrypt", "decrypt"]);
|
|
59
|
-
}
|
|
60
|
-
async function sealSession(session, secret, options = {}) {
|
|
61
|
-
const key = await deriveKey(secret);
|
|
62
|
-
const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES));
|
|
63
|
-
const plaintext = new TextEncoder().encode(JSON.stringify(session));
|
|
64
|
-
const ciphertext = new Uint8Array(await crypto.subtle.encrypt({
|
|
65
|
-
name: "AES-GCM",
|
|
66
|
-
iv
|
|
67
|
-
}, key, plaintext));
|
|
68
|
-
const packed = new Uint8Array(iv.length + ciphertext.length);
|
|
69
|
-
packed.set(iv);
|
|
70
|
-
packed.set(ciphertext, iv.length);
|
|
71
|
-
const sealed = base64UrlEncode(packed);
|
|
72
|
-
const cookieName = options.cookieName ?? "polyx_session";
|
|
73
|
-
const bytes = sealedCookieByteLength(sealed, cookieName);
|
|
74
|
-
if (bytes > 4096) throw new SessionCookieTooLargeError(bytes, SESSION_COOKIE_MAX_BYTES, cookieName);
|
|
75
|
-
return sealed;
|
|
76
|
-
}
|
|
77
|
-
async function openSession(token, secret) {
|
|
78
|
-
try {
|
|
79
|
-
const packed = base64UrlDecode(token);
|
|
80
|
-
if (packed.length <= IV_BYTES) return null;
|
|
81
|
-
const iv = packed.slice(0, IV_BYTES);
|
|
82
|
-
const ciphertext = packed.slice(IV_BYTES);
|
|
83
|
-
const key = await deriveKey(secret);
|
|
84
|
-
const plaintext = await crypto.subtle.decrypt({
|
|
85
|
-
name: "AES-GCM",
|
|
86
|
-
iv
|
|
87
|
-
}, key, ciphertext);
|
|
88
|
-
return JSON.parse(new TextDecoder().decode(plaintext));
|
|
89
|
-
} catch {
|
|
90
|
-
return null;
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
//#endregion
|
|
94
|
-
//#region src/cookie-adapter.ts
|
|
95
|
-
/**
|
|
96
|
-
* Attributes for the session cookie (ADR-0004 secure custody). `secure` is decided at
|
|
97
|
-
* write time from the actual transport: a `Secure` cookie is silently dropped by the
|
|
98
|
-
* browser over plain http, so http/localhost dev must set `secure:false` while https
|
|
99
|
-
* always sets `secure:true`. This is not a downgrade — `Secure` carries no meaning on
|
|
100
|
-
* http; setting it there just breaks the session. httpOnly/sameSite/path are unchanged.
|
|
101
|
-
*/
|
|
102
|
-
function buildSessionCookieOptions(secure) {
|
|
103
|
-
return {
|
|
104
|
-
httpOnly: true,
|
|
105
|
-
secure,
|
|
106
|
-
sameSite: "lax",
|
|
107
|
-
path: "/"
|
|
108
|
-
};
|
|
109
|
-
}
|
|
110
|
-
//#endregion
|
|
111
|
-
//#region src/server-session.ts
|
|
112
|
-
/**
|
|
113
|
-
* The framework-agnostic BFF session controller (F007): tokens sealed in an
|
|
114
|
-
* httpOnly cookie on the consumer's domain, exchanged/refreshed server-side.
|
|
115
|
-
* Composes core's AuthClient with a CookieAdapter seam; fully unit-testable.
|
|
116
|
-
*/
|
|
117
|
-
const DEFAULT_SESSION_COOKIE = "polyx_session";
|
|
118
|
-
var ServerSession = class {
|
|
119
|
-
authClient;
|
|
120
|
-
secret;
|
|
121
|
-
cookies;
|
|
122
|
-
cookieName;
|
|
123
|
-
secure;
|
|
124
|
-
constructor(options) {
|
|
125
|
-
this.authClient = options.authClient;
|
|
126
|
-
this.secret = options.secret;
|
|
127
|
-
this.cookies = options.cookies;
|
|
128
|
-
this.cookieName = options.cookieName ?? "polyx_session";
|
|
129
|
-
this.secure = options.secure ?? true;
|
|
130
|
-
}
|
|
131
|
-
async establishFromCode(code, codeVerifier, redirectUri, forwardedHeaders) {
|
|
132
|
-
const stored = await this.authClient.exchangeCode({
|
|
133
|
-
code,
|
|
134
|
-
codeVerifier,
|
|
135
|
-
redirectUri,
|
|
136
|
-
forwardedHeaders
|
|
137
|
-
});
|
|
138
|
-
await this.write(stored);
|
|
139
|
-
return stored;
|
|
140
|
-
}
|
|
141
|
-
async read() {
|
|
142
|
-
const raw = this.cookies.get(this.cookieName);
|
|
143
|
-
if (!raw) return null;
|
|
144
|
-
return openSession(raw, this.secret);
|
|
145
|
-
}
|
|
146
|
-
/** Rotate the tokens; clears the cookie (returns null) on a terminal refresh failure. */
|
|
147
|
-
async refresh() {
|
|
148
|
-
const current = await this.read();
|
|
149
|
-
if (!current) return null;
|
|
150
|
-
try {
|
|
151
|
-
const next = await this.authClient.createRefresher()(current);
|
|
152
|
-
await this.write(next);
|
|
153
|
-
return next;
|
|
154
|
-
} catch (error) {
|
|
155
|
-
if (error instanceof SessionRevokedError || error instanceof SessionExpiredError) {
|
|
156
|
-
this.clear();
|
|
157
|
-
return null;
|
|
158
|
-
}
|
|
159
|
-
if (error instanceof SessionCookieTooLargeError) {
|
|
160
|
-
console.error(`[polyx] ${error.message}`);
|
|
161
|
-
this.clear();
|
|
162
|
-
return null;
|
|
163
|
-
}
|
|
164
|
-
throw error;
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
/**
|
|
168
|
-
* End the session upstream, then locally. `device` revokes this session; `everywhere`
|
|
169
|
-
* revokes every session for the user (FR-SSO-5).
|
|
170
|
-
*
|
|
171
|
-
* Order is load-bearing: revocation is authenticated with the access token sealed in the
|
|
172
|
-
* cookie, so the cookie must be read before it is dropped. The upstream call is
|
|
173
|
-
* best-effort — an unreachable platform must not strand the user half-signed-in — but the
|
|
174
|
-
* call itself is real: the ecosystem logout the SDK used before resolves its session from
|
|
175
|
-
* a browser cookie, which a server-to-server fetch never carries, so it silently revoked
|
|
176
|
-
* nothing and answered 200.
|
|
177
|
-
*/
|
|
178
|
-
async revoke(scope = "device") {
|
|
179
|
-
const current = await this.read();
|
|
180
|
-
if (current) try {
|
|
181
|
-
await this.authClient.revokeSession(current.tokens.accessToken, scope);
|
|
182
|
-
} catch {}
|
|
183
|
-
this.clear();
|
|
184
|
-
}
|
|
185
|
-
clear() {
|
|
186
|
-
this.cookies.delete(this.cookieName);
|
|
187
|
-
}
|
|
188
|
-
async write(session) {
|
|
189
|
-
const sealed = await sealSession(session, this.secret, { cookieName: this.cookieName });
|
|
190
|
-
this.cookies.set(this.cookieName, sealed, buildSessionCookieOptions(this.secure));
|
|
191
|
-
}
|
|
192
|
-
};
|
|
193
|
-
function toAuthContext(session) {
|
|
194
|
-
if (!session) return {
|
|
195
|
-
isSignedIn: false,
|
|
196
|
-
userId: null,
|
|
197
|
-
claims: null
|
|
198
|
-
};
|
|
199
|
-
return {
|
|
200
|
-
isSignedIn: true,
|
|
201
|
-
userId: session.session.claims.userId,
|
|
202
|
-
organizationId: session.session.claims.organizationId,
|
|
203
|
-
claims: session.session.claims
|
|
204
|
-
};
|
|
205
|
-
}
|
|
206
|
-
//#endregion
|
|
207
|
-
export { SessionCookieTooLargeError as a, sealedCookieByteLength as c, SESSION_COOKIE_MAX_BYTES as i, ServerSession as n, openSession as o, toAuthContext as r, sealSession as s, DEFAULT_SESSION_COOKIE as t };
|