@spfn/auth 0.3.0-beta.23 → 0.3.0-beta.25
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 +328 -7
- package/dist/client-proof.d.ts +10 -1
- package/dist/client-proof.js +136 -11
- package/dist/client-proof.js.map +1 -1
- package/dist/client.d.ts +101 -1
- package/dist/client.js +65 -0
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts +122 -0
- package/dist/config.js +53 -0
- package/dist/config.js.map +1 -1
- package/dist/crypto.d.ts +1 -1
- package/dist/errors.d.ts +159 -3
- package/dist/errors.js +95 -2
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +52 -12
- package/dist/index.js +104 -3
- package/dist/index.js.map +1 -1
- package/dist/{machine-principals-CaEFq61K.d.ts → machine-principals-CdEgxOB1.d.ts} +2049 -771
- package/dist/nextjs/api.js +329 -37
- package/dist/nextjs/api.js.map +1 -1
- package/dist/nextjs/server.d.ts +59 -24
- package/dist/nextjs/server.js +105 -11
- package/dist/nextjs/server.js.map +1 -1
- package/dist/server.d.ts +415 -332
- package/dist/server.js +2933 -1545
- package/dist/server.js.map +1 -1
- package/dist/{session-Dfwu5g2W.d.ts → session-BbhAGZtA.d.ts} +57 -1
- package/dist/{types-DYyhze28.d.ts → types-CTdoTOxM.d.ts} +24 -1
- package/migrations/20260918184037_happy_mordo/migration.sql +4 -0
- package/migrations/20260918184037_happy_mordo/snapshot.json +6000 -0
- package/migrations/20260918184152_dear_rictor/migration.sql +3 -0
- package/migrations/20260918184152_dear_rictor/snapshot.json +6039 -0
- package/migrations/20260919023107_even_mikhail_rasputin/migration.sql +20 -0
- package/migrations/20260919023107_even_mikhail_rasputin/snapshot.json +6300 -0
- package/package.json +1 -1
package/dist/nextjs/server.d.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
2
2
|
import { ReactNode } from 'react';
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
import { authApi } from '@spfn/auth';
|
|
4
|
+
import { S as SessionData } from '../session-BbhAGZtA.js';
|
|
5
|
+
export { i as isSafeReturnPath } from '../session-BbhAGZtA.js';
|
|
6
|
+
import { K as KeyAlgorithmType } from '../types-CTdoTOxM.js';
|
|
6
7
|
import { NextResponse, NextRequest } from 'next/server';
|
|
7
8
|
|
|
8
9
|
interface RequireAuthProps {
|
|
@@ -19,6 +20,17 @@ interface RequireAuthProps {
|
|
|
19
20
|
* Fallback UI to show instead of redirecting
|
|
20
21
|
*/
|
|
21
22
|
fallback?: ReactNode;
|
|
23
|
+
/**
|
|
24
|
+
* Path to send a bound session whose key has run out (#97)
|
|
25
|
+
*
|
|
26
|
+
* Not the sign-in page: the person is still signed in and one WebAuthn
|
|
27
|
+
* ceremony puts a live key back in the cookie. The page this names is the
|
|
28
|
+
* app's own, and all it has to do is render a client component that calls
|
|
29
|
+
* `renewSession(api)` and returns them to where they were.
|
|
30
|
+
*
|
|
31
|
+
* @default env SPFN_AUTH_SESSION_RENEW_PATH, or '/auth/renew'
|
|
32
|
+
*/
|
|
33
|
+
renewalPath?: string;
|
|
22
34
|
}
|
|
23
35
|
/**
|
|
24
36
|
* Require Authentication Guard
|
|
@@ -38,8 +50,17 @@ interface RequireAuthProps {
|
|
|
38
50
|
* <PrivateContent />
|
|
39
51
|
* </RequireAuth>
|
|
40
52
|
* ```
|
|
53
|
+
*
|
|
54
|
+
* @example A bound session whose key ran out
|
|
55
|
+
* ```tsx
|
|
56
|
+
* // Sent to /account/renew instead of the sign-in page. That page renders a
|
|
57
|
+
* // client component calling renewSession(api).
|
|
58
|
+
* <RequireAuth renewalPath="/account/renew">
|
|
59
|
+
* <DashboardContent />
|
|
60
|
+
* </RequireAuth>
|
|
61
|
+
* ```
|
|
41
62
|
*/
|
|
42
|
-
declare function RequireAuth({ children, redirectTo, fallback, }: RequireAuthProps): Promise<react_jsx_runtime.JSX.Element>;
|
|
63
|
+
declare function RequireAuth({ children, redirectTo, renewalPath, fallback, }: RequireAuthProps): Promise<react_jsx_runtime.JSX.Element>;
|
|
43
64
|
|
|
44
65
|
interface RequireRoleProps {
|
|
45
66
|
/**
|
|
@@ -140,29 +161,26 @@ declare function RequirePermission({ permissions, children, redirectTo, fallback
|
|
|
140
161
|
*
|
|
141
162
|
* Uses authApi to check permissions in real-time
|
|
142
163
|
*/
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* A bound session whose key has run out, seen from a server component.
|
|
167
|
+
*
|
|
168
|
+
* The third state `getAuthSessionData` answers with, and the reason it is a
|
|
169
|
+
* sentinel rather than `null`: a server component's `api.` call goes through the
|
|
170
|
+
* same proxy a browser's does, so it meets the same renewal-required refusal —
|
|
171
|
+
* and `null` there would read as "not signed in" and send the person to the
|
|
172
|
+
* sign-in page, which is precisely the thing renewal exists to avoid. A server
|
|
173
|
+
* component cannot run a WebAuthn ceremony, so the guard hands the work to a page
|
|
174
|
+
* that can.
|
|
175
|
+
*/
|
|
176
|
+
declare const RENEWAL_REQUIRED = "renewal-required";
|
|
177
|
+
type AuthSessionData = Awaited<ReturnType<typeof authApi.getAuthSession.call>>;
|
|
178
|
+
/** What a guard gets back: the session, the renewal sentinel, or nothing. */
|
|
179
|
+
type AuthSessionState = AuthSessionData | typeof RENEWAL_REQUIRED | null;
|
|
143
180
|
/**
|
|
144
181
|
* Get current auth session with roles and permissions via API
|
|
145
182
|
*/
|
|
146
|
-
declare function getAuthSessionData(): Promise<
|
|
147
|
-
role: {
|
|
148
|
-
id: number;
|
|
149
|
-
name: string;
|
|
150
|
-
displayName: string;
|
|
151
|
-
priority: number;
|
|
152
|
-
};
|
|
153
|
-
permissions: {
|
|
154
|
-
id: number;
|
|
155
|
-
name: string;
|
|
156
|
-
displayName: string;
|
|
157
|
-
category: "auth" | "custom" | "user" | "rbac" | "system" | undefined;
|
|
158
|
-
}[];
|
|
159
|
-
userId: number;
|
|
160
|
-
publicId: string;
|
|
161
|
-
email: string | null;
|
|
162
|
-
emailVerified: boolean;
|
|
163
|
-
phoneVerified: boolean;
|
|
164
|
-
hasPassword: boolean;
|
|
165
|
-
} | null>;
|
|
183
|
+
declare function getAuthSessionData(): Promise<AuthSessionState>;
|
|
166
184
|
/**
|
|
167
185
|
* Get user role
|
|
168
186
|
*/
|
|
@@ -351,6 +369,18 @@ interface OAuthCallbackOptions {
|
|
|
351
369
|
* @default '/auth/error'
|
|
352
370
|
*/
|
|
353
371
|
errorRedirectUrl?: string;
|
|
372
|
+
/**
|
|
373
|
+
* App page that asks for the second factor, when the callback carries a
|
|
374
|
+
* step-up challenge instead of a session (#95).
|
|
375
|
+
*
|
|
376
|
+
* An override for `SPFN_AUTH_MFA_CONFIRM_PATH`, which is where every other
|
|
377
|
+
* app-page path in this package lives; the env var is the one to set, and
|
|
378
|
+
* this exists for an app mounting two handlers on different screens. The
|
|
379
|
+
* handler redirects to it with `?challenge=` and `?returnUrl=`.
|
|
380
|
+
*
|
|
381
|
+
* @default env SPFN_AUTH_MFA_CONFIRM_PATH, then '/auth/mfa'
|
|
382
|
+
*/
|
|
383
|
+
mfaPath?: string;
|
|
354
384
|
}
|
|
355
385
|
/**
|
|
356
386
|
* Create OAuth callback handler for Next.js API Route
|
|
@@ -361,6 +391,11 @@ interface OAuthCallbackOptions {
|
|
|
361
391
|
* 3. Creates full session and saves to cookie
|
|
362
392
|
* 4. Redirects to returnUrl
|
|
363
393
|
*
|
|
394
|
+
* When the account has a second factor and this device is new to it (#95) the
|
|
395
|
+
* backend sends `mfaChallenge` in place of `userId` and `keyId`. No session is
|
|
396
|
+
* sealed; the browser goes to `SPFN_AUTH_MFA_CONFIRM_PATH` with the challenge,
|
|
397
|
+
* and the session is sealed by `mfaVerifyInterceptor` once the page proves it.
|
|
398
|
+
*
|
|
364
399
|
* @example
|
|
365
400
|
* ```typescript
|
|
366
401
|
* // /api/auth/callback/route.ts
|
package/dist/nextjs/server.js
CHANGED
|
@@ -158,6 +158,18 @@ var COOKIE_NAMES = {
|
|
|
158
158
|
get OAUTH_PENDING() {
|
|
159
159
|
return `spfn_oauth_pending${getCookieSuffix()}`;
|
|
160
160
|
},
|
|
161
|
+
/**
|
|
162
|
+
* Pending second-factor session (privateKey, keyId, challengeHash) (#95)
|
|
163
|
+
*
|
|
164
|
+
* Its own name and its own audience, separate from OAUTH_PENDING. The two
|
|
165
|
+
* coexist: a person who starts a social login in one tab while a password
|
|
166
|
+
* step-up is outstanding in another has both flows live, and one name would
|
|
167
|
+
* mean the second overwrote the first — sealing a session with a private key
|
|
168
|
+
* that does not match the key being activated.
|
|
169
|
+
*/
|
|
170
|
+
get MFA_PENDING() {
|
|
171
|
+
return `spfn_mfa_pending${getCookieSuffix()}`;
|
|
172
|
+
},
|
|
161
173
|
/** OAuth CSRF nonce — double-submit against the (encrypted) state.nonce at callback */
|
|
162
174
|
get OAUTH_CSRF() {
|
|
163
175
|
return `spfn_oauth_csrf${getCookieSuffix()}`;
|
|
@@ -215,6 +227,10 @@ function getSessionTtl(override) {
|
|
|
215
227
|
}
|
|
216
228
|
return 7 * 24 * 60 * 60;
|
|
217
229
|
}
|
|
230
|
+
function getSessionRenewPath() {
|
|
231
|
+
return env3.SPFN_AUTH_SESSION_RENEW_PATH?.trim() || DEFAULT_SESSION_RENEW_PATH;
|
|
232
|
+
}
|
|
233
|
+
var DEFAULT_SESSION_RENEW_PATH = "/auth/renew";
|
|
218
234
|
|
|
219
235
|
// src/nextjs/session-helpers.ts
|
|
220
236
|
import { env as env4 } from "@spfn/auth/config";
|
|
@@ -268,20 +284,21 @@ async function clearSession() {
|
|
|
268
284
|
cookieStore.delete(COOKIE_NAMES.SESSION_KEY_ID);
|
|
269
285
|
cookieStore.delete(COOKIE_NAMES.CSRF);
|
|
270
286
|
}
|
|
271
|
-
async function getPendingSessionKey() {
|
|
287
|
+
async function getPendingSessionKey(purpose) {
|
|
272
288
|
const secret = env4.SPFN_AUTH_SESSION_SECRET;
|
|
273
289
|
const encoder = new TextEncoder();
|
|
274
|
-
const data = encoder.encode(`oauth-pending:${secret}`);
|
|
290
|
+
const data = encoder.encode(purpose === "oauth" ? `oauth-pending:${secret}` : `mfa-pending:${secret}`);
|
|
275
291
|
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
|
276
292
|
return new Uint8Array(hashBuffer);
|
|
277
293
|
}
|
|
278
294
|
async function sealPendingSession(data, ttl = 600) {
|
|
279
|
-
|
|
280
|
-
|
|
295
|
+
return await sealFor("oauth", data, ttl);
|
|
296
|
+
}
|
|
297
|
+
async function sealFor(purpose, data, ttl) {
|
|
298
|
+
return await new jose2.EncryptJWT({ data }).setProtectedHeader({ alg: "dir", enc: "A256GCM" }).setIssuedAt().setExpirationTime(`${ttl}s`).setIssuer("spfn-auth").setAudience(purpose === "oauth" ? "spfn-oauth" : "spfn-mfa").encrypt(await getPendingSessionKey(purpose));
|
|
281
299
|
}
|
|
282
300
|
async function unsealPendingSession(jwt) {
|
|
283
|
-
const
|
|
284
|
-
const { payload } = await jose2.jwtDecrypt(jwt, key, {
|
|
301
|
+
const { payload } = await jose2.jwtDecrypt(jwt, await getPendingSessionKey("oauth"), {
|
|
285
302
|
issuer: "spfn-auth",
|
|
286
303
|
audience: "spfn-oauth"
|
|
287
304
|
});
|
|
@@ -309,36 +326,48 @@ async function clearPendingSession() {
|
|
|
309
326
|
|
|
310
327
|
// src/nextjs/guards/auth-utils.ts
|
|
311
328
|
import { authApi } from "@spfn/auth";
|
|
329
|
+
import { SessionRenewalRequiredError } from "@spfn/auth/errors";
|
|
330
|
+
var RENEWAL_REQUIRED = "renewal-required";
|
|
312
331
|
async function getAuthSessionData() {
|
|
313
332
|
try {
|
|
314
333
|
const session = await authApi.getAuthSession.call();
|
|
315
334
|
authLogger.middleware.debug("Auth session retrieved", { name: session.role?.name });
|
|
316
335
|
return session;
|
|
317
336
|
} catch (error) {
|
|
337
|
+
if (isRenewalRequired(error)) {
|
|
338
|
+
authLogger.middleware.debug("Auth session needs renewing");
|
|
339
|
+
return RENEWAL_REQUIRED;
|
|
340
|
+
}
|
|
318
341
|
authLogger.middleware.error("Failed to get auth session", { error });
|
|
319
342
|
return null;
|
|
320
343
|
}
|
|
321
344
|
}
|
|
345
|
+
function isRenewalRequired(error) {
|
|
346
|
+
return error instanceof SessionRenewalRequiredError || error?.name === "SessionRenewalRequiredError";
|
|
347
|
+
}
|
|
348
|
+
function resolvedSession(state) {
|
|
349
|
+
return state && state !== RENEWAL_REQUIRED ? state : null;
|
|
350
|
+
}
|
|
322
351
|
async function getUserRole() {
|
|
323
|
-
const session = await getAuthSessionData();
|
|
352
|
+
const session = resolvedSession(await getAuthSessionData());
|
|
324
353
|
return session?.role?.name || null;
|
|
325
354
|
}
|
|
326
355
|
async function getUserPermissions() {
|
|
327
|
-
const session = await getAuthSessionData();
|
|
356
|
+
const session = resolvedSession(await getAuthSessionData());
|
|
328
357
|
if (!session) {
|
|
329
358
|
return [];
|
|
330
359
|
}
|
|
331
360
|
return session.permissions?.map((p) => p.name) || [];
|
|
332
361
|
}
|
|
333
362
|
async function hasAnyRole(requiredRoles) {
|
|
334
|
-
const session = await getAuthSessionData();
|
|
363
|
+
const session = resolvedSession(await getAuthSessionData());
|
|
335
364
|
if (!session) {
|
|
336
365
|
return false;
|
|
337
366
|
}
|
|
338
367
|
return requiredRoles.includes(session.role?.name);
|
|
339
368
|
}
|
|
340
369
|
async function hasAnyPermission(requiredPermissions) {
|
|
341
|
-
const session = await getAuthSessionData();
|
|
370
|
+
const session = resolvedSession(await getAuthSessionData());
|
|
342
371
|
if (!session) {
|
|
343
372
|
return false;
|
|
344
373
|
}
|
|
@@ -351,6 +380,7 @@ import { Fragment, jsx } from "react/jsx-runtime";
|
|
|
351
380
|
async function RequireAuth({
|
|
352
381
|
children,
|
|
353
382
|
redirectTo = "/auth/login",
|
|
383
|
+
renewalPath,
|
|
354
384
|
fallback
|
|
355
385
|
}) {
|
|
356
386
|
const session = await getSession();
|
|
@@ -361,6 +391,9 @@ async function RequireAuth({
|
|
|
361
391
|
redirect(redirectTo);
|
|
362
392
|
}
|
|
363
393
|
const serverSession = await getAuthSessionData();
|
|
394
|
+
if (serverSession === RENEWAL_REQUIRED) {
|
|
395
|
+
redirect(renewalPath ?? getSessionRenewPath());
|
|
396
|
+
}
|
|
364
397
|
if (!serverSession) {
|
|
365
398
|
redirect(redirectTo);
|
|
366
399
|
}
|
|
@@ -441,6 +474,7 @@ function clearSessionCookies(response) {
|
|
|
441
474
|
import { NextResponse } from "next/server";
|
|
442
475
|
import { cookies as cookies2 } from "next/headers.js";
|
|
443
476
|
import { env as env5 } from "@spfn/core/config";
|
|
477
|
+
import { env as authEnv } from "@spfn/auth/config";
|
|
444
478
|
import { logger as logger2 } from "@spfn/core/logger";
|
|
445
479
|
|
|
446
480
|
// src/lib/return-path.ts
|
|
@@ -458,10 +492,65 @@ function isSafeReturnPath(returnPath) {
|
|
|
458
492
|
return !/^\/[^/?#]*:/.test(returnPath);
|
|
459
493
|
}
|
|
460
494
|
|
|
495
|
+
// src/nextjs/interceptors/session-binding.ts
|
|
496
|
+
import { SessionResealFailedError } from "@spfn/auth/errors";
|
|
497
|
+
|
|
498
|
+
// src/server/lib/ua-family.ts
|
|
499
|
+
var FAMILY_MARKERS = [
|
|
500
|
+
{ family: "edge", marker: /\bEdg(?:A|iOS)?\// },
|
|
501
|
+
{ family: "chrome", marker: /\b(?:Chrome|CriOS)\// },
|
|
502
|
+
{ family: "firefox", marker: /\b(?:Firefox|FxiOS)\// },
|
|
503
|
+
{ family: "safari", marker: /\bSafari\// }
|
|
504
|
+
];
|
|
505
|
+
function uaFamily(userAgent) {
|
|
506
|
+
if (!userAgent) {
|
|
507
|
+
return "other";
|
|
508
|
+
}
|
|
509
|
+
return FAMILY_MARKERS.find((entry) => entry.marker.test(userAgent))?.family ?? "other";
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// src/nextjs/interceptors/cookie-options.ts
|
|
513
|
+
function resolveSecure() {
|
|
514
|
+
const override = process.env.SPFN_AUTH_COOKIE_SECURE;
|
|
515
|
+
if (override !== void 0) {
|
|
516
|
+
return override === "true";
|
|
517
|
+
}
|
|
518
|
+
return process.env.NODE_ENV === "production";
|
|
519
|
+
}
|
|
520
|
+
var cookieSecure = resolveSecure();
|
|
521
|
+
|
|
522
|
+
// src/nextjs/interceptors/session-binding.ts
|
|
523
|
+
function bindingSessionFields(body, userAgent) {
|
|
524
|
+
if (body?.sessionBinding !== "passkey" || typeof body.keyExpiresAtMillis !== "number") {
|
|
525
|
+
return {};
|
|
526
|
+
}
|
|
527
|
+
return {
|
|
528
|
+
binding: "passkey",
|
|
529
|
+
keyExpiresAt: body.keyExpiresAtMillis,
|
|
530
|
+
...userAgent ? { uaFamily: uaFamily(userAgent) } : {}
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
|
|
461
534
|
// src/nextjs/oauth-handlers.ts
|
|
462
535
|
function safeReturnUrl(requested, defaultRedirect) {
|
|
463
536
|
return requested && isSafeReturnPath(requested) ? requested : defaultRedirect;
|
|
464
537
|
}
|
|
538
|
+
function bindingFromQuery(searchParams) {
|
|
539
|
+
const expiresAt = Number(searchParams.get("keyExpiresAtMillis"));
|
|
540
|
+
if (searchParams.get("sessionBinding") !== "passkey" || !Number.isFinite(expiresAt)) {
|
|
541
|
+
return {};
|
|
542
|
+
}
|
|
543
|
+
return { sessionBinding: "passkey", keyExpiresAtMillis: expiresAt };
|
|
544
|
+
}
|
|
545
|
+
function mfaRedirect(request, challenge, returnUrl, configured) {
|
|
546
|
+
const path = configured || authEnv.SPFN_AUTH_MFA_CONFIRM_PATH || DEFAULT_MFA_CONFIRM_PATH;
|
|
547
|
+
const target = new URL(path, request.url);
|
|
548
|
+
target.searchParams.set("challenge", challenge);
|
|
549
|
+
target.searchParams.set("returnUrl", returnUrl);
|
|
550
|
+
logger2.debug("OAuth callback needs a second factor", { path });
|
|
551
|
+
return NextResponse.redirect(target);
|
|
552
|
+
}
|
|
553
|
+
var DEFAULT_MFA_CONFIRM_PATH = "/auth/mfa";
|
|
465
554
|
function createOAuthCallbackHandler(options) {
|
|
466
555
|
const defaultRedirect = options?.defaultRedirectUrl || "/";
|
|
467
556
|
const errorRedirect = options?.errorRedirectUrl || "/auth/error";
|
|
@@ -476,6 +565,10 @@ function createOAuthCallbackHandler(options) {
|
|
|
476
565
|
errorUrl.searchParams.set("error", error);
|
|
477
566
|
return NextResponse.redirect(errorUrl);
|
|
478
567
|
}
|
|
568
|
+
const mfaChallenge = searchParams.get("mfaChallenge");
|
|
569
|
+
if (mfaChallenge) {
|
|
570
|
+
return mfaRedirect(request, mfaChallenge, returnUrl, options?.mfaPath);
|
|
571
|
+
}
|
|
479
572
|
if (!userId || !keyId) {
|
|
480
573
|
logger2.error("OAuth callback missing required params", { userId: !!userId, keyId: !!keyId });
|
|
481
574
|
const errorUrl = new URL(errorRedirect, request.url);
|
|
@@ -497,7 +590,8 @@ function createOAuthCallbackHandler(options) {
|
|
|
497
590
|
userId,
|
|
498
591
|
privateKey: pendingSession.privateKey,
|
|
499
592
|
keyId: pendingSession.keyId,
|
|
500
|
-
algorithm: pendingSession.algorithm
|
|
593
|
+
algorithm: pendingSession.algorithm,
|
|
594
|
+
...bindingSessionFields(bindingFromQuery(searchParams), request.headers.get("user-agent"))
|
|
501
595
|
}, ttl);
|
|
502
596
|
const redirectUrl = new URL(returnUrl, request.url);
|
|
503
597
|
const response = NextResponse.redirect(redirectUrl);
|