@lastshotlabs/bunshot 0.0.13 → 0.0.18
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 +2816 -1747
- package/dist/adapters/memoryAuth.d.ts +7 -0
- package/dist/adapters/memoryAuth.js +177 -2
- package/dist/adapters/mongoAuth.js +94 -0
- package/dist/adapters/sqliteAuth.d.ts +9 -0
- package/dist/adapters/sqliteAuth.js +190 -2
- package/dist/app.d.ts +120 -2
- package/dist/app.js +104 -4
- package/dist/entrypoints/queue.d.ts +2 -2
- package/dist/entrypoints/queue.js +1 -1
- package/dist/index.d.ts +24 -8
- package/dist/index.js +15 -5
- package/dist/lib/appConfig.d.ts +81 -0
- package/dist/lib/appConfig.js +30 -0
- package/dist/lib/authAdapter.d.ts +54 -0
- package/dist/lib/authRateLimit.d.ts +2 -0
- package/dist/lib/authRateLimit.js +4 -0
- package/dist/lib/clientIp.d.ts +14 -0
- package/dist/lib/clientIp.js +52 -0
- package/dist/lib/constants.d.ts +4 -0
- package/dist/lib/constants.js +4 -0
- package/dist/lib/context.d.ts +2 -0
- package/dist/lib/createDtoMapper.d.ts +33 -0
- package/dist/lib/createDtoMapper.js +69 -0
- package/dist/lib/crypto.d.ts +11 -0
- package/dist/lib/crypto.js +22 -0
- package/dist/lib/emailVerification.d.ts +4 -0
- package/dist/lib/emailVerification.js +20 -12
- package/dist/lib/jwt.d.ts +1 -1
- package/dist/lib/jwt.js +19 -6
- package/dist/lib/mfaChallenge.d.ts +42 -0
- package/dist/lib/mfaChallenge.js +293 -0
- package/dist/lib/oauth.d.ts +14 -1
- package/dist/lib/oauth.js +19 -1
- package/dist/lib/oauthCode.d.ts +15 -0
- package/dist/lib/oauthCode.js +90 -0
- package/dist/lib/queue.d.ts +33 -0
- package/dist/lib/queue.js +98 -0
- package/dist/lib/resetPassword.js +12 -16
- package/dist/lib/roles.d.ts +4 -0
- package/dist/lib/roles.js +27 -0
- package/dist/lib/session.d.ts +12 -0
- package/dist/lib/session.js +165 -5
- package/dist/lib/tenant.d.ts +15 -0
- package/dist/lib/tenant.js +65 -0
- package/dist/lib/ws.js +5 -1
- package/dist/lib/zodToMongoose.d.ts +38 -0
- package/dist/lib/zodToMongoose.js +84 -0
- package/dist/middleware/bearerAuth.js +4 -3
- package/dist/middleware/botProtection.js +2 -2
- package/dist/middleware/cacheResponse.d.ts +1 -0
- package/dist/middleware/cacheResponse.js +18 -3
- package/dist/middleware/cors.d.ts +2 -0
- package/dist/middleware/cors.js +22 -8
- package/dist/middleware/csrf.d.ts +18 -0
- package/dist/middleware/csrf.js +115 -0
- package/dist/middleware/rateLimit.d.ts +2 -1
- package/dist/middleware/rateLimit.js +7 -5
- package/dist/middleware/requireRole.d.ts +14 -3
- package/dist/middleware/requireRole.js +46 -6
- package/dist/middleware/tenant.d.ts +5 -0
- package/dist/middleware/tenant.js +116 -0
- package/dist/models/AuthUser.d.ts +17 -0
- package/dist/models/AuthUser.js +17 -0
- package/dist/models/TenantRole.d.ts +15 -0
- package/dist/models/TenantRole.js +23 -0
- package/dist/routes/auth.d.ts +5 -3
- package/dist/routes/auth.js +173 -30
- package/dist/routes/jobs.d.ts +2 -0
- package/dist/routes/jobs.js +270 -0
- package/dist/routes/mfa.d.ts +5 -0
- package/dist/routes/mfa.js +616 -0
- package/dist/routes/oauth.js +378 -23
- package/dist/schemas/auth.d.ts +2 -0
- package/dist/schemas/auth.js +22 -1
- package/dist/server.d.ts +6 -0
- package/dist/server.js +19 -3
- package/dist/services/auth.d.ts +18 -5
- package/dist/services/auth.js +112 -18
- package/dist/services/mfa.d.ts +84 -0
- package/dist/services/mfa.js +543 -0
- package/dist/ws/index.js +3 -2
- package/docs/sections/adding-middleware/full.md +35 -0
- package/docs/sections/adding-models/full.md +125 -0
- package/docs/sections/adding-models/overview.md +13 -0
- package/docs/sections/adding-routes/full.md +182 -0
- package/docs/sections/adding-routes/overview.md +23 -0
- package/docs/sections/auth-flow/full.md +634 -0
- package/docs/sections/auth-flow/overview.md +10 -0
- package/docs/sections/cli/full.md +30 -0
- package/docs/sections/configuration/full.md +155 -0
- package/docs/sections/configuration/overview.md +17 -0
- package/docs/sections/configuration-example/full.md +117 -0
- package/docs/sections/configuration-example/overview.md +30 -0
- package/docs/sections/documentation/full.md +171 -0
- package/docs/sections/environment-variables/full.md +55 -0
- package/docs/sections/exports/full.md +92 -0
- package/docs/sections/extending-context/full.md +59 -0
- package/docs/sections/header.md +3 -0
- package/docs/sections/installation/full.md +6 -0
- package/docs/sections/jobs/full.md +140 -0
- package/docs/sections/jobs/overview.md +15 -0
- package/docs/sections/mongodb-connections/full.md +45 -0
- package/docs/sections/mongodb-connections/overview.md +7 -0
- package/docs/sections/multi-tenancy/full.md +66 -0
- package/docs/sections/multi-tenancy/overview.md +15 -0
- package/docs/sections/oauth/full.md +189 -0
- package/docs/sections/oauth/overview.md +16 -0
- package/docs/sections/package-development/full.md +7 -0
- package/docs/sections/peer-dependencies/full.md +47 -0
- package/docs/sections/quick-start/full.md +43 -0
- package/docs/sections/response-caching/full.md +117 -0
- package/docs/sections/response-caching/overview.md +13 -0
- package/docs/sections/roles/full.md +136 -0
- package/docs/sections/roles/overview.md +12 -0
- package/docs/sections/running-without-redis/full.md +16 -0
- package/docs/sections/running-without-redis-or-mongodb/full.md +60 -0
- package/docs/sections/stack/full.md +10 -0
- package/docs/sections/websocket/full.md +101 -0
- package/docs/sections/websocket/overview.md +5 -0
- package/docs/sections/websocket-rooms/full.md +97 -0
- package/docs/sections/websocket-rooms/overview.md +5 -0
- package/package.json +30 -9
package/dist/routes/oauth.js
CHANGED
|
@@ -1,22 +1,30 @@
|
|
|
1
|
+
import { createRoute, withSecurity } from "../lib/createRoute";
|
|
1
2
|
import { createRouter } from "../lib/context";
|
|
2
3
|
import { setCookie } from "hono/cookie";
|
|
3
4
|
import { decodeIdToken } from "arctic";
|
|
4
|
-
import {
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { getGoogle, getApple, getMicrosoft, getGitHub, storeOAuthState, consumeOAuthState, generateState, generateCodeVerifier, } from "../lib/oauth";
|
|
5
7
|
import { getAuthAdapter } from "../lib/authAdapter";
|
|
6
8
|
import { HttpError } from "../lib/HttpError";
|
|
7
9
|
import { signToken } from "../lib/jwt";
|
|
8
|
-
import { createSession, getActiveSessionCount, evictOldestSession } from "../lib/session";
|
|
9
|
-
import {
|
|
10
|
+
import { createSession, getActiveSessionCount, evictOldestSession, setRefreshToken } from "../lib/session";
|
|
11
|
+
import { storeOAuthCode, consumeOAuthCode } from "../lib/oauthCode";
|
|
12
|
+
import { COOKIE_TOKEN, COOKIE_REFRESH_TOKEN } from "../lib/constants";
|
|
10
13
|
import { userAuth } from "../middleware/userAuth";
|
|
11
|
-
import { getDefaultRole, getMaxSessions } from "../lib/appConfig";
|
|
14
|
+
import { getDefaultRole, getMaxSessions, getRefreshTokenConfig, getAccessTokenExpiry, getRefreshTokenExpiry, getCsrfEnabled } from "../lib/appConfig";
|
|
15
|
+
import { refreshCsrfToken } from "../middleware/csrf";
|
|
16
|
+
import { trackAttempt } from "../lib/authRateLimit";
|
|
17
|
+
import { getClientIp } from "../lib/clientIp";
|
|
12
18
|
const isProd = process.env.NODE_ENV === "production";
|
|
13
|
-
const cookieOptions = {
|
|
19
|
+
const cookieOptions = (maxAge) => ({
|
|
14
20
|
httpOnly: true,
|
|
15
21
|
secure: isProd,
|
|
16
22
|
sameSite: "Lax",
|
|
17
23
|
path: "/",
|
|
18
|
-
maxAge: 60 * 60 * 24 * 7,
|
|
19
|
-
};
|
|
24
|
+
maxAge: maxAge ?? 60 * 60 * 24 * 7,
|
|
25
|
+
});
|
|
26
|
+
const tags = ["OAuth"];
|
|
27
|
+
const OAuthErrorResponse = z.object({ error: z.string().describe("Human-readable error message.") }).openapi("OAuthErrorResponse");
|
|
20
28
|
const finishOAuth = async (c, provider, providerId, profile, postLoginRedirect) => {
|
|
21
29
|
const adapter = getAuthAdapter();
|
|
22
30
|
if (!adapter.findOrCreateByProvider) {
|
|
@@ -37,22 +45,33 @@ const finishOAuth = async (c, provider, providerId, profile, postLoginRedirect)
|
|
|
37
45
|
await adapter.setRoles(user.id, [role]);
|
|
38
46
|
}
|
|
39
47
|
const sessionId = crypto.randomUUID();
|
|
40
|
-
const
|
|
41
|
-
const
|
|
48
|
+
const rtConfig = getRefreshTokenConfig();
|
|
49
|
+
const expirySeconds = rtConfig ? getAccessTokenExpiry() : undefined;
|
|
50
|
+
const token = await signToken(user.id, sessionId, expirySeconds);
|
|
42
51
|
const metadata = {
|
|
43
|
-
ipAddress: (
|
|
52
|
+
ipAddress: getClientIp(c),
|
|
44
53
|
userAgent: c.req.header("user-agent") ?? undefined,
|
|
45
54
|
};
|
|
46
55
|
while (await getActiveSessionCount(user.id) >= getMaxSessions()) {
|
|
47
56
|
await evictOldestSession(user.id);
|
|
48
57
|
}
|
|
49
58
|
await createSession(user.id, token, sessionId, metadata);
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
59
|
+
let refreshTokenValue;
|
|
60
|
+
if (rtConfig) {
|
|
61
|
+
refreshTokenValue = crypto.randomUUID();
|
|
62
|
+
await setRefreshToken(sessionId, refreshTokenValue);
|
|
63
|
+
}
|
|
64
|
+
// Store a one-time authorization code instead of exposing the token in the redirect URL.
|
|
65
|
+
// The client exchanges this code via POST /auth/oauth/exchange to get the session token.
|
|
66
|
+
const code = await storeOAuthCode({
|
|
67
|
+
token,
|
|
68
|
+
userId: user.id,
|
|
69
|
+
email: profile.email,
|
|
70
|
+
refreshToken: refreshTokenValue,
|
|
71
|
+
});
|
|
53
72
|
try {
|
|
54
73
|
const url = new URL(postLoginRedirect);
|
|
55
|
-
url.searchParams.set("
|
|
74
|
+
url.searchParams.set("code", code);
|
|
56
75
|
if (profile.email)
|
|
57
76
|
url.searchParams.set("user", profile.email);
|
|
58
77
|
return c.redirect(url.toString());
|
|
@@ -61,22 +80,48 @@ const finishOAuth = async (c, provider, providerId, profile, postLoginRedirect)
|
|
|
61
80
|
// Relative path fallback
|
|
62
81
|
const sep = postLoginRedirect.includes("?") ? "&" : "?";
|
|
63
82
|
const userParam = profile.email ? `&user=${encodeURIComponent(profile.email)}` : "";
|
|
64
|
-
return c.redirect(`${postLoginRedirect}${sep}
|
|
83
|
+
return c.redirect(`${postLoginRedirect}${sep}code=${code}${userParam}`);
|
|
65
84
|
}
|
|
66
85
|
};
|
|
67
86
|
export const createOAuthRouter = (providers, postLoginRedirect) => {
|
|
68
87
|
const router = createRouter();
|
|
69
88
|
// ─── Google ───────────────────────────────────────────────────────────────
|
|
70
89
|
if (providers.includes("google")) {
|
|
71
|
-
router.
|
|
90
|
+
router.openapi(createRoute({
|
|
91
|
+
method: "get",
|
|
92
|
+
path: "/auth/google",
|
|
93
|
+
summary: "Initiate Google OAuth",
|
|
94
|
+
description: "Redirects the user to Google's consent screen to begin the OAuth login flow. After the user authorizes, Google redirects back to `/auth/google/callback`.",
|
|
95
|
+
tags,
|
|
96
|
+
responses: {
|
|
97
|
+
302: { description: "Redirect to Google's OAuth consent screen." },
|
|
98
|
+
500: { content: { "application/json": { schema: OAuthErrorResponse } }, description: "OAuth provider not configured." },
|
|
99
|
+
},
|
|
100
|
+
}), async (c) => {
|
|
72
101
|
const state = generateState();
|
|
73
102
|
const codeVerifier = generateCodeVerifier();
|
|
74
103
|
await storeOAuthState(state, codeVerifier);
|
|
75
104
|
const url = getGoogle().createAuthorizationURL(state, codeVerifier, ["openid", "profile", "email"]);
|
|
76
105
|
return c.redirect(url.toString());
|
|
77
106
|
});
|
|
78
|
-
router.
|
|
79
|
-
|
|
107
|
+
router.openapi(createRoute({
|
|
108
|
+
method: "get",
|
|
109
|
+
path: "/auth/google/callback",
|
|
110
|
+
summary: "Google OAuth callback",
|
|
111
|
+
description: "Handles the redirect from Google after user authorization. Validates the OAuth state and code, then creates or finds the user account. Sets a session cookie and redirects to the configured post-login URL.",
|
|
112
|
+
tags,
|
|
113
|
+
request: {
|
|
114
|
+
query: z.object({
|
|
115
|
+
code: z.string().describe("Authorization code from Google."),
|
|
116
|
+
state: z.string().describe("OAuth state parameter for CSRF protection."),
|
|
117
|
+
}),
|
|
118
|
+
},
|
|
119
|
+
responses: {
|
|
120
|
+
302: { description: "Redirect to the post-login URL with session token." },
|
|
121
|
+
400: { content: { "application/json": { schema: OAuthErrorResponse } }, description: "Invalid callback parameters or expired state." },
|
|
122
|
+
},
|
|
123
|
+
}), async (c) => {
|
|
124
|
+
const { code, state } = c.req.valid("query");
|
|
80
125
|
if (!code || !state)
|
|
81
126
|
return c.json({ error: "Invalid callback" }, 400);
|
|
82
127
|
const stored = await consumeOAuthState(state);
|
|
@@ -96,14 +141,36 @@ export const createOAuthRouter = (providers, postLoginRedirect) => {
|
|
|
96
141
|
}
|
|
97
142
|
return finishOAuth(c, "google", info.sub, { email: info.email, name: info.name, avatarUrl: info.picture }, postLoginRedirect);
|
|
98
143
|
});
|
|
99
|
-
router.
|
|
144
|
+
router.use("/auth/google/link", userAuth);
|
|
145
|
+
router.openapi(withSecurity(createRoute({
|
|
146
|
+
method: "get",
|
|
147
|
+
path: "/auth/google/link",
|
|
148
|
+
summary: "Link Google account",
|
|
149
|
+
description: "Initiates an OAuth flow to link a Google account to the authenticated user. Requires a valid session. Redirects to Google's consent screen.",
|
|
150
|
+
tags,
|
|
151
|
+
responses: {
|
|
152
|
+
302: { description: "Redirect to Google's OAuth consent screen." },
|
|
153
|
+
401: { content: { "application/json": { schema: OAuthErrorResponse } }, description: "No valid session." },
|
|
154
|
+
},
|
|
155
|
+
}), { cookieAuth: [] }, { userToken: [] }), async (c) => {
|
|
100
156
|
const state = generateState();
|
|
101
157
|
const codeVerifier = generateCodeVerifier();
|
|
102
158
|
await storeOAuthState(state, codeVerifier, c.get("authUserId"));
|
|
103
159
|
const url = getGoogle().createAuthorizationURL(state, codeVerifier, ["openid", "profile", "email"]);
|
|
104
160
|
return c.redirect(url.toString());
|
|
105
161
|
});
|
|
106
|
-
router.
|
|
162
|
+
router.openapi(withSecurity(createRoute({
|
|
163
|
+
method: "delete",
|
|
164
|
+
path: "/auth/google/link",
|
|
165
|
+
summary: "Unlink Google account",
|
|
166
|
+
description: "Removes the linked Google OAuth account from the authenticated user. Requires a valid session.",
|
|
167
|
+
tags,
|
|
168
|
+
responses: {
|
|
169
|
+
204: { description: "Google account unlinked successfully." },
|
|
170
|
+
401: { content: { "application/json": { schema: OAuthErrorResponse } }, description: "No valid session." },
|
|
171
|
+
500: { content: { "application/json": { schema: OAuthErrorResponse } }, description: "Auth adapter does not support unlinkProvider." },
|
|
172
|
+
},
|
|
173
|
+
}), { cookieAuth: [] }, { userToken: [] }), async (c) => {
|
|
107
174
|
const adapter = getAuthAdapter();
|
|
108
175
|
if (!adapter.unlinkProvider) {
|
|
109
176
|
return c.json({ error: "Auth adapter does not support unlinkProvider" }, 500);
|
|
@@ -114,14 +181,34 @@ export const createOAuthRouter = (providers, postLoginRedirect) => {
|
|
|
114
181
|
}
|
|
115
182
|
// ─── Apple ────────────────────────────────────────────────────────────────
|
|
116
183
|
if (providers.includes("apple")) {
|
|
117
|
-
router.
|
|
184
|
+
router.openapi(createRoute({
|
|
185
|
+
method: "get",
|
|
186
|
+
path: "/auth/apple",
|
|
187
|
+
summary: "Initiate Apple OAuth",
|
|
188
|
+
description: "Redirects the user to Apple's sign-in page to begin the OAuth login flow. After the user authorizes, Apple posts back to `/auth/apple/callback`.",
|
|
189
|
+
tags,
|
|
190
|
+
responses: {
|
|
191
|
+
302: { description: "Redirect to Apple's OAuth sign-in page." },
|
|
192
|
+
500: { content: { "application/json": { schema: OAuthErrorResponse } }, description: "OAuth provider not configured." },
|
|
193
|
+
},
|
|
194
|
+
}), async (c) => {
|
|
118
195
|
const state = generateState();
|
|
119
196
|
await storeOAuthState(state);
|
|
120
197
|
const url = getApple().createAuthorizationURL(state, ["name", "email"]);
|
|
121
198
|
return c.redirect(url.toString());
|
|
122
199
|
});
|
|
123
200
|
// Apple sends a POST with form data to the callback URL
|
|
124
|
-
router.
|
|
201
|
+
router.openapi(createRoute({
|
|
202
|
+
method: "post",
|
|
203
|
+
path: "/auth/apple/callback",
|
|
204
|
+
summary: "Apple OAuth callback",
|
|
205
|
+
description: "Handles the POST redirect from Apple after user authorization. Apple sends form-encoded data containing the authorization code and state. Validates the OAuth state, exchanges the code for tokens, then creates or finds the user account. Sets a session cookie and redirects to the configured post-login URL.",
|
|
206
|
+
tags,
|
|
207
|
+
responses: {
|
|
208
|
+
302: { description: "Redirect to the post-login URL with session token." },
|
|
209
|
+
400: { content: { "application/json": { schema: OAuthErrorResponse } }, description: "Invalid callback parameters or expired state." },
|
|
210
|
+
},
|
|
211
|
+
}), async (c) => {
|
|
125
212
|
const form = await c.req.formData();
|
|
126
213
|
const code = form.get("code");
|
|
127
214
|
const state = form.get("state");
|
|
@@ -148,12 +235,280 @@ export const createOAuthRouter = (providers, postLoginRedirect) => {
|
|
|
148
235
|
: undefined;
|
|
149
236
|
return finishOAuth(c, "apple", claims.sub, { email: claims.email, name }, postLoginRedirect);
|
|
150
237
|
});
|
|
151
|
-
router.
|
|
238
|
+
router.use("/auth/apple/link", userAuth);
|
|
239
|
+
router.openapi(withSecurity(createRoute({
|
|
240
|
+
method: "get",
|
|
241
|
+
path: "/auth/apple/link",
|
|
242
|
+
summary: "Link Apple account",
|
|
243
|
+
description: "Initiates an OAuth flow to link an Apple account to the authenticated user. Requires a valid session. Redirects to Apple's sign-in page.",
|
|
244
|
+
tags,
|
|
245
|
+
responses: {
|
|
246
|
+
302: { description: "Redirect to Apple's OAuth sign-in page." },
|
|
247
|
+
401: { content: { "application/json": { schema: OAuthErrorResponse } }, description: "No valid session." },
|
|
248
|
+
},
|
|
249
|
+
}), { cookieAuth: [] }, { userToken: [] }), async (c) => {
|
|
152
250
|
const state = generateState();
|
|
153
251
|
await storeOAuthState(state, undefined, c.get("authUserId"));
|
|
154
252
|
const url = getApple().createAuthorizationURL(state, ["name", "email"]);
|
|
155
253
|
return c.redirect(url.toString());
|
|
156
254
|
});
|
|
157
255
|
}
|
|
256
|
+
// ─── Microsoft ──────────────────────────────────────────────────────────
|
|
257
|
+
if (providers.includes("microsoft")) {
|
|
258
|
+
router.openapi(createRoute({
|
|
259
|
+
method: "get",
|
|
260
|
+
path: "/auth/microsoft",
|
|
261
|
+
summary: "Initiate Microsoft OAuth",
|
|
262
|
+
description: "Redirects the user to Microsoft's sign-in page to begin the OAuth login flow. After the user authorizes, Microsoft redirects back to `/auth/microsoft/callback`.",
|
|
263
|
+
tags,
|
|
264
|
+
responses: {
|
|
265
|
+
302: { description: "Redirect to Microsoft's OAuth sign-in page." },
|
|
266
|
+
500: { content: { "application/json": { schema: OAuthErrorResponse } }, description: "OAuth provider not configured." },
|
|
267
|
+
},
|
|
268
|
+
}), async (c) => {
|
|
269
|
+
const state = generateState();
|
|
270
|
+
const codeVerifier = generateCodeVerifier();
|
|
271
|
+
await storeOAuthState(state, codeVerifier);
|
|
272
|
+
const url = getMicrosoft().createAuthorizationURL(state, codeVerifier, ["openid", "profile", "email"]);
|
|
273
|
+
return c.redirect(url.toString());
|
|
274
|
+
});
|
|
275
|
+
router.openapi(createRoute({
|
|
276
|
+
method: "get",
|
|
277
|
+
path: "/auth/microsoft/callback",
|
|
278
|
+
summary: "Microsoft OAuth callback",
|
|
279
|
+
description: "Handles the redirect from Microsoft after user authorization. Validates the OAuth state and code, then creates or finds the user account. Sets a session cookie and redirects to the configured post-login URL.",
|
|
280
|
+
tags,
|
|
281
|
+
request: {
|
|
282
|
+
query: z.object({
|
|
283
|
+
code: z.string().describe("Authorization code from Microsoft."),
|
|
284
|
+
state: z.string().describe("OAuth state parameter for CSRF protection."),
|
|
285
|
+
}),
|
|
286
|
+
},
|
|
287
|
+
responses: {
|
|
288
|
+
302: { description: "Redirect to the post-login URL with session token." },
|
|
289
|
+
400: { content: { "application/json": { schema: OAuthErrorResponse } }, description: "Invalid callback parameters or expired state." },
|
|
290
|
+
},
|
|
291
|
+
}), async (c) => {
|
|
292
|
+
const { code, state } = c.req.valid("query");
|
|
293
|
+
if (!code || !state)
|
|
294
|
+
return c.json({ error: "Invalid callback" }, 400);
|
|
295
|
+
const stored = await consumeOAuthState(state);
|
|
296
|
+
if (!stored?.codeVerifier)
|
|
297
|
+
return c.json({ error: "Invalid or expired state" }, 400);
|
|
298
|
+
const tokens = await getMicrosoft().validateAuthorizationCode(code, stored.codeVerifier);
|
|
299
|
+
const info = await fetch("https://graph.microsoft.com/v1.0/me", {
|
|
300
|
+
headers: { Authorization: `Bearer ${tokens.accessToken()}` },
|
|
301
|
+
}).then((r) => r.json());
|
|
302
|
+
if (stored.linkUserId) {
|
|
303
|
+
const adapter = getAuthAdapter();
|
|
304
|
+
if (!adapter.linkProvider)
|
|
305
|
+
return c.json({ error: "Auth adapter does not support linkProvider" }, 500);
|
|
306
|
+
await adapter.linkProvider(stored.linkUserId, "microsoft", info.id);
|
|
307
|
+
const sep = postLoginRedirect.includes("?") ? "&" : "?";
|
|
308
|
+
return c.redirect(`${postLoginRedirect}${sep}linked=microsoft`);
|
|
309
|
+
}
|
|
310
|
+
return finishOAuth(c, "microsoft", info.id, { email: info.mail ?? info.userPrincipalName, name: info.displayName }, postLoginRedirect);
|
|
311
|
+
});
|
|
312
|
+
router.use("/auth/microsoft/link", userAuth);
|
|
313
|
+
router.openapi(withSecurity(createRoute({
|
|
314
|
+
method: "get",
|
|
315
|
+
path: "/auth/microsoft/link",
|
|
316
|
+
summary: "Link Microsoft account",
|
|
317
|
+
description: "Initiates an OAuth flow to link a Microsoft account to the authenticated user. Requires a valid session. Redirects to Microsoft's sign-in page.",
|
|
318
|
+
tags,
|
|
319
|
+
responses: {
|
|
320
|
+
302: { description: "Redirect to Microsoft's OAuth sign-in page." },
|
|
321
|
+
401: { content: { "application/json": { schema: OAuthErrorResponse } }, description: "No valid session." },
|
|
322
|
+
},
|
|
323
|
+
}), { cookieAuth: [] }, { userToken: [] }), async (c) => {
|
|
324
|
+
const state = generateState();
|
|
325
|
+
const codeVerifier = generateCodeVerifier();
|
|
326
|
+
await storeOAuthState(state, codeVerifier, c.get("authUserId"));
|
|
327
|
+
const url = getMicrosoft().createAuthorizationURL(state, codeVerifier, ["openid", "profile", "email"]);
|
|
328
|
+
return c.redirect(url.toString());
|
|
329
|
+
});
|
|
330
|
+
router.openapi(withSecurity(createRoute({
|
|
331
|
+
method: "delete",
|
|
332
|
+
path: "/auth/microsoft/link",
|
|
333
|
+
summary: "Unlink Microsoft account",
|
|
334
|
+
description: "Removes the linked Microsoft OAuth account from the authenticated user. Requires a valid session.",
|
|
335
|
+
tags,
|
|
336
|
+
responses: {
|
|
337
|
+
204: { description: "Microsoft account unlinked successfully." },
|
|
338
|
+
401: { content: { "application/json": { schema: OAuthErrorResponse } }, description: "No valid session." },
|
|
339
|
+
500: { content: { "application/json": { schema: OAuthErrorResponse } }, description: "Auth adapter does not support unlinkProvider." },
|
|
340
|
+
},
|
|
341
|
+
}), { cookieAuth: [] }, { userToken: [] }), async (c) => {
|
|
342
|
+
const adapter = getAuthAdapter();
|
|
343
|
+
if (!adapter.unlinkProvider) {
|
|
344
|
+
return c.json({ error: "Auth adapter does not support unlinkProvider" }, 500);
|
|
345
|
+
}
|
|
346
|
+
await adapter.unlinkProvider(c.get("authUserId"), "microsoft");
|
|
347
|
+
return c.body(null, 204);
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
// ─── GitHub ────────────────────────────────────────────────────────────
|
|
351
|
+
if (providers.includes("github")) {
|
|
352
|
+
router.openapi(createRoute({
|
|
353
|
+
method: "get",
|
|
354
|
+
path: "/auth/github",
|
|
355
|
+
summary: "Initiate GitHub OAuth",
|
|
356
|
+
description: "Redirects the user to GitHub's authorization page to begin the OAuth login flow. After the user authorizes, GitHub redirects back to `/auth/github/callback`.",
|
|
357
|
+
tags,
|
|
358
|
+
responses: {
|
|
359
|
+
302: { description: "Redirect to GitHub's OAuth authorization page." },
|
|
360
|
+
500: { content: { "application/json": { schema: OAuthErrorResponse } }, description: "OAuth provider not configured." },
|
|
361
|
+
},
|
|
362
|
+
}), async (c) => {
|
|
363
|
+
const state = generateState();
|
|
364
|
+
await storeOAuthState(state);
|
|
365
|
+
const url = getGitHub().createAuthorizationURL(state, ["read:user", "user:email"]);
|
|
366
|
+
return c.redirect(url.toString());
|
|
367
|
+
});
|
|
368
|
+
router.openapi(createRoute({
|
|
369
|
+
method: "get",
|
|
370
|
+
path: "/auth/github/callback",
|
|
371
|
+
summary: "GitHub OAuth callback",
|
|
372
|
+
description: "Handles the redirect from GitHub after user authorization. Validates the OAuth state and code, then creates or finds the user account. Sets a session cookie and redirects to the configured post-login URL.",
|
|
373
|
+
tags,
|
|
374
|
+
request: {
|
|
375
|
+
query: z.object({
|
|
376
|
+
code: z.string().describe("Authorization code from GitHub."),
|
|
377
|
+
state: z.string().describe("OAuth state parameter for CSRF protection."),
|
|
378
|
+
}),
|
|
379
|
+
},
|
|
380
|
+
responses: {
|
|
381
|
+
302: { description: "Redirect to the post-login URL with session token." },
|
|
382
|
+
400: { content: { "application/json": { schema: OAuthErrorResponse } }, description: "Invalid callback parameters or expired state." },
|
|
383
|
+
},
|
|
384
|
+
}), async (c) => {
|
|
385
|
+
const { code, state } = c.req.valid("query");
|
|
386
|
+
if (!code || !state)
|
|
387
|
+
return c.json({ error: "Invalid callback" }, 400);
|
|
388
|
+
const stored = await consumeOAuthState(state);
|
|
389
|
+
if (!stored)
|
|
390
|
+
return c.json({ error: "Invalid or expired state" }, 400);
|
|
391
|
+
const tokens = await getGitHub().validateAuthorizationCode(code);
|
|
392
|
+
const headers = { Authorization: `Bearer ${tokens.accessToken()}`, "User-Agent": "bunshot" };
|
|
393
|
+
const info = await fetch("https://api.github.com/user", { headers })
|
|
394
|
+
.then((r) => r.json());
|
|
395
|
+
// GitHub may not return email on /user if it's private — fetch from /user/emails
|
|
396
|
+
let email = info.email;
|
|
397
|
+
if (!email) {
|
|
398
|
+
const emails = await fetch("https://api.github.com/user/emails", { headers })
|
|
399
|
+
.then((r) => r.json());
|
|
400
|
+
email = emails.find((e) => e.primary && e.verified)?.email ?? emails.find((e) => e.verified)?.email;
|
|
401
|
+
}
|
|
402
|
+
if (stored.linkUserId) {
|
|
403
|
+
const adapter = getAuthAdapter();
|
|
404
|
+
if (!adapter.linkProvider)
|
|
405
|
+
return c.json({ error: "Auth adapter does not support linkProvider" }, 500);
|
|
406
|
+
await adapter.linkProvider(stored.linkUserId, "github", String(info.id));
|
|
407
|
+
const sep = postLoginRedirect.includes("?") ? "&" : "?";
|
|
408
|
+
return c.redirect(`${postLoginRedirect}${sep}linked=github`);
|
|
409
|
+
}
|
|
410
|
+
return finishOAuth(c, "github", String(info.id), { email, name: info.name, avatarUrl: info.avatar_url }, postLoginRedirect);
|
|
411
|
+
});
|
|
412
|
+
router.use("/auth/github/link", userAuth);
|
|
413
|
+
router.openapi(withSecurity(createRoute({
|
|
414
|
+
method: "get",
|
|
415
|
+
path: "/auth/github/link",
|
|
416
|
+
summary: "Link GitHub account",
|
|
417
|
+
description: "Initiates an OAuth flow to link a GitHub account to the authenticated user. Requires a valid session. Redirects to GitHub's authorization page.",
|
|
418
|
+
tags,
|
|
419
|
+
responses: {
|
|
420
|
+
302: { description: "Redirect to GitHub's OAuth authorization page." },
|
|
421
|
+
401: { content: { "application/json": { schema: OAuthErrorResponse } }, description: "No valid session." },
|
|
422
|
+
},
|
|
423
|
+
}), { cookieAuth: [] }, { userToken: [] }), async (c) => {
|
|
424
|
+
const state = generateState();
|
|
425
|
+
await storeOAuthState(state, undefined, c.get("authUserId"));
|
|
426
|
+
const url = getGitHub().createAuthorizationURL(state, ["read:user", "user:email"]);
|
|
427
|
+
return c.redirect(url.toString());
|
|
428
|
+
});
|
|
429
|
+
router.openapi(withSecurity(createRoute({
|
|
430
|
+
method: "delete",
|
|
431
|
+
path: "/auth/github/link",
|
|
432
|
+
summary: "Unlink GitHub account",
|
|
433
|
+
description: "Removes the linked GitHub OAuth account from the authenticated user. Requires a valid session.",
|
|
434
|
+
tags,
|
|
435
|
+
responses: {
|
|
436
|
+
204: { description: "GitHub account unlinked successfully." },
|
|
437
|
+
401: { content: { "application/json": { schema: OAuthErrorResponse } }, description: "No valid session." },
|
|
438
|
+
500: { content: { "application/json": { schema: OAuthErrorResponse } }, description: "Auth adapter does not support unlinkProvider." },
|
|
439
|
+
},
|
|
440
|
+
}), { cookieAuth: [] }, { userToken: [] }), async (c) => {
|
|
441
|
+
const adapter = getAuthAdapter();
|
|
442
|
+
if (!adapter.unlinkProvider) {
|
|
443
|
+
return c.json({ error: "Auth adapter does not support unlinkProvider" }, 500);
|
|
444
|
+
}
|
|
445
|
+
await adapter.unlinkProvider(c.get("authUserId"), "github");
|
|
446
|
+
return c.body(null, 204);
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
// ─── Code Exchange ─────────────────────────────────────────────────────
|
|
450
|
+
router.openapi(createRoute({
|
|
451
|
+
method: "post",
|
|
452
|
+
path: "/auth/oauth/exchange",
|
|
453
|
+
summary: "Exchange OAuth authorization code for session token",
|
|
454
|
+
description: "Exchanges a one-time authorization code (received from the OAuth redirect) for a session token. The code is single-use and expires after 60 seconds. Sets session cookies for browser clients; returns the token in the JSON response for mobile/SPA clients.",
|
|
455
|
+
tags,
|
|
456
|
+
request: {
|
|
457
|
+
body: {
|
|
458
|
+
content: {
|
|
459
|
+
"application/json": {
|
|
460
|
+
schema: z.object({
|
|
461
|
+
code: z.string().describe("One-time authorization code from the OAuth redirect."),
|
|
462
|
+
}),
|
|
463
|
+
},
|
|
464
|
+
},
|
|
465
|
+
},
|
|
466
|
+
},
|
|
467
|
+
responses: {
|
|
468
|
+
200: {
|
|
469
|
+
content: {
|
|
470
|
+
"application/json": {
|
|
471
|
+
schema: z.object({
|
|
472
|
+
token: z.string().describe("Session JWT."),
|
|
473
|
+
userId: z.string().describe("Authenticated user ID."),
|
|
474
|
+
email: z.string().optional().describe("User email if available."),
|
|
475
|
+
refreshToken: z.string().optional().describe("Refresh token if refresh tokens are configured."),
|
|
476
|
+
}),
|
|
477
|
+
},
|
|
478
|
+
},
|
|
479
|
+
description: "Session token and user info.",
|
|
480
|
+
},
|
|
481
|
+
400: { content: { "application/json": { schema: OAuthErrorResponse } }, description: "Missing code parameter." },
|
|
482
|
+
401: { content: { "application/json": { schema: OAuthErrorResponse } }, description: "Invalid, expired, or already-used code." },
|
|
483
|
+
429: { content: { "application/json": { schema: OAuthErrorResponse } }, description: "Rate limit exceeded." },
|
|
484
|
+
},
|
|
485
|
+
}), async (c) => {
|
|
486
|
+
// Rate limit by IP to prevent brute-forcing codes within the 60s TTL
|
|
487
|
+
const ip = getClientIp(c);
|
|
488
|
+
const limited = await trackAttempt(`oauth-exchange:ip:${ip}`, { max: 20, windowMs: 60_000 });
|
|
489
|
+
if (limited) {
|
|
490
|
+
return c.json({ error: "Too many requests" }, 429);
|
|
491
|
+
}
|
|
492
|
+
const { code } = c.req.valid("json");
|
|
493
|
+
if (!code)
|
|
494
|
+
return c.json({ error: "Missing code" }, 400);
|
|
495
|
+
const payload = await consumeOAuthCode(code);
|
|
496
|
+
if (!payload)
|
|
497
|
+
return c.json({ error: "Invalid or expired code" }, 401);
|
|
498
|
+
// Set session cookies for browser clients
|
|
499
|
+
const rtConfig = getRefreshTokenConfig();
|
|
500
|
+
setCookie(c, COOKIE_TOKEN, payload.token, cookieOptions(rtConfig ? getAccessTokenExpiry() : undefined));
|
|
501
|
+
if (payload.refreshToken && rtConfig) {
|
|
502
|
+
setCookie(c, COOKIE_REFRESH_TOKEN, payload.refreshToken, cookieOptions(getRefreshTokenExpiry()));
|
|
503
|
+
}
|
|
504
|
+
if (getCsrfEnabled())
|
|
505
|
+
refreshCsrfToken(c);
|
|
506
|
+
return c.json({
|
|
507
|
+
token: payload.token,
|
|
508
|
+
userId: payload.userId,
|
|
509
|
+
email: payload.email,
|
|
510
|
+
refreshToken: payload.refreshToken,
|
|
511
|
+
}, 200);
|
|
512
|
+
});
|
|
158
513
|
return router;
|
|
159
514
|
};
|
package/dist/schemas/auth.d.ts
CHANGED
|
@@ -8,3 +8,5 @@ export declare const makeLoginSchema: (primaryField: PrimaryField) => z.ZodObjec
|
|
|
8
8
|
[x: string]: z.ZodString;
|
|
9
9
|
password: z.ZodString;
|
|
10
10
|
}, z.core.$strip>;
|
|
11
|
+
/** Password schema for reset-password — same policy as registration. */
|
|
12
|
+
export declare const resetPasswordSchema: () => z.ZodString;
|
package/dist/schemas/auth.js
CHANGED
|
@@ -1,9 +1,30 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { getPasswordPolicy } from "../lib/appConfig";
|
|
3
|
+
/** Build a Zod schema for the password field based on the configured policy.
|
|
4
|
+
* Applied to registration and reset-password. Login uses min(1) intentionally
|
|
5
|
+
* to avoid locking out users registered under older/weaker policies. */
|
|
6
|
+
const passwordSchema = () => {
|
|
7
|
+
const policy = getPasswordPolicy();
|
|
8
|
+
const minLen = policy.minLength ?? 8;
|
|
9
|
+
let schema = z.string().min(minLen, `Password must be at least ${minLen} characters`);
|
|
10
|
+
if (policy.requireLetter !== false) {
|
|
11
|
+
schema = schema.regex(/[a-zA-Z]/, "Password must contain at least one letter");
|
|
12
|
+
}
|
|
13
|
+
if (policy.requireDigit !== false) {
|
|
14
|
+
schema = schema.regex(/\d/, "Password must contain at least one digit");
|
|
15
|
+
}
|
|
16
|
+
if (policy.requireSpecial) {
|
|
17
|
+
schema = schema.regex(/[^a-zA-Z0-9]/, "Password must contain at least one special character");
|
|
18
|
+
}
|
|
19
|
+
return schema;
|
|
20
|
+
};
|
|
2
21
|
export const makeRegisterSchema = (primaryField) => z.object({
|
|
3
22
|
[primaryField]: primaryField === "email" ? z.string().email() : z.string().min(3),
|
|
4
|
-
password:
|
|
23
|
+
password: passwordSchema(),
|
|
5
24
|
});
|
|
6
25
|
export const makeLoginSchema = (primaryField) => z.object({
|
|
7
26
|
[primaryField]: primaryField === "email" ? z.string().email() : z.string().min(1),
|
|
8
27
|
password: z.string().min(1),
|
|
9
28
|
});
|
|
29
|
+
/** Password schema for reset-password — same policy as registration. */
|
|
30
|
+
export const resetPasswordSchema = () => passwordSchema();
|
package/dist/server.d.ts
CHANGED
|
@@ -12,6 +12,12 @@ export interface WsConfig<T extends object = object> {
|
|
|
12
12
|
* ws.data.userId is available for auth checks.
|
|
13
13
|
*/
|
|
14
14
|
onRoomSubscribe?: (ws: ServerWebSocket<SocketData<T>>, room: string) => boolean | Promise<boolean>;
|
|
15
|
+
/**
|
|
16
|
+
* Maximum allowed WebSocket message size in bytes.
|
|
17
|
+
* Messages exceeding this limit will cause the connection to be closed with code 1009.
|
|
18
|
+
* Defaults to 65536 (64 KB).
|
|
19
|
+
*/
|
|
20
|
+
maxMessageSize?: number;
|
|
15
21
|
}
|
|
16
22
|
export interface CreateServerConfig<T extends object = object> extends CreateAppConfig {
|
|
17
23
|
port?: number;
|
package/dist/server.js
CHANGED
|
@@ -6,16 +6,23 @@ export const createServer = async (config) => {
|
|
|
6
6
|
const app = await createApp(config);
|
|
7
7
|
const port = Number(process.env.PORT ?? config.port ?? 3000);
|
|
8
8
|
const { workersDir, enableWorkers = true, ws: wsConfig = {} } = config;
|
|
9
|
-
const { handler: userWs, upgradeHandler: wsUpgradeHandler, onRoomSubscribe } = wsConfig;
|
|
9
|
+
const { handler: userWs, upgradeHandler: wsUpgradeHandler, onRoomSubscribe, maxMessageSize = 65_536 } = wsConfig;
|
|
10
10
|
const defaultOpen = defaultWebsocket.open;
|
|
11
|
-
const defaultMessage = defaultWebsocket.message;
|
|
12
11
|
const defaultClose = defaultWebsocket.close;
|
|
13
12
|
const defaultDrain = defaultWebsocket.drain;
|
|
14
13
|
const ws = {
|
|
15
14
|
open: userWs?.open ?? defaultOpen,
|
|
16
15
|
async message(socket, message) {
|
|
16
|
+
const size = typeof message === "string" ? message.length : message.byteLength;
|
|
17
|
+
if (size > maxMessageSize) {
|
|
18
|
+
socket.close(1009, "Message too large");
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
17
21
|
if (!await handleRoomActions(socket, message, onRoomSubscribe)) {
|
|
18
|
-
(userWs?.message
|
|
22
|
+
if (userWs?.message) {
|
|
23
|
+
userWs.message(socket, message);
|
|
24
|
+
}
|
|
25
|
+
// No default echo — without a custom handler, non-room messages are silently dropped
|
|
19
26
|
}
|
|
20
27
|
},
|
|
21
28
|
close(socket, code, reason) {
|
|
@@ -46,6 +53,15 @@ export const createServer = async (config) => {
|
|
|
46
53
|
for await (const file of glob.scan({ cwd: workersDir })) {
|
|
47
54
|
await import(`${workersDir}/${file}`);
|
|
48
55
|
}
|
|
56
|
+
// Clean up ghost cron schedulers after all workers are loaded
|
|
57
|
+
try {
|
|
58
|
+
const { getRegisteredCronNames, cleanupStaleSchedulers } = await import("./lib/queue");
|
|
59
|
+
const activeNames = [...getRegisteredCronNames()];
|
|
60
|
+
if (activeNames.length > 0) {
|
|
61
|
+
await cleanupStaleSchedulers(activeNames);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
catch { /* bullmq not installed or no cron workers */ }
|
|
49
65
|
}
|
|
50
66
|
log(`[server] running at http://localhost:${server.port}`);
|
|
51
67
|
log(`[server] API docs at http://localhost:${server.port}/docs`);
|
package/dist/services/auth.d.ts
CHANGED
|
@@ -1,14 +1,27 @@
|
|
|
1
1
|
import type { SessionMetadata } from "../lib/session";
|
|
2
|
-
export
|
|
2
|
+
export interface AuthResult {
|
|
3
3
|
token: string;
|
|
4
4
|
userId: string;
|
|
5
5
|
email?: string;
|
|
6
|
+
emailVerified?: boolean;
|
|
7
|
+
googleLinked?: boolean;
|
|
8
|
+
refreshToken?: string;
|
|
9
|
+
mfaRequired?: boolean;
|
|
10
|
+
mfaToken?: string;
|
|
11
|
+
mfaMethods?: string[];
|
|
12
|
+
webauthnOptions?: Record<string, unknown>;
|
|
13
|
+
}
|
|
14
|
+
/** Create a session for a user (used internally and by MFA verify). */
|
|
15
|
+
export declare const createSessionForUser: (userId: string, metadata?: SessionMetadata) => Promise<{
|
|
16
|
+
token: string;
|
|
17
|
+
refreshToken?: string;
|
|
6
18
|
}>;
|
|
7
|
-
export declare const
|
|
19
|
+
export declare const register: (identifier: string, password: string, metadata?: SessionMetadata) => Promise<AuthResult>;
|
|
20
|
+
export declare const login: (identifier: string, password: string, metadata?: SessionMetadata) => Promise<AuthResult>;
|
|
21
|
+
export declare const refresh: (refreshTokenValue: string) => Promise<{
|
|
8
22
|
token: string;
|
|
23
|
+
refreshToken: string;
|
|
9
24
|
userId: string;
|
|
10
|
-
email?: string;
|
|
11
|
-
emailVerified?: boolean;
|
|
12
|
-
googleLinked?: boolean;
|
|
13
25
|
}>;
|
|
26
|
+
export declare const deleteAccount: (userId: string, password?: string) => Promise<void>;
|
|
14
27
|
export declare const logout: (token: string | null) => Promise<void>;
|