@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/auth-hooks.ts
CHANGED
|
@@ -45,6 +45,7 @@ import {
|
|
|
45
45
|
import type { PasswordValidationResult } from "./password";
|
|
46
46
|
import type { AuthRepository, UserData, CreateUserData } from "./interfaces";
|
|
47
47
|
import type { EmailService, EmailConfig } from "../email";
|
|
48
|
+
import type { AuthResponsePayload, TransformAuthResponseContext } from "@rebasepro/types";
|
|
48
49
|
|
|
49
50
|
/**
|
|
50
51
|
* Authentication method identifier for lifecycle hooks.
|
|
@@ -181,6 +182,26 @@ export interface AuthHooks {
|
|
|
181
182
|
*/
|
|
182
183
|
customizeAccessToken?(claims: Record<string, unknown>, user: UserData): Promise<Record<string, unknown>>;
|
|
183
184
|
|
|
185
|
+
/**
|
|
186
|
+
* Transform the auth response before sending it to the client.
|
|
187
|
+
*
|
|
188
|
+
* Called after successful login, register, refresh, OAuth, anonymous,
|
|
189
|
+
* magic-link, and MFA flows. The hook receives the fully-formed
|
|
190
|
+
* response and returns a (potentially enriched) response.
|
|
191
|
+
*
|
|
192
|
+
* Use cases:
|
|
193
|
+
* - Inject tokens from external auth systems (custom provider tokens, etc.)
|
|
194
|
+
* - Add project-specific metadata to the response
|
|
195
|
+
* - Enrich the user object with data from external sources
|
|
196
|
+
*
|
|
197
|
+
* The hook runs in the request path — keep it fast.
|
|
198
|
+
* Heavy work should be offloaded to `onAuthenticated` (fire-and-forget).
|
|
199
|
+
*/
|
|
200
|
+
transformAuthResponse?(
|
|
201
|
+
response: AuthResponsePayload,
|
|
202
|
+
context: TransformAuthResponseContext
|
|
203
|
+
): Promise<AuthResponsePayload>;
|
|
204
|
+
|
|
184
205
|
/**
|
|
185
206
|
* Called after a successful password reset.
|
|
186
207
|
*
|
|
@@ -72,7 +72,8 @@ export function createBitbucketProvider(config: { clientId: string; clientSecret
|
|
|
72
72
|
providerId: p.uuid,
|
|
73
73
|
email,
|
|
74
74
|
displayName: p.display_name || p.username || null,
|
|
75
|
-
photoUrl: p.links?.avatar?.href || null
|
|
75
|
+
photoUrl: p.links?.avatar?.href || null,
|
|
76
|
+
emailVerified: true
|
|
76
77
|
};
|
|
77
78
|
} catch (error) {
|
|
78
79
|
logger.error("Bitbucket OAuth error", { error: error });
|
|
@@ -22,11 +22,13 @@ import type {
|
|
|
22
22
|
BootstrappedAuth
|
|
23
23
|
} from "@rebasepro/types";
|
|
24
24
|
|
|
25
|
-
import
|
|
25
|
+
import { Hono } from "hono";
|
|
26
26
|
import { verifyAccessToken } from "./jwt";
|
|
27
27
|
import type { AccessTokenPayload } from "./jwt";
|
|
28
28
|
import { createAuthRoutes } from "./routes";
|
|
29
29
|
import { createResetPasswordRoute } from "./reset-password-admin";
|
|
30
|
+
import { createAdminRolesRoute } from "./admin-roles-route";
|
|
31
|
+
import { createAdminUsersRoute } from "./admin-users-route";
|
|
30
32
|
import { prepareAdminUserValues, finalizeAdminUserCreation } from "./admin-user-ops";
|
|
31
33
|
import type { AuthRepository, OAuthProvider } from "./interfaces";
|
|
32
34
|
import type { AuthHooks, ResolvedAuthHooks } from "./auth-hooks";
|
|
@@ -54,14 +56,15 @@ export interface BuiltinAuthAdapterConfig {
|
|
|
54
56
|
/** Default role to assign to new users. */
|
|
55
57
|
defaultRole?: string;
|
|
56
58
|
/** OAuth providers to register. */
|
|
57
|
-
|
|
58
|
-
oauthProviders?: OAuthProvider<any>[];
|
|
59
|
+
oauthProviders?: OAuthProvider<unknown>[];
|
|
59
60
|
/** Static service key for server-to-server auth. */
|
|
60
61
|
serviceKey?: string;
|
|
61
62
|
/** Auth hooks for customizing password, credentials, lifecycle, etc. */
|
|
62
63
|
authHooks?: AuthHooks;
|
|
63
64
|
/** The parsed auth config from the collection (if `auth` is an object, not just `true`). */
|
|
64
65
|
collectionAuthConfig?: import("@rebasepro/types").AuthCollectionConfig;
|
|
66
|
+
/** Enable magic link (passwordless email) login. Requires email service. */
|
|
67
|
+
enableMagicLink?: boolean;
|
|
65
68
|
}
|
|
66
69
|
|
|
67
70
|
/**
|
|
@@ -81,7 +84,8 @@ export function createBuiltinAuthAdapter(config: BuiltinAuthAdapterConfig): Auth
|
|
|
81
84
|
oauthProviders = [],
|
|
82
85
|
serviceKey,
|
|
83
86
|
authHooks,
|
|
84
|
-
collectionAuthConfig
|
|
87
|
+
collectionAuthConfig,
|
|
88
|
+
enableMagicLink = false
|
|
85
89
|
} = config;
|
|
86
90
|
|
|
87
91
|
const resolvedOps = resolveAuthHooks(authHooks);
|
|
@@ -188,12 +192,26 @@ export function createBuiltinAuthAdapter(config: BuiltinAuthAdapterConfig): Auth
|
|
|
188
192
|
allowRegistration,
|
|
189
193
|
defaultRole,
|
|
190
194
|
oauthProviders,
|
|
191
|
-
authHooks
|
|
195
|
+
authHooks,
|
|
196
|
+
enableMagicLink
|
|
192
197
|
});
|
|
193
198
|
},
|
|
194
199
|
|
|
195
200
|
createAdminRoutes(): Hono<HonoEnv> | undefined {
|
|
196
|
-
|
|
201
|
+
const router = new Hono<HonoEnv>();
|
|
202
|
+
const resetPasswordRoute = createResetPasswordRoute({
|
|
203
|
+
authRepo: authRepository,
|
|
204
|
+
emailService,
|
|
205
|
+
emailConfig,
|
|
206
|
+
serviceKey,
|
|
207
|
+
authHooks,
|
|
208
|
+
collectionAuthConfig
|
|
209
|
+
});
|
|
210
|
+
const rolesRoute = createAdminRolesRoute({
|
|
211
|
+
authRepo: authRepository,
|
|
212
|
+
serviceKey
|
|
213
|
+
});
|
|
214
|
+
const adminUsersRoute = createAdminUsersRoute({
|
|
197
215
|
authRepo: authRepository,
|
|
198
216
|
emailService,
|
|
199
217
|
emailConfig,
|
|
@@ -201,6 +219,10 @@ export function createBuiltinAuthAdapter(config: BuiltinAuthAdapterConfig): Auth
|
|
|
201
219
|
authHooks,
|
|
202
220
|
collectionAuthConfig
|
|
203
221
|
});
|
|
222
|
+
router.route("/", resetPasswordRoute);
|
|
223
|
+
router.route("/", rolesRoute);
|
|
224
|
+
router.route("/", adminUsersRoute);
|
|
225
|
+
return router;
|
|
204
226
|
},
|
|
205
227
|
|
|
206
228
|
async prepareUserCreation(values, collectionAuth) {
|
|
@@ -245,6 +267,7 @@ export function createBuiltinAuthAdapter(config: BuiltinAuthAdapterConfig): Auth
|
|
|
245
267
|
sessionManagement: true,
|
|
246
268
|
profileUpdate: true,
|
|
247
269
|
emailVerification: !!emailService?.isConfigured(),
|
|
270
|
+
magicLink: enableMagicLink && !!emailService?.isConfigured(),
|
|
248
271
|
enabledProviders,
|
|
249
272
|
needsSetup
|
|
250
273
|
};
|
|
@@ -51,6 +51,7 @@ export function createCustomAuthAdapter(options: CustomAuthAdapterOptions): Auth
|
|
|
51
51
|
sessionManagement: false,
|
|
52
52
|
profileUpdate: false,
|
|
53
53
|
emailVerification: false,
|
|
54
|
+
magicLink: false,
|
|
54
55
|
enabledProviders: [],
|
|
55
56
|
...options.capabilities
|
|
56
57
|
};
|
|
@@ -76,6 +77,7 @@ export function createCustomAuthAdapter(options: CustomAuthAdapterOptions): Auth
|
|
|
76
77
|
|
|
77
78
|
userManagement: options.userManagement,
|
|
78
79
|
|
|
80
|
+
transformAuthResponse: options.transformAuthResponse,
|
|
79
81
|
|
|
80
82
|
getCapabilities() {
|
|
81
83
|
return defaultCapabilities;
|
|
@@ -73,7 +73,8 @@ export function createDiscordProvider(config: { clientId: string; clientSecret:
|
|
|
73
73
|
providerId: profileData.id,
|
|
74
74
|
email: profileData.email,
|
|
75
75
|
displayName: profileData.global_name || profileData.username || null,
|
|
76
|
-
photoUrl
|
|
76
|
+
photoUrl,
|
|
77
|
+
emailVerified: profileData.verified === true
|
|
77
78
|
};
|
|
78
79
|
} catch (error) {
|
|
79
80
|
logger.error("Discord OAuth error", { error: error });
|
|
@@ -62,7 +62,8 @@ export function createFacebookProvider(config: { clientId: string; clientSecret:
|
|
|
62
62
|
providerId: profileData.id,
|
|
63
63
|
email: profileData.email,
|
|
64
64
|
displayName: profileData.name || null,
|
|
65
|
-
photoUrl: profileData.picture?.data?.url || null
|
|
65
|
+
photoUrl: profileData.picture?.data?.url || null,
|
|
66
|
+
emailVerified: true
|
|
66
67
|
};
|
|
67
68
|
} catch (error) {
|
|
68
69
|
logger.error("Facebook OAuth error", { error: error });
|
package/src/auth/github-oauth.ts
CHANGED
|
@@ -100,7 +100,8 @@ export function createGitHubProvider(config: { clientId: string; clientSecret: s
|
|
|
100
100
|
providerId: String(profileData.id),
|
|
101
101
|
email,
|
|
102
102
|
displayName: profileData.name || profileData.login || null,
|
|
103
|
-
photoUrl: profileData.avatar_url || null
|
|
103
|
+
photoUrl: profileData.avatar_url || null,
|
|
104
|
+
emailVerified: true
|
|
104
105
|
};
|
|
105
106
|
} catch (error) {
|
|
106
107
|
logger.error("GitHub OAuth error", { error: error });
|
package/src/auth/gitlab-oauth.ts
CHANGED
|
@@ -60,7 +60,8 @@ export function createGitLabProvider(config: {
|
|
|
60
60
|
providerId: String(p.id),
|
|
61
61
|
email: p.email,
|
|
62
62
|
displayName: p.name || p.username || null,
|
|
63
|
-
photoUrl: p.avatar_url || null
|
|
63
|
+
photoUrl: p.avatar_url || null,
|
|
64
|
+
emailVerified: true
|
|
64
65
|
};
|
|
65
66
|
} catch (error) {
|
|
66
67
|
logger.error("GitLab OAuth error", { error: error });
|
package/src/auth/google-oauth.ts
CHANGED
|
@@ -89,7 +89,8 @@ export function createGoogleProvider(config: GoogleProviderConfig | string): OAu
|
|
|
89
89
|
providerId: content.sub,
|
|
90
90
|
email: content.email || "",
|
|
91
91
|
displayName: content.name || null,
|
|
92
|
-
photoUrl: content.picture || null
|
|
92
|
+
photoUrl: content.picture || null,
|
|
93
|
+
emailVerified: true
|
|
93
94
|
};
|
|
94
95
|
}
|
|
95
96
|
|
|
@@ -115,7 +116,8 @@ export function createGoogleProvider(config: GoogleProviderConfig | string): OAu
|
|
|
115
116
|
providerId: info.sub,
|
|
116
117
|
email: info.email,
|
|
117
118
|
displayName: info.name || null,
|
|
118
|
-
photoUrl: info.picture || null
|
|
119
|
+
photoUrl: info.picture || null,
|
|
120
|
+
emailVerified: true
|
|
119
121
|
};
|
|
120
122
|
}
|
|
121
123
|
|
|
@@ -176,7 +178,8 @@ export function createGoogleProvider(config: GoogleProviderConfig | string): OAu
|
|
|
176
178
|
providerId: content.sub,
|
|
177
179
|
email: content.email || "",
|
|
178
180
|
displayName: content.name || null,
|
|
179
|
-
photoUrl: content.picture || null
|
|
181
|
+
photoUrl: content.picture || null,
|
|
182
|
+
emailVerified: true
|
|
180
183
|
};
|
|
181
184
|
}
|
|
182
185
|
|
|
@@ -202,7 +205,8 @@ export function createGoogleProvider(config: GoogleProviderConfig | string): OAu
|
|
|
202
205
|
providerId: info.sub,
|
|
203
206
|
email: info.email,
|
|
204
207
|
displayName: info.name || null,
|
|
205
|
-
photoUrl: info.picture || null
|
|
208
|
+
photoUrl: info.picture || null,
|
|
209
|
+
emailVerified: true
|
|
206
210
|
};
|
|
207
211
|
}
|
|
208
212
|
|
package/src/auth/index.ts
CHANGED
|
@@ -35,6 +35,8 @@ export type { AuthMiddlewareOptions, AuthResult } from "./middleware";
|
|
|
35
35
|
export { createAuthRoutes } from "./routes";
|
|
36
36
|
export type { AuthModuleConfig } from "./routes";
|
|
37
37
|
|
|
38
|
+
export { mountMagicLinkRoutes } from "./magic-link-routes";
|
|
39
|
+
|
|
38
40
|
export { createResetPasswordRoute } from "./reset-password-admin";
|
|
39
41
|
export type { ResetPasswordRouteConfig } from "./reset-password-admin";
|
|
40
42
|
|
package/src/auth/interfaces.ts
CHANGED
|
@@ -60,6 +60,8 @@ export interface OAuthProviderProfile {
|
|
|
60
60
|
email: string;
|
|
61
61
|
displayName?: string | null;
|
|
62
62
|
photoUrl?: string | null;
|
|
63
|
+
/** Whether the OAuth provider has verified the user's email address */
|
|
64
|
+
emailVerified?: boolean;
|
|
63
65
|
}
|
|
64
66
|
|
|
65
67
|
/**
|
|
@@ -72,8 +74,17 @@ export interface OAuthProvider<T = unknown> {
|
|
|
72
74
|
/** Zod schema validating the expected request payload (e.g. { code: string }) */
|
|
73
75
|
schema: z.ZodSchema<T>;
|
|
74
76
|
|
|
75
|
-
/**
|
|
76
|
-
|
|
77
|
+
/**
|
|
78
|
+
* Verify external tokens/codes and return a standardized user profile.
|
|
79
|
+
*
|
|
80
|
+
* NOTE: Declared as method syntax (not arrow property) intentionally.
|
|
81
|
+
* This makes `OAuthProvider` bivariant in `T`, which is correct because
|
|
82
|
+
* each provider is self-contained — `schema` validates the request body
|
|
83
|
+
* and `verify` consumes the same `T`. Bivariance lets heterogeneous
|
|
84
|
+
* providers (`OAuthProvider<SpecificPayload>`) coexist in a single
|
|
85
|
+
* `OAuthProvider<unknown>[]` array without resorting to `any`.
|
|
86
|
+
*/
|
|
87
|
+
verify(payload: T): Promise<OAuthProviderProfile | null>;
|
|
77
88
|
}
|
|
78
89
|
|
|
79
90
|
/**
|
|
@@ -129,6 +140,14 @@ export interface PasswordResetTokenInfo {
|
|
|
129
140
|
expiresAt: Date;
|
|
130
141
|
}
|
|
131
142
|
|
|
143
|
+
/**
|
|
144
|
+
* Magic link token info
|
|
145
|
+
*/
|
|
146
|
+
export interface MagicLinkTokenInfo {
|
|
147
|
+
userId: string;
|
|
148
|
+
expiresAt: Date;
|
|
149
|
+
}
|
|
150
|
+
|
|
132
151
|
// =============================================================================
|
|
133
152
|
// AUTH REPOSITORY INTERFACES
|
|
134
153
|
// =============================================================================
|
|
@@ -357,6 +376,23 @@ export interface TokenRepository {
|
|
|
357
376
|
* Clean up expired tokens
|
|
358
377
|
*/
|
|
359
378
|
deleteExpiredTokens(): Promise<void>;
|
|
379
|
+
|
|
380
|
+
// Magic link tokens
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Create a magic link token
|
|
384
|
+
*/
|
|
385
|
+
createMagicLinkToken(userId: string, tokenHash: string, expiresAt: Date): Promise<void>;
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Find a valid (not expired, not used) magic link token by hash
|
|
389
|
+
*/
|
|
390
|
+
findValidMagicLinkToken(tokenHash: string): Promise<MagicLinkTokenInfo | null>;
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Mark a magic link token as used
|
|
394
|
+
*/
|
|
395
|
+
markMagicLinkTokenUsed(tokenHash: string): Promise<void>;
|
|
360
396
|
}
|
|
361
397
|
|
|
362
398
|
// =============================================================================
|
|
@@ -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
|
+
}
|