@poly-x/next 0.1.0-alpha.8 → 0.1.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/dist/proxy.cjs +1 -1
- package/dist/proxy.mjs +1 -1
- package/dist/{server-session-B3zJ7CS8.mjs → server-session-CjP44zFC.mjs} +39 -10
- package/dist/{server-session-YbnXBt3c.cjs → server-session-QJLA_Tz-.cjs} +39 -10
- package/dist/server.cjs +147 -13
- package/dist/server.d.cts +42 -1
- package/dist/server.d.mts +42 -1
- package/dist/server.mjs +147 -13
- package/dist/styles.css +273 -0
- package/package.json +3 -3
package/dist/proxy.cjs
CHANGED
package/dist/proxy.mjs
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
import { SessionExpiredError, SessionRevokedError } from "@poly-x/core";
|
|
2
2
|
//#region src/cookie-adapter.ts
|
|
3
|
-
/**
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
3
|
+
/**
|
|
4
|
+
* Attributes for the session cookie (ADR-0004 secure custody). `secure` is decided at
|
|
5
|
+
* write time from the actual transport: a `Secure` cookie is silently dropped by the
|
|
6
|
+
* browser over plain http, so http/localhost dev must set `secure:false` while https
|
|
7
|
+
* always sets `secure:true`. This is not a downgrade — `Secure` carries no meaning on
|
|
8
|
+
* http; setting it there just breaks the session. httpOnly/sameSite/path are unchanged.
|
|
9
|
+
*/
|
|
10
|
+
function buildSessionCookieOptions(secure) {
|
|
11
|
+
return {
|
|
12
|
+
httpOnly: true,
|
|
13
|
+
secure,
|
|
14
|
+
sameSite: "lax",
|
|
15
|
+
path: "/"
|
|
16
|
+
};
|
|
17
|
+
}
|
|
10
18
|
//#endregion
|
|
11
19
|
//#region src/cookie-codec.ts
|
|
12
20
|
const IV_BYTES = 12;
|
|
@@ -69,17 +77,20 @@ var ServerSession = class {
|
|
|
69
77
|
secret;
|
|
70
78
|
cookies;
|
|
71
79
|
cookieName;
|
|
80
|
+
secure;
|
|
72
81
|
constructor(options) {
|
|
73
82
|
this.authClient = options.authClient;
|
|
74
83
|
this.secret = options.secret;
|
|
75
84
|
this.cookies = options.cookies;
|
|
76
85
|
this.cookieName = options.cookieName ?? "polyx_session";
|
|
86
|
+
this.secure = options.secure ?? true;
|
|
77
87
|
}
|
|
78
|
-
async establishFromCode(code, codeVerifier, redirectUri) {
|
|
88
|
+
async establishFromCode(code, codeVerifier, redirectUri, forwardedHeaders) {
|
|
79
89
|
const stored = await this.authClient.exchangeCode({
|
|
80
90
|
code,
|
|
81
91
|
codeVerifier,
|
|
82
|
-
redirectUri
|
|
92
|
+
redirectUri,
|
|
93
|
+
forwardedHeaders
|
|
83
94
|
});
|
|
84
95
|
await this.write(stored);
|
|
85
96
|
return stored;
|
|
@@ -105,12 +116,30 @@ var ServerSession = class {
|
|
|
105
116
|
throw error;
|
|
106
117
|
}
|
|
107
118
|
}
|
|
119
|
+
/**
|
|
120
|
+
* End the session upstream, then locally. `device` revokes this session; `everywhere`
|
|
121
|
+
* revokes every session for the user (FR-SSO-5).
|
|
122
|
+
*
|
|
123
|
+
* Order is load-bearing: revocation is authenticated with the access token sealed in the
|
|
124
|
+
* cookie, so the cookie must be read before it is dropped. The upstream call is
|
|
125
|
+
* best-effort — an unreachable platform must not strand the user half-signed-in — but the
|
|
126
|
+
* call itself is real: the ecosystem logout the SDK used before resolves its session from
|
|
127
|
+
* a browser cookie, which a server-to-server fetch never carries, so it silently revoked
|
|
128
|
+
* nothing and answered 200.
|
|
129
|
+
*/
|
|
130
|
+
async revoke(scope = "device") {
|
|
131
|
+
const current = await this.read();
|
|
132
|
+
if (current) try {
|
|
133
|
+
await this.authClient.revokeSession(current.tokens.accessToken, scope);
|
|
134
|
+
} catch {}
|
|
135
|
+
this.clear();
|
|
136
|
+
}
|
|
108
137
|
clear() {
|
|
109
138
|
this.cookies.delete(this.cookieName);
|
|
110
139
|
}
|
|
111
140
|
async write(session) {
|
|
112
141
|
const sealed = await sealSession(session, this.secret);
|
|
113
|
-
this.cookies.set(this.cookieName, sealed,
|
|
142
|
+
this.cookies.set(this.cookieName, sealed, buildSessionCookieOptions(this.secure));
|
|
114
143
|
}
|
|
115
144
|
};
|
|
116
145
|
function toAuthContext(session) {
|
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
let _poly_x_core = require("@poly-x/core");
|
|
2
2
|
//#region src/cookie-adapter.ts
|
|
3
|
-
/**
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
3
|
+
/**
|
|
4
|
+
* Attributes for the session cookie (ADR-0004 secure custody). `secure` is decided at
|
|
5
|
+
* write time from the actual transport: a `Secure` cookie is silently dropped by the
|
|
6
|
+
* browser over plain http, so http/localhost dev must set `secure:false` while https
|
|
7
|
+
* always sets `secure:true`. This is not a downgrade — `Secure` carries no meaning on
|
|
8
|
+
* http; setting it there just breaks the session. httpOnly/sameSite/path are unchanged.
|
|
9
|
+
*/
|
|
10
|
+
function buildSessionCookieOptions(secure) {
|
|
11
|
+
return {
|
|
12
|
+
httpOnly: true,
|
|
13
|
+
secure,
|
|
14
|
+
sameSite: "lax",
|
|
15
|
+
path: "/"
|
|
16
|
+
};
|
|
17
|
+
}
|
|
10
18
|
//#endregion
|
|
11
19
|
//#region src/cookie-codec.ts
|
|
12
20
|
const IV_BYTES = 12;
|
|
@@ -69,17 +77,20 @@ var ServerSession = class {
|
|
|
69
77
|
secret;
|
|
70
78
|
cookies;
|
|
71
79
|
cookieName;
|
|
80
|
+
secure;
|
|
72
81
|
constructor(options) {
|
|
73
82
|
this.authClient = options.authClient;
|
|
74
83
|
this.secret = options.secret;
|
|
75
84
|
this.cookies = options.cookies;
|
|
76
85
|
this.cookieName = options.cookieName ?? "polyx_session";
|
|
86
|
+
this.secure = options.secure ?? true;
|
|
77
87
|
}
|
|
78
|
-
async establishFromCode(code, codeVerifier, redirectUri) {
|
|
88
|
+
async establishFromCode(code, codeVerifier, redirectUri, forwardedHeaders) {
|
|
79
89
|
const stored = await this.authClient.exchangeCode({
|
|
80
90
|
code,
|
|
81
91
|
codeVerifier,
|
|
82
|
-
redirectUri
|
|
92
|
+
redirectUri,
|
|
93
|
+
forwardedHeaders
|
|
83
94
|
});
|
|
84
95
|
await this.write(stored);
|
|
85
96
|
return stored;
|
|
@@ -105,12 +116,30 @@ var ServerSession = class {
|
|
|
105
116
|
throw error;
|
|
106
117
|
}
|
|
107
118
|
}
|
|
119
|
+
/**
|
|
120
|
+
* End the session upstream, then locally. `device` revokes this session; `everywhere`
|
|
121
|
+
* revokes every session for the user (FR-SSO-5).
|
|
122
|
+
*
|
|
123
|
+
* Order is load-bearing: revocation is authenticated with the access token sealed in the
|
|
124
|
+
* cookie, so the cookie must be read before it is dropped. The upstream call is
|
|
125
|
+
* best-effort — an unreachable platform must not strand the user half-signed-in — but the
|
|
126
|
+
* call itself is real: the ecosystem logout the SDK used before resolves its session from
|
|
127
|
+
* a browser cookie, which a server-to-server fetch never carries, so it silently revoked
|
|
128
|
+
* nothing and answered 200.
|
|
129
|
+
*/
|
|
130
|
+
async revoke(scope = "device") {
|
|
131
|
+
const current = await this.read();
|
|
132
|
+
if (current) try {
|
|
133
|
+
await this.authClient.revokeSession(current.tokens.accessToken, scope);
|
|
134
|
+
} catch {}
|
|
135
|
+
this.clear();
|
|
136
|
+
}
|
|
108
137
|
clear() {
|
|
109
138
|
this.cookies.delete(this.cookieName);
|
|
110
139
|
}
|
|
111
140
|
async write(session) {
|
|
112
141
|
const sealed = await sealSession(session, this.secret);
|
|
113
|
-
this.cookies.set(this.cookieName, sealed,
|
|
142
|
+
this.cookies.set(this.cookieName, sealed, buildSessionCookieOptions(this.secure));
|
|
114
143
|
}
|
|
115
144
|
};
|
|
116
145
|
function toAuthContext(session) {
|
package/dist/server.cjs
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
-
const require_server_session = require("./server-session-
|
|
2
|
+
const require_server_session = require("./server-session-QJLA_Tz-.cjs");
|
|
3
3
|
let _poly_x_core = require("@poly-x/core");
|
|
4
4
|
let next_headers = require("next/headers");
|
|
5
5
|
//#region src/config.ts
|
|
6
|
+
/** Parse a boolean-ish env var; undefined when unset/unrecognized (→ auto-derive). */
|
|
7
|
+
function parseBoolEnv(value) {
|
|
8
|
+
if (value === void 0) return void 0;
|
|
9
|
+
const v = value.trim().toLowerCase();
|
|
10
|
+
if (v === "true" || v === "1") return true;
|
|
11
|
+
if (v === "false" || v === "0") return false;
|
|
12
|
+
}
|
|
6
13
|
function resolveNextConfig(overrides = {}) {
|
|
7
14
|
const publishableKey = overrides.publishableKey ?? process.env.NEXT_PUBLIC_POLYX_PUBLISHABLE_KEY;
|
|
8
15
|
const secret = overrides.secret ?? process.env.POLYX_SESSION_SECRET;
|
|
@@ -12,7 +19,8 @@ function resolveNextConfig(overrides = {}) {
|
|
|
12
19
|
publishableKey,
|
|
13
20
|
secret,
|
|
14
21
|
baseUrl: overrides.baseUrl ?? process.env.POLYX_BASE_URL,
|
|
15
|
-
signInUrl: overrides.signInUrl ?? process.env.NEXT_PUBLIC_POLYX_SIGN_IN_URL
|
|
22
|
+
signInUrl: overrides.signInUrl ?? process.env.NEXT_PUBLIC_POLYX_SIGN_IN_URL,
|
|
23
|
+
cookieSecure: overrides.cookieSecure ?? parseBoolEnv(process.env.POLYX_COOKIE_SECURE)
|
|
16
24
|
};
|
|
17
25
|
}
|
|
18
26
|
function buildServerAuthClient(config) {
|
|
@@ -48,6 +56,95 @@ function adapt(store) {
|
|
|
48
56
|
async function requestCookies() {
|
|
49
57
|
return adapt(await (0, next_headers.cookies)());
|
|
50
58
|
}
|
|
59
|
+
/**
|
|
60
|
+
* Decide the cookie `Secure` attribute for this request. An explicit `cookieSecure`
|
|
61
|
+
* (from `POLYX_COOKIE_SECURE` or code) wins; otherwise derive from the transport —
|
|
62
|
+
* `x-forwarded-proto` (proxied TLS) then the request URL — and fail secure if unknown.
|
|
63
|
+
* A Secure cookie is dropped by the browser over http, so http/localhost must be false.
|
|
64
|
+
*/
|
|
65
|
+
function resolveCookieSecure(request, config) {
|
|
66
|
+
if (config.cookieSecure !== void 0) return config.cookieSecure;
|
|
67
|
+
const forwarded = request.headers.get("x-forwarded-proto");
|
|
68
|
+
if (forwarded) return forwarded.split(",")[0].trim() === "https";
|
|
69
|
+
try {
|
|
70
|
+
return new URL(request.url).protocol === "https:";
|
|
71
|
+
} catch {
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/** Browser request headers the platform reads for session device attribution. */
|
|
76
|
+
const FORWARDED_DEVICE_HEADERS = [
|
|
77
|
+
"user-agent",
|
|
78
|
+
"accept-language",
|
|
79
|
+
"sec-ch-ua",
|
|
80
|
+
"sec-ch-ua-platform",
|
|
81
|
+
"sec-ch-ua-mobile",
|
|
82
|
+
"sec-ch-ua-model"
|
|
83
|
+
];
|
|
84
|
+
/**
|
|
85
|
+
* The end user's IP as observed by THIS BFF's own infrastructure — the left-most
|
|
86
|
+
* entry of the inbound `x-forwarded-for` (what the platform/proxy in front of the
|
|
87
|
+
* app set), falling back to `x-real-ip`. Returns undefined on localhost/dev where
|
|
88
|
+
* neither is present, letting the platform fall back to its own resolution rather
|
|
89
|
+
* than recording a blank. (This trusts the infra-set forwarded chain, not a value
|
|
90
|
+
* the browser could set on the same-origin BFF request.)
|
|
91
|
+
*/
|
|
92
|
+
function resolveClientIp(request) {
|
|
93
|
+
const first = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim();
|
|
94
|
+
if (first) return first;
|
|
95
|
+
return request.headers.get("x-real-ip")?.trim() || void 0;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Curate the browser context to forward on the session-creating token exchange.
|
|
99
|
+
* In a BFF deployment the outbound call to poly-auth is made by Node, so without
|
|
100
|
+
* this the platform attributes the session to the server ("node"/Unknown UA, the
|
|
101
|
+
* server's IP). We forward a fixed allowlist of the browser's own request headers
|
|
102
|
+
* plus the observed client IP (as `x-forwarded-for`). Only headers actually
|
|
103
|
+
* present are forwarded — never blank values that would clobber the platform's
|
|
104
|
+
* own fallback resolution.
|
|
105
|
+
*/
|
|
106
|
+
function collectForwardedDeviceHeaders(request) {
|
|
107
|
+
const forwarded = {};
|
|
108
|
+
for (const name of FORWARDED_DEVICE_HEADERS) {
|
|
109
|
+
const value = request.headers.get(name);
|
|
110
|
+
if (value) forwarded[name] = value;
|
|
111
|
+
}
|
|
112
|
+
const clientIp = resolveClientIp(request);
|
|
113
|
+
if (clientIp) forwarded["x-forwarded-for"] = clientIp;
|
|
114
|
+
return forwarded;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Build the forwarded precise-location headers (`x-polyx-geo-*`) from the browser's login
|
|
118
|
+
* body (v6, FR-LOC-7). The browser captures a consented position client-side and posts it;
|
|
119
|
+
* the platform reads these on the token exchange. Only well-formed values are forwarded, and
|
|
120
|
+
* the consent state rides even when coordinates are absent (denied/timeout → coarse fallback).
|
|
121
|
+
*/
|
|
122
|
+
function geoHeadersFromBody(geo) {
|
|
123
|
+
const headers = {};
|
|
124
|
+
if (!geo || typeof geo !== "object") return headers;
|
|
125
|
+
const g = geo;
|
|
126
|
+
const lat = Number(g.latitude);
|
|
127
|
+
const lon = Number(g.longitude);
|
|
128
|
+
if (typeof g.latitude === "string" && Number.isFinite(lat) && lat >= -90 && lat <= 90) headers["x-polyx-geo-lat"] = g.latitude;
|
|
129
|
+
if (typeof g.longitude === "string" && Number.isFinite(lon) && lon >= -180 && lon <= 180) headers["x-polyx-geo-lon"] = g.longitude;
|
|
130
|
+
if (typeof g.accuracyMeters === "number" && Number.isFinite(g.accuracyMeters) && g.accuracyMeters >= 0) headers["x-polyx-geo-accuracy"] = String(g.accuracyMeters);
|
|
131
|
+
if (g.consent === "granted" || g.consent === "denied" || g.consent === "revoked") headers["x-polyx-geo-consent"] = g.consent;
|
|
132
|
+
return headers;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Build the device-context headers (`x-timezone` / `x-screen-resolution`) from the browser's login
|
|
136
|
+
* body (v6). These are not automatic request headers — the browser captures them client-side and
|
|
137
|
+
* posts them; the platform reads them for session device attribution. Only well-formed values are
|
|
138
|
+
* forwarded (a sane timezone string, a `WxH` resolution) so junk never reaches the platform.
|
|
139
|
+
*/
|
|
140
|
+
function deviceContextHeadersFromBody(deviceContext) {
|
|
141
|
+
const headers = {};
|
|
142
|
+
if (!deviceContext || typeof deviceContext !== "object") return headers;
|
|
143
|
+
const d = deviceContext;
|
|
144
|
+
if (typeof d.timezone === "string" && d.timezone.length > 0 && d.timezone.length <= 64) headers["x-timezone"] = d.timezone;
|
|
145
|
+
if (typeof d.screenResolution === "string" && /^\d{1,5}x\d{1,5}$/.test(d.screenResolution)) headers["x-screen-resolution"] = d.screenResolution;
|
|
146
|
+
return headers;
|
|
147
|
+
}
|
|
51
148
|
/** Read the current session — safe in Server Components (no cookie write). */
|
|
52
149
|
async function auth(overrides) {
|
|
53
150
|
const config = resolveNextConfig(overrides);
|
|
@@ -60,16 +157,18 @@ async function auth(overrides) {
|
|
|
60
157
|
function createAuthHandlers(handlersConfig = {}) {
|
|
61
158
|
const callbackPath = handlersConfig.callbackPath ?? "/api/polyx/callback";
|
|
62
159
|
const afterSignInPath = handlersConfig.afterSignInPath ?? "/";
|
|
63
|
-
function newSession(store, config) {
|
|
160
|
+
function newSession(store, config, secure) {
|
|
64
161
|
return new require_server_session.ServerSession({
|
|
65
162
|
authClient: buildServerAuthClient(config),
|
|
66
163
|
secret: config.secret,
|
|
67
|
-
cookies: store
|
|
164
|
+
cookies: store,
|
|
165
|
+
secure
|
|
68
166
|
});
|
|
69
167
|
}
|
|
70
168
|
return {
|
|
71
169
|
async signin(request) {
|
|
72
170
|
const config = resolveNextConfig(handlersConfig);
|
|
171
|
+
const secure = resolveCookieSecure(request, config);
|
|
73
172
|
const redirectUri = `${new URL(request.url).origin}${callbackPath}`;
|
|
74
173
|
const { verifier, challenge } = await (0, _poly_x_core.generatePkce)();
|
|
75
174
|
const state = (0, _poly_x_core.randomState)();
|
|
@@ -79,7 +178,7 @@ function createAuthHandlers(handlersConfig = {}) {
|
|
|
79
178
|
redirectUri
|
|
80
179
|
}), {
|
|
81
180
|
httpOnly: true,
|
|
82
|
-
secure
|
|
181
|
+
secure,
|
|
83
182
|
sameSite: "lax",
|
|
84
183
|
path: "/",
|
|
85
184
|
maxAge: VERIFIER_MAX_AGE
|
|
@@ -93,6 +192,7 @@ function createAuthHandlers(handlersConfig = {}) {
|
|
|
93
192
|
},
|
|
94
193
|
async authenticate(request) {
|
|
95
194
|
const config = resolveNextConfig(handlersConfig);
|
|
195
|
+
const secure = resolveCookieSecure(request, config);
|
|
96
196
|
const redirectUri = `${new URL(request.url).origin}${callbackPath}`;
|
|
97
197
|
const client = buildServerAuthClient(config);
|
|
98
198
|
let payload;
|
|
@@ -101,6 +201,8 @@ function createAuthHandlers(handlersConfig = {}) {
|
|
|
101
201
|
} catch {
|
|
102
202
|
return Response.json({ status: "invalid_request" }, { status: 400 });
|
|
103
203
|
}
|
|
204
|
+
const geoHeaders = geoHeadersFromBody(payload.geo);
|
|
205
|
+
const deviceContextHeaders = deviceContextHeadersFromBody(payload.deviceContext);
|
|
104
206
|
const email = typeof payload.email === "string" ? payload.email : "";
|
|
105
207
|
const password = typeof payload.password === "string" ? payload.password : "";
|
|
106
208
|
if (!email || !password) return Response.json({ status: "invalid_request" }, { status: 400 });
|
|
@@ -122,7 +224,11 @@ function createAuthHandlers(handlersConfig = {}) {
|
|
|
122
224
|
});
|
|
123
225
|
switch (outcome.type) {
|
|
124
226
|
case "authorized":
|
|
125
|
-
await newSession(adapt(await (0, next_headers.cookies)()), config).establishFromCode(outcome.code, verifier, redirectUri
|
|
227
|
+
await newSession(adapt(await (0, next_headers.cookies)()), config, secure).establishFromCode(outcome.code, verifier, redirectUri, {
|
|
228
|
+
...collectForwardedDeviceHeaders(request),
|
|
229
|
+
...geoHeaders,
|
|
230
|
+
...deviceContextHeaders
|
|
231
|
+
});
|
|
126
232
|
return Response.json({
|
|
127
233
|
status: "success",
|
|
128
234
|
redirectTo: afterSignInPath
|
|
@@ -211,6 +317,7 @@ function createAuthHandlers(handlersConfig = {}) {
|
|
|
211
317
|
},
|
|
212
318
|
async callback(request) {
|
|
213
319
|
const config = resolveNextConfig(handlersConfig);
|
|
320
|
+
const secure = resolveCookieSecure(request, config);
|
|
214
321
|
const url = new URL(request.url);
|
|
215
322
|
const code = url.searchParams.get("code");
|
|
216
323
|
const state = url.searchParams.get("state");
|
|
@@ -221,21 +328,48 @@ function createAuthHandlers(handlersConfig = {}) {
|
|
|
221
328
|
if (!code || !state || !raw) return Response.redirect(home, 302);
|
|
222
329
|
const tx = JSON.parse(raw);
|
|
223
330
|
if (tx.state !== state) return Response.redirect(home, 302);
|
|
224
|
-
await newSession(adapt(store), config).establishFromCode(code, tx.verifier, tx.redirectUri);
|
|
331
|
+
await newSession(adapt(store), config, secure).establishFromCode(code, tx.verifier, tx.redirectUri, collectForwardedDeviceHeaders(request));
|
|
225
332
|
return Response.redirect(home, 302);
|
|
226
333
|
},
|
|
227
|
-
async refresh() {
|
|
334
|
+
async refresh(request) {
|
|
228
335
|
const config = resolveNextConfig(handlersConfig);
|
|
229
|
-
const
|
|
336
|
+
const secure = resolveCookieSecure(request, config);
|
|
337
|
+
const session = await newSession(await requestCookies(), config, secure).refresh();
|
|
230
338
|
return Response.json({ isSignedIn: session !== null }, { status: session ? 200 : 401 });
|
|
231
339
|
},
|
|
340
|
+
/**
|
|
341
|
+
* End the session: revoke it upstream (bearer-authenticated, using the sealed token)
|
|
342
|
+
* and unseal the cookie. `?scope=everywhere` signs the user out of every device.
|
|
343
|
+
*
|
|
344
|
+
* Content-negotiated so both callers work: a plain link/form navigation gets the 302 it
|
|
345
|
+
* expects, while `Accept: application/json` (what `<UserButton/>` sends) gets JSON and
|
|
346
|
+
* decides its own destination client-side. Deliberately no server-side redirect target
|
|
347
|
+
* — a caller-supplied one would be an open redirect on the auth path, and the client
|
|
348
|
+
* already knows where it wants to land.
|
|
349
|
+
*/
|
|
232
350
|
async signout(request) {
|
|
233
351
|
const config = resolveNextConfig(handlersConfig);
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
352
|
+
const secure = resolveCookieSecure(request, config);
|
|
353
|
+
const scope = new URL(request.url).searchParams.get("scope") === "everywhere" ? "everywhere" : "device";
|
|
354
|
+
await newSession(adapt(await (0, next_headers.cookies)()), config, secure).revoke(scope);
|
|
355
|
+
if (request.headers.get("accept")?.includes("application/json")) return Response.json({ signedOut: true });
|
|
238
356
|
return Response.redirect(new URL(request.url).origin + "/", 302);
|
|
357
|
+
},
|
|
358
|
+
async session() {
|
|
359
|
+
return Response.json(await auth(handlersConfig));
|
|
360
|
+
},
|
|
361
|
+
async subscriptionCredential() {
|
|
362
|
+
const config = resolveNextConfig(handlersConfig);
|
|
363
|
+
const current = await new require_server_session.ServerSession({
|
|
364
|
+
authClient: buildServerAuthClient(config),
|
|
365
|
+
secret: config.secret,
|
|
366
|
+
cookies: await requestCookies()
|
|
367
|
+
}).read();
|
|
368
|
+
if (!current) return Response.json({ status: "signed_out" }, { status: 401 });
|
|
369
|
+
return Response.json({
|
|
370
|
+
token: current.tokens.accessToken,
|
|
371
|
+
organizationId: current.session.claims.organizationId
|
|
372
|
+
});
|
|
239
373
|
}
|
|
240
374
|
};
|
|
241
375
|
}
|
package/dist/server.d.cts
CHANGED
|
@@ -8,6 +8,13 @@ interface NextServerConfig {
|
|
|
8
8
|
baseUrl?: string;
|
|
9
9
|
/** Hosted sign-in page URL the signin route redirects to. */
|
|
10
10
|
signInUrl?: string;
|
|
11
|
+
/**
|
|
12
|
+
* Force the session/verifier cookie `Secure` attribute. Leave undefined (default) to
|
|
13
|
+
* derive it from the request transport (https → true, http/localhost → false). Set via
|
|
14
|
+
* `POLYX_COOKIE_SECURE=true|false` only for edge cases (e.g. a TLS-terminating proxy
|
|
15
|
+
* that doesn't forward `x-forwarded-proto`).
|
|
16
|
+
*/
|
|
17
|
+
cookieSecure?: boolean;
|
|
11
18
|
}
|
|
12
19
|
declare function resolveNextConfig(overrides?: Partial<NextServerConfig>): NextServerConfig;
|
|
13
20
|
//#endregion
|
|
@@ -37,17 +44,36 @@ interface ServerSessionOptions {
|
|
|
37
44
|
secret: string;
|
|
38
45
|
cookies: CookieAdapter;
|
|
39
46
|
cookieName?: string;
|
|
47
|
+
/**
|
|
48
|
+
* Whether to set the `Secure` attribute on the session cookie. Defaults to `true`
|
|
49
|
+
* (fail secure). Handlers derive it from the request transport so http/localhost dev
|
|
50
|
+
* works (a Secure cookie is dropped over http). See buildSessionCookieOptions.
|
|
51
|
+
*/
|
|
52
|
+
secure?: boolean;
|
|
40
53
|
}
|
|
41
54
|
declare class ServerSession {
|
|
42
55
|
private readonly authClient;
|
|
43
56
|
private readonly secret;
|
|
44
57
|
private readonly cookies;
|
|
45
58
|
private readonly cookieName;
|
|
59
|
+
private readonly secure;
|
|
46
60
|
constructor(options: ServerSessionOptions);
|
|
47
|
-
establishFromCode(code: string, codeVerifier: string, redirectUri: string): Promise<StoredSession>;
|
|
61
|
+
establishFromCode(code: string, codeVerifier: string, redirectUri: string, forwardedHeaders?: Record<string, string>): Promise<StoredSession>;
|
|
48
62
|
read(): Promise<StoredSession | null>;
|
|
49
63
|
/** Rotate the tokens; clears the cookie (returns null) on a terminal refresh failure. */
|
|
50
64
|
refresh(): Promise<StoredSession | null>;
|
|
65
|
+
/**
|
|
66
|
+
* End the session upstream, then locally. `device` revokes this session; `everywhere`
|
|
67
|
+
* revokes every session for the user (FR-SSO-5).
|
|
68
|
+
*
|
|
69
|
+
* Order is load-bearing: revocation is authenticated with the access token sealed in the
|
|
70
|
+
* cookie, so the cookie must be read before it is dropped. The upstream call is
|
|
71
|
+
* best-effort — an unreachable platform must not strand the user half-signed-in — but the
|
|
72
|
+
* call itself is real: the ecosystem logout the SDK used before resolves its session from
|
|
73
|
+
* a browser cookie, which a server-to-server fetch never carries, so it silently revoked
|
|
74
|
+
* nothing and answered 200.
|
|
75
|
+
*/
|
|
76
|
+
revoke(scope?: "device" | "everywhere"): Promise<void>;
|
|
51
77
|
clear(): void;
|
|
52
78
|
private write;
|
|
53
79
|
}
|
|
@@ -94,6 +120,21 @@ interface AuthHandlers {
|
|
|
94
120
|
callback(request: Request): Promise<Response>;
|
|
95
121
|
refresh(request: Request): Promise<Response>;
|
|
96
122
|
signout(request: Request): Promise<Response>;
|
|
123
|
+
/**
|
|
124
|
+
* The client's view of the session: the same `AuthContext` `auth()` returns in a Server
|
|
125
|
+
* Component, as JSON. This is what makes client components work under BFF custody, where
|
|
126
|
+
* the browser holds no token and so cannot derive a session for itself — the hooks read
|
|
127
|
+
* server truth instead. Never carries a token.
|
|
128
|
+
*/
|
|
129
|
+
session(request: Request): Promise<Response>;
|
|
130
|
+
/**
|
|
131
|
+
* v2 (ADR-0008): a session-derived credential for the real-time `authz:changed` channel. Returns
|
|
132
|
+
* `{ token, organizationId }` read from the sealed session server-side — the token is for the
|
|
133
|
+
* socket handshake only (in-memory; never persisted), per the pragmatic custody decision. A
|
|
134
|
+
* signed-out caller gets `401` with no token. This is the one place a token leaves the BFF, and
|
|
135
|
+
* only to open the live-authz subscription.
|
|
136
|
+
*/
|
|
137
|
+
subscriptionCredential(request: Request): Promise<Response>;
|
|
97
138
|
}
|
|
98
139
|
/** The JSON the `forgotPassword` handler returns — always uniform (no enumeration). */
|
|
99
140
|
type ForgotPasswordResult = {
|
package/dist/server.d.mts
CHANGED
|
@@ -8,6 +8,13 @@ interface NextServerConfig {
|
|
|
8
8
|
baseUrl?: string;
|
|
9
9
|
/** Hosted sign-in page URL the signin route redirects to. */
|
|
10
10
|
signInUrl?: string;
|
|
11
|
+
/**
|
|
12
|
+
* Force the session/verifier cookie `Secure` attribute. Leave undefined (default) to
|
|
13
|
+
* derive it from the request transport (https → true, http/localhost → false). Set via
|
|
14
|
+
* `POLYX_COOKIE_SECURE=true|false` only for edge cases (e.g. a TLS-terminating proxy
|
|
15
|
+
* that doesn't forward `x-forwarded-proto`).
|
|
16
|
+
*/
|
|
17
|
+
cookieSecure?: boolean;
|
|
11
18
|
}
|
|
12
19
|
declare function resolveNextConfig(overrides?: Partial<NextServerConfig>): NextServerConfig;
|
|
13
20
|
//#endregion
|
|
@@ -37,17 +44,36 @@ interface ServerSessionOptions {
|
|
|
37
44
|
secret: string;
|
|
38
45
|
cookies: CookieAdapter;
|
|
39
46
|
cookieName?: string;
|
|
47
|
+
/**
|
|
48
|
+
* Whether to set the `Secure` attribute on the session cookie. Defaults to `true`
|
|
49
|
+
* (fail secure). Handlers derive it from the request transport so http/localhost dev
|
|
50
|
+
* works (a Secure cookie is dropped over http). See buildSessionCookieOptions.
|
|
51
|
+
*/
|
|
52
|
+
secure?: boolean;
|
|
40
53
|
}
|
|
41
54
|
declare class ServerSession {
|
|
42
55
|
private readonly authClient;
|
|
43
56
|
private readonly secret;
|
|
44
57
|
private readonly cookies;
|
|
45
58
|
private readonly cookieName;
|
|
59
|
+
private readonly secure;
|
|
46
60
|
constructor(options: ServerSessionOptions);
|
|
47
|
-
establishFromCode(code: string, codeVerifier: string, redirectUri: string): Promise<StoredSession>;
|
|
61
|
+
establishFromCode(code: string, codeVerifier: string, redirectUri: string, forwardedHeaders?: Record<string, string>): Promise<StoredSession>;
|
|
48
62
|
read(): Promise<StoredSession | null>;
|
|
49
63
|
/** Rotate the tokens; clears the cookie (returns null) on a terminal refresh failure. */
|
|
50
64
|
refresh(): Promise<StoredSession | null>;
|
|
65
|
+
/**
|
|
66
|
+
* End the session upstream, then locally. `device` revokes this session; `everywhere`
|
|
67
|
+
* revokes every session for the user (FR-SSO-5).
|
|
68
|
+
*
|
|
69
|
+
* Order is load-bearing: revocation is authenticated with the access token sealed in the
|
|
70
|
+
* cookie, so the cookie must be read before it is dropped. The upstream call is
|
|
71
|
+
* best-effort — an unreachable platform must not strand the user half-signed-in — but the
|
|
72
|
+
* call itself is real: the ecosystem logout the SDK used before resolves its session from
|
|
73
|
+
* a browser cookie, which a server-to-server fetch never carries, so it silently revoked
|
|
74
|
+
* nothing and answered 200.
|
|
75
|
+
*/
|
|
76
|
+
revoke(scope?: "device" | "everywhere"): Promise<void>;
|
|
51
77
|
clear(): void;
|
|
52
78
|
private write;
|
|
53
79
|
}
|
|
@@ -94,6 +120,21 @@ interface AuthHandlers {
|
|
|
94
120
|
callback(request: Request): Promise<Response>;
|
|
95
121
|
refresh(request: Request): Promise<Response>;
|
|
96
122
|
signout(request: Request): Promise<Response>;
|
|
123
|
+
/**
|
|
124
|
+
* The client's view of the session: the same `AuthContext` `auth()` returns in a Server
|
|
125
|
+
* Component, as JSON. This is what makes client components work under BFF custody, where
|
|
126
|
+
* the browser holds no token and so cannot derive a session for itself — the hooks read
|
|
127
|
+
* server truth instead. Never carries a token.
|
|
128
|
+
*/
|
|
129
|
+
session(request: Request): Promise<Response>;
|
|
130
|
+
/**
|
|
131
|
+
* v2 (ADR-0008): a session-derived credential for the real-time `authz:changed` channel. Returns
|
|
132
|
+
* `{ token, organizationId }` read from the sealed session server-side — the token is for the
|
|
133
|
+
* socket handshake only (in-memory; never persisted), per the pragmatic custody decision. A
|
|
134
|
+
* signed-out caller gets `401` with no token. This is the one place a token leaves the BFF, and
|
|
135
|
+
* only to open the live-authz subscription.
|
|
136
|
+
*/
|
|
137
|
+
subscriptionCredential(request: Request): Promise<Response>;
|
|
97
138
|
}
|
|
98
139
|
/** The JSON the `forgotPassword` handler returns — always uniform (no enumeration). */
|
|
99
140
|
type ForgotPasswordResult = {
|
package/dist/server.mjs
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
|
-
import { a as sealSession, i as openSession, n as ServerSession, r as toAuthContext, t as DEFAULT_SESSION_COOKIE } from "./server-session-
|
|
1
|
+
import { a as sealSession, i as openSession, n as ServerSession, r as toAuthContext, t as DEFAULT_SESSION_COOKIE } from "./server-session-CjP44zFC.mjs";
|
|
2
2
|
import { AuthClient, FetchTransport, generatePkce, randomState, resolveConfig } from "@poly-x/core";
|
|
3
3
|
import { cookies } from "next/headers";
|
|
4
4
|
//#region src/config.ts
|
|
5
|
+
/** Parse a boolean-ish env var; undefined when unset/unrecognized (→ auto-derive). */
|
|
6
|
+
function parseBoolEnv(value) {
|
|
7
|
+
if (value === void 0) return void 0;
|
|
8
|
+
const v = value.trim().toLowerCase();
|
|
9
|
+
if (v === "true" || v === "1") return true;
|
|
10
|
+
if (v === "false" || v === "0") return false;
|
|
11
|
+
}
|
|
5
12
|
function resolveNextConfig(overrides = {}) {
|
|
6
13
|
const publishableKey = overrides.publishableKey ?? process.env.NEXT_PUBLIC_POLYX_PUBLISHABLE_KEY;
|
|
7
14
|
const secret = overrides.secret ?? process.env.POLYX_SESSION_SECRET;
|
|
@@ -11,7 +18,8 @@ function resolveNextConfig(overrides = {}) {
|
|
|
11
18
|
publishableKey,
|
|
12
19
|
secret,
|
|
13
20
|
baseUrl: overrides.baseUrl ?? process.env.POLYX_BASE_URL,
|
|
14
|
-
signInUrl: overrides.signInUrl ?? process.env.NEXT_PUBLIC_POLYX_SIGN_IN_URL
|
|
21
|
+
signInUrl: overrides.signInUrl ?? process.env.NEXT_PUBLIC_POLYX_SIGN_IN_URL,
|
|
22
|
+
cookieSecure: overrides.cookieSecure ?? parseBoolEnv(process.env.POLYX_COOKIE_SECURE)
|
|
15
23
|
};
|
|
16
24
|
}
|
|
17
25
|
function buildServerAuthClient(config) {
|
|
@@ -47,6 +55,95 @@ function adapt(store) {
|
|
|
47
55
|
async function requestCookies() {
|
|
48
56
|
return adapt(await cookies());
|
|
49
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* Decide the cookie `Secure` attribute for this request. An explicit `cookieSecure`
|
|
60
|
+
* (from `POLYX_COOKIE_SECURE` or code) wins; otherwise derive from the transport —
|
|
61
|
+
* `x-forwarded-proto` (proxied TLS) then the request URL — and fail secure if unknown.
|
|
62
|
+
* A Secure cookie is dropped by the browser over http, so http/localhost must be false.
|
|
63
|
+
*/
|
|
64
|
+
function resolveCookieSecure(request, config) {
|
|
65
|
+
if (config.cookieSecure !== void 0) return config.cookieSecure;
|
|
66
|
+
const forwarded = request.headers.get("x-forwarded-proto");
|
|
67
|
+
if (forwarded) return forwarded.split(",")[0].trim() === "https";
|
|
68
|
+
try {
|
|
69
|
+
return new URL(request.url).protocol === "https:";
|
|
70
|
+
} catch {
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/** Browser request headers the platform reads for session device attribution. */
|
|
75
|
+
const FORWARDED_DEVICE_HEADERS = [
|
|
76
|
+
"user-agent",
|
|
77
|
+
"accept-language",
|
|
78
|
+
"sec-ch-ua",
|
|
79
|
+
"sec-ch-ua-platform",
|
|
80
|
+
"sec-ch-ua-mobile",
|
|
81
|
+
"sec-ch-ua-model"
|
|
82
|
+
];
|
|
83
|
+
/**
|
|
84
|
+
* The end user's IP as observed by THIS BFF's own infrastructure — the left-most
|
|
85
|
+
* entry of the inbound `x-forwarded-for` (what the platform/proxy in front of the
|
|
86
|
+
* app set), falling back to `x-real-ip`. Returns undefined on localhost/dev where
|
|
87
|
+
* neither is present, letting the platform fall back to its own resolution rather
|
|
88
|
+
* than recording a blank. (This trusts the infra-set forwarded chain, not a value
|
|
89
|
+
* the browser could set on the same-origin BFF request.)
|
|
90
|
+
*/
|
|
91
|
+
function resolveClientIp(request) {
|
|
92
|
+
const first = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim();
|
|
93
|
+
if (first) return first;
|
|
94
|
+
return request.headers.get("x-real-ip")?.trim() || void 0;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Curate the browser context to forward on the session-creating token exchange.
|
|
98
|
+
* In a BFF deployment the outbound call to poly-auth is made by Node, so without
|
|
99
|
+
* this the platform attributes the session to the server ("node"/Unknown UA, the
|
|
100
|
+
* server's IP). We forward a fixed allowlist of the browser's own request headers
|
|
101
|
+
* plus the observed client IP (as `x-forwarded-for`). Only headers actually
|
|
102
|
+
* present are forwarded — never blank values that would clobber the platform's
|
|
103
|
+
* own fallback resolution.
|
|
104
|
+
*/
|
|
105
|
+
function collectForwardedDeviceHeaders(request) {
|
|
106
|
+
const forwarded = {};
|
|
107
|
+
for (const name of FORWARDED_DEVICE_HEADERS) {
|
|
108
|
+
const value = request.headers.get(name);
|
|
109
|
+
if (value) forwarded[name] = value;
|
|
110
|
+
}
|
|
111
|
+
const clientIp = resolveClientIp(request);
|
|
112
|
+
if (clientIp) forwarded["x-forwarded-for"] = clientIp;
|
|
113
|
+
return forwarded;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Build the forwarded precise-location headers (`x-polyx-geo-*`) from the browser's login
|
|
117
|
+
* body (v6, FR-LOC-7). The browser captures a consented position client-side and posts it;
|
|
118
|
+
* the platform reads these on the token exchange. Only well-formed values are forwarded, and
|
|
119
|
+
* the consent state rides even when coordinates are absent (denied/timeout → coarse fallback).
|
|
120
|
+
*/
|
|
121
|
+
function geoHeadersFromBody(geo) {
|
|
122
|
+
const headers = {};
|
|
123
|
+
if (!geo || typeof geo !== "object") return headers;
|
|
124
|
+
const g = geo;
|
|
125
|
+
const lat = Number(g.latitude);
|
|
126
|
+
const lon = Number(g.longitude);
|
|
127
|
+
if (typeof g.latitude === "string" && Number.isFinite(lat) && lat >= -90 && lat <= 90) headers["x-polyx-geo-lat"] = g.latitude;
|
|
128
|
+
if (typeof g.longitude === "string" && Number.isFinite(lon) && lon >= -180 && lon <= 180) headers["x-polyx-geo-lon"] = g.longitude;
|
|
129
|
+
if (typeof g.accuracyMeters === "number" && Number.isFinite(g.accuracyMeters) && g.accuracyMeters >= 0) headers["x-polyx-geo-accuracy"] = String(g.accuracyMeters);
|
|
130
|
+
if (g.consent === "granted" || g.consent === "denied" || g.consent === "revoked") headers["x-polyx-geo-consent"] = g.consent;
|
|
131
|
+
return headers;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Build the device-context headers (`x-timezone` / `x-screen-resolution`) from the browser's login
|
|
135
|
+
* body (v6). These are not automatic request headers — the browser captures them client-side and
|
|
136
|
+
* posts them; the platform reads them for session device attribution. Only well-formed values are
|
|
137
|
+
* forwarded (a sane timezone string, a `WxH` resolution) so junk never reaches the platform.
|
|
138
|
+
*/
|
|
139
|
+
function deviceContextHeadersFromBody(deviceContext) {
|
|
140
|
+
const headers = {};
|
|
141
|
+
if (!deviceContext || typeof deviceContext !== "object") return headers;
|
|
142
|
+
const d = deviceContext;
|
|
143
|
+
if (typeof d.timezone === "string" && d.timezone.length > 0 && d.timezone.length <= 64) headers["x-timezone"] = d.timezone;
|
|
144
|
+
if (typeof d.screenResolution === "string" && /^\d{1,5}x\d{1,5}$/.test(d.screenResolution)) headers["x-screen-resolution"] = d.screenResolution;
|
|
145
|
+
return headers;
|
|
146
|
+
}
|
|
50
147
|
/** Read the current session — safe in Server Components (no cookie write). */
|
|
51
148
|
async function auth(overrides) {
|
|
52
149
|
const config = resolveNextConfig(overrides);
|
|
@@ -59,16 +156,18 @@ async function auth(overrides) {
|
|
|
59
156
|
function createAuthHandlers(handlersConfig = {}) {
|
|
60
157
|
const callbackPath = handlersConfig.callbackPath ?? "/api/polyx/callback";
|
|
61
158
|
const afterSignInPath = handlersConfig.afterSignInPath ?? "/";
|
|
62
|
-
function newSession(store, config) {
|
|
159
|
+
function newSession(store, config, secure) {
|
|
63
160
|
return new ServerSession({
|
|
64
161
|
authClient: buildServerAuthClient(config),
|
|
65
162
|
secret: config.secret,
|
|
66
|
-
cookies: store
|
|
163
|
+
cookies: store,
|
|
164
|
+
secure
|
|
67
165
|
});
|
|
68
166
|
}
|
|
69
167
|
return {
|
|
70
168
|
async signin(request) {
|
|
71
169
|
const config = resolveNextConfig(handlersConfig);
|
|
170
|
+
const secure = resolveCookieSecure(request, config);
|
|
72
171
|
const redirectUri = `${new URL(request.url).origin}${callbackPath}`;
|
|
73
172
|
const { verifier, challenge } = await generatePkce();
|
|
74
173
|
const state = randomState();
|
|
@@ -78,7 +177,7 @@ function createAuthHandlers(handlersConfig = {}) {
|
|
|
78
177
|
redirectUri
|
|
79
178
|
}), {
|
|
80
179
|
httpOnly: true,
|
|
81
|
-
secure
|
|
180
|
+
secure,
|
|
82
181
|
sameSite: "lax",
|
|
83
182
|
path: "/",
|
|
84
183
|
maxAge: VERIFIER_MAX_AGE
|
|
@@ -92,6 +191,7 @@ function createAuthHandlers(handlersConfig = {}) {
|
|
|
92
191
|
},
|
|
93
192
|
async authenticate(request) {
|
|
94
193
|
const config = resolveNextConfig(handlersConfig);
|
|
194
|
+
const secure = resolveCookieSecure(request, config);
|
|
95
195
|
const redirectUri = `${new URL(request.url).origin}${callbackPath}`;
|
|
96
196
|
const client = buildServerAuthClient(config);
|
|
97
197
|
let payload;
|
|
@@ -100,6 +200,8 @@ function createAuthHandlers(handlersConfig = {}) {
|
|
|
100
200
|
} catch {
|
|
101
201
|
return Response.json({ status: "invalid_request" }, { status: 400 });
|
|
102
202
|
}
|
|
203
|
+
const geoHeaders = geoHeadersFromBody(payload.geo);
|
|
204
|
+
const deviceContextHeaders = deviceContextHeadersFromBody(payload.deviceContext);
|
|
103
205
|
const email = typeof payload.email === "string" ? payload.email : "";
|
|
104
206
|
const password = typeof payload.password === "string" ? payload.password : "";
|
|
105
207
|
if (!email || !password) return Response.json({ status: "invalid_request" }, { status: 400 });
|
|
@@ -121,7 +223,11 @@ function createAuthHandlers(handlersConfig = {}) {
|
|
|
121
223
|
});
|
|
122
224
|
switch (outcome.type) {
|
|
123
225
|
case "authorized":
|
|
124
|
-
await newSession(adapt(await cookies()), config).establishFromCode(outcome.code, verifier, redirectUri
|
|
226
|
+
await newSession(adapt(await cookies()), config, secure).establishFromCode(outcome.code, verifier, redirectUri, {
|
|
227
|
+
...collectForwardedDeviceHeaders(request),
|
|
228
|
+
...geoHeaders,
|
|
229
|
+
...deviceContextHeaders
|
|
230
|
+
});
|
|
125
231
|
return Response.json({
|
|
126
232
|
status: "success",
|
|
127
233
|
redirectTo: afterSignInPath
|
|
@@ -210,6 +316,7 @@ function createAuthHandlers(handlersConfig = {}) {
|
|
|
210
316
|
},
|
|
211
317
|
async callback(request) {
|
|
212
318
|
const config = resolveNextConfig(handlersConfig);
|
|
319
|
+
const secure = resolveCookieSecure(request, config);
|
|
213
320
|
const url = new URL(request.url);
|
|
214
321
|
const code = url.searchParams.get("code");
|
|
215
322
|
const state = url.searchParams.get("state");
|
|
@@ -220,21 +327,48 @@ function createAuthHandlers(handlersConfig = {}) {
|
|
|
220
327
|
if (!code || !state || !raw) return Response.redirect(home, 302);
|
|
221
328
|
const tx = JSON.parse(raw);
|
|
222
329
|
if (tx.state !== state) return Response.redirect(home, 302);
|
|
223
|
-
await newSession(adapt(store), config).establishFromCode(code, tx.verifier, tx.redirectUri);
|
|
330
|
+
await newSession(adapt(store), config, secure).establishFromCode(code, tx.verifier, tx.redirectUri, collectForwardedDeviceHeaders(request));
|
|
224
331
|
return Response.redirect(home, 302);
|
|
225
332
|
},
|
|
226
|
-
async refresh() {
|
|
333
|
+
async refresh(request) {
|
|
227
334
|
const config = resolveNextConfig(handlersConfig);
|
|
228
|
-
const
|
|
335
|
+
const secure = resolveCookieSecure(request, config);
|
|
336
|
+
const session = await newSession(await requestCookies(), config, secure).refresh();
|
|
229
337
|
return Response.json({ isSignedIn: session !== null }, { status: session ? 200 : 401 });
|
|
230
338
|
},
|
|
339
|
+
/**
|
|
340
|
+
* End the session: revoke it upstream (bearer-authenticated, using the sealed token)
|
|
341
|
+
* and unseal the cookie. `?scope=everywhere` signs the user out of every device.
|
|
342
|
+
*
|
|
343
|
+
* Content-negotiated so both callers work: a plain link/form navigation gets the 302 it
|
|
344
|
+
* expects, while `Accept: application/json` (what `<UserButton/>` sends) gets JSON and
|
|
345
|
+
* decides its own destination client-side. Deliberately no server-side redirect target
|
|
346
|
+
* — a caller-supplied one would be an open redirect on the auth path, and the client
|
|
347
|
+
* already knows where it wants to land.
|
|
348
|
+
*/
|
|
231
349
|
async signout(request) {
|
|
232
350
|
const config = resolveNextConfig(handlersConfig);
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
351
|
+
const secure = resolveCookieSecure(request, config);
|
|
352
|
+
const scope = new URL(request.url).searchParams.get("scope") === "everywhere" ? "everywhere" : "device";
|
|
353
|
+
await newSession(adapt(await cookies()), config, secure).revoke(scope);
|
|
354
|
+
if (request.headers.get("accept")?.includes("application/json")) return Response.json({ signedOut: true });
|
|
237
355
|
return Response.redirect(new URL(request.url).origin + "/", 302);
|
|
356
|
+
},
|
|
357
|
+
async session() {
|
|
358
|
+
return Response.json(await auth(handlersConfig));
|
|
359
|
+
},
|
|
360
|
+
async subscriptionCredential() {
|
|
361
|
+
const config = resolveNextConfig(handlersConfig);
|
|
362
|
+
const current = await new ServerSession({
|
|
363
|
+
authClient: buildServerAuthClient(config),
|
|
364
|
+
secret: config.secret,
|
|
365
|
+
cookies: await requestCookies()
|
|
366
|
+
}).read();
|
|
367
|
+
if (!current) return Response.json({ status: "signed_out" }, { status: 401 });
|
|
368
|
+
return Response.json({
|
|
369
|
+
token: current.tokens.accessToken,
|
|
370
|
+
organizationId: current.session.claims.organizationId
|
|
371
|
+
});
|
|
238
372
|
}
|
|
239
373
|
};
|
|
240
374
|
}
|
package/dist/styles.css
CHANGED
|
@@ -10,8 +10,33 @@
|
|
|
10
10
|
* (`:root`, a wrapper div, …) — that is the supported theming surface. Dark mode
|
|
11
11
|
* follows the OS by default and defers to an explicit `.dark` / `[data-theme]`
|
|
12
12
|
* on a host ancestor when one is present.
|
|
13
|
+
*
|
|
14
|
+
* ---------------------------------------------------------------------------
|
|
15
|
+
* Cascade: everything here lives in the `polyx` layer, and that is load-bearing.
|
|
16
|
+
*
|
|
17
|
+
* Unlayered CSS beats layered CSS outright — not by specificity, but by rank; no
|
|
18
|
+
* import order or selector weight changes it. Shipping these rules unlayered
|
|
19
|
+
* therefore beat every Tailwind utility a host wrote (Tailwind emits utilities
|
|
20
|
+
* into `@layer utilities`), so `className`/`triggerClassName` silently did
|
|
21
|
+
* nothing and hosts were pushed toward `!important`. Layering hands that control
|
|
22
|
+
* back: a host's utilities and unlayered CSS both outrank us now.
|
|
23
|
+
*
|
|
24
|
+
* The statement below fixes where `polyx` sits. It must land AFTER `base` —
|
|
25
|
+
* Tailwind's preflight puts `*, ::before, ::after { border: 0 solid }` there, and
|
|
26
|
+
* that would erase our own borders if we ranked lower — and BEFORE `utilities`,
|
|
27
|
+
* so host utilities still win. Naming Tailwind's layers costs nothing when the
|
|
28
|
+
* host doesn't use Tailwind: the names simply resolve to empty layers.
|
|
29
|
+
*
|
|
30
|
+
* Layer order is set by first declaration, so this only holds if our stylesheet
|
|
31
|
+
* is imported BEFORE the host's Tailwind entry. Import it later and `polyx` is
|
|
32
|
+
* appended last, which puts us back to outranking utilities — no worse than the
|
|
33
|
+
* unlayered behaviour, but not the fix either. Import us first.
|
|
13
34
|
*/
|
|
14
35
|
|
|
36
|
+
@layer theme, base, polyx, components, utilities;
|
|
37
|
+
|
|
38
|
+
@layer polyx {
|
|
39
|
+
|
|
15
40
|
.polyx-signin {
|
|
16
41
|
/* Colors */
|
|
17
42
|
--polyx-brand: #4f46e5;
|
|
@@ -208,6 +233,13 @@
|
|
|
208
233
|
margin-bottom: 0.75rem;
|
|
209
234
|
}
|
|
210
235
|
|
|
236
|
+
/* Branding logo fetched from the app's tokens (FR-BRND). */
|
|
237
|
+
.polyx-signin__brand-logo {
|
|
238
|
+
max-height: 2.25rem;
|
|
239
|
+
width: auto;
|
|
240
|
+
object-fit: contain;
|
|
241
|
+
}
|
|
242
|
+
|
|
211
243
|
.polyx-signin__heading {
|
|
212
244
|
margin: 0;
|
|
213
245
|
font-size: 1.375rem;
|
|
@@ -270,6 +302,56 @@
|
|
|
270
302
|
cursor: not-allowed;
|
|
271
303
|
}
|
|
272
304
|
|
|
305
|
+
/* --- Password reveal toggle --------------------------------------------- */
|
|
306
|
+
|
|
307
|
+
.polyx-signin__input-wrap {
|
|
308
|
+
position: relative;
|
|
309
|
+
display: flex;
|
|
310
|
+
align-items: center;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/* Leave room for the reveal button so masked text never slides under it. */
|
|
314
|
+
.polyx-signin__input--has-reveal {
|
|
315
|
+
padding-right: 2.75rem;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
.polyx-signin__reveal {
|
|
319
|
+
position: absolute;
|
|
320
|
+
right: 0.25rem;
|
|
321
|
+
display: inline-flex;
|
|
322
|
+
align-items: center;
|
|
323
|
+
justify-content: center;
|
|
324
|
+
width: 2rem;
|
|
325
|
+
height: 2rem;
|
|
326
|
+
padding: 0;
|
|
327
|
+
color: var(--polyx-muted-fg);
|
|
328
|
+
background: transparent;
|
|
329
|
+
border: none;
|
|
330
|
+
border-radius: var(--polyx-radius-sm);
|
|
331
|
+
cursor: pointer;
|
|
332
|
+
transition: color 0.15s ease, box-shadow 0.15s ease;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
.polyx-signin__reveal:hover:not(:disabled) {
|
|
336
|
+
color: var(--polyx-fg);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
.polyx-signin__reveal:focus-visible {
|
|
340
|
+
outline: none;
|
|
341
|
+
color: var(--polyx-fg);
|
|
342
|
+
box-shadow: 0 0 0 3px var(--polyx-ring);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
.polyx-signin__reveal:disabled {
|
|
346
|
+
cursor: not-allowed;
|
|
347
|
+
opacity: 0.6;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
.polyx-signin__reveal svg {
|
|
351
|
+
width: 1.125rem;
|
|
352
|
+
height: 1.125rem;
|
|
353
|
+
}
|
|
354
|
+
|
|
273
355
|
/* --- Buttons ------------------------------------------------------------- */
|
|
274
356
|
|
|
275
357
|
.polyx-signin__submit,
|
|
@@ -440,3 +522,194 @@
|
|
|
440
522
|
background: var(--polyx-input-bg);
|
|
441
523
|
border-color: var(--polyx-brand);
|
|
442
524
|
}
|
|
525
|
+
|
|
526
|
+
/* --- User button --------------------------------------------------------- */
|
|
527
|
+
|
|
528
|
+
/* The root carries `polyx-signin` too, so the theme tokens above (and any consumer
|
|
529
|
+
override of them) reach this menu without a second declaration to keep in sync. */
|
|
530
|
+
|
|
531
|
+
.polyx-userbutton {
|
|
532
|
+
position: relative;
|
|
533
|
+
display: inline-block;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
.polyx-userbutton__trigger {
|
|
537
|
+
display: inline-flex;
|
|
538
|
+
align-items: center;
|
|
539
|
+
gap: 0.5rem;
|
|
540
|
+
padding: 0.125rem;
|
|
541
|
+
color: var(--polyx-fg);
|
|
542
|
+
background: none;
|
|
543
|
+
border: none;
|
|
544
|
+
border-radius: 999px;
|
|
545
|
+
cursor: pointer;
|
|
546
|
+
font: inherit;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
.polyx-userbutton__trigger:focus-visible {
|
|
550
|
+
outline: none;
|
|
551
|
+
box-shadow: 0 0 0 3px var(--polyx-ring);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
.polyx-userbutton__trigger-name {
|
|
555
|
+
padding-right: 0.25rem;
|
|
556
|
+
font-size: 0.875rem;
|
|
557
|
+
font-weight: 500;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
.polyx-userbutton__avatar {
|
|
561
|
+
display: inline-flex;
|
|
562
|
+
flex: none;
|
|
563
|
+
align-items: center;
|
|
564
|
+
justify-content: center;
|
|
565
|
+
overflow: hidden;
|
|
566
|
+
border-radius: 999px;
|
|
567
|
+
background: color-mix(in srgb, var(--polyx-brand) 12%, transparent);
|
|
568
|
+
color: var(--polyx-brand);
|
|
569
|
+
font-size: 0.75rem;
|
|
570
|
+
font-weight: 600;
|
|
571
|
+
letter-spacing: 0.02em;
|
|
572
|
+
object-fit: cover;
|
|
573
|
+
user-select: none;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/* Placement is expressed as data attributes and resolved in CSS: the component measures which
|
|
577
|
+
side has room and says so; where that lands is a styling concern. `--polyx-gap` is the space
|
|
578
|
+
between trigger and menu, and the JS assumes the same 8px when it measures. */
|
|
579
|
+
.polyx-userbutton__menu {
|
|
580
|
+
--polyx-gap: 0.5rem;
|
|
581
|
+
position: absolute;
|
|
582
|
+
z-index: 50;
|
|
583
|
+
min-width: 15rem;
|
|
584
|
+
padding: 0.375rem;
|
|
585
|
+
background: var(--polyx-bg);
|
|
586
|
+
border: 1px solid var(--polyx-border);
|
|
587
|
+
border-radius: var(--polyx-radius);
|
|
588
|
+
box-shadow: var(--polyx-shadow);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/* Vertical sides: offset above/below, then align along the horizontal axis. */
|
|
592
|
+
.polyx-userbutton__menu[data-polyx-side="bottom"] {
|
|
593
|
+
top: calc(100% + var(--polyx-gap));
|
|
594
|
+
}
|
|
595
|
+
.polyx-userbutton__menu[data-polyx-side="top"] {
|
|
596
|
+
bottom: calc(100% + var(--polyx-gap));
|
|
597
|
+
}
|
|
598
|
+
.polyx-userbutton__menu[data-polyx-side="bottom"][data-polyx-align="end"],
|
|
599
|
+
.polyx-userbutton__menu[data-polyx-side="top"][data-polyx-align="end"] {
|
|
600
|
+
right: 0;
|
|
601
|
+
}
|
|
602
|
+
.polyx-userbutton__menu[data-polyx-side="bottom"][data-polyx-align="start"],
|
|
603
|
+
.polyx-userbutton__menu[data-polyx-side="top"][data-polyx-align="start"] {
|
|
604
|
+
left: 0;
|
|
605
|
+
}
|
|
606
|
+
.polyx-userbutton__menu[data-polyx-side="bottom"][data-polyx-align="center"],
|
|
607
|
+
.polyx-userbutton__menu[data-polyx-side="top"][data-polyx-align="center"] {
|
|
608
|
+
left: 50%;
|
|
609
|
+
transform: translateX(-50%);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/* Horizontal sides: offset left/right, then align along the vertical axis. */
|
|
613
|
+
.polyx-userbutton__menu[data-polyx-side="right"] {
|
|
614
|
+
left: calc(100% + var(--polyx-gap));
|
|
615
|
+
}
|
|
616
|
+
.polyx-userbutton__menu[data-polyx-side="left"] {
|
|
617
|
+
right: calc(100% + var(--polyx-gap));
|
|
618
|
+
}
|
|
619
|
+
.polyx-userbutton__menu[data-polyx-side="right"][data-polyx-align="end"],
|
|
620
|
+
.polyx-userbutton__menu[data-polyx-side="left"][data-polyx-align="end"] {
|
|
621
|
+
bottom: 0;
|
|
622
|
+
}
|
|
623
|
+
.polyx-userbutton__menu[data-polyx-side="right"][data-polyx-align="start"],
|
|
624
|
+
.polyx-userbutton__menu[data-polyx-side="left"][data-polyx-align="start"] {
|
|
625
|
+
top: 0;
|
|
626
|
+
}
|
|
627
|
+
.polyx-userbutton__menu[data-polyx-side="right"][data-polyx-align="center"],
|
|
628
|
+
.polyx-userbutton__menu[data-polyx-side="left"][data-polyx-align="center"] {
|
|
629
|
+
top: 50%;
|
|
630
|
+
transform: translateY(-50%);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/* Consumer-supplied rows: give them room and get out of the way — no colours, no type, no
|
|
634
|
+
cursor. Whatever they render is theirs. */
|
|
635
|
+
.polyx-userbutton__custom {
|
|
636
|
+
padding: 0.375rem 0.625rem;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
.polyx-userbutton__identity {
|
|
640
|
+
display: flex;
|
|
641
|
+
align-items: center;
|
|
642
|
+
gap: 0.625rem;
|
|
643
|
+
padding: 0.5rem 0.625rem 0.625rem;
|
|
644
|
+
border-bottom: 1px solid var(--polyx-border);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
.polyx-userbutton__identity-text {
|
|
648
|
+
display: flex;
|
|
649
|
+
min-width: 0;
|
|
650
|
+
flex-direction: column;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/* Names and emails are user data: let them truncate rather than stretch the menu. */
|
|
654
|
+
.polyx-userbutton__name,
|
|
655
|
+
.polyx-userbutton__email {
|
|
656
|
+
overflow: hidden;
|
|
657
|
+
text-overflow: ellipsis;
|
|
658
|
+
white-space: nowrap;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
.polyx-userbutton__name {
|
|
662
|
+
font-size: 0.875rem;
|
|
663
|
+
font-weight: 600;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
.polyx-userbutton__email {
|
|
667
|
+
font-size: 0.75rem;
|
|
668
|
+
color: var(--polyx-muted-fg);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
.polyx-userbutton__items {
|
|
672
|
+
display: flex;
|
|
673
|
+
flex-direction: column;
|
|
674
|
+
margin: 0.375rem 0 0;
|
|
675
|
+
padding: 0;
|
|
676
|
+
list-style: none;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
.polyx-userbutton__item {
|
|
680
|
+
display: flex;
|
|
681
|
+
width: 100%;
|
|
682
|
+
align-items: center;
|
|
683
|
+
gap: 0.5rem;
|
|
684
|
+
padding: 0.5rem 0.625rem;
|
|
685
|
+
color: var(--polyx-fg);
|
|
686
|
+
background: none;
|
|
687
|
+
border: none;
|
|
688
|
+
border-radius: var(--polyx-radius-sm);
|
|
689
|
+
cursor: pointer;
|
|
690
|
+
font: inherit;
|
|
691
|
+
font-size: 0.875rem;
|
|
692
|
+
text-align: left;
|
|
693
|
+
text-decoration: none;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
.polyx-userbutton__item:hover {
|
|
697
|
+
background: color-mix(in srgb, var(--polyx-fg) 6%, transparent);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
.polyx-userbutton__item:focus-visible {
|
|
701
|
+
outline: none;
|
|
702
|
+
box-shadow: 0 0 0 2px var(--polyx-ring);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
.polyx-userbutton__icon {
|
|
706
|
+
flex: none;
|
|
707
|
+
width: 1rem;
|
|
708
|
+
height: 1rem;
|
|
709
|
+
color: var(--polyx-muted-fg);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/* Closes `@layer polyx` — opened at the top of the file. The rules above are left
|
|
713
|
+
unindented deliberately: wrapping the sheet in a layer is a cascade change, not a
|
|
714
|
+
reformat, and re-indenting 600 lines would bury it in the diff. */
|
|
715
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@poly-x/next",
|
|
3
|
-
"version": "0.1.0
|
|
3
|
+
"version": "0.1.0",
|
|
4
4
|
"description": "PolyX SDK for Next.js App Router - components plus server helpers",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -47,8 +47,8 @@
|
|
|
47
47
|
"module": "./dist/index.mjs",
|
|
48
48
|
"types": "./dist/index.d.cts",
|
|
49
49
|
"dependencies": {
|
|
50
|
-
"@poly-x/core": "0.1.0
|
|
51
|
-
"@poly-x/react": "0.1.0
|
|
50
|
+
"@poly-x/core": "0.1.0",
|
|
51
|
+
"@poly-x/react": "0.1.0"
|
|
52
52
|
},
|
|
53
53
|
"peerDependencies": {
|
|
54
54
|
"next": ">=14",
|