@rebasepro/server-core 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +14 -14
- package/src/api/errors.ts +16 -2
- package/src/api/graphql/graphql-schema-generator.ts +45 -10
- package/src/api/rest/api-generator.ts +19 -2
- package/src/api/server.ts +10 -2
- package/src/api/types.ts +6 -0
- package/src/auth/adapter-middleware.ts +10 -2
- package/src/auth/admin-roles-route.ts +36 -0
- package/src/auth/admin-users-route.ts +302 -0
- package/src/auth/api-keys/api-key-middleware.ts +4 -3
- package/src/auth/api-keys/api-key-routes.ts +12 -2
- package/src/auth/api-keys/api-key-store.ts +83 -66
- package/src/auth/api-keys/api-key-types.ts +8 -0
- package/src/auth/apple-oauth.ts +2 -1
- package/src/auth/auth-hooks.ts +21 -0
- package/src/auth/bitbucket-oauth.ts +2 -1
- package/src/auth/builtin-auth-adapter.ts +28 -4
- package/src/auth/custom-auth-adapter.ts +2 -0
- package/src/auth/discord-oauth.ts +2 -1
- package/src/auth/facebook-oauth.ts +2 -1
- package/src/auth/github-oauth.ts +2 -1
- package/src/auth/gitlab-oauth.ts +2 -1
- package/src/auth/google-oauth.ts +8 -4
- package/src/auth/index.ts +2 -0
- package/src/auth/interfaces.ts +27 -0
- package/src/auth/linkedin-oauth.ts +2 -1
- package/src/auth/magic-link-routes.ts +167 -0
- package/src/auth/mfa-crypto.ts +91 -0
- package/src/auth/mfa-routes.ts +34 -10
- package/src/auth/microsoft-oauth.ts +2 -1
- package/src/auth/middleware.ts +10 -1
- package/src/auth/reset-password-admin.ts +17 -1
- package/src/auth/routes.ts +77 -8
- package/src/auth/session-routes.ts +15 -3
- package/src/auth/slack-oauth.ts +2 -1
- package/src/auth/spotify-oauth.ts +2 -1
- package/src/auth/twitter-oauth.ts +8 -1
- package/src/cron/cron-store.ts +25 -23
- package/src/email/index.ts +3 -2
- package/src/email/templates.ts +82 -0
- package/src/email/types.ts +16 -0
- package/src/init.ts +137 -5
- package/src/services/routed-realtime-service.ts +113 -0
- package/src/storage/routes.ts +4 -0
- package/src/utils/dev-port.ts +13 -7
- package/test/auth-routes.test.ts +54 -4
- package/test/custom-auth-adapter.test.ts +20 -1
- package/test/env.test.ts +9 -19
- package/test/multi-datasource-routing.test.ts +113 -0
- package/test/routed-realtime-service.test.ts +86 -0
- package/test/transform-auth-response.test.ts +305 -0
- package/vite.config.ts +5 -0
- package/build-errors.txt +0 -52
- package/dist/api/ast-schema-editor.d.ts +0 -21
- package/dist/api/collections_for_test/callbacks_test_collection.d.ts +0 -2
- package/dist/api/errors.d.ts +0 -53
- package/dist/api/graphql/graphql-schema-generator.d.ts +0 -35
- package/dist/api/graphql/index.d.ts +0 -1
- package/dist/api/index.d.ts +0 -9
- package/dist/api/logs-routes.d.ts +0 -37
- package/dist/api/openapi-generator.d.ts +0 -16
- package/dist/api/rest/api-generator.d.ts +0 -88
- package/dist/api/rest/index.d.ts +0 -1
- package/dist/api/rest/query-parser.d.ts +0 -11
- package/dist/api/schema-editor-routes.d.ts +0 -3
- package/dist/api/server.d.ts +0 -40
- package/dist/api/types.d.ts +0 -98
- package/dist/auth/adapter-middleware.d.ts +0 -37
- package/dist/auth/admin-user-ops.d.ts +0 -79
- package/dist/auth/api-keys/api-key-middleware.d.ts +0 -39
- package/dist/auth/api-keys/api-key-permission-guard.d.ts +0 -32
- package/dist/auth/api-keys/api-key-routes.d.ts +0 -20
- package/dist/auth/api-keys/api-key-store.d.ts +0 -35
- package/dist/auth/api-keys/api-key-types.d.ts +0 -88
- package/dist/auth/api-keys/index.d.ts +0 -17
- package/dist/auth/apple-oauth.d.ts +0 -30
- package/dist/auth/auth-hooks.d.ts +0 -225
- package/dist/auth/bitbucket-oauth.d.ts +0 -11
- package/dist/auth/builtin-auth-adapter.d.ts +0 -49
- package/dist/auth/crypto-utils.d.ts +0 -16
- package/dist/auth/custom-auth-adapter.d.ts +0 -39
- package/dist/auth/discord-oauth.d.ts +0 -14
- package/dist/auth/facebook-oauth.d.ts +0 -14
- package/dist/auth/github-oauth.d.ts +0 -15
- package/dist/auth/gitlab-oauth.d.ts +0 -13
- package/dist/auth/google-oauth.d.ts +0 -47
- package/dist/auth/index.d.ts +0 -36
- package/dist/auth/interfaces.d.ts +0 -401
- package/dist/auth/jwt.d.ts +0 -55
- package/dist/auth/linkedin-oauth.d.ts +0 -18
- package/dist/auth/mfa-routes.d.ts +0 -6
- package/dist/auth/mfa.d.ts +0 -49
- package/dist/auth/microsoft-oauth.d.ts +0 -16
- package/dist/auth/middleware.d.ts +0 -106
- package/dist/auth/password.d.ts +0 -22
- package/dist/auth/rate-limiter.d.ts +0 -50
- package/dist/auth/reset-password-admin.d.ts +0 -29
- package/dist/auth/rls-scope.d.ts +0 -31
- package/dist/auth/routes.d.ts +0 -33
- package/dist/auth/session-routes.d.ts +0 -25
- package/dist/auth/slack-oauth.d.ts +0 -12
- package/dist/auth/spotify-oauth.d.ts +0 -12
- package/dist/auth/twitter-oauth.d.ts +0 -18
- package/dist/backend-CIxN4FVm.js +0 -15
- package/dist/backend-CIxN4FVm.js.map +0 -1
- package/dist/chunk-Dze3rakg.js +0 -42
- package/dist/collections/BackendCollectionRegistry.d.ts +0 -13
- package/dist/collections/loader.d.ts +0 -5
- package/dist/cron/cron-loader.d.ts +0 -17
- package/dist/cron/cron-routes.d.ts +0 -14
- package/dist/cron/cron-scheduler.d.ts +0 -106
- package/dist/cron/cron-store.d.ts +0 -32
- package/dist/cron/index.d.ts +0 -6
- package/dist/db/interfaces.d.ts +0 -18
- package/dist/dist-CZKP-Xz4.js +0 -832
- package/dist/dist-CZKP-Xz4.js.map +0 -1
- package/dist/email/index.d.ts +0 -6
- package/dist/email/smtp-email-service.d.ts +0 -25
- package/dist/email/templates.d.ts +0 -42
- package/dist/email/types.d.ts +0 -108
- package/dist/env.d.ts +0 -102
- package/dist/from-VbwD7xRf.js +0 -3849
- package/dist/from-VbwD7xRf.js.map +0 -1
- package/dist/functions/function-loader.d.ts +0 -17
- package/dist/functions/function-routes.d.ts +0 -10
- package/dist/functions/index.d.ts +0 -3
- package/dist/history/history-routes.d.ts +0 -23
- package/dist/history/index.d.ts +0 -1
- package/dist/index.d.ts +0 -33
- package/dist/index.es.js +0 -61726
- package/dist/index.es.js.map +0 -1
- package/dist/index.umd.js +0 -73216
- package/dist/index.umd.js.map +0 -1
- package/dist/init/docs.d.ts +0 -4
- package/dist/init/health.d.ts +0 -2
- package/dist/init/middlewares.d.ts +0 -10
- package/dist/init/shutdown.d.ts +0 -11
- package/dist/init/storage.d.ts +0 -5
- package/dist/init.d.ts +0 -258
- package/dist/jwt-DHcQRGC3.js +0 -4168
- package/dist/jwt-DHcQRGC3.js.map +0 -1
- package/dist/logger-BYU66ENZ.js +0 -94
- package/dist/logger-BYU66ENZ.js.map +0 -1
- package/dist/ms-BeBSuOXN.js +0 -125
- package/dist/ms-BeBSuOXN.js.map +0 -1
- package/dist/multipart-parser-CedBDOeC.js +0 -299
- package/dist/multipart-parser-CedBDOeC.js.map +0 -1
- package/dist/serve-spa.d.ts +0 -30
- package/dist/services/driver-registry.d.ts +0 -78
- package/dist/services/webhook-service.d.ts +0 -29
- package/dist/singleton.d.ts +0 -35
- package/dist/src-COaj0G3P.js +0 -1182
- package/dist/src-COaj0G3P.js.map +0 -1
- package/dist/storage/LocalStorageController.d.ts +0 -46
- package/dist/storage/S3StorageController.d.ts +0 -36
- package/dist/storage/image-transform.d.ts +0 -50
- package/dist/storage/index.d.ts +0 -28
- package/dist/storage/routes.d.ts +0 -38
- package/dist/storage/storage-registry.d.ts +0 -78
- package/dist/storage/tus-handler.d.ts +0 -51
- package/dist/storage/types.d.ts +0 -103
- package/dist/types/index.d.ts +0 -11
- package/dist/utils/dev-port.d.ts +0 -35
- package/dist/utils/logger.d.ts +0 -31
- package/dist/utils/logging.d.ts +0 -9
- package/dist/utils/request-id.d.ts +0 -4
- package/dist/utils/request-logger.d.ts +0 -19
- package/dist/utils/sql.d.ts +0 -27
|
@@ -71,7 +71,8 @@ export function createLinkedinProvider(config: { clientId: string, clientSecret:
|
|
|
71
71
|
providerId: profileData.sub,
|
|
72
72
|
email: profileData.email,
|
|
73
73
|
displayName: profileData.name || null,
|
|
74
|
-
photoUrl: profileData.picture || null
|
|
74
|
+
photoUrl: profileData.picture || null,
|
|
75
|
+
emailVerified: profileData.email_verified === true
|
|
75
76
|
};
|
|
76
77
|
} catch (error) {
|
|
77
78
|
logger.error("LinkedIn OAuth error", { error: error });
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type { AuthModuleConfig } from "./routes";
|
|
3
|
+
import type { ResolvedAuthHooks } from "./auth-hooks";
|
|
4
|
+
import type { HonoEnv } from "../api/types";
|
|
5
|
+
import { ApiError } from "../api/errors";
|
|
6
|
+
import { generateSecureToken, hashToken } from "./admin-user-ops";
|
|
7
|
+
import { getMagicLinkTemplate } from "../email/templates";
|
|
8
|
+
import { strictAuthLimiter } from "./rate-limiter";
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
import { logger } from "../utils/logger";
|
|
11
|
+
import type { AuthResponsePayload, TransformAuthResponseContext } from "@rebasepro/types";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Magic link token expiry (15 minutes from now)
|
|
15
|
+
*/
|
|
16
|
+
function getMagicLinkExpiry(): Date {
|
|
17
|
+
return new Date(Date.now() + 15 * 60 * 1000);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Mount magic link routes onto the auth router.
|
|
22
|
+
*
|
|
23
|
+
* Follows the same delegation pattern as `mountMfaRoutes()` and `mountSessionRoutes()`.
|
|
24
|
+
*/
|
|
25
|
+
export function mountMagicLinkRoutes(deps: {
|
|
26
|
+
router: Hono<HonoEnv>;
|
|
27
|
+
config: AuthModuleConfig;
|
|
28
|
+
ops: ResolvedAuthHooks;
|
|
29
|
+
parseBody: <T>(schema: z.ZodSchema<T>, body: unknown) => T;
|
|
30
|
+
buildAuthResponse: (
|
|
31
|
+
user: { id: string; email: string; displayName?: string | null; photoUrl?: string | null; metadata?: Record<string, unknown> | null },
|
|
32
|
+
roleIds: string[],
|
|
33
|
+
accessToken: string,
|
|
34
|
+
refreshToken: string
|
|
35
|
+
) => unknown;
|
|
36
|
+
createSessionAndTokens: (userId: string, userAgent: string, ipAddress: string) => Promise<{ roleIds: string[]; accessToken: string; refreshToken: string }>;
|
|
37
|
+
applyTransformHook: (
|
|
38
|
+
response: AuthResponsePayload,
|
|
39
|
+
method: TransformAuthResponseContext["method"],
|
|
40
|
+
request: Request,
|
|
41
|
+
userId: string
|
|
42
|
+
) => Promise<AuthResponsePayload>;
|
|
43
|
+
}) {
|
|
44
|
+
const { router, config, ops, parseBody, buildAuthResponse, createSessionAndTokens, applyTransformHook } = deps;
|
|
45
|
+
const { authRepo, emailService, emailConfig } = config;
|
|
46
|
+
|
|
47
|
+
const magicLinkSchema = z.object({
|
|
48
|
+
email: z.string().email("Invalid email address").max(255)
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const verifyMagicLinkSchema = z.object({
|
|
52
|
+
token: z.string().min(1, "Token is required")
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
function isEmailConfigured(): boolean {
|
|
56
|
+
return !!(emailService && emailService.isConfigured());
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* POST /auth/magic-link
|
|
61
|
+
* Request a magic link email
|
|
62
|
+
*/
|
|
63
|
+
router.post("/magic-link", strictAuthLimiter, async (c) => {
|
|
64
|
+
const { email } = parseBody(magicLinkSchema, await c.req.json());
|
|
65
|
+
|
|
66
|
+
// Require email service
|
|
67
|
+
if (!isEmailConfigured()) {
|
|
68
|
+
throw ApiError.serviceUnavailable("Email service not configured. Magic link login is not available.", "EMAIL_NOT_CONFIGURED");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Always return success (security: don't reveal if email exists)
|
|
72
|
+
const user = await authRepo.getUserByEmail(email);
|
|
73
|
+
|
|
74
|
+
if (user) {
|
|
75
|
+
// Call beforeLogin hook if provided (throw to reject)
|
|
76
|
+
if (ops.beforeLogin) {
|
|
77
|
+
await ops.beforeLogin(email, "magic-link");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Generate magic link token
|
|
81
|
+
const token = generateSecureToken();
|
|
82
|
+
const tokenHash = hashToken(token);
|
|
83
|
+
const expiresAt = getMagicLinkExpiry();
|
|
84
|
+
|
|
85
|
+
await authRepo.createMagicLinkToken(user.id, tokenHash, expiresAt);
|
|
86
|
+
|
|
87
|
+
// Build magic link URL
|
|
88
|
+
const baseUrl = emailConfig?.magicLinkUrl || emailConfig?.resetPasswordUrl || "";
|
|
89
|
+
const magicLinkUrl = `${baseUrl}/auth/magic-link?token=${token}`;
|
|
90
|
+
|
|
91
|
+
// Get email template
|
|
92
|
+
const appName = emailConfig?.appName || "Rebase";
|
|
93
|
+
const templateFn = emailConfig?.templates?.magicLink;
|
|
94
|
+
const emailContent = templateFn
|
|
95
|
+
? templateFn(magicLinkUrl, { email: user.email, displayName: user.displayName })
|
|
96
|
+
: getMagicLinkTemplate(magicLinkUrl, { email: user.email, displayName: user.displayName }, appName);
|
|
97
|
+
|
|
98
|
+
// Send email
|
|
99
|
+
try {
|
|
100
|
+
await emailService!.send({
|
|
101
|
+
to: user.email,
|
|
102
|
+
subject: emailContent.subject,
|
|
103
|
+
html: emailContent.html,
|
|
104
|
+
text: emailContent.text
|
|
105
|
+
});
|
|
106
|
+
} catch (emailError: unknown) {
|
|
107
|
+
logger.error("Failed to send magic link email", { error: emailError instanceof Error ? emailError.message : emailError });
|
|
108
|
+
// Don't reveal email sending failure to client
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Always return success
|
|
113
|
+
return c.json({
|
|
114
|
+
success: true,
|
|
115
|
+
message: "If an account with that email exists, a magic link has been sent."
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* POST /auth/magic-link/verify
|
|
121
|
+
* Verify magic link token and create session
|
|
122
|
+
*/
|
|
123
|
+
router.post("/magic-link/verify", strictAuthLimiter, async (c) => {
|
|
124
|
+
const { token } = parseBody(verifyMagicLinkSchema, await c.req.json());
|
|
125
|
+
|
|
126
|
+
// Find valid token
|
|
127
|
+
const tokenHash = hashToken(token);
|
|
128
|
+
const storedToken = await authRepo.findValidMagicLinkToken(tokenHash);
|
|
129
|
+
|
|
130
|
+
if (!storedToken) {
|
|
131
|
+
throw ApiError.badRequest("Invalid or expired magic link", "INVALID_TOKEN");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Mark token as used (one-time use)
|
|
135
|
+
await authRepo.markMagicLinkTokenUsed(tokenHash);
|
|
136
|
+
|
|
137
|
+
// Get user
|
|
138
|
+
const user = await authRepo.getUserById(storedToken.userId);
|
|
139
|
+
if (!user) {
|
|
140
|
+
throw ApiError.badRequest("Invalid or expired magic link", "INVALID_TOKEN");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Clicking a magic link proves email ownership — auto-verify
|
|
144
|
+
if (!user.emailVerified) {
|
|
145
|
+
await authRepo.setEmailVerified(user.id, true);
|
|
146
|
+
user.emailVerified = true;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Create session
|
|
150
|
+
const { roleIds, accessToken, refreshToken } = await createSessionAndTokens(
|
|
151
|
+
user.id,
|
|
152
|
+
c.req.header("user-agent") || "unknown",
|
|
153
|
+
c.req.header("x-forwarded-for") || "unknown"
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
// Fire onAuthenticated hook (fire-and-forget)
|
|
157
|
+
if (ops.onAuthenticated) {
|
|
158
|
+
ops.onAuthenticated(user, "magic-link").catch(err => {
|
|
159
|
+
logger.error("[AuthHooks] onAuthenticated error", { error: err instanceof Error ? err.message : err });
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const authResponse = buildAuthResponse(user, roleIds, accessToken, refreshToken) as AuthResponsePayload;
|
|
164
|
+
const finalResponse = await applyTransformHook(authResponse, "magic-link", c.req.raw, user.id);
|
|
165
|
+
return c.json(finalResponse);
|
|
166
|
+
});
|
|
167
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Envelope encryption for TOTP secrets using AES-256-GCM.
|
|
3
|
+
*
|
|
4
|
+
* - Derives a 32-byte key via SHA-256 from `MFA_ENCRYPTION_KEY` or `JWT_SECRET`.
|
|
5
|
+
* - Generates a random 12-byte IV per encryption call.
|
|
6
|
+
* - Returns ciphertexts in the format `iv_hex:authTag_hex:ciphertext_hex`.
|
|
7
|
+
*
|
|
8
|
+
* @module
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "crypto";
|
|
12
|
+
import { logger } from "../utils/logger";
|
|
13
|
+
|
|
14
|
+
const ALGORITHM = "aes-256-gcm";
|
|
15
|
+
const IV_BYTES = 12;
|
|
16
|
+
const AUTH_TAG_LENGTH = 16;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Resolve the raw key string from environment variables.
|
|
20
|
+
*
|
|
21
|
+
* Prefers `MFA_ENCRYPTION_KEY`; falls back to `JWT_SECRET` with a warning.
|
|
22
|
+
* Throws if neither is set.
|
|
23
|
+
*/
|
|
24
|
+
function resolveKeyString(): string {
|
|
25
|
+
const explicit = process.env.MFA_ENCRYPTION_KEY;
|
|
26
|
+
if (explicit) {
|
|
27
|
+
return explicit;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const fallback = process.env.JWT_SECRET;
|
|
31
|
+
if (fallback) {
|
|
32
|
+
logger.warn(
|
|
33
|
+
"[MFA-Crypto] MFA_ENCRYPTION_KEY is not set — falling back to JWT_SECRET. " +
|
|
34
|
+
"Set a dedicated MFA_ENCRYPTION_KEY for production."
|
|
35
|
+
);
|
|
36
|
+
return fallback;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
throw new Error(
|
|
40
|
+
"Cannot encrypt TOTP secret: neither MFA_ENCRYPTION_KEY nor JWT_SECRET is configured."
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Derive a deterministic 32-byte AES-256 key from the raw secret string.
|
|
46
|
+
*/
|
|
47
|
+
function deriveKey(raw: string): Buffer {
|
|
48
|
+
return createHash("sha256").update(raw).digest();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Encrypt a plaintext TOTP secret.
|
|
53
|
+
*
|
|
54
|
+
* @param plaintext - The Base32 TOTP secret to encrypt.
|
|
55
|
+
* @returns A string in the format `iv_hex:authTag_hex:ciphertext_hex`.
|
|
56
|
+
*/
|
|
57
|
+
export function encryptTotpSecret(plaintext: string): string {
|
|
58
|
+
const key = deriveKey(resolveKeyString());
|
|
59
|
+
const iv = randomBytes(IV_BYTES);
|
|
60
|
+
|
|
61
|
+
const cipher = createCipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
|
|
62
|
+
const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
63
|
+
const authTag = cipher.getAuthTag();
|
|
64
|
+
|
|
65
|
+
return `${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted.toString("hex")}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Decrypt a previously encrypted TOTP secret.
|
|
70
|
+
*
|
|
71
|
+
* @param ciphertext - A string in the format `iv_hex:authTag_hex:ciphertext_hex`.
|
|
72
|
+
* @returns The original Base32 TOTP secret.
|
|
73
|
+
*/
|
|
74
|
+
export function decryptTotpSecret(ciphertext: string): string {
|
|
75
|
+
const parts = ciphertext.split(":");
|
|
76
|
+
if (parts.length !== 3) {
|
|
77
|
+
throw new Error("Invalid encrypted TOTP secret format — expected iv:authTag:ciphertext");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const [ivHex, authTagHex, encryptedHex] = parts;
|
|
81
|
+
const key = deriveKey(resolveKeyString());
|
|
82
|
+
const iv = Buffer.from(ivHex, "hex");
|
|
83
|
+
const authTag = Buffer.from(authTagHex, "hex");
|
|
84
|
+
const encrypted = Buffer.from(encryptedHex, "hex");
|
|
85
|
+
|
|
86
|
+
const decipher = createDecipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
|
|
87
|
+
decipher.setAuthTag(authTag);
|
|
88
|
+
|
|
89
|
+
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
|
|
90
|
+
return decrypted.toString("utf8");
|
|
91
|
+
}
|
package/src/auth/mfa-routes.ts
CHANGED
|
@@ -11,21 +11,30 @@ import {
|
|
|
11
11
|
generateRecoveryCodes,
|
|
12
12
|
hashRecoveryCode
|
|
13
13
|
} from "./mfa";
|
|
14
|
+
import { encryptTotpSecret, decryptTotpSecret } from "./mfa-crypto";
|
|
14
15
|
import {
|
|
15
16
|
generateAccessToken,
|
|
16
17
|
generateRefreshToken,
|
|
17
18
|
hashRefreshToken,
|
|
18
19
|
getRefreshTokenExpiry,
|
|
19
|
-
getAccessTokenExpiry
|
|
20
|
+
getAccessTokenExpiry,
|
|
21
|
+
type AccessTokenPayload
|
|
20
22
|
} from "./jwt";
|
|
21
23
|
import type { AuthModuleConfig } from "./routes";
|
|
22
24
|
import { resolveAuthHooks } from "./auth-hooks";
|
|
25
|
+
import type { AuthResponsePayload, TransformAuthResponseContext } from "@rebasepro/types";
|
|
23
26
|
|
|
24
27
|
export function mountMfaRoutes(
|
|
25
28
|
router: Hono<HonoEnv>,
|
|
26
29
|
config: AuthModuleConfig,
|
|
27
30
|
ops: ReturnType<typeof resolveAuthHooks>,
|
|
28
|
-
parseBody: <T>(schema: z.ZodSchema<T>, body: unknown) => T
|
|
31
|
+
parseBody: <T>(schema: z.ZodSchema<T>, body: unknown) => T,
|
|
32
|
+
applyTransformHook?: (
|
|
33
|
+
response: AuthResponsePayload,
|
|
34
|
+
method: TransformAuthResponseContext["method"],
|
|
35
|
+
request: Request,
|
|
36
|
+
userId: string
|
|
37
|
+
) => Promise<AuthResponsePayload>
|
|
29
38
|
): void {
|
|
30
39
|
const authRepo = config.authRepo;
|
|
31
40
|
const emailConfig = config.emailConfig;
|
|
@@ -53,11 +62,12 @@ export function mountMfaRoutes(
|
|
|
53
62
|
// Generate TOTP secret
|
|
54
63
|
const { secret, uri } = generateTotpSecret(issuer, user.email);
|
|
55
64
|
|
|
56
|
-
//
|
|
65
|
+
// Encrypt the TOTP secret before storage (AES-256-GCM envelope encryption)
|
|
66
|
+
const encryptedSecret = encryptTotpSecret(secret);
|
|
57
67
|
const factor = await authRepo.createMfaFactor(
|
|
58
68
|
user.id,
|
|
59
69
|
"totp",
|
|
60
|
-
|
|
70
|
+
encryptedSecret,
|
|
61
71
|
friendlyName
|
|
62
72
|
);
|
|
63
73
|
|
|
@@ -110,8 +120,9 @@ export function mountMfaRoutes(
|
|
|
110
120
|
throw ApiError.badRequest("Factor is already verified", "ALREADY_VERIFIED");
|
|
111
121
|
}
|
|
112
122
|
|
|
113
|
-
//
|
|
114
|
-
const
|
|
123
|
+
// Decrypt and verify the TOTP code
|
|
124
|
+
const decryptedSecret = decryptTotpSecret(factor.secretEncrypted);
|
|
125
|
+
const secretBuffer = base32Decode(decryptedSecret);
|
|
115
126
|
const isValid = verifyTotp(secretBuffer, code);
|
|
116
127
|
|
|
117
128
|
if (!isValid) {
|
|
@@ -191,7 +202,8 @@ export function mountMfaRoutes(
|
|
|
191
202
|
}
|
|
192
203
|
|
|
193
204
|
// Try TOTP verification first (standard 6-digit codes)
|
|
194
|
-
const
|
|
205
|
+
const decryptedSecret = decryptTotpSecret(factor.secretEncrypted);
|
|
206
|
+
const secretBuffer = base32Decode(decryptedSecret);
|
|
195
207
|
let isValid = verifyTotp(secretBuffer, code);
|
|
196
208
|
|
|
197
209
|
// Fall back to recovery code verification if TOTP didn't match
|
|
@@ -231,13 +243,17 @@ export function mountMfaRoutes(
|
|
|
231
243
|
});
|
|
232
244
|
}
|
|
233
245
|
|
|
234
|
-
|
|
246
|
+
let mfaResponse: AuthResponsePayload = {
|
|
235
247
|
tokens: {
|
|
236
248
|
accessToken,
|
|
237
249
|
refreshToken,
|
|
238
250
|
accessTokenExpiresAt: getAccessTokenExpiry()
|
|
239
251
|
}
|
|
240
|
-
}
|
|
252
|
+
};
|
|
253
|
+
if (applyTransformHook) {
|
|
254
|
+
mfaResponse = await applyTransformHook(mfaResponse, "mfa", c.req.raw, userCtx.userId);
|
|
255
|
+
}
|
|
256
|
+
return c.json(mfaResponse);
|
|
241
257
|
});
|
|
242
258
|
|
|
243
259
|
/**
|
|
@@ -267,11 +283,19 @@ export function mountMfaRoutes(
|
|
|
267
283
|
* Remove an MFA factor
|
|
268
284
|
*/
|
|
269
285
|
router.delete("/mfa/unenroll", requireAuth, async (c) => {
|
|
270
|
-
const userCtx = c.get("user") as
|
|
286
|
+
const userCtx = c.get("user") as AccessTokenPayload | undefined;
|
|
271
287
|
if (!userCtx) {
|
|
272
288
|
throw ApiError.unauthorized("Not authenticated");
|
|
273
289
|
}
|
|
274
290
|
|
|
291
|
+
// Require aal2 (MFA-verified session) to unenroll MFA factors
|
|
292
|
+
if (userCtx.aal !== "aal2") {
|
|
293
|
+
throw ApiError.forbidden(
|
|
294
|
+
"MFA verification required to unenroll. Please re-authenticate with your second factor.",
|
|
295
|
+
"AAL2_REQUIRED"
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
275
299
|
const unenrollSchema = z.object({
|
|
276
300
|
factorId: z.string().min(1, "Factor ID is required")
|
|
277
301
|
});
|
|
@@ -78,7 +78,8 @@ export function createMicrosoftProvider(config: {
|
|
|
78
78
|
providerId: profileData.id,
|
|
79
79
|
email,
|
|
80
80
|
displayName: profileData.displayName || null,
|
|
81
|
-
photoUrl: null
|
|
81
|
+
photoUrl: null,
|
|
82
|
+
emailVerified: true
|
|
82
83
|
};
|
|
83
84
|
} catch (error) {
|
|
84
85
|
logger.error("Microsoft OAuth error", { error: error });
|
package/src/auth/middleware.ts
CHANGED
|
@@ -22,6 +22,13 @@ export type AuthResult = boolean | null | undefined | { userId?: string; uid?: s
|
|
|
22
22
|
export interface AuthMiddlewareOptions {
|
|
23
23
|
/** DataDriver to scope via withAuth() for RLS */
|
|
24
24
|
driver: DataDriver;
|
|
25
|
+
/**
|
|
26
|
+
* Optional per-request driver resolver for multi-data-source backends.
|
|
27
|
+
* Given the request context, returns the unscoped delegate to use (e.g.
|
|
28
|
+
* Postgres vs Mongo, picked by the request's collection data source).
|
|
29
|
+
* When omitted, `driver` is used for every request.
|
|
30
|
+
*/
|
|
31
|
+
resolveDriver?: (c: Context<HonoEnv>) => DataDriver;
|
|
25
32
|
/**
|
|
26
33
|
* If true, return 401 when no valid token is present.
|
|
27
34
|
*
|
|
@@ -232,9 +239,11 @@ export function extractUserFromToken(token: string): AccessTokenPayload | null {
|
|
|
232
239
|
*/
|
|
233
240
|
|
|
234
241
|
export function createAuthMiddleware(options: AuthMiddlewareOptions): MiddlewareHandler<HonoEnv> {
|
|
235
|
-
const { driver, requireAuth: enforceAuth = true, validator, serviceKey, apiKeyStore } = options;
|
|
242
|
+
const { driver: baseDriver, resolveDriver, requireAuth: enforceAuth = true, validator, serviceKey, apiKeyStore } = options;
|
|
236
243
|
|
|
237
244
|
return async (c, next) => {
|
|
245
|
+
// Pick the per-request delegate (multi-data-source) before scoping.
|
|
246
|
+
const driver = resolveDriver ? resolveDriver(c) : baseDriver;
|
|
238
247
|
if (validator) {
|
|
239
248
|
// Custom validator path (e.g., Firebase Auth, API keys)
|
|
240
249
|
try {
|
|
@@ -54,8 +54,24 @@ export function createResetPasswordRoute(config: ResetPasswordRouteConfig): Hono
|
|
|
54
54
|
let invitationSent = false;
|
|
55
55
|
let temporaryPassword: string | undefined;
|
|
56
56
|
|
|
57
|
+
// Parse optional body — if a password is provided, set it directly
|
|
58
|
+
const body = await c.req.json().catch(() => ({}));
|
|
59
|
+
|
|
60
|
+
if (body.password) {
|
|
61
|
+
const password = body.password as string;
|
|
62
|
+
const validation = ops.validatePasswordStrength(password);
|
|
63
|
+
if (!validation.valid) {
|
|
64
|
+
throw ApiError.badRequest(
|
|
65
|
+
`Password too weak: ${validation.errors.join(", ")}`
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
const passwordHash = await ops.hashPassword(password);
|
|
69
|
+
await authRepo.updatePassword(existing.id, passwordHash);
|
|
70
|
+
temporaryPassword = undefined;
|
|
71
|
+
invitationSent = false;
|
|
72
|
+
}
|
|
57
73
|
// 1. Collection-level hook (closest to the data)
|
|
58
|
-
if (collectionAuthConfig?.onResetPassword) {
|
|
74
|
+
else if (collectionAuthConfig?.onResetPassword) {
|
|
59
75
|
const isEmailConfigured = !!(emailService && emailService.isConfigured());
|
|
60
76
|
const hookResult = await collectionAuthConfig.onResetPassword(existing.id, {
|
|
61
77
|
hashPassword: (password: string) => ops.hashPassword(password),
|
package/src/auth/routes.ts
CHANGED
|
@@ -15,6 +15,8 @@ import { z } from "zod";
|
|
|
15
15
|
import { logger } from "../utils/logger";
|
|
16
16
|
import { mountMfaRoutes } from "./mfa-routes";
|
|
17
17
|
import { mountSessionRoutes } from "./session-routes";
|
|
18
|
+
import { mountMagicLinkRoutes } from "./magic-link-routes";
|
|
19
|
+
import type { AuthResponsePayload, TransformAuthResponseContext } from "@rebasepro/types";
|
|
18
20
|
|
|
19
21
|
/**
|
|
20
22
|
* Shared configuration for auth and admin route factories.
|
|
@@ -43,6 +45,8 @@ export interface AuthModuleConfig {
|
|
|
43
45
|
* When not provided, falls back to checking if any users exist.
|
|
44
46
|
*/
|
|
45
47
|
isBootstrapCompleted?: () => Promise<boolean>;
|
|
48
|
+
/** Enable magic link (passwordless email) login. Requires email service. */
|
|
49
|
+
enableMagicLink?: boolean;
|
|
46
50
|
}
|
|
47
51
|
|
|
48
52
|
/**
|
|
@@ -53,7 +57,7 @@ function buildAuthResponse(
|
|
|
53
57
|
roleIds: string[],
|
|
54
58
|
accessToken: string,
|
|
55
59
|
refreshToken: string
|
|
56
|
-
) {
|
|
60
|
+
): AuthResponsePayload {
|
|
57
61
|
return {
|
|
58
62
|
user: {
|
|
59
63
|
uid: user.id,
|
|
@@ -95,6 +99,29 @@ export function createAuthRoutes(config: AuthModuleConfig): Hono<HonoEnv> {
|
|
|
95
99
|
const { emailService, emailConfig, allowRegistration = false } = config;
|
|
96
100
|
const ops = resolveAuthHooks(config.authHooks);
|
|
97
101
|
|
|
102
|
+
/**
|
|
103
|
+
* Apply the `transformAuthResponse` hook if provided.
|
|
104
|
+
*
|
|
105
|
+
* Errors are caught and logged — the untransformed response is returned
|
|
106
|
+
* as a graceful fallback so auth never breaks due to a hook failure.
|
|
107
|
+
*/
|
|
108
|
+
async function applyTransformHook(
|
|
109
|
+
response: AuthResponsePayload,
|
|
110
|
+
method: TransformAuthResponseContext["method"],
|
|
111
|
+
request: Request,
|
|
112
|
+
userId: string
|
|
113
|
+
): Promise<AuthResponsePayload> {
|
|
114
|
+
if (!ops.transformAuthResponse) return response;
|
|
115
|
+
try {
|
|
116
|
+
return await ops.transformAuthResponse(response, { userId, method, request });
|
|
117
|
+
} catch (err) {
|
|
118
|
+
logger.error("[AuthHooks] transformAuthResponse error", {
|
|
119
|
+
error: err instanceof Error ? err.message : err
|
|
120
|
+
});
|
|
121
|
+
return response;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
98
125
|
// ── Zod input schemas ──────────────────────────────────────────────
|
|
99
126
|
const registerSchema = z.object({
|
|
100
127
|
email: z.string().email("Invalid email address").max(255),
|
|
@@ -291,7 +318,9 @@ displayName: user.displayName });
|
|
|
291
318
|
});
|
|
292
319
|
}
|
|
293
320
|
|
|
294
|
-
|
|
321
|
+
const authResponse = buildAuthResponse(user, roleIds, accessToken, refreshToken);
|
|
322
|
+
const finalResponse = await applyTransformHook(authResponse, "register", c.req.raw, user.id);
|
|
323
|
+
return c.json(finalResponse, 201);
|
|
295
324
|
});
|
|
296
325
|
|
|
297
326
|
/**
|
|
@@ -327,6 +356,11 @@ displayName: user.displayName });
|
|
|
327
356
|
|
|
328
357
|
const isValidPassword = await ops.verifyPassword(password, user.passwordHash);
|
|
329
358
|
if (!isValidPassword) {
|
|
359
|
+
logger.warn("[Security Audit] Auth login failure", {
|
|
360
|
+
eventType: "auth.login.failure",
|
|
361
|
+
email,
|
|
362
|
+
userId: user.id
|
|
363
|
+
});
|
|
330
364
|
throw ApiError.unauthorized("Invalid email or password", "INVALID_CREDENTIALS");
|
|
331
365
|
}
|
|
332
366
|
}
|
|
@@ -344,7 +378,15 @@ displayName: user.displayName });
|
|
|
344
378
|
});
|
|
345
379
|
}
|
|
346
380
|
|
|
347
|
-
|
|
381
|
+
logger.info("[Security Audit] Auth login success", {
|
|
382
|
+
eventType: "auth.login.success",
|
|
383
|
+
userId: user.id,
|
|
384
|
+
email
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
const authResponse = buildAuthResponse(user, roleIds, accessToken, refreshToken);
|
|
388
|
+
const finalResponse = await applyTransformHook(authResponse, "login", c.req.raw, user.id);
|
|
389
|
+
return c.json(finalResponse);
|
|
348
390
|
});
|
|
349
391
|
|
|
350
392
|
/**
|
|
@@ -374,6 +416,13 @@ displayName: user.displayName });
|
|
|
374
416
|
user = await authRepo.getUserByEmail(externalUser.email);
|
|
375
417
|
|
|
376
418
|
if (user) {
|
|
419
|
+
// Only auto-link if the OAuth provider confirmed the email is verified
|
|
420
|
+
if (!externalUser.emailVerified) {
|
|
421
|
+
throw ApiError.forbidden(
|
|
422
|
+
"Cannot auto-link account: email not verified by the OAuth provider. Please log in with your password first and link the provider from your profile.",
|
|
423
|
+
"EMAIL_NOT_VERIFIED"
|
|
424
|
+
);
|
|
425
|
+
}
|
|
377
426
|
// Link Provider to existing account
|
|
378
427
|
await authRepo.linkUserIdentity(user.id, provider.id, externalUser.providerId, { email: externalUser.email });
|
|
379
428
|
|
|
@@ -430,7 +479,9 @@ displayName: user.displayName });
|
|
|
430
479
|
c.req.header("x-forwarded-for") || "unknown"
|
|
431
480
|
);
|
|
432
481
|
|
|
433
|
-
|
|
482
|
+
const authResponse = buildAuthResponse(user, roleIds, accessToken, refreshToken);
|
|
483
|
+
const finalResponse = await applyTransformHook(authResponse, "oauth", c.req.raw, user.id);
|
|
484
|
+
return c.json(finalResponse);
|
|
434
485
|
});
|
|
435
486
|
}
|
|
436
487
|
}
|
|
@@ -706,13 +757,15 @@ aal: "aal1" };
|
|
|
706
757
|
ipAddress
|
|
707
758
|
);
|
|
708
759
|
|
|
709
|
-
|
|
760
|
+
const refreshResponse: AuthResponsePayload = {
|
|
710
761
|
tokens: {
|
|
711
762
|
accessToken: newAccessToken,
|
|
712
763
|
refreshToken: newRefreshToken,
|
|
713
764
|
accessTokenExpiresAt: getAccessTokenExpiry()
|
|
714
765
|
}
|
|
715
|
-
}
|
|
766
|
+
};
|
|
767
|
+
const finalResponse = await applyTransformHook(refreshResponse, "refresh", c.req.raw, storedToken.userId);
|
|
768
|
+
return c.json(finalResponse);
|
|
716
769
|
});
|
|
717
770
|
|
|
718
771
|
mountSessionRoutes({
|
|
@@ -721,13 +774,29 @@ aal: "aal1" };
|
|
|
721
774
|
ops,
|
|
722
775
|
parseBody,
|
|
723
776
|
buildAuthResponse,
|
|
724
|
-
createSessionAndTokens
|
|
777
|
+
createSessionAndTokens,
|
|
778
|
+
applyTransformHook
|
|
725
779
|
});
|
|
726
780
|
|
|
727
781
|
// ═══════════════════════════════════════════════════════════════════════
|
|
728
782
|
// MFA / TOTP
|
|
729
783
|
// ═══════════════════════════════════════════════════════════════════════
|
|
730
|
-
mountMfaRoutes(router, config, ops, parseBody);
|
|
784
|
+
mountMfaRoutes(router, config, ops, parseBody, applyTransformHook);
|
|
785
|
+
|
|
786
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
787
|
+
// Magic Link (passwordless email login)
|
|
788
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
789
|
+
if (config.enableMagicLink) {
|
|
790
|
+
mountMagicLinkRoutes({
|
|
791
|
+
router,
|
|
792
|
+
config,
|
|
793
|
+
ops,
|
|
794
|
+
parseBody,
|
|
795
|
+
buildAuthResponse,
|
|
796
|
+
createSessionAndTokens,
|
|
797
|
+
applyTransformHook
|
|
798
|
+
});
|
|
799
|
+
}
|
|
731
800
|
|
|
732
801
|
return router;
|
|
733
802
|
}
|
|
@@ -10,6 +10,7 @@ import { hashRefreshToken } from "./jwt";
|
|
|
10
10
|
import type { AuthModuleConfig } from "./routes";
|
|
11
11
|
import { resolveAuthHooks } from "./auth-hooks";
|
|
12
12
|
import type { CreateUserData } from "./interfaces";
|
|
13
|
+
import type { AuthResponsePayload, TransformAuthResponseContext } from "@rebasepro/types";
|
|
13
14
|
|
|
14
15
|
interface SessionRoutesConfig {
|
|
15
16
|
router: Hono<HonoEnv>;
|
|
@@ -27,10 +28,16 @@ interface SessionRoutesConfig {
|
|
|
27
28
|
accessToken: string;
|
|
28
29
|
refreshToken: string;
|
|
29
30
|
}>;
|
|
31
|
+
applyTransformHook: (
|
|
32
|
+
response: AuthResponsePayload,
|
|
33
|
+
method: TransformAuthResponseContext["method"],
|
|
34
|
+
request: Request,
|
|
35
|
+
userId: string
|
|
36
|
+
) => Promise<AuthResponsePayload>;
|
|
30
37
|
}
|
|
31
38
|
|
|
32
39
|
export function mountSessionRoutes(opts: SessionRoutesConfig): void {
|
|
33
|
-
const { router, config, ops, parseBody, buildAuthResponse, createSessionAndTokens } = opts;
|
|
40
|
+
const { router, config, ops, parseBody, buildAuthResponse, createSessionAndTokens, applyTransformHook } = opts;
|
|
34
41
|
const authRepo = config.authRepo;
|
|
35
42
|
|
|
36
43
|
const logoutSchema = z.object({
|
|
@@ -230,6 +237,7 @@ export function mountSessionRoutes(opts: SessionRoutesConfig): void {
|
|
|
230
237
|
needsSetup,
|
|
231
238
|
registrationEnabled: registrationAllowed,
|
|
232
239
|
emailServiceEnabled: isEmailConfigured(),
|
|
240
|
+
magicLinkEnabled: !!config.enableMagicLink && isEmailConfigured(),
|
|
233
241
|
enabledProviders
|
|
234
242
|
});
|
|
235
243
|
});
|
|
@@ -283,7 +291,9 @@ export function mountSessionRoutes(opts: SessionRoutesConfig): void {
|
|
|
283
291
|
});
|
|
284
292
|
}
|
|
285
293
|
|
|
286
|
-
|
|
294
|
+
const authResponse = buildAuthResponse(user, roleIds, accessToken, refreshToken) as AuthResponsePayload;
|
|
295
|
+
const finalResponse = await applyTransformHook(authResponse, "anonymous", c.req.raw, user.id);
|
|
296
|
+
return c.json(finalResponse, 201);
|
|
287
297
|
});
|
|
288
298
|
|
|
289
299
|
/**
|
|
@@ -336,6 +346,8 @@ export function mountSessionRoutes(opts: SessionRoutesConfig): void {
|
|
|
336
346
|
c.req.header("x-forwarded-for") || "unknown"
|
|
337
347
|
);
|
|
338
348
|
|
|
339
|
-
|
|
349
|
+
const authResponse = buildAuthResponse(updatedUser, roleIds, accessToken, refreshToken) as AuthResponsePayload;
|
|
350
|
+
const finalResponse = await applyTransformHook(authResponse, "anonymous", c.req.raw, user.id);
|
|
351
|
+
return c.json(finalResponse);
|
|
340
352
|
});
|
|
341
353
|
}
|
package/src/auth/slack-oauth.ts
CHANGED
|
@@ -61,7 +61,8 @@ export function createSlackProvider(config: { clientId: string; clientSecret: st
|
|
|
61
61
|
providerId: p.sub,
|
|
62
62
|
email: p.email,
|
|
63
63
|
displayName: p.name || null,
|
|
64
|
-
photoUrl: p.picture || null
|
|
64
|
+
photoUrl: p.picture || null,
|
|
65
|
+
emailVerified: p.email_verified === true
|
|
65
66
|
};
|
|
66
67
|
} catch (error) {
|
|
67
68
|
logger.error("Slack OAuth error", { error: error });
|