@puzzle-ui/fullstack 0.0.0-stage → 0.5.1
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 +65 -2
- package/dist/LICENSE +21 -0
- package/dist/auth/client/auth.css +206 -0
- package/dist/auth/client/experience.cjs +340 -0
- package/dist/auth/client/experience.cjs.map +1 -0
- package/dist/auth/client/experience.d.cts +67 -0
- package/dist/auth/client/experience.d.ts +67 -0
- package/dist/auth/client/experience.js +340 -0
- package/dist/auth/client/experience.js.map +1 -0
- package/dist/auth/client/index.cjs +234 -0
- package/dist/auth/client/index.cjs.map +1 -0
- package/dist/auth/client/index.d.cts +48 -0
- package/dist/auth/client/index.d.ts +48 -0
- package/dist/auth/client/index.js +216 -0
- package/dist/auth/client/index.js.map +1 -0
- package/dist/auth/client/messages.cjs +80 -0
- package/dist/auth/client/messages.cjs.map +1 -0
- package/dist/auth/client/messages.d.cts +92 -0
- package/dist/auth/client/messages.d.ts +92 -0
- package/dist/auth/client/messages.js +79 -0
- package/dist/auth/client/messages.js.map +1 -0
- package/dist/auth/client/passkeys.cjs +43 -0
- package/dist/auth/client/passkeys.cjs.map +1 -0
- package/dist/auth/client/passkeys.d.cts +23 -0
- package/dist/auth/client/passkeys.d.ts +23 -0
- package/dist/auth/client/passkeys.js +40 -0
- package/dist/auth/client/passkeys.js.map +1 -0
- package/dist/auth/client/react.cjs +859 -0
- package/dist/auth/client/react.cjs.map +1 -0
- package/dist/auth/client/react.d.cts +72 -0
- package/dist/auth/client/react.d.ts +72 -0
- package/dist/auth/client/react.js +850 -0
- package/dist/auth/client/react.js.map +1 -0
- package/dist/auth/next/index.cjs +40 -0
- package/dist/auth/next/index.cjs.map +1 -0
- package/dist/auth/next/index.d.cts +12 -0
- package/dist/auth/next/index.d.ts +12 -0
- package/dist/auth/next/index.js +38 -0
- package/dist/auth/next/index.js.map +1 -0
- package/dist/auth/server/health.cjs +51 -0
- package/dist/auth/server/health.cjs.map +1 -0
- package/dist/auth/server/health.d.cts +15 -0
- package/dist/auth/server/health.d.ts +15 -0
- package/dist/auth/server/health.js +51 -0
- package/dist/auth/server/health.js.map +1 -0
- package/dist/auth/server/index.cjs +108 -0
- package/dist/auth/server/index.cjs.map +1 -0
- package/dist/auth/server/index.d.cts +22 -0
- package/dist/auth/server/index.d.ts +22 -0
- package/dist/auth/server/index.js +88 -0
- package/dist/auth/server/index.js.map +1 -0
- package/dist/auth/server/lifecycle.cjs +126 -0
- package/dist/auth/server/lifecycle.cjs.map +1 -0
- package/dist/auth/server/lifecycle.d.cts +20 -0
- package/dist/auth/server/lifecycle.d.ts +20 -0
- package/dist/auth/server/lifecycle.js +126 -0
- package/dist/auth/server/lifecycle.js.map +1 -0
- package/dist/auth/server/mfa.cjs +682 -0
- package/dist/auth/server/mfa.cjs.map +1 -0
- package/dist/auth/server/mfa.d.cts +90 -0
- package/dist/auth/server/mfa.d.ts +90 -0
- package/dist/auth/server/mfa.js +673 -0
- package/dist/auth/server/mfa.js.map +1 -0
- package/dist/auth/server/session.cjs +58 -0
- package/dist/auth/server/session.cjs.map +1 -0
- package/dist/auth/server/session.d.cts +14 -0
- package/dist/auth/server/session.d.ts +14 -0
- package/dist/auth/server/session.js +53 -0
- package/dist/auth/server/session.js.map +1 -0
- package/dist/auth/shared/index.cjs +0 -0
- package/dist/auth/shared/index.d.cts +64 -0
- package/dist/auth/shared/index.d.ts +64 -0
- package/dist/auth/shared/index.js +1 -0
- package/dist/auth/tanstack/index.cjs +34 -0
- package/dist/auth/tanstack/index.cjs.map +1 -0
- package/dist/auth/tanstack/index.d.cts +7 -0
- package/dist/auth/tanstack/index.d.ts +7 -0
- package/dist/auth/tanstack/index.js +32 -0
- package/dist/auth/tanstack/index.js.map +1 -0
- package/package.json +113 -4
|
@@ -0,0 +1,682 @@
|
|
|
1
|
+
const require_session = require("./session.cjs");
|
|
2
|
+
let _puzzle_ui_auth = require("@puzzle-ui/auth");
|
|
3
|
+
let _puzzle_ui_session = require("@puzzle-ui/session");
|
|
4
|
+
// src/auth/server/mfa.ts
|
|
5
|
+
const MFA_CHALLENGE_COOKIE_NAME = "__Host-puzzle.mfa";
|
|
6
|
+
const MAX_MFA_COOKIE_LIFETIME_SECONDS = 600;
|
|
7
|
+
const isMfaEnrollmentPendingResult = (result) => "status" in result && result.status === "mfa-enrollment-pending";
|
|
8
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
9
|
+
const isMfaPendingResult = (result) => "status" in result && result.status === "mfa-pending";
|
|
10
|
+
const jsonResponse = (value, status = 200, additionalHeaders) => {
|
|
11
|
+
const headers = new Headers(additionalHeaders);
|
|
12
|
+
headers.set("Content-Type", "application/json; charset=utf-8");
|
|
13
|
+
headers.set("Cache-Control", "no-store");
|
|
14
|
+
headers.set("Pragma", "no-cache");
|
|
15
|
+
return new Response(JSON.stringify(value), {
|
|
16
|
+
status,
|
|
17
|
+
headers
|
|
18
|
+
});
|
|
19
|
+
};
|
|
20
|
+
const toClientUser = (user) => ({
|
|
21
|
+
id: user.id,
|
|
22
|
+
email: user.email,
|
|
23
|
+
name: user.name,
|
|
24
|
+
image: user.image,
|
|
25
|
+
emailVerified: user.emailVerifiedAt !== null
|
|
26
|
+
});
|
|
27
|
+
const containsChallengeToken = (value) => {
|
|
28
|
+
if (Array.isArray(value)) return value.some(containsChallengeToken);
|
|
29
|
+
if (!isRecord(value)) return false;
|
|
30
|
+
return Object.entries(value).some(([key, nested]) => key === "challengeToken" || containsChallengeToken(nested));
|
|
31
|
+
};
|
|
32
|
+
const getErrorResponse = (error) => {
|
|
33
|
+
if (error instanceof _puzzle_ui_auth.AuthError) {
|
|
34
|
+
const status = error.code === "RATE_LIMITED" ? 429 : error.code === "INVALID_CREDENTIALS" || error.code === "SESSION" || error.code === "MFA_CHALLENGE" ? 401 : error.code === "PROVIDER" ? 503 : 400;
|
|
35
|
+
return jsonResponse({ error: {
|
|
36
|
+
code: error.code,
|
|
37
|
+
message: error.message
|
|
38
|
+
} }, status);
|
|
39
|
+
}
|
|
40
|
+
if (error instanceof Error && error.name === "AuthStepUpRequiredError") return jsonResponse({ error: {
|
|
41
|
+
code: "STEP_UP_REQUIRED",
|
|
42
|
+
message: "Recent MFA verification is required."
|
|
43
|
+
} }, 403);
|
|
44
|
+
if (error instanceof Error && (error.name === "SessionSecurityError" || error.name === "SessionConfigurationError")) return jsonResponse({ error: {
|
|
45
|
+
code: "REQUEST_FORBIDDEN",
|
|
46
|
+
message: "The request could not be authorized."
|
|
47
|
+
} }, 403);
|
|
48
|
+
return jsonResponse({ error: {
|
|
49
|
+
code: "INTERNAL_AUTH_ERROR",
|
|
50
|
+
message: "An authentication error occurred."
|
|
51
|
+
} }, 500);
|
|
52
|
+
};
|
|
53
|
+
const endpoint = (handler) => async (request) => {
|
|
54
|
+
try {
|
|
55
|
+
return await handler(request);
|
|
56
|
+
} catch (error) {
|
|
57
|
+
return getErrorResponse(error);
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
const readJsonObject = async (request) => {
|
|
61
|
+
let value;
|
|
62
|
+
try {
|
|
63
|
+
value = await request.json();
|
|
64
|
+
} catch {
|
|
65
|
+
throw new _puzzle_ui_auth.AuthValidationError("A valid JSON request body is required.");
|
|
66
|
+
}
|
|
67
|
+
if (!isRecord(value)) throw new _puzzle_ui_auth.AuthValidationError("The request body must be a JSON object.");
|
|
68
|
+
if (containsChallengeToken(value)) throw new _puzzle_ui_auth.AuthValidationError("MFA challenge tokens are managed by the HttpOnly cookie.");
|
|
69
|
+
return value;
|
|
70
|
+
};
|
|
71
|
+
const requiredString = (body, name, maximumLength = 2048) => {
|
|
72
|
+
const value = body[name];
|
|
73
|
+
if (typeof value !== "string" || value.trim().length === 0 || value.length > maximumLength) throw new _puzzle_ui_auth.AuthValidationError(`A valid ${name} value is required.`);
|
|
74
|
+
return value;
|
|
75
|
+
};
|
|
76
|
+
const optionalString = (body, name, maximumLength = 2048) => {
|
|
77
|
+
const value = body[name];
|
|
78
|
+
if (value === void 0 || value === null || value === "") return;
|
|
79
|
+
if (typeof value !== "string" || value.length > maximumLength) throw new _puzzle_ui_auth.AuthValidationError(`The ${name} value is invalid.`);
|
|
80
|
+
return value;
|
|
81
|
+
};
|
|
82
|
+
const requireRequestMethod = (request, expected) => {
|
|
83
|
+
if (request.method.toUpperCase() !== expected) throw new _puzzle_ui_auth.AuthValidationError(`This authentication endpoint requires ${expected}.`);
|
|
84
|
+
};
|
|
85
|
+
const protectPost = (request, options) => {
|
|
86
|
+
requireRequestMethod(request, "POST");
|
|
87
|
+
(0, _puzzle_ui_session.protectStateChangingRequest)(request, options.csrf);
|
|
88
|
+
};
|
|
89
|
+
const requireSignInChallengeToken = (request, options) => {
|
|
90
|
+
if (getSessionToken(request, options.cookie)) throw new _puzzle_ui_auth.AuthMfaChallengeError();
|
|
91
|
+
return requireMfaChallengeToken(request);
|
|
92
|
+
};
|
|
93
|
+
const requireMfaChallengeToken = (request) => {
|
|
94
|
+
const token = readMfaChallengeCookie(request.headers.get("cookie"));
|
|
95
|
+
if (!token) throw new _puzzle_ui_auth.AuthMfaChallengeError();
|
|
96
|
+
return token;
|
|
97
|
+
};
|
|
98
|
+
const getSessionToken = (request, cookie) => (0, _puzzle_ui_session.readSessionCookie)(request.headers.get("cookie"), cookie.name);
|
|
99
|
+
const getAuthenticatedContext = async (request, options) => {
|
|
100
|
+
const sessionToken = getSessionToken(request, options.cookie);
|
|
101
|
+
if (!sessionToken) throw new _puzzle_ui_auth.AuthSessionError("An authenticated session is required.");
|
|
102
|
+
let session;
|
|
103
|
+
try {
|
|
104
|
+
session = await options.sessions.validate(sessionToken);
|
|
105
|
+
} catch (error) {
|
|
106
|
+
throw new _puzzle_ui_auth.AuthSessionError("Could not validate the authentication session.", { cause: error });
|
|
107
|
+
}
|
|
108
|
+
if (!session || session.kind !== "authenticated" || !session.userId || session.revokedAt !== null || session.expiresAt.getTime() <= Date.now()) throw new _puzzle_ui_auth.AuthSessionError("An authenticated session is required.");
|
|
109
|
+
return {
|
|
110
|
+
session,
|
|
111
|
+
sessionToken
|
|
112
|
+
};
|
|
113
|
+
};
|
|
114
|
+
const getClientIp = (request, options) => {
|
|
115
|
+
const ipAddress = options.resolveClientIp(request);
|
|
116
|
+
if (typeof ipAddress !== "string" || ipAddress.trim().length === 0 || ipAddress.length > 128) throw new _puzzle_ui_auth.AuthValidationError("The request does not contain a valid client IP address.");
|
|
117
|
+
return ipAddress.trim();
|
|
118
|
+
};
|
|
119
|
+
const withSetCookie = (response, cookie) => {
|
|
120
|
+
const headers = new Headers(response.headers);
|
|
121
|
+
headers.append("Set-Cookie", cookie);
|
|
122
|
+
return new Response(response.body, {
|
|
123
|
+
status: response.status,
|
|
124
|
+
statusText: response.statusText,
|
|
125
|
+
headers
|
|
126
|
+
});
|
|
127
|
+
};
|
|
128
|
+
const withClearedMfaChallengeCookie = (response) => withSetCookie(response, serializeClearedMfaChallengeCookie());
|
|
129
|
+
const withClearedSessionCookie = (response, cookie) => withSetCookie(response, (0, _puzzle_ui_session.serializeClearedSessionCookie)(cookie));
|
|
130
|
+
const maxAgeUntil = (expiresAt) => {
|
|
131
|
+
const seconds = Math.ceil((expiresAt.getTime() - Date.now()) / 1e3);
|
|
132
|
+
if (!Number.isFinite(seconds) || seconds <= 0) throw new _puzzle_ui_auth.AuthMfaChallengeError();
|
|
133
|
+
return Math.min(seconds, MAX_MFA_COOKIE_LIFETIME_SECONDS);
|
|
134
|
+
};
|
|
135
|
+
const createSessionResultResponse = (result, options) => {
|
|
136
|
+
const response = require_session.createSessionResponse(JSON.stringify({
|
|
137
|
+
status: "authenticated",
|
|
138
|
+
user: toClientUser(result.user)
|
|
139
|
+
}), result.token, {
|
|
140
|
+
sessions: options.sessions,
|
|
141
|
+
cookie: options.cookie
|
|
142
|
+
}, result.session.expiresAt);
|
|
143
|
+
return withClearedMfaChallengeCookie(response);
|
|
144
|
+
};
|
|
145
|
+
const createMfaEnrollmentPendingResponse = (challenge) => createMfaChallengeResponse(challenge.challengeToken, challenge.expiresAt, {
|
|
146
|
+
status: "mfa-enrollment-pending",
|
|
147
|
+
purpose: "enrollment",
|
|
148
|
+
challengeId: challenge.challengeId,
|
|
149
|
+
expiresAt: challenge.expiresAt.toISOString(),
|
|
150
|
+
availableFactors: challenge.availableFactors
|
|
151
|
+
});
|
|
152
|
+
const createEnrollmentSessionResultResponse = (completed, options) => {
|
|
153
|
+
const response = require_session.createSessionResponse(JSON.stringify({
|
|
154
|
+
status: "mfa-factor-added",
|
|
155
|
+
factor: completed.factor,
|
|
156
|
+
recoveryCodes: completed.recoveryCodes
|
|
157
|
+
}), completed.auth.token, {
|
|
158
|
+
sessions: options.sessions,
|
|
159
|
+
cookie: options.cookie
|
|
160
|
+
}, completed.auth.session.expiresAt);
|
|
161
|
+
return withClearedMfaChallengeCookie(response);
|
|
162
|
+
};
|
|
163
|
+
const serializeMfaChallengeCookie = (challengeToken, maxAgeSeconds) => {
|
|
164
|
+
if (typeof challengeToken !== "string" || !/^[a-f0-9]{64}$/i.test(challengeToken)) throw new _puzzle_ui_auth.AuthMfaChallengeError();
|
|
165
|
+
if (!Number.isInteger(maxAgeSeconds) || maxAgeSeconds < 1 || maxAgeSeconds > MAX_MFA_COOKIE_LIFETIME_SECONDS) throw new _puzzle_ui_auth.AuthValidationError("The MFA challenge cookie lifetime is invalid.");
|
|
166
|
+
return [
|
|
167
|
+
`${MFA_CHALLENGE_COOKIE_NAME}=${encodeURIComponent(challengeToken)}`,
|
|
168
|
+
"Path=/",
|
|
169
|
+
"HttpOnly",
|
|
170
|
+
"Secure",
|
|
171
|
+
"SameSite=Strict",
|
|
172
|
+
`Max-Age=${maxAgeSeconds}`
|
|
173
|
+
].join("; ");
|
|
174
|
+
};
|
|
175
|
+
const serializeClearedMfaChallengeCookie = () => [
|
|
176
|
+
`${MFA_CHALLENGE_COOKIE_NAME}=`,
|
|
177
|
+
"Path=/",
|
|
178
|
+
"HttpOnly",
|
|
179
|
+
"Secure",
|
|
180
|
+
"SameSite=Strict",
|
|
181
|
+
"Max-Age=0",
|
|
182
|
+
"Expires=Thu, 01 Jan 1970 00:00:00 GMT"
|
|
183
|
+
].join("; ");
|
|
184
|
+
const readMfaChallengeCookie = (cookieHeader) => {
|
|
185
|
+
if (!cookieHeader) return null;
|
|
186
|
+
for (const part of cookieHeader.split(";")) {
|
|
187
|
+
const separator = part.indexOf("=");
|
|
188
|
+
if (separator < 0) continue;
|
|
189
|
+
if (part.slice(0, separator).trim() !== "__Host-puzzle.mfa") continue;
|
|
190
|
+
let token;
|
|
191
|
+
try {
|
|
192
|
+
token = decodeURIComponent(part.slice(separator + 1).trim());
|
|
193
|
+
} catch {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
return /^[a-f0-9]{64}$/i.test(token) ? token : null;
|
|
197
|
+
}
|
|
198
|
+
return null;
|
|
199
|
+
};
|
|
200
|
+
/**
|
|
201
|
+
* Builds a safe public MFA-pending response.
|
|
202
|
+
*
|
|
203
|
+
* The raw challenge token is written only to Set-Cookie and is deliberately
|
|
204
|
+
* excluded from the JSON body.
|
|
205
|
+
*/
|
|
206
|
+
const createMfaPendingResponse = (challenge, purpose = "sign-in") => createMfaChallengeResponse(challenge.challengeToken, challenge.expiresAt, {
|
|
207
|
+
status: "mfa-pending",
|
|
208
|
+
purpose,
|
|
209
|
+
challengeId: challenge.challengeId,
|
|
210
|
+
expiresAt: challenge.expiresAt.toISOString(),
|
|
211
|
+
availableFactors: challenge.availableFactors
|
|
212
|
+
});
|
|
213
|
+
const createMfaChallengeResponse = (challengeToken, expiresAt, body, status = 200) => {
|
|
214
|
+
if (containsChallengeToken(body)) throw new _puzzle_ui_auth.AuthValidationError("The raw MFA challenge token cannot be included in a response body.");
|
|
215
|
+
const headers = new Headers({
|
|
216
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
217
|
+
"Cache-Control": "no-store",
|
|
218
|
+
Pragma: "no-cache"
|
|
219
|
+
});
|
|
220
|
+
headers.append("Set-Cookie", serializeMfaChallengeCookie(challengeToken, maxAgeUntil(expiresAt)));
|
|
221
|
+
return new Response(JSON.stringify(body), {
|
|
222
|
+
status,
|
|
223
|
+
headers
|
|
224
|
+
});
|
|
225
|
+
};
|
|
226
|
+
const createAuthMfaHandlers = (options) => {
|
|
227
|
+
const sessionOptions = {
|
|
228
|
+
sessions: options.sessions,
|
|
229
|
+
cookie: options.cookie
|
|
230
|
+
};
|
|
231
|
+
return {
|
|
232
|
+
register: endpoint(async (request) => {
|
|
233
|
+
protectPost(request, options);
|
|
234
|
+
const body = await readJsonObject(request);
|
|
235
|
+
const result = await options.auth.register({
|
|
236
|
+
email: requiredString(body, "email", 320),
|
|
237
|
+
password: requiredString(body, "password", 4096),
|
|
238
|
+
name: optionalString(body, "name", 128),
|
|
239
|
+
ipAddress: getClientIp(request, options)
|
|
240
|
+
});
|
|
241
|
+
if (isMfaEnrollmentPendingResult(result)) {
|
|
242
|
+
const pending = createMfaEnrollmentPendingResponse(result);
|
|
243
|
+
return withClearedSessionCookie(pending, options.cookie);
|
|
244
|
+
}
|
|
245
|
+
return createSessionResultResponse(result, options);
|
|
246
|
+
}),
|
|
247
|
+
signIn: endpoint(async (request) => {
|
|
248
|
+
protectPost(request, options);
|
|
249
|
+
const body = await readJsonObject(request);
|
|
250
|
+
const result = await options.auth.signIn({
|
|
251
|
+
email: requiredString(body, "email", 320),
|
|
252
|
+
password: requiredString(body, "password", 4096),
|
|
253
|
+
ipAddress: getClientIp(request, options)
|
|
254
|
+
});
|
|
255
|
+
if (isMfaEnrollmentPendingResult(result)) {
|
|
256
|
+
const pending = createMfaEnrollmentPendingResponse(result);
|
|
257
|
+
return withClearedSessionCookie(pending, options.cookie);
|
|
258
|
+
}
|
|
259
|
+
if (isMfaPendingResult(result)) {
|
|
260
|
+
const pending = createMfaPendingResponse(result, "sign-in");
|
|
261
|
+
return withClearedSessionCookie(pending, options.cookie);
|
|
262
|
+
}
|
|
263
|
+
return createSessionResultResponse(result, options);
|
|
264
|
+
}),
|
|
265
|
+
getAuthState: endpoint(async (request) => {
|
|
266
|
+
requireRequestMethod(request, "GET");
|
|
267
|
+
const challengeToken = readMfaChallengeCookie(request.headers.get("cookie"));
|
|
268
|
+
const sessionToken = getSessionToken(request, options.cookie);
|
|
269
|
+
if (challengeToken && sessionToken) {
|
|
270
|
+
const stepUpState = await options.stepUp.getPendingAuthState(challengeToken, sessionToken);
|
|
271
|
+
if (stepUpState) {
|
|
272
|
+
const availableFactors = await options.mfa.getAvailableFactors(challengeToken);
|
|
273
|
+
return jsonResponse({
|
|
274
|
+
status: "step-up-pending",
|
|
275
|
+
user: null,
|
|
276
|
+
mfa: {
|
|
277
|
+
purpose: "step-up",
|
|
278
|
+
challengeId: stepUpState.challengeId,
|
|
279
|
+
expiresAt: stepUpState.expiresAt.toISOString(),
|
|
280
|
+
availableFactors
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
} else if (challengeToken) {
|
|
285
|
+
const pending = await options.auth.getAuthState(challengeToken);
|
|
286
|
+
if (pending.status === "mfa-pending") {
|
|
287
|
+
const availableFactors = await options.mfa.getAvailableFactors(challengeToken);
|
|
288
|
+
return jsonResponse({
|
|
289
|
+
status: "mfa-pending",
|
|
290
|
+
user: null,
|
|
291
|
+
mfa: {
|
|
292
|
+
purpose: "sign-in",
|
|
293
|
+
challengeId: pending.challengeId,
|
|
294
|
+
expiresAt: pending.expiresAt.toISOString(),
|
|
295
|
+
availableFactors
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
if (pending.status === "mfa-enrollment-pending") return jsonResponse({
|
|
300
|
+
status: "mfa-enrollment-pending",
|
|
301
|
+
user: null,
|
|
302
|
+
mfa: {
|
|
303
|
+
purpose: "enrollment",
|
|
304
|
+
challengeId: pending.challengeId,
|
|
305
|
+
expiresAt: pending.expiresAt.toISOString(),
|
|
306
|
+
availableFactors: pending.availableFactors
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
if (!sessionToken) return jsonResponse({
|
|
311
|
+
status: "unauthenticated",
|
|
312
|
+
user: null
|
|
313
|
+
});
|
|
314
|
+
const state = await options.auth.getAuthState(sessionToken);
|
|
315
|
+
if (state.status === "mfa-enrollment-required") return jsonResponse({
|
|
316
|
+
status: "mfa-enrollment-required",
|
|
317
|
+
user: null
|
|
318
|
+
});
|
|
319
|
+
if (state.status !== "authenticated") return jsonResponse({
|
|
320
|
+
status: "unauthenticated",
|
|
321
|
+
user: null
|
|
322
|
+
});
|
|
323
|
+
const user = await options.auth.getCurrentUser(sessionToken);
|
|
324
|
+
if (!user) return jsonResponse({
|
|
325
|
+
status: "unauthenticated",
|
|
326
|
+
user: null
|
|
327
|
+
});
|
|
328
|
+
return jsonResponse({
|
|
329
|
+
status: "authenticated",
|
|
330
|
+
user: toClientUser(user)
|
|
331
|
+
});
|
|
332
|
+
}),
|
|
333
|
+
signOut: endpoint(async (request) => {
|
|
334
|
+
protectPost(request, options);
|
|
335
|
+
const sessionToken = getSessionToken(request, options.cookie);
|
|
336
|
+
if (sessionToken) await options.auth.signOut(sessionToken);
|
|
337
|
+
const response = require_session.clearSessionResponse(JSON.stringify({ success: true }), sessionOptions);
|
|
338
|
+
return withClearedMfaChallengeCookie(response);
|
|
339
|
+
}),
|
|
340
|
+
verifySignInTotp: endpoint(async (request) => {
|
|
341
|
+
protectPost(request, options);
|
|
342
|
+
const body = await readJsonObject(request);
|
|
343
|
+
const challengeToken = await requireSignInChallengeToken(request, options);
|
|
344
|
+
const result = await options.mfa.verifySignInTotp({
|
|
345
|
+
challengeToken,
|
|
346
|
+
code: requiredString(body, "code", 16),
|
|
347
|
+
ipAddress: getClientIp(request, options)
|
|
348
|
+
});
|
|
349
|
+
return createSessionResultResponse(result, options);
|
|
350
|
+
}),
|
|
351
|
+
verifySignInRecoveryCode: endpoint(async (request) => {
|
|
352
|
+
protectPost(request, options);
|
|
353
|
+
const body = await readJsonObject(request);
|
|
354
|
+
const challengeToken = await requireSignInChallengeToken(request, options);
|
|
355
|
+
const result = await options.mfa.verifySignInRecoveryCode({
|
|
356
|
+
challengeToken,
|
|
357
|
+
code: requiredString(body, "code", 128),
|
|
358
|
+
ipAddress: getClientIp(request, options)
|
|
359
|
+
});
|
|
360
|
+
return createSessionResultResponse(result, options);
|
|
361
|
+
}),
|
|
362
|
+
beginPasskeySignIn: endpoint(async (request) => {
|
|
363
|
+
protectPost(request, options);
|
|
364
|
+
const challenge = await options.mfa.beginPasskeySignIn({ ipAddress: getClientIp(request, options) });
|
|
365
|
+
return createMfaChallengeResponse(challenge.challengeToken, challenge.expiresAt, {
|
|
366
|
+
status: "mfa-pending",
|
|
367
|
+
purpose: "passkey-sign-in",
|
|
368
|
+
challengeId: challenge.challengeId,
|
|
369
|
+
expiresAt: challenge.expiresAt.toISOString(),
|
|
370
|
+
options: challenge.options
|
|
371
|
+
});
|
|
372
|
+
}),
|
|
373
|
+
finishPasskeySignIn: endpoint(async (request) => {
|
|
374
|
+
protectPost(request, options);
|
|
375
|
+
const response = (await readJsonObject(request)).response;
|
|
376
|
+
if (!isRecord(response)) throw new _puzzle_ui_auth.AuthValidationError("A valid passkey authentication response is required.");
|
|
377
|
+
const result = await options.mfa.finishPasskeySignIn({
|
|
378
|
+
challengeToken: requireMfaChallengeToken(request),
|
|
379
|
+
response,
|
|
380
|
+
ipAddress: getClientIp(request, options)
|
|
381
|
+
});
|
|
382
|
+
return createSessionResultResponse(result, options);
|
|
383
|
+
}),
|
|
384
|
+
beginPasskeyMfa: endpoint(async (request) => {
|
|
385
|
+
protectPost(request, options);
|
|
386
|
+
const challengeToken = await requireSignInChallengeToken(request, options);
|
|
387
|
+
const started = await options.mfa.beginPasskeyMfa(challengeToken, getClientIp(request, options));
|
|
388
|
+
const pending = await options.auth.getAuthState(challengeToken);
|
|
389
|
+
if (pending.status !== "mfa-pending") throw new _puzzle_ui_auth.AuthMfaChallengeError();
|
|
390
|
+
return createMfaChallengeResponse(challengeToken, started.expiresAt, {
|
|
391
|
+
status: "mfa-pending",
|
|
392
|
+
purpose: "sign-in",
|
|
393
|
+
challengeId: pending.challengeId,
|
|
394
|
+
expiresAt: started.expiresAt.toISOString(),
|
|
395
|
+
options: started.options
|
|
396
|
+
});
|
|
397
|
+
}),
|
|
398
|
+
finishPasskeyMfa: endpoint(async (request) => {
|
|
399
|
+
protectPost(request, options);
|
|
400
|
+
const response = (await readJsonObject(request)).response;
|
|
401
|
+
if (!isRecord(response)) throw new _puzzle_ui_auth.AuthValidationError("A valid passkey authentication response is required.");
|
|
402
|
+
const result = await options.mfa.finishPasskeyMfa({
|
|
403
|
+
challengeToken: await requireSignInChallengeToken(request, options),
|
|
404
|
+
response,
|
|
405
|
+
ipAddress: getClientIp(request, options)
|
|
406
|
+
});
|
|
407
|
+
return createSessionResultResponse(result, options);
|
|
408
|
+
}),
|
|
409
|
+
beginRequiredTotpEnrollment: endpoint(async (request) => {
|
|
410
|
+
protectPost(request, options);
|
|
411
|
+
const body = await readJsonObject(request);
|
|
412
|
+
const challengeToken = await requireSignInChallengeToken(request, options);
|
|
413
|
+
const enrollment = await options.mfa.beginRequiredTotpEnrollment({
|
|
414
|
+
challengeToken,
|
|
415
|
+
ipAddress: getClientIp(request, options),
|
|
416
|
+
label: optionalString(body, "label", 64)
|
|
417
|
+
});
|
|
418
|
+
return createMfaChallengeResponse(challengeToken, enrollment.expiresAt, {
|
|
419
|
+
status: "totp-enrollment-pending",
|
|
420
|
+
purpose: "enrollment",
|
|
421
|
+
factorId: enrollment.factorId,
|
|
422
|
+
secret: enrollment.secret,
|
|
423
|
+
otpauthUrl: enrollment.otpauthUrl,
|
|
424
|
+
expiresAt: enrollment.expiresAt.toISOString()
|
|
425
|
+
});
|
|
426
|
+
}),
|
|
427
|
+
verifyRequiredTotpEnrollment: endpoint(async (request) => {
|
|
428
|
+
protectPost(request, options);
|
|
429
|
+
const body = await readJsonObject(request);
|
|
430
|
+
const challengeToken = await requireSignInChallengeToken(request, options);
|
|
431
|
+
const completed = await options.mfa.verifyRequiredTotpEnrollment({
|
|
432
|
+
challengeToken,
|
|
433
|
+
factorId: requiredString(body, "factorId", 256),
|
|
434
|
+
code: requiredString(body, "code", 16),
|
|
435
|
+
ipAddress: getClientIp(request, options)
|
|
436
|
+
});
|
|
437
|
+
return createEnrollmentSessionResultResponse(completed, options);
|
|
438
|
+
}),
|
|
439
|
+
beginRequiredPasskeyEnrollment: endpoint(async (request) => {
|
|
440
|
+
protectPost(request, options);
|
|
441
|
+
const body = await readJsonObject(request);
|
|
442
|
+
const challengeToken = await requireSignInChallengeToken(request, options);
|
|
443
|
+
const started = await options.mfa.beginRequiredPasskeyEnrollment({
|
|
444
|
+
challengeToken,
|
|
445
|
+
ipAddress: getClientIp(request, options),
|
|
446
|
+
label: optionalString(body, "label", 64)
|
|
447
|
+
});
|
|
448
|
+
return createMfaChallengeResponse(challengeToken, started.expiresAt, {
|
|
449
|
+
status: "passkey-enrollment-pending",
|
|
450
|
+
purpose: "enrollment",
|
|
451
|
+
challengeId: started.challengeId,
|
|
452
|
+
expiresAt: started.expiresAt.toISOString(),
|
|
453
|
+
options: started.options
|
|
454
|
+
});
|
|
455
|
+
}),
|
|
456
|
+
finishRequiredPasskeyEnrollment: endpoint(async (request) => {
|
|
457
|
+
protectPost(request, options);
|
|
458
|
+
const body = await readJsonObject(request);
|
|
459
|
+
if (!isRecord(body.response)) throw new _puzzle_ui_auth.AuthValidationError("A valid passkey registration response is required.");
|
|
460
|
+
const challengeToken = await requireSignInChallengeToken(request, options);
|
|
461
|
+
const completed = await options.mfa.finishRequiredPasskeyEnrollment({
|
|
462
|
+
challengeToken,
|
|
463
|
+
response: body.response,
|
|
464
|
+
ipAddress: getClientIp(request, options)
|
|
465
|
+
});
|
|
466
|
+
return createEnrollmentSessionResultResponse(completed, options);
|
|
467
|
+
}),
|
|
468
|
+
beginTotpEnrollment: endpoint(async (request) => {
|
|
469
|
+
protectPost(request, options);
|
|
470
|
+
const context = await getAuthenticatedContext(request, options);
|
|
471
|
+
const body = await readJsonObject(request);
|
|
472
|
+
const enrollment = await options.mfa.beginTotpEnrollment({
|
|
473
|
+
sessionToken: context.sessionToken,
|
|
474
|
+
currentPassword: requiredString(body, "currentPassword", 4096),
|
|
475
|
+
ipAddress: getClientIp(request, options),
|
|
476
|
+
label: optionalString(body, "label", 64)
|
|
477
|
+
});
|
|
478
|
+
return jsonResponse({
|
|
479
|
+
status: "totp-enrollment-pending",
|
|
480
|
+
factorId: enrollment.factorId,
|
|
481
|
+
secret: enrollment.secret,
|
|
482
|
+
otpauthUrl: enrollment.otpauthUrl,
|
|
483
|
+
expiresAt: enrollment.expiresAt.toISOString()
|
|
484
|
+
});
|
|
485
|
+
}),
|
|
486
|
+
verifyTotpEnrollment: endpoint(async (request) => {
|
|
487
|
+
protectPost(request, options);
|
|
488
|
+
const context = await getAuthenticatedContext(request, options);
|
|
489
|
+
const body = await readJsonObject(request);
|
|
490
|
+
const completed = await options.mfa.verifyTotpEnrollment({
|
|
491
|
+
sessionToken: context.sessionToken,
|
|
492
|
+
factorId: requiredString(body, "factorId", 256),
|
|
493
|
+
code: requiredString(body, "code", 16),
|
|
494
|
+
ipAddress: getClientIp(request, options)
|
|
495
|
+
});
|
|
496
|
+
return jsonResponse({
|
|
497
|
+
status: "mfa-factor-added",
|
|
498
|
+
factor: completed.factor,
|
|
499
|
+
recoveryCodes: completed.recoveryCodes
|
|
500
|
+
});
|
|
501
|
+
}),
|
|
502
|
+
beginPasskeyRegistration: endpoint(async (request) => {
|
|
503
|
+
protectPost(request, options);
|
|
504
|
+
const context = await getAuthenticatedContext(request, options);
|
|
505
|
+
const body = await readJsonObject(request);
|
|
506
|
+
const started = await options.mfa.beginPasskeyRegistration({
|
|
507
|
+
sessionToken: context.sessionToken,
|
|
508
|
+
currentPassword: requiredString(body, "currentPassword", 4096),
|
|
509
|
+
ipAddress: getClientIp(request, options),
|
|
510
|
+
label: optionalString(body, "label", 64)
|
|
511
|
+
});
|
|
512
|
+
return createMfaChallengeResponse(started.challengeToken, started.expiresAt, {
|
|
513
|
+
status: "passkey-registration-pending",
|
|
514
|
+
challengeId: started.challengeId,
|
|
515
|
+
expiresAt: started.expiresAt.toISOString(),
|
|
516
|
+
options: started.options
|
|
517
|
+
});
|
|
518
|
+
}),
|
|
519
|
+
finishPasskeyRegistration: endpoint(async (request) => {
|
|
520
|
+
protectPost(request, options);
|
|
521
|
+
const context = await getAuthenticatedContext(request, options);
|
|
522
|
+
const body = await readJsonObject(request);
|
|
523
|
+
if (!isRecord(body.response)) throw new _puzzle_ui_auth.AuthValidationError("A valid passkey registration response is required.");
|
|
524
|
+
const completed = await options.mfa.finishPasskeyRegistration({
|
|
525
|
+
sessionToken: context.sessionToken,
|
|
526
|
+
challengeToken: requireMfaChallengeToken(request),
|
|
527
|
+
response: body.response,
|
|
528
|
+
ipAddress: getClientIp(request, options)
|
|
529
|
+
});
|
|
530
|
+
return withClearedMfaChallengeCookie(jsonResponse({
|
|
531
|
+
status: "mfa-factor-added",
|
|
532
|
+
factor: completed.factor,
|
|
533
|
+
recoveryCodes: completed.recoveryCodes
|
|
534
|
+
}));
|
|
535
|
+
}),
|
|
536
|
+
listMfaFactors: endpoint(async (request) => {
|
|
537
|
+
requireRequestMethod(request, "GET");
|
|
538
|
+
const context = await getAuthenticatedContext(request, options);
|
|
539
|
+
const factors = await options.mfa.listFactors(context.sessionToken);
|
|
540
|
+
const recoveryCodeCount = await options.mfa.getRecoveryCodeCount(context.sessionToken);
|
|
541
|
+
return jsonResponse({
|
|
542
|
+
factors,
|
|
543
|
+
recoveryCodeCount
|
|
544
|
+
});
|
|
545
|
+
}),
|
|
546
|
+
removeMfaFactor: endpoint(async (request) => {
|
|
547
|
+
protectPost(request, options);
|
|
548
|
+
const context = await getAuthenticatedContext(request, options);
|
|
549
|
+
const body = await readJsonObject(request);
|
|
550
|
+
await options.mfa.removeFactor({
|
|
551
|
+
sessionToken: context.sessionToken,
|
|
552
|
+
factorId: requiredString(body, "factorId", 256),
|
|
553
|
+
currentPassword: requiredString(body, "currentPassword", 4096),
|
|
554
|
+
ipAddress: getClientIp(request, options)
|
|
555
|
+
});
|
|
556
|
+
return jsonResponse({ success: true });
|
|
557
|
+
}),
|
|
558
|
+
regenerateRecoveryCodes: endpoint(async (request) => {
|
|
559
|
+
protectPost(request, options);
|
|
560
|
+
const context = await getAuthenticatedContext(request, options);
|
|
561
|
+
const body = await readJsonObject(request);
|
|
562
|
+
const recoveryCodes = await options.mfa.regenerateRecoveryCodes({
|
|
563
|
+
sessionToken: context.sessionToken,
|
|
564
|
+
currentPassword: requiredString(body, "currentPassword", 4096),
|
|
565
|
+
ipAddress: getClientIp(request, options)
|
|
566
|
+
});
|
|
567
|
+
return jsonResponse({
|
|
568
|
+
status: "recovery-codes-regenerated",
|
|
569
|
+
recoveryCodes
|
|
570
|
+
});
|
|
571
|
+
}),
|
|
572
|
+
beginStepUp: endpoint(async (request) => {
|
|
573
|
+
protectPost(request, options);
|
|
574
|
+
const context = await getAuthenticatedContext(request, options);
|
|
575
|
+
const challenge = await options.stepUp.beginStepUp({
|
|
576
|
+
sessionToken: context.sessionToken,
|
|
577
|
+
ipAddress: getClientIp(request, options)
|
|
578
|
+
});
|
|
579
|
+
return createMfaPendingResponse(challenge, "step-up");
|
|
580
|
+
}),
|
|
581
|
+
beginStepUpPasskey: endpoint(async (request) => {
|
|
582
|
+
protectPost(request, options);
|
|
583
|
+
const context = await getAuthenticatedContext(request, options);
|
|
584
|
+
const challengeToken = requireMfaChallengeToken(request);
|
|
585
|
+
const started = await options.stepUp.beginPasskey({
|
|
586
|
+
challengeToken,
|
|
587
|
+
sessionToken: context.sessionToken,
|
|
588
|
+
ipAddress: getClientIp(request, options)
|
|
589
|
+
});
|
|
590
|
+
const pending = await options.stepUp.getPendingAuthState(challengeToken, context.sessionToken);
|
|
591
|
+
if (!pending) throw new _puzzle_ui_auth.AuthMfaChallengeError();
|
|
592
|
+
return createMfaChallengeResponse(challengeToken, started.expiresAt, {
|
|
593
|
+
status: "step-up-pending",
|
|
594
|
+
purpose: "step-up",
|
|
595
|
+
challengeId: pending.challengeId,
|
|
596
|
+
expiresAt: started.expiresAt.toISOString(),
|
|
597
|
+
options: started.options
|
|
598
|
+
});
|
|
599
|
+
}),
|
|
600
|
+
verifyStepUpTotp: endpoint(async (request) => {
|
|
601
|
+
protectPost(request, options);
|
|
602
|
+
const context = await getAuthenticatedContext(request, options);
|
|
603
|
+
const body = await readJsonObject(request);
|
|
604
|
+
const result = await options.stepUp.verifyTotp({
|
|
605
|
+
challengeToken: requireMfaChallengeToken(request),
|
|
606
|
+
sessionToken: context.sessionToken,
|
|
607
|
+
code: requiredString(body, "code", 16),
|
|
608
|
+
ipAddress: getClientIp(request, options)
|
|
609
|
+
});
|
|
610
|
+
return createSessionResultResponse(result, options);
|
|
611
|
+
}),
|
|
612
|
+
verifyStepUpRecoveryCode: endpoint(async (request) => {
|
|
613
|
+
protectPost(request, options);
|
|
614
|
+
const context = await getAuthenticatedContext(request, options);
|
|
615
|
+
const body = await readJsonObject(request);
|
|
616
|
+
const result = await options.stepUp.verifyRecoveryCode({
|
|
617
|
+
challengeToken: requireMfaChallengeToken(request),
|
|
618
|
+
sessionToken: context.sessionToken,
|
|
619
|
+
code: requiredString(body, "code", 128),
|
|
620
|
+
ipAddress: getClientIp(request, options)
|
|
621
|
+
});
|
|
622
|
+
return createSessionResultResponse(result, options);
|
|
623
|
+
}),
|
|
624
|
+
finishStepUpPasskey: endpoint(async (request) => {
|
|
625
|
+
protectPost(request, options);
|
|
626
|
+
const context = await getAuthenticatedContext(request, options);
|
|
627
|
+
const body = await readJsonObject(request);
|
|
628
|
+
if (!isRecord(body.response)) throw new _puzzle_ui_auth.AuthValidationError("A valid passkey authentication response is required.");
|
|
629
|
+
const result = await options.stepUp.finishPasskey({
|
|
630
|
+
challengeToken: requireMfaChallengeToken(request),
|
|
631
|
+
sessionToken: context.sessionToken,
|
|
632
|
+
response: body.response,
|
|
633
|
+
ipAddress: getClientIp(request, options)
|
|
634
|
+
});
|
|
635
|
+
return createSessionResultResponse(result, options);
|
|
636
|
+
})
|
|
637
|
+
};
|
|
638
|
+
};
|
|
639
|
+
/**
|
|
640
|
+
* Returns true when the current authenticated session has a recent,
|
|
641
|
+
* successfully verified step-up.
|
|
642
|
+
*
|
|
643
|
+
* This can be used by sensitive server actions after `getRequestSession()`.
|
|
644
|
+
*/
|
|
645
|
+
const hasRecentStepUp = (session, maxAgeSeconds = 300, now = /* @__PURE__ */ new Date()) => {
|
|
646
|
+
if (!session || session.kind !== "authenticated" || !session.userId || !Number.isInteger(maxAgeSeconds) || maxAgeSeconds < 1 || maxAgeSeconds > 3600) return false;
|
|
647
|
+
const verifiedAt = session.metadata?.stepUpVerifiedAt;
|
|
648
|
+
const previousSessionId = session.metadata?.stepUpOfSessionId;
|
|
649
|
+
if (typeof verifiedAt !== "string" || typeof previousSessionId !== "string" || previousSessionId.length === 0) return false;
|
|
650
|
+
const timestamp = Date.parse(verifiedAt);
|
|
651
|
+
if (!Number.isFinite(timestamp)) return false;
|
|
652
|
+
const age = now.getTime() - timestamp;
|
|
653
|
+
return age >= -3e4 && age <= maxAgeSeconds * 1e3;
|
|
654
|
+
};
|
|
655
|
+
var AuthStepUpRequiredError = class extends Error {
|
|
656
|
+
constructor() {
|
|
657
|
+
super("Recent MFA verification is required.");
|
|
658
|
+
this.name = "AuthStepUpRequiredError";
|
|
659
|
+
}
|
|
660
|
+
};
|
|
661
|
+
/**
|
|
662
|
+
* Use this from server actions that require recent MFA verification.
|
|
663
|
+
*
|
|
664
|
+
* The returned session has already been validated by SessionManager.
|
|
665
|
+
*/
|
|
666
|
+
const requireRecentStepUp = async (request, options, maxAgeSeconds = 300) => {
|
|
667
|
+
const session = await require_session.getRequestSession(request, options);
|
|
668
|
+
if (!session || !hasRecentStepUp(session, maxAgeSeconds)) throw new AuthStepUpRequiredError();
|
|
669
|
+
return session;
|
|
670
|
+
};
|
|
671
|
+
exports.AuthStepUpRequiredError = AuthStepUpRequiredError;
|
|
672
|
+
exports.MFA_CHALLENGE_COOKIE_NAME = MFA_CHALLENGE_COOKIE_NAME;
|
|
673
|
+
exports.createAuthMfaHandlers = createAuthMfaHandlers;
|
|
674
|
+
exports.createMfaChallengeResponse = createMfaChallengeResponse;
|
|
675
|
+
exports.createMfaPendingResponse = createMfaPendingResponse;
|
|
676
|
+
exports.hasRecentStepUp = hasRecentStepUp;
|
|
677
|
+
exports.readMfaChallengeCookie = readMfaChallengeCookie;
|
|
678
|
+
exports.requireRecentStepUp = requireRecentStepUp;
|
|
679
|
+
exports.serializeClearedMfaChallengeCookie = serializeClearedMfaChallengeCookie;
|
|
680
|
+
exports.serializeMfaChallengeCookie = serializeMfaChallengeCookie;
|
|
681
|
+
|
|
682
|
+
//# sourceMappingURL=mfa.cjs.map
|