@rebasepro/server-core 0.6.1 → 0.8.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/dist/api/graphql/graphql-schema-generator.d.ts +9 -0
- package/dist/api/rest/api-generator.d.ts +2 -14
- package/dist/api/rest/query-parser.d.ts +2 -5
- package/dist/api/types.d.ts +8 -2
- package/dist/auth/adapter-middleware.d.ts +7 -1
- package/dist/auth/admin-roles-route.d.ts +18 -0
- package/dist/auth/admin-users-route.d.ts +28 -0
- package/dist/auth/api-keys/api-key-types.d.ts +8 -0
- package/dist/auth/auth-hooks.d.ts +17 -0
- package/dist/auth/builtin-auth-adapter.d.ts +3 -1
- package/dist/auth/index.d.ts +1 -0
- package/dist/auth/interfaces.d.ts +32 -2
- package/dist/auth/magic-link-routes.d.ts +30 -0
- package/dist/auth/mfa-crypto.d.ts +23 -0
- package/dist/auth/mfa-routes.d.ts +2 -1
- package/dist/auth/middleware.d.ts +9 -2
- package/dist/auth/routes.d.ts +3 -1
- package/dist/auth/session-routes.d.ts +2 -0
- package/dist/backend-CIxN4FVm.js.map +1 -1
- package/dist/base64-js-C_frYBkI.js +1687 -0
- package/dist/base64-js-C_frYBkI.js.map +1 -0
- package/dist/{dist-CZKP-Xz4.js → dist-DMO-zF6D.js} +2 -2
- package/dist/{dist-CZKP-Xz4.js.map → dist-DMO-zF6D.js.map} +1 -1
- package/dist/email/index.d.ts +2 -2
- package/dist/email/templates.d.ts +8 -0
- package/dist/email/types.d.ts +18 -0
- package/dist/functions/define-function.d.ts +50 -0
- package/dist/functions/index.d.ts +2 -0
- package/dist/index.d.ts +16 -18
- package/dist/index.es.js +1947 -2356
- package/dist/index.es.js.map +1 -1
- package/dist/index.umd.js +40615 -10203
- package/dist/index.umd.js.map +1 -1
- package/dist/init.d.ts +68 -12
- package/dist/jws-BqRRaK11.js +616 -0
- package/dist/jws-BqRRaK11.js.map +1 -0
- package/dist/{jwt-DHcQRGC3.js → jwt-BETC8a1J.js} +3 -611
- package/dist/jwt-BETC8a1J.js.map +1 -0
- package/dist/services/routed-realtime-service.d.ts +43 -0
- package/dist/{src-COaj0G3P.js → src-CB2PIpBe.js} +2 -2
- package/dist/{src-COaj0G3P.js.map → src-CB2PIpBe.js.map} +1 -1
- package/dist/src-DjzOT1kG.js +29746 -0
- package/dist/src-DjzOT1kG.js.map +1 -0
- package/dist/storage/GCSStorageController.d.ts +43 -0
- package/dist/storage/index.d.ts +5 -2
- package/dist/storage/routes.d.ts +33 -1
- package/dist/storage/tus-handler.d.ts +3 -1
- package/dist/storage/types.d.ts +28 -3
- package/dist/utils/dev-port.d.ts +2 -0
- package/package.json +13 -5
- package/src/api/errors.ts +16 -2
- package/src/api/graphql/graphql-schema-generator.ts +50 -15
- package/src/api/openapi-generator.ts +2 -2
- package/src/api/rest/api-generator.ts +44 -123
- package/src/api/rest/query-parser.ts +6 -23
- package/src/api/server.ts +10 -2
- package/src/api/types.ts +8 -2
- 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 +29 -6
- 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 +38 -2
- 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 +14 -5
- package/src/auth/reset-password-admin.ts +17 -1
- package/src/auth/routes.ts +78 -10
- 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/functions/define-function.ts +59 -0
- package/src/functions/function-loader.ts +16 -0
- package/src/functions/index.ts +2 -0
- package/src/index.ts +70 -37
- package/src/init.ts +214 -25
- package/src/services/routed-realtime-service.ts +113 -0
- package/src/storage/GCSStorageController.ts +334 -0
- package/src/storage/index.ts +9 -3
- package/src/storage/routes.ts +191 -23
- package/src/storage/tus-handler.ts +16 -4
- package/src/storage/types.ts +25 -3
- package/src/utils/dev-port.ts +13 -7
- package/test/api-generator.test.ts +1 -1
- package/test/auth-config-types.test.ts +40 -0
- package/test/auth-routes.test.ts +54 -4
- package/test/custom-auth-adapter.test.ts +20 -1
- package/test/define-function.test.ts +45 -0
- 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/storage-routes.test.ts +160 -0
- package/test/transform-auth-response.test.ts +305 -0
- package/dist/jwt-DHcQRGC3.js.map +0 -1
- package/test/backend-hooks-data.test.ts +0 -477
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
|
*
|
|
@@ -30,7 +37,7 @@ export interface AuthMiddlewareOptions {
|
|
|
30
37
|
* to Postgres Row-Level Security policies.
|
|
31
38
|
*/
|
|
32
39
|
requireAuth?: boolean;
|
|
33
|
-
/** Optional custom validator (for non-JWT auth, e.g.
|
|
40
|
+
/** Optional custom validator (for non-JWT auth, e.g. external auth providers) */
|
|
34
41
|
validator?: (c: Context<HonoEnv>) => Promise<AuthResult>;
|
|
35
42
|
/**
|
|
36
43
|
* A static secret key for server-to-server / script authentication.
|
|
@@ -40,7 +47,7 @@ export interface AuthMiddlewareOptions {
|
|
|
40
47
|
* roles: `["admin"]`) **without** JWT verification. The driver is scoped
|
|
41
48
|
* via `withAuth()` with the service identity.
|
|
42
49
|
*
|
|
43
|
-
* This is the Rebase equivalent of a
|
|
50
|
+
* This is the Rebase equivalent of a Service Account key.
|
|
44
51
|
* Set via `REBASE_SERVICE_KEY` in `.env` and pass through the backend config.
|
|
45
52
|
*
|
|
46
53
|
* **Security:** The comparison uses constant-time equality to prevent
|
|
@@ -232,11 +239,13 @@ 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
|
-
// Custom validator path (e.g.,
|
|
248
|
+
// Custom validator path (e.g., API keys, external auth)
|
|
240
249
|
try {
|
|
241
250
|
const authResult = await validator(c);
|
|
242
251
|
if (authResult && typeof authResult === "object") {
|
|
@@ -282,7 +291,7 @@ code: "UNAUTHORIZED" } }, 401);
|
|
|
282
291
|
|
|
283
292
|
// ── Service Key check ──────────────────────────────────
|
|
284
293
|
// Check BEFORE JWT verification. Service keys are static
|
|
285
|
-
// secrets (like
|
|
294
|
+
// secrets (like Service Account keys) that grant admin access
|
|
286
295
|
// for scripts, cron jobs, and server-to-server calls.
|
|
287
296
|
if (serviceKey && safeCompare(token, serviceKey)) {
|
|
288
297
|
const serviceUser: AccessTokenPayload = {
|
|
@@ -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.
|
|
@@ -28,8 +30,7 @@ export interface AuthModuleConfig {
|
|
|
28
30
|
/** Default role ID to assign to new users (default: none). Must NOT be "admin". */
|
|
29
31
|
defaultRole?: string;
|
|
30
32
|
/** Optional array of OAuth providers */
|
|
31
|
-
|
|
32
|
-
oauthProviders?: OAuthProvider<any>[];
|
|
33
|
+
oauthProviders?: OAuthProvider<unknown>[];
|
|
33
34
|
/** When true, blocks all self-registration regardless of `allowRegistration`. */
|
|
34
35
|
disableSelfRegistration?: boolean;
|
|
35
36
|
/**
|
|
@@ -43,6 +44,8 @@ export interface AuthModuleConfig {
|
|
|
43
44
|
* When not provided, falls back to checking if any users exist.
|
|
44
45
|
*/
|
|
45
46
|
isBootstrapCompleted?: () => Promise<boolean>;
|
|
47
|
+
/** Enable magic link (passwordless email) login. Requires email service. */
|
|
48
|
+
enableMagicLink?: boolean;
|
|
46
49
|
}
|
|
47
50
|
|
|
48
51
|
/**
|
|
@@ -53,7 +56,7 @@ function buildAuthResponse(
|
|
|
53
56
|
roleIds: string[],
|
|
54
57
|
accessToken: string,
|
|
55
58
|
refreshToken: string
|
|
56
|
-
) {
|
|
59
|
+
): AuthResponsePayload {
|
|
57
60
|
return {
|
|
58
61
|
user: {
|
|
59
62
|
uid: user.id,
|
|
@@ -95,6 +98,29 @@ export function createAuthRoutes(config: AuthModuleConfig): Hono<HonoEnv> {
|
|
|
95
98
|
const { emailService, emailConfig, allowRegistration = false } = config;
|
|
96
99
|
const ops = resolveAuthHooks(config.authHooks);
|
|
97
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Apply the `transformAuthResponse` hook if provided.
|
|
103
|
+
*
|
|
104
|
+
* Errors are caught and logged — the untransformed response is returned
|
|
105
|
+
* as a graceful fallback so auth never breaks due to a hook failure.
|
|
106
|
+
*/
|
|
107
|
+
async function applyTransformHook(
|
|
108
|
+
response: AuthResponsePayload,
|
|
109
|
+
method: TransformAuthResponseContext["method"],
|
|
110
|
+
request: Request,
|
|
111
|
+
userId: string
|
|
112
|
+
): Promise<AuthResponsePayload> {
|
|
113
|
+
if (!ops.transformAuthResponse) return response;
|
|
114
|
+
try {
|
|
115
|
+
return await ops.transformAuthResponse(response, { userId, method, request });
|
|
116
|
+
} catch (err) {
|
|
117
|
+
logger.error("[AuthHooks] transformAuthResponse error", {
|
|
118
|
+
error: err instanceof Error ? err.message : err
|
|
119
|
+
});
|
|
120
|
+
return response;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
98
124
|
// ── Zod input schemas ──────────────────────────────────────────────
|
|
99
125
|
const registerSchema = z.object({
|
|
100
126
|
email: z.string().email("Invalid email address").max(255),
|
|
@@ -291,7 +317,9 @@ displayName: user.displayName });
|
|
|
291
317
|
});
|
|
292
318
|
}
|
|
293
319
|
|
|
294
|
-
|
|
320
|
+
const authResponse = buildAuthResponse(user, roleIds, accessToken, refreshToken);
|
|
321
|
+
const finalResponse = await applyTransformHook(authResponse, "register", c.req.raw, user.id);
|
|
322
|
+
return c.json(finalResponse, 201);
|
|
295
323
|
});
|
|
296
324
|
|
|
297
325
|
/**
|
|
@@ -327,6 +355,11 @@ displayName: user.displayName });
|
|
|
327
355
|
|
|
328
356
|
const isValidPassword = await ops.verifyPassword(password, user.passwordHash);
|
|
329
357
|
if (!isValidPassword) {
|
|
358
|
+
logger.warn("[Security Audit] Auth login failure", {
|
|
359
|
+
eventType: "auth.login.failure",
|
|
360
|
+
email,
|
|
361
|
+
userId: user.id
|
|
362
|
+
});
|
|
330
363
|
throw ApiError.unauthorized("Invalid email or password", "INVALID_CREDENTIALS");
|
|
331
364
|
}
|
|
332
365
|
}
|
|
@@ -344,7 +377,15 @@ displayName: user.displayName });
|
|
|
344
377
|
});
|
|
345
378
|
}
|
|
346
379
|
|
|
347
|
-
|
|
380
|
+
logger.info("[Security Audit] Auth login success", {
|
|
381
|
+
eventType: "auth.login.success",
|
|
382
|
+
userId: user.id,
|
|
383
|
+
email
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
const authResponse = buildAuthResponse(user, roleIds, accessToken, refreshToken);
|
|
387
|
+
const finalResponse = await applyTransformHook(authResponse, "login", c.req.raw, user.id);
|
|
388
|
+
return c.json(finalResponse);
|
|
348
389
|
});
|
|
349
390
|
|
|
350
391
|
/**
|
|
@@ -374,6 +415,13 @@ displayName: user.displayName });
|
|
|
374
415
|
user = await authRepo.getUserByEmail(externalUser.email);
|
|
375
416
|
|
|
376
417
|
if (user) {
|
|
418
|
+
// Only auto-link if the OAuth provider confirmed the email is verified
|
|
419
|
+
if (!externalUser.emailVerified) {
|
|
420
|
+
throw ApiError.forbidden(
|
|
421
|
+
"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.",
|
|
422
|
+
"EMAIL_NOT_VERIFIED"
|
|
423
|
+
);
|
|
424
|
+
}
|
|
377
425
|
// Link Provider to existing account
|
|
378
426
|
await authRepo.linkUserIdentity(user.id, provider.id, externalUser.providerId, { email: externalUser.email });
|
|
379
427
|
|
|
@@ -430,7 +478,9 @@ displayName: user.displayName });
|
|
|
430
478
|
c.req.header("x-forwarded-for") || "unknown"
|
|
431
479
|
);
|
|
432
480
|
|
|
433
|
-
|
|
481
|
+
const authResponse = buildAuthResponse(user, roleIds, accessToken, refreshToken);
|
|
482
|
+
const finalResponse = await applyTransformHook(authResponse, "oauth", c.req.raw, user.id);
|
|
483
|
+
return c.json(finalResponse);
|
|
434
484
|
});
|
|
435
485
|
}
|
|
436
486
|
}
|
|
@@ -706,13 +756,15 @@ aal: "aal1" };
|
|
|
706
756
|
ipAddress
|
|
707
757
|
);
|
|
708
758
|
|
|
709
|
-
|
|
759
|
+
const refreshResponse: AuthResponsePayload = {
|
|
710
760
|
tokens: {
|
|
711
761
|
accessToken: newAccessToken,
|
|
712
762
|
refreshToken: newRefreshToken,
|
|
713
763
|
accessTokenExpiresAt: getAccessTokenExpiry()
|
|
714
764
|
}
|
|
715
|
-
}
|
|
765
|
+
};
|
|
766
|
+
const finalResponse = await applyTransformHook(refreshResponse, "refresh", c.req.raw, storedToken.userId);
|
|
767
|
+
return c.json(finalResponse);
|
|
716
768
|
});
|
|
717
769
|
|
|
718
770
|
mountSessionRoutes({
|
|
@@ -721,13 +773,29 @@ aal: "aal1" };
|
|
|
721
773
|
ops,
|
|
722
774
|
parseBody,
|
|
723
775
|
buildAuthResponse,
|
|
724
|
-
createSessionAndTokens
|
|
776
|
+
createSessionAndTokens,
|
|
777
|
+
applyTransformHook
|
|
725
778
|
});
|
|
726
779
|
|
|
727
780
|
// ═══════════════════════════════════════════════════════════════════════
|
|
728
781
|
// MFA / TOTP
|
|
729
782
|
// ═══════════════════════════════════════════════════════════════════════
|
|
730
|
-
mountMfaRoutes(router, config, ops, parseBody);
|
|
783
|
+
mountMfaRoutes(router, config, ops, parseBody, applyTransformHook);
|
|
784
|
+
|
|
785
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
786
|
+
// Magic Link (passwordless email login)
|
|
787
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
788
|
+
if (config.enableMagicLink) {
|
|
789
|
+
mountMagicLinkRoutes({
|
|
790
|
+
router,
|
|
791
|
+
config,
|
|
792
|
+
ops,
|
|
793
|
+
parseBody,
|
|
794
|
+
buildAuthResponse,
|
|
795
|
+
createSessionAndTokens,
|
|
796
|
+
applyTransformHook
|
|
797
|
+
});
|
|
798
|
+
}
|
|
731
799
|
|
|
732
800
|
return router;
|
|
733
801
|
}
|
|
@@ -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 });
|
|
@@ -57,7 +57,8 @@ export function createSpotifyProvider(config: { clientId: string; clientSecret:
|
|
|
57
57
|
providerId: p.id,
|
|
58
58
|
email: p.email,
|
|
59
59
|
displayName: p.display_name || null,
|
|
60
|
-
photoUrl: p.images?.[0]?.url || null
|
|
60
|
+
photoUrl: p.images?.[0]?.url || null,
|
|
61
|
+
emailVerified: true
|
|
61
62
|
};
|
|
62
63
|
} catch (error) {
|
|
63
64
|
logger.error("Spotify OAuth error", { error: error });
|
|
@@ -84,6 +84,7 @@ export function createTwitterProvider(config: { clientId: string; clientSecret:
|
|
|
84
84
|
// generate a placeholder email — the user can update it later.
|
|
85
85
|
// For apps with elevated access, fetch from v1.1 endpoint.
|
|
86
86
|
let email: string | null = null;
|
|
87
|
+
let emailVerified = false;
|
|
87
88
|
try {
|
|
88
89
|
const emailResponse = await fetch(
|
|
89
90
|
"https://api.twitter.com/1.1/account/verify_credentials.json?include_email=true",
|
|
@@ -94,6 +95,9 @@ export function createTwitterProvider(config: { clientId: string; clientSecret:
|
|
|
94
95
|
if (emailResponse.ok) {
|
|
95
96
|
const emailData = await emailResponse.json() as { email?: string };
|
|
96
97
|
email = emailData.email || null;
|
|
98
|
+
if (email) {
|
|
99
|
+
emailVerified = true;
|
|
100
|
+
}
|
|
97
101
|
}
|
|
98
102
|
} catch {
|
|
99
103
|
// Elevated access not available — fall through
|
|
@@ -104,13 +108,16 @@ export function createTwitterProvider(config: { clientId: string; clientSecret:
|
|
|
104
108
|
// This allows the account to be created and linked; the user should
|
|
105
109
|
// update their email through the profile settings.
|
|
106
110
|
email = `${profileData.id}@twitter.placeholder.rebase`;
|
|
111
|
+
// Placeholder emails are NOT verified — prevents auto-linking to existing accounts
|
|
112
|
+
emailVerified = false;
|
|
107
113
|
}
|
|
108
114
|
|
|
109
115
|
return {
|
|
110
116
|
providerId: profileData.id,
|
|
111
117
|
email,
|
|
112
118
|
displayName: profileData.name || profileData.username || null,
|
|
113
|
-
photoUrl: profileData.profile_image_url?.replace("_normal", "_400x400") || null
|
|
119
|
+
photoUrl: profileData.profile_image_url?.replace("_normal", "_400x400") || null,
|
|
120
|
+
emailVerified
|
|
114
121
|
};
|
|
115
122
|
} catch (error) {
|
|
116
123
|
logger.error("Twitter OAuth error", { error: error });
|
package/src/cron/cron-store.ts
CHANGED
|
@@ -43,7 +43,8 @@ export function createCronStore(driver: DataDriver): CronStore | undefined {
|
|
|
43
43
|
return undefined;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
const exec =
|
|
46
|
+
const exec = (sqlText: string, options?: { params?: unknown[] }) =>
|
|
47
|
+
admin.executeSql(sqlText, options?.params ? { params: options.params } : undefined);
|
|
47
48
|
|
|
48
49
|
return {
|
|
49
50
|
async ensureTable(): Promise<void> {
|
|
@@ -80,22 +81,22 @@ export function createCronStore(driver: DataDriver): CronStore | undefined {
|
|
|
80
81
|
try {
|
|
81
82
|
const resultJson = entry.result !== undefined ? JSON.stringify(entry.result) : null;
|
|
82
83
|
const logsJson = entry.logs.length > 0 ? JSON.stringify(entry.logs) : null;
|
|
83
|
-
const errorEscaped = entry.error ? entry.error.replace(/'/g, "''") : null;
|
|
84
84
|
|
|
85
|
-
await exec(
|
|
86
|
-
INSERT INTO ${TABLE} (job_id, started_at, finished_at, duration_ms, success, error, result, logs, manual)
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
85
|
+
await exec(
|
|
86
|
+
`INSERT INTO ${TABLE} (job_id, started_at, finished_at, duration_ms, success, error, result, logs, manual)
|
|
87
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8::jsonb, $9)`,
|
|
88
|
+
{ params: [
|
|
89
|
+
entry.jobId,
|
|
90
|
+
entry.startedAt,
|
|
91
|
+
entry.finishedAt,
|
|
92
|
+
entry.durationMs,
|
|
93
|
+
entry.success,
|
|
94
|
+
entry.error || null,
|
|
95
|
+
resultJson,
|
|
96
|
+
logsJson,
|
|
97
|
+
entry.manual
|
|
98
|
+
]}
|
|
99
|
+
);
|
|
99
100
|
} catch (err) {
|
|
100
101
|
// Non-blocking — log persistence should never crash the scheduler
|
|
101
102
|
logger.error(`[cron-store] Failed to persist log for "${entry.jobId}"`, { error: err });
|
|
@@ -104,13 +105,14 @@ export function createCronStore(driver: DataDriver): CronStore | undefined {
|
|
|
104
105
|
|
|
105
106
|
async fetchLogs(jobId: string, limit = 50): Promise<CronJobLogEntry[]> {
|
|
106
107
|
try {
|
|
107
|
-
const rows = await exec(
|
|
108
|
-
SELECT job_id, started_at, finished_at, duration_ms, success, error, result, logs, manual
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
108
|
+
const rows = await exec(
|
|
109
|
+
`SELECT job_id, started_at, finished_at, duration_ms, success, error, result, logs, manual
|
|
110
|
+
FROM ${TABLE}
|
|
111
|
+
WHERE job_id = $1
|
|
112
|
+
ORDER BY started_at DESC
|
|
113
|
+
LIMIT $2`,
|
|
114
|
+
{ params: [jobId, limit] }
|
|
115
|
+
);
|
|
114
116
|
|
|
115
117
|
return rows.map(rowToLogEntry);
|
|
116
118
|
} catch (err) {
|
package/src/email/index.ts
CHANGED
|
@@ -10,9 +10,10 @@ export type {
|
|
|
10
10
|
PasswordResetTemplateFunction,
|
|
11
11
|
EmailVerificationTemplateFunction,
|
|
12
12
|
UserInvitationTemplateFunction,
|
|
13
|
-
WelcomeEmailTemplateFunction
|
|
13
|
+
WelcomeEmailTemplateFunction,
|
|
14
|
+
MagicLinkTemplateFunction
|
|
14
15
|
} from "./types";
|
|
15
16
|
|
|
16
17
|
export { SMTPEmailService, createEmailService } from "./smtp-email-service";
|
|
17
18
|
|
|
18
|
-
export { getPasswordResetTemplate, getEmailVerificationTemplate, getUserInvitationTemplate, getWelcomeEmailTemplate } from "./templates";
|
|
19
|
+
export { getPasswordResetTemplate, getEmailVerificationTemplate, getUserInvitationTemplate, getWelcomeEmailTemplate, getMagicLinkTemplate } from "./templates";
|
package/src/email/templates.ts
CHANGED
|
@@ -386,3 +386,85 @@ Si tienes alguna pregunta, no dudes en contactarnos respondiendo a este correo.
|
|
|
386
386
|
html,
|
|
387
387
|
text };
|
|
388
388
|
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Default magic link email template
|
|
392
|
+
*/
|
|
393
|
+
export function getMagicLinkTemplate(
|
|
394
|
+
magicLinkUrl: string,
|
|
395
|
+
user: TemplateUser,
|
|
396
|
+
appName = "Rebase"
|
|
397
|
+
): { subject: string; html: string; text: string } {
|
|
398
|
+
const greeting = getGreeting(user);
|
|
399
|
+
|
|
400
|
+
const subject = `Sign in to ${appName}`;
|
|
401
|
+
|
|
402
|
+
const html = `
|
|
403
|
+
<!DOCTYPE html>
|
|
404
|
+
<html>
|
|
405
|
+
<head>
|
|
406
|
+
<meta charset="utf-8">
|
|
407
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
408
|
+
<title>${subject}</title>
|
|
409
|
+
</head>
|
|
410
|
+
<body style="margin: 0; padding: 0; background-color: #f8fafc;">
|
|
411
|
+
<div style="${styles.container}">
|
|
412
|
+
<div style="${styles.card}">
|
|
413
|
+
<h1 style="${styles.heading}">Sign In to ${appName}</h1>
|
|
414
|
+
|
|
415
|
+
<p style="${styles.paragraph}">
|
|
416
|
+
Hi ${greeting},
|
|
417
|
+
</p>
|
|
418
|
+
|
|
419
|
+
<p style="${styles.paragraph}">
|
|
420
|
+
We received a request to sign in to your ${appName} account.
|
|
421
|
+
Click the button below to log in:
|
|
422
|
+
</p>
|
|
423
|
+
|
|
424
|
+
<div style="text-align: center;">
|
|
425
|
+
<a href="${magicLinkUrl}" style="${styles.button}">Sign In</a>
|
|
426
|
+
</div>
|
|
427
|
+
|
|
428
|
+
<p style="${styles.paragraph}">
|
|
429
|
+
Or copy and paste this link into your browser:
|
|
430
|
+
</p>
|
|
431
|
+
<p style="color: #3b82f6; word-break: break-all; font-size: 14px;">
|
|
432
|
+
${magicLinkUrl}
|
|
433
|
+
</p>
|
|
434
|
+
|
|
435
|
+
<div style="${styles.warning}">
|
|
436
|
+
⏰ This link will expire in 15 minutes for security reasons and can only be used once.
|
|
437
|
+
</div>
|
|
438
|
+
|
|
439
|
+
<div style="${styles.footer}">
|
|
440
|
+
<p style="margin: 0;">
|
|
441
|
+
If you didn't request this sign-in link, you can safely ignore this email.
|
|
442
|
+
No action is needed.
|
|
443
|
+
</p>
|
|
444
|
+
</div>
|
|
445
|
+
</div>
|
|
446
|
+
</div>
|
|
447
|
+
</body>
|
|
448
|
+
</html>
|
|
449
|
+
`.trim();
|
|
450
|
+
|
|
451
|
+
const text = `
|
|
452
|
+
Sign In to ${appName}
|
|
453
|
+
|
|
454
|
+
Hi ${greeting},
|
|
455
|
+
|
|
456
|
+
We received a request to sign in to your ${appName} account.
|
|
457
|
+
|
|
458
|
+
Click this link to log in:
|
|
459
|
+
${magicLinkUrl}
|
|
460
|
+
|
|
461
|
+
This link will expire in 15 minutes for security reasons and can only be used once.
|
|
462
|
+
|
|
463
|
+
If you didn't request this sign-in link, you can safely ignore this email.
|
|
464
|
+
No action is needed.
|
|
465
|
+
`.trim();
|
|
466
|
+
|
|
467
|
+
return { subject,
|
|
468
|
+
html,
|
|
469
|
+
text };
|
|
470
|
+
}
|