@poly-x/next 0.1.0-alpha.0 → 0.1.0-alpha.10

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/server.d.mts CHANGED
@@ -6,6 +6,15 @@ interface NextServerConfig {
6
6
  /** Secret for encrypting the session cookie (AES-GCM key derivation). */
7
7
  secret: string;
8
8
  baseUrl?: string;
9
+ /** Hosted sign-in page URL the signin route redirects to. */
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;
9
18
  }
10
19
  declare function resolveNextConfig(overrides?: Partial<NextServerConfig>): NextServerConfig;
11
20
  //#endregion
@@ -35,17 +44,36 @@ interface ServerSessionOptions {
35
44
  secret: string;
36
45
  cookies: CookieAdapter;
37
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;
38
53
  }
39
54
  declare class ServerSession {
40
55
  private readonly authClient;
41
56
  private readonly secret;
42
57
  private readonly cookies;
43
58
  private readonly cookieName;
59
+ private readonly secure;
44
60
  constructor(options: ServerSessionOptions);
45
61
  establishFromCode(code: string, codeVerifier: string, redirectUri: string): Promise<StoredSession>;
46
62
  read(): Promise<StoredSession | null>;
47
63
  /** Rotate the tokens; clears the cookie (returns null) on a terminal refresh failure. */
48
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>;
49
77
  clear(): void;
50
78
  private write;
51
79
  }
@@ -71,10 +99,77 @@ interface AuthHandlersConfig extends Partial<NextServerConfig> {
71
99
  }
72
100
  interface AuthHandlers {
73
101
  signin(request: Request): Promise<Response>;
102
+ /**
103
+ * The embedded `<SignIn/>` BFF endpoint: reads `{email, password}` (+ optional
104
+ * `organizationCode`/`tenantId`), runs the password authorize entirely
105
+ * server-side, and — on success — seals the httpOnly session before returning.
106
+ * The browser never sees the code, the tokens, or the credentials after POST.
107
+ */
108
+ authenticate(request: Request): Promise<Response>;
109
+ /**
110
+ * The `<ForgotPassword/>` BFF endpoint: reads `{email}`, adds this app's publishable
111
+ * key, and asks poly-auth to email a reset link server-side. Always relays a uniform
112
+ * `accepted` — it never reveals whether the account exists.
113
+ */
114
+ forgotPassword(request: Request): Promise<Response>;
115
+ /**
116
+ * The `<ResetPassword/>` BFF endpoint: reads `{token, newPassword}` and redeems the
117
+ * opaque single-use token. `success` on completion; `invalid_or_expired` otherwise.
118
+ */
119
+ resetPassword(request: Request): Promise<Response>;
74
120
  callback(request: Request): Promise<Response>;
75
121
  refresh(request: Request): Promise<Response>;
76
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>;
77
130
  }
131
+ /** The JSON the `forgotPassword` handler returns — always uniform (no enumeration). */
132
+ type ForgotPasswordResult = {
133
+ status: "accepted";
134
+ } | {
135
+ status: "invalid_request";
136
+ };
137
+ /** The JSON the `resetPassword` handler returns — the `<ResetPassword/>` form branches on `status`. */
138
+ type ResetPasswordResult = {
139
+ status: "success";
140
+ } | {
141
+ status: "weak_password";
142
+ minLength: number;
143
+ } | {
144
+ status: "password_mismatch";
145
+ } | {
146
+ status: "invalid_or_expired";
147
+ } | {
148
+ status: "invalid_request";
149
+ };
150
+ /** The JSON the `authenticate` handler returns — the `<SignIn/>` form branches on `status`. */
151
+ type AuthenticateResult = {
152
+ status: "success";
153
+ redirectTo: string;
154
+ } | {
155
+ status: "select_tenant";
156
+ tenants: unknown[];
157
+ state: string;
158
+ } | {
159
+ status: "password_change_required";
160
+ userId: string;
161
+ } | {
162
+ status: "weak_password";
163
+ minLength: number;
164
+ } | {
165
+ status: "password_mismatch";
166
+ } | {
167
+ status: "no_access";
168
+ } | {
169
+ status: "invalid_credentials";
170
+ } | {
171
+ status: "invalid_request";
172
+ };
78
173
  declare function createAuthHandlers(handlersConfig?: AuthHandlersConfig): AuthHandlers;
79
174
  //#endregion
80
- export { type AuthContext, AuthHandlers, AuthHandlersConfig, type CookieAdapter, type CookieSetOptions, DEFAULT_SESSION_COOKIE, type NextServerConfig, ServerSession, auth, createAuthHandlers, openSession, resolveNextConfig, sealSession };
175
+ export { type AuthContext, AuthHandlers, AuthHandlersConfig, AuthenticateResult, type CookieAdapter, type CookieSetOptions, DEFAULT_SESSION_COOKIE, ForgotPasswordResult, type NextServerConfig, ResetPasswordResult, ServerSession, auth, createAuthHandlers, openSession, resolveNextConfig, sealSession };
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-B3zJ7CS8.mjs";
1
+ import { a as sealSession, i as openSession, n as ServerSession, r as toAuthContext, t as DEFAULT_SESSION_COOKIE } from "./server-session-DedPwl4b.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;
@@ -10,7 +17,9 @@ function resolveNextConfig(overrides = {}) {
10
17
  return {
11
18
  publishableKey,
12
19
  secret,
13
- baseUrl: overrides.baseUrl ?? process.env.POLYX_BASE_URL
20
+ baseUrl: overrides.baseUrl ?? process.env.POLYX_BASE_URL,
21
+ signInUrl: overrides.signInUrl ?? process.env.NEXT_PUBLIC_POLYX_SIGN_IN_URL,
22
+ cookieSecure: overrides.cookieSecure ?? parseBoolEnv(process.env.POLYX_COOKIE_SECURE)
14
23
  };
15
24
  }
16
25
  function buildServerAuthClient(config) {
@@ -18,6 +27,7 @@ function buildServerAuthClient(config) {
18
27
  config: resolveConfig({
19
28
  key: config.publishableKey,
20
29
  baseUrl: config.baseUrl,
30
+ signInUrl: config.signInUrl,
21
31
  context: "server"
22
32
  }),
23
33
  transport: new FetchTransport()
@@ -33,6 +43,8 @@ function buildServerAuthClient(config) {
33
43
  */
34
44
  const VERIFIER_COOKIE = "polyx_pkce";
35
45
  const VERIFIER_MAX_AGE = 600;
46
+ /** poly-auth's own floor (Joi: min 6, max 128) — reject early rather than round-trip. */
47
+ const MIN_PASSWORD_LENGTH = 6;
36
48
  function adapt(store) {
37
49
  return {
38
50
  get: (name) => store.get(name)?.value,
@@ -43,6 +55,22 @@ function adapt(store) {
43
55
  async function requestCookies() {
44
56
  return adapt(await cookies());
45
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
+ }
46
74
  /** Read the current session — safe in Server Components (no cookie write). */
47
75
  async function auth(overrides) {
48
76
  const config = resolveNextConfig(overrides);
@@ -55,16 +83,18 @@ async function auth(overrides) {
55
83
  function createAuthHandlers(handlersConfig = {}) {
56
84
  const callbackPath = handlersConfig.callbackPath ?? "/api/polyx/callback";
57
85
  const afterSignInPath = handlersConfig.afterSignInPath ?? "/";
58
- function newSession(store, config) {
86
+ function newSession(store, config, secure) {
59
87
  return new ServerSession({
60
88
  authClient: buildServerAuthClient(config),
61
89
  secret: config.secret,
62
- cookies: store
90
+ cookies: store,
91
+ secure
63
92
  });
64
93
  }
65
94
  return {
66
95
  async signin(request) {
67
96
  const config = resolveNextConfig(handlersConfig);
97
+ const secure = resolveCookieSecure(request, config);
68
98
  const redirectUri = `${new URL(request.url).origin}${callbackPath}`;
69
99
  const { verifier, challenge } = await generatePkce();
70
100
  const state = randomState();
@@ -74,20 +104,140 @@ function createAuthHandlers(handlersConfig = {}) {
74
104
  redirectUri
75
105
  }), {
76
106
  httpOnly: true,
77
- secure: true,
107
+ secure,
78
108
  sameSite: "lax",
79
109
  path: "/",
80
110
  maxAge: VERIFIER_MAX_AGE
81
111
  });
82
- const url = buildServerAuthClient(config).buildAuthorizeUrl({
112
+ const url = buildServerAuthClient(config).buildSignInUrl({
83
113
  redirectUri,
84
114
  state,
85
115
  challenge
86
116
  });
87
117
  return Response.redirect(url, 302);
88
118
  },
119
+ async authenticate(request) {
120
+ const config = resolveNextConfig(handlersConfig);
121
+ const secure = resolveCookieSecure(request, config);
122
+ const redirectUri = `${new URL(request.url).origin}${callbackPath}`;
123
+ const client = buildServerAuthClient(config);
124
+ let payload;
125
+ try {
126
+ payload = await request.json();
127
+ } catch {
128
+ return Response.json({ status: "invalid_request" }, { status: 400 });
129
+ }
130
+ const email = typeof payload.email === "string" ? payload.email : "";
131
+ const password = typeof payload.password === "string" ? payload.password : "";
132
+ if (!email || !password) return Response.json({ status: "invalid_request" }, { status: 400 });
133
+ const organizationCode = typeof payload.organizationCode === "string" ? payload.organizationCode : void 0;
134
+ const tenantId = typeof payload.tenantId === "string" ? payload.tenantId : void 0;
135
+ const newPassword = typeof payload.newPassword === "string" ? payload.newPassword : void 0;
136
+ const confirmPassword = typeof payload.confirmPassword === "string" ? payload.confirmPassword : void 0;
137
+ /** One authorize attempt, mapped to the wire response (sealing the session on success). */
138
+ async function attempt(withPassword) {
139
+ const { verifier, challenge } = await generatePkce();
140
+ const outcome = await client.authorizeWithPassword({
141
+ email,
142
+ password: withPassword,
143
+ redirectUri,
144
+ state: randomState(),
145
+ challenge,
146
+ organizationCode,
147
+ tenantId
148
+ });
149
+ switch (outcome.type) {
150
+ case "authorized":
151
+ await newSession(adapt(await cookies()), config, secure).establishFromCode(outcome.code, verifier, redirectUri);
152
+ return Response.json({
153
+ status: "success",
154
+ redirectTo: afterSignInPath
155
+ });
156
+ case "select_tenant": return Response.json({
157
+ status: "select_tenant",
158
+ tenants: outcome.tenants,
159
+ state: outcome.state
160
+ });
161
+ case "password_change_required": return Response.json({
162
+ status: "password_change_required",
163
+ userId: outcome.userId
164
+ }, { status: 403 });
165
+ case "no_access": return Response.json({ status: "no_access" }, { status: 403 });
166
+ default: return Response.json({ status: "invalid_credentials" }, { status: 401 });
167
+ }
168
+ }
169
+ if (newPassword === void 0) return attempt(password);
170
+ if (newPassword.length < MIN_PASSWORD_LENGTH) return Response.json({
171
+ status: "weak_password",
172
+ minLength: MIN_PASSWORD_LENGTH
173
+ }, { status: 400 });
174
+ if (newPassword !== confirmPassword) return Response.json({ status: "password_mismatch" }, { status: 400 });
175
+ const { challenge } = await generatePkce();
176
+ const proof = await client.authorizeWithPassword({
177
+ email,
178
+ password,
179
+ redirectUri,
180
+ state: randomState(),
181
+ challenge,
182
+ organizationCode,
183
+ tenantId
184
+ });
185
+ if (proof.type !== "password_change_required") return Response.json({ status: "invalid_credentials" }, { status: 401 });
186
+ await client.setPassword({
187
+ userId: proof.userId,
188
+ newPassword,
189
+ confirmPassword,
190
+ ticket: proof.ticket
191
+ });
192
+ return attempt(newPassword);
193
+ },
194
+ async forgotPassword(request) {
195
+ const config = resolveNextConfig(handlersConfig);
196
+ let payload;
197
+ try {
198
+ payload = await request.json();
199
+ } catch {
200
+ return Response.json({ status: "invalid_request" }, { status: 400 });
201
+ }
202
+ const email = typeof payload.email === "string" ? payload.email.trim() : "";
203
+ if (!email) return Response.json({ status: "invalid_request" }, { status: 400 });
204
+ try {
205
+ await buildServerAuthClient(config).requestPasswordReset({ email });
206
+ } catch {}
207
+ return Response.json({ status: "accepted" });
208
+ },
209
+ async resetPassword(request) {
210
+ const config = resolveNextConfig(handlersConfig);
211
+ let payload;
212
+ try {
213
+ payload = await request.json();
214
+ } catch {
215
+ return Response.json({ status: "invalid_request" }, { status: 400 });
216
+ }
217
+ const token = typeof payload.token === "string" ? payload.token : "";
218
+ const newPassword = typeof payload.newPassword === "string" ? payload.newPassword : "";
219
+ const confirmPassword = typeof payload.confirmPassword === "string" ? payload.confirmPassword : void 0;
220
+ if (!token) return Response.json({ status: "invalid_request" }, { status: 400 });
221
+ if (newPassword.length < MIN_PASSWORD_LENGTH) return Response.json({
222
+ status: "weak_password",
223
+ minLength: MIN_PASSWORD_LENGTH
224
+ }, { status: 400 });
225
+ if (confirmPassword !== void 0 && newPassword !== confirmPassword) return Response.json({ status: "password_mismatch" }, { status: 400 });
226
+ switch (await buildServerAuthClient(config).resetPassword({
227
+ token,
228
+ newPassword
229
+ })) {
230
+ case "ok": return Response.json({ status: "success" });
231
+ case "weak_password": return Response.json({
232
+ status: "weak_password",
233
+ minLength: MIN_PASSWORD_LENGTH
234
+ }, { status: 400 });
235
+ default: return Response.json({ status: "invalid_or_expired" }, { status: 400 });
236
+ }
237
+ },
89
238
  async callback(request) {
90
239
  const config = resolveNextConfig(handlersConfig);
240
+ const secure = resolveCookieSecure(request, config);
91
241
  const url = new URL(request.url);
92
242
  const code = url.searchParams.get("code");
93
243
  const state = url.searchParams.get("state");
@@ -98,21 +248,35 @@ function createAuthHandlers(handlersConfig = {}) {
98
248
  if (!code || !state || !raw) return Response.redirect(home, 302);
99
249
  const tx = JSON.parse(raw);
100
250
  if (tx.state !== state) return Response.redirect(home, 302);
101
- await newSession(adapt(store), config).establishFromCode(code, tx.verifier, tx.redirectUri);
251
+ await newSession(adapt(store), config, secure).establishFromCode(code, tx.verifier, tx.redirectUri);
102
252
  return Response.redirect(home, 302);
103
253
  },
104
- async refresh() {
254
+ async refresh(request) {
105
255
  const config = resolveNextConfig(handlersConfig);
106
- const session = await newSession(await requestCookies(), config).refresh();
256
+ const secure = resolveCookieSecure(request, config);
257
+ const session = await newSession(await requestCookies(), config, secure).refresh();
107
258
  return Response.json({ isSignedIn: session !== null }, { status: session ? 200 : 401 });
108
259
  },
260
+ /**
261
+ * End the session: revoke it upstream (bearer-authenticated, using the sealed token)
262
+ * and unseal the cookie. `?scope=everywhere` signs the user out of every device.
263
+ *
264
+ * Content-negotiated so both callers work: a plain link/form navigation gets the 302 it
265
+ * expects, while `Accept: application/json` (what `<UserButton/>` sends) gets JSON and
266
+ * decides its own destination client-side. Deliberately no server-side redirect target
267
+ * — a caller-supplied one would be an open redirect on the auth path, and the client
268
+ * already knows where it wants to land.
269
+ */
109
270
  async signout(request) {
110
271
  const config = resolveNextConfig(handlersConfig);
111
- newSession(adapt(await cookies()), config).clear();
112
- try {
113
- await buildServerAuthClient(config).logout("device");
114
- } catch {}
272
+ const secure = resolveCookieSecure(request, config);
273
+ const scope = new URL(request.url).searchParams.get("scope") === "everywhere" ? "everywhere" : "device";
274
+ await newSession(adapt(await cookies()), config, secure).revoke(scope);
275
+ if (request.headers.get("accept")?.includes("application/json")) return Response.json({ signedOut: true });
115
276
  return Response.redirect(new URL(request.url).origin + "/", 302);
277
+ },
278
+ async session() {
279
+ return Response.json(await auth(handlersConfig));
116
280
  }
117
281
  };
118
282
  }