@rebasepro/server-core 0.0.1-canary.09e5ec5
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/LICENSE +6 -0
- package/README.md +40 -0
- package/build-errors.txt +52 -0
- package/coverage/clover.xml +3739 -0
- package/coverage/coverage-final.json +31 -0
- package/coverage/lcov-report/base.css +224 -0
- package/coverage/lcov-report/block-navigation.js +87 -0
- package/coverage/lcov-report/favicon.png +0 -0
- package/coverage/lcov-report/index.html +266 -0
- package/coverage/lcov-report/prettify.css +1 -0
- package/coverage/lcov-report/prettify.js +2 -0
- package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
- package/coverage/lcov-report/sorter.js +210 -0
- package/coverage/lcov-report/src/api/ast-schema-editor.ts.html +952 -0
- package/coverage/lcov-report/src/api/errors.ts.html +472 -0
- package/coverage/lcov-report/src/api/graphql/graphql-schema-generator.ts.html +1069 -0
- package/coverage/lcov-report/src/api/graphql/index.html +116 -0
- package/coverage/lcov-report/src/api/index.html +176 -0
- package/coverage/lcov-report/src/api/openapi-generator.ts.html +565 -0
- package/coverage/lcov-report/src/api/rest/api-generator.ts.html +994 -0
- package/coverage/lcov-report/src/api/rest/index.html +131 -0
- package/coverage/lcov-report/src/api/rest/query-parser.ts.html +550 -0
- package/coverage/lcov-report/src/api/schema-editor-routes.ts.html +202 -0
- package/coverage/lcov-report/src/api/server.ts.html +823 -0
- package/coverage/lcov-report/src/auth/admin-routes.ts.html +973 -0
- package/coverage/lcov-report/src/auth/index.html +176 -0
- package/coverage/lcov-report/src/auth/jwt.ts.html +574 -0
- package/coverage/lcov-report/src/auth/middleware.ts.html +745 -0
- package/coverage/lcov-report/src/auth/password.ts.html +310 -0
- package/coverage/lcov-report/src/auth/services.ts.html +2074 -0
- package/coverage/lcov-report/src/collections/index.html +116 -0
- package/coverage/lcov-report/src/collections/loader.ts.html +232 -0
- package/coverage/lcov-report/src/db/auth-schema.ts.html +523 -0
- package/coverage/lcov-report/src/db/data-transformer.ts.html +1753 -0
- package/coverage/lcov-report/src/db/entityService.ts.html +700 -0
- package/coverage/lcov-report/src/db/index.html +146 -0
- package/coverage/lcov-report/src/db/services/EntityFetchService.ts.html +4048 -0
- package/coverage/lcov-report/src/db/services/EntityPersistService.ts.html +883 -0
- package/coverage/lcov-report/src/db/services/RelationService.ts.html +3121 -0
- package/coverage/lcov-report/src/db/services/entity-helpers.ts.html +442 -0
- package/coverage/lcov-report/src/db/services/index.html +176 -0
- package/coverage/lcov-report/src/db/services/index.ts.html +124 -0
- package/coverage/lcov-report/src/generate-drizzle-schema-logic.ts.html +1960 -0
- package/coverage/lcov-report/src/index.html +116 -0
- package/coverage/lcov-report/src/services/driver-registry.ts.html +631 -0
- package/coverage/lcov-report/src/services/index.html +131 -0
- package/coverage/lcov-report/src/services/postgresDataDriver.ts.html +3025 -0
- package/coverage/lcov-report/src/storage/LocalStorageController.ts.html +1189 -0
- package/coverage/lcov-report/src/storage/S3StorageController.ts.html +970 -0
- package/coverage/lcov-report/src/storage/index.html +161 -0
- package/coverage/lcov-report/src/storage/storage-registry.ts.html +646 -0
- package/coverage/lcov-report/src/storage/types.ts.html +451 -0
- package/coverage/lcov-report/src/utils/drizzle-conditions.ts.html +3082 -0
- package/coverage/lcov-report/src/utils/index.html +116 -0
- package/coverage/lcov.info +7179 -0
- package/dist/common/src/collections/CollectionRegistry.d.ts +56 -0
- package/dist/common/src/collections/index.d.ts +1 -0
- package/dist/common/src/data/buildRebaseData.d.ts +14 -0
- package/dist/common/src/index.d.ts +3 -0
- package/dist/common/src/util/builders.d.ts +57 -0
- package/dist/common/src/util/callbacks.d.ts +6 -0
- package/dist/common/src/util/collections.d.ts +11 -0
- package/dist/common/src/util/common.d.ts +2 -0
- package/dist/common/src/util/conditions.d.ts +26 -0
- package/dist/common/src/util/entities.d.ts +58 -0
- package/dist/common/src/util/enums.d.ts +3 -0
- package/dist/common/src/util/index.d.ts +16 -0
- package/dist/common/src/util/navigation_from_path.d.ts +34 -0
- package/dist/common/src/util/navigation_utils.d.ts +20 -0
- package/dist/common/src/util/parent_references_from_path.d.ts +6 -0
- package/dist/common/src/util/paths.d.ts +14 -0
- package/dist/common/src/util/permissions.d.ts +5 -0
- package/dist/common/src/util/references.d.ts +2 -0
- package/dist/common/src/util/relations.d.ts +22 -0
- package/dist/common/src/util/resolutions.d.ts +72 -0
- package/dist/common/src/util/storage.d.ts +24 -0
- package/dist/index-DXVBFp5V.js +37 -0
- package/dist/index-DXVBFp5V.js.map +1 -0
- package/dist/index.es.js +49934 -0
- package/dist/index.es.js.map +1 -0
- package/dist/index.umd.js +49968 -0
- package/dist/index.umd.js.map +1 -0
- package/dist/server-core/src/api/ast-schema-editor.d.ts +21 -0
- package/dist/server-core/src/api/collections_for_test/callbacks_test_collection.d.ts +2 -0
- package/dist/server-core/src/api/errors.d.ts +35 -0
- package/dist/server-core/src/api/graphql/graphql-schema-generator.d.ts +35 -0
- package/dist/server-core/src/api/graphql/index.d.ts +1 -0
- package/dist/server-core/src/api/index.d.ts +9 -0
- package/dist/server-core/src/api/openapi-generator.d.ts +16 -0
- package/dist/server-core/src/api/rest/api-generator.d.ts +64 -0
- package/dist/server-core/src/api/rest/index.d.ts +1 -0
- package/dist/server-core/src/api/rest/query-parser.d.ts +9 -0
- package/dist/server-core/src/api/schema-editor-routes.d.ts +3 -0
- package/dist/server-core/src/api/server.d.ts +40 -0
- package/dist/server-core/src/api/types.d.ts +90 -0
- package/dist/server-core/src/auth/admin-routes.d.ts +16 -0
- package/dist/server-core/src/auth/apple-oauth.d.ts +30 -0
- package/dist/server-core/src/auth/bitbucket-oauth.d.ts +11 -0
- package/dist/server-core/src/auth/discord-oauth.d.ts +14 -0
- package/dist/server-core/src/auth/facebook-oauth.d.ts +14 -0
- package/dist/server-core/src/auth/github-oauth.d.ts +15 -0
- package/dist/server-core/src/auth/gitlab-oauth.d.ts +13 -0
- package/dist/server-core/src/auth/google-oauth.d.ts +14 -0
- package/dist/server-core/src/auth/index.d.ts +23 -0
- package/dist/server-core/src/auth/interfaces.d.ts +309 -0
- package/dist/server-core/src/auth/jwt.d.ts +43 -0
- package/dist/server-core/src/auth/linkedin-oauth.d.ts +18 -0
- package/dist/server-core/src/auth/microsoft-oauth.d.ts +16 -0
- package/dist/server-core/src/auth/middleware.d.ts +81 -0
- package/dist/server-core/src/auth/password.d.ts +22 -0
- package/dist/server-core/src/auth/rate-limiter.d.ts +31 -0
- package/dist/server-core/src/auth/routes.d.ts +27 -0
- package/dist/server-core/src/auth/slack-oauth.d.ts +12 -0
- package/dist/server-core/src/auth/spotify-oauth.d.ts +12 -0
- package/dist/server-core/src/auth/twitter-oauth.d.ts +18 -0
- package/dist/server-core/src/bootstrappers/index.d.ts +0 -0
- package/dist/server-core/src/collections/BackendCollectionRegistry.d.ts +13 -0
- package/dist/server-core/src/collections/loader.d.ts +5 -0
- package/dist/server-core/src/cron/cron-loader.d.ts +17 -0
- package/dist/server-core/src/cron/cron-routes.d.ts +14 -0
- package/dist/server-core/src/cron/cron-scheduler.d.ts +61 -0
- package/dist/server-core/src/cron/cron-store.d.ts +32 -0
- package/dist/server-core/src/cron/index.d.ts +6 -0
- package/dist/server-core/src/db/interfaces.d.ts +18 -0
- package/dist/server-core/src/email/index.d.ts +6 -0
- package/dist/server-core/src/email/smtp-email-service.d.ts +25 -0
- package/dist/server-core/src/email/templates.d.ts +42 -0
- package/dist/server-core/src/email/types.d.ts +107 -0
- package/dist/server-core/src/functions/function-loader.d.ts +17 -0
- package/dist/server-core/src/functions/function-routes.d.ts +10 -0
- package/dist/server-core/src/functions/index.d.ts +3 -0
- package/dist/server-core/src/history/history-routes.d.ts +23 -0
- package/dist/server-core/src/history/index.d.ts +1 -0
- package/dist/server-core/src/index.d.ts +29 -0
- package/dist/server-core/src/init.d.ts +159 -0
- package/dist/server-core/src/serve-spa.d.ts +30 -0
- package/dist/server-core/src/services/driver-registry.d.ts +78 -0
- package/dist/server-core/src/singleton.d.ts +35 -0
- package/dist/server-core/src/storage/LocalStorageController.d.ts +46 -0
- package/dist/server-core/src/storage/S3StorageController.d.ts +36 -0
- package/dist/server-core/src/storage/index.d.ts +25 -0
- package/dist/server-core/src/storage/routes.d.ts +38 -0
- package/dist/server-core/src/storage/storage-registry.d.ts +78 -0
- package/dist/server-core/src/storage/types.d.ts +103 -0
- package/dist/server-core/src/types/index.d.ts +11 -0
- package/dist/server-core/src/utils/dev-port.d.ts +35 -0
- package/dist/server-core/src/utils/logger.d.ts +31 -0
- package/dist/server-core/src/utils/logging.d.ts +9 -0
- package/dist/server-core/src/utils/request-logger.d.ts +19 -0
- package/dist/server-core/src/utils/sql.d.ts +27 -0
- package/dist/types/src/controllers/analytics_controller.d.ts +7 -0
- package/dist/types/src/controllers/auth.d.ts +119 -0
- package/dist/types/src/controllers/client.d.ts +170 -0
- package/dist/types/src/controllers/collection_registry.d.ts +45 -0
- package/dist/types/src/controllers/customization_controller.d.ts +60 -0
- package/dist/types/src/controllers/data.d.ts +168 -0
- package/dist/types/src/controllers/data_driver.d.ts +160 -0
- package/dist/types/src/controllers/database_admin.d.ts +11 -0
- package/dist/types/src/controllers/dialogs_controller.d.ts +36 -0
- package/dist/types/src/controllers/effective_role.d.ts +4 -0
- package/dist/types/src/controllers/email.d.ts +34 -0
- package/dist/types/src/controllers/index.d.ts +18 -0
- package/dist/types/src/controllers/local_config_persistence.d.ts +20 -0
- package/dist/types/src/controllers/navigation.d.ts +213 -0
- package/dist/types/src/controllers/registry.d.ts +54 -0
- package/dist/types/src/controllers/side_dialogs_controller.d.ts +67 -0
- package/dist/types/src/controllers/side_entity_controller.d.ts +90 -0
- package/dist/types/src/controllers/snackbar.d.ts +24 -0
- package/dist/types/src/controllers/storage.d.ts +171 -0
- package/dist/types/src/index.d.ts +4 -0
- package/dist/types/src/rebase_context.d.ts +105 -0
- package/dist/types/src/types/backend.d.ts +536 -0
- package/dist/types/src/types/builders.d.ts +15 -0
- package/dist/types/src/types/chips.d.ts +5 -0
- package/dist/types/src/types/collections.d.ts +856 -0
- package/dist/types/src/types/cron.d.ts +102 -0
- package/dist/types/src/types/data_source.d.ts +64 -0
- package/dist/types/src/types/entities.d.ts +145 -0
- package/dist/types/src/types/entity_actions.d.ts +98 -0
- package/dist/types/src/types/entity_callbacks.d.ts +173 -0
- package/dist/types/src/types/entity_link_builder.d.ts +7 -0
- package/dist/types/src/types/entity_overrides.d.ts +10 -0
- package/dist/types/src/types/entity_views.d.ts +61 -0
- package/dist/types/src/types/export_import.d.ts +21 -0
- package/dist/types/src/types/index.d.ts +23 -0
- package/dist/types/src/types/locales.d.ts +4 -0
- package/dist/types/src/types/modify_collections.d.ts +5 -0
- package/dist/types/src/types/plugins.d.ts +279 -0
- package/dist/types/src/types/properties.d.ts +1176 -0
- package/dist/types/src/types/property_config.d.ts +70 -0
- package/dist/types/src/types/relations.d.ts +336 -0
- package/dist/types/src/types/slots.d.ts +252 -0
- package/dist/types/src/types/translations.d.ts +870 -0
- package/dist/types/src/types/user_management_delegate.d.ts +121 -0
- package/dist/types/src/types/websockets.d.ts +78 -0
- package/dist/types/src/users/index.d.ts +2 -0
- package/dist/types/src/users/roles.d.ts +22 -0
- package/dist/types/src/users/user.d.ts +46 -0
- package/history_diff.log +385 -0
- package/jest.config.cjs +16 -0
- package/package.json +86 -0
- package/scratch.ts +9 -0
- package/src/api/ast-schema-editor.ts +289 -0
- package/src/api/collections_for_test/callbacks_test_collection.ts +60 -0
- package/src/api/errors.ts +179 -0
- package/src/api/graphql/graphql-schema-generator.ts +336 -0
- package/src/api/graphql/index.ts +2 -0
- package/src/api/index.ts +11 -0
- package/src/api/openapi-generator.ts +715 -0
- package/src/api/rest/api-generator.ts +472 -0
- package/src/api/rest/index.ts +2 -0
- package/src/api/rest/query-parser.ts +155 -0
- package/src/api/schema-editor-routes.ts +41 -0
- package/src/api/server.ts +248 -0
- package/src/api/types.ts +90 -0
- package/src/auth/admin-routes.ts +529 -0
- package/src/auth/apple-oauth.ts +130 -0
- package/src/auth/bitbucket-oauth.ts +82 -0
- package/src/auth/discord-oauth.ts +83 -0
- package/src/auth/facebook-oauth.ts +72 -0
- package/src/auth/github-oauth.ts +110 -0
- package/src/auth/gitlab-oauth.ts +70 -0
- package/src/auth/google-oauth.ts +48 -0
- package/src/auth/index.ts +34 -0
- package/src/auth/interfaces.ts +363 -0
- package/src/auth/jwt.ts +181 -0
- package/src/auth/linkedin-oauth.ts +81 -0
- package/src/auth/microsoft-oauth.ts +88 -0
- package/src/auth/middleware.ts +384 -0
- package/src/auth/password.ts +77 -0
- package/src/auth/rate-limiter.ts +129 -0
- package/src/auth/routes.ts +788 -0
- package/src/auth/slack-oauth.ts +71 -0
- package/src/auth/spotify-oauth.ts +67 -0
- package/src/auth/twitter-oauth.ts +120 -0
- package/src/bootstrappers/index.ts +1 -0
- package/src/collections/BackendCollectionRegistry.ts +20 -0
- package/src/collections/loader.ts +49 -0
- package/src/cron/cron-loader.ts +89 -0
- package/src/cron/cron-routes.test.ts +265 -0
- package/src/cron/cron-routes.ts +85 -0
- package/src/cron/cron-scheduler.test.ts +421 -0
- package/src/cron/cron-scheduler.ts +413 -0
- package/src/cron/cron-store.ts +163 -0
- package/src/cron/index.ts +6 -0
- package/src/db/interfaces.ts +60 -0
- package/src/email/index.ts +18 -0
- package/src/email/smtp-email-service.ts +91 -0
- package/src/email/templates.ts +388 -0
- package/src/email/types.ts +105 -0
- package/src/functions/function-loader.ts +119 -0
- package/src/functions/function-routes.ts +31 -0
- package/src/functions/index.ts +3 -0
- package/src/history/history-routes.ts +129 -0
- package/src/history/index.ts +2 -0
- package/src/index.ts +66 -0
- package/src/init.ts +727 -0
- package/src/serve-spa.ts +81 -0
- package/src/services/driver-registry.ts +182 -0
- package/src/singleton.test.ts +28 -0
- package/src/singleton.ts +70 -0
- package/src/storage/LocalStorageController.ts +365 -0
- package/src/storage/S3StorageController.ts +298 -0
- package/src/storage/index.ts +43 -0
- package/src/storage/routes.ts +264 -0
- package/src/storage/storage-registry.ts +187 -0
- package/src/storage/types.ts +134 -0
- package/src/types/index.ts +27 -0
- package/src/utils/dev-port.ts +176 -0
- package/src/utils/logger.ts +143 -0
- package/src/utils/logging.ts +38 -0
- package/src/utils/request-logger.ts +66 -0
- package/src/utils/sql.ts +38 -0
- package/test/admin-routes.test.ts +640 -0
- package/test/api-generator.test.ts +501 -0
- package/test/ast-schema-editor.test.ts +63 -0
- package/test/auth-middleware-hono.test.ts +556 -0
- package/test/auth-routes.test.ts +1047 -0
- package/test/driver-registry.test.ts +282 -0
- package/test/error-propagation.test.ts +226 -0
- package/test/errors-hono.test.ts +133 -0
- package/test/errors.test.ts +155 -0
- package/test/jwt-security.test.ts +182 -0
- package/test/jwt.test.ts +324 -0
- package/test/middleware.test.ts +300 -0
- package/test/password.test.ts +165 -0
- package/test/query-parser.test.ts +263 -0
- package/test/rate-limiter.test.ts +102 -0
- package/test/safe-compare.test.ts +66 -0
- package/test/singleton.test.ts +59 -0
- package/test/storage-local.test.ts +271 -0
- package/test/storage-registry.test.ts +282 -0
- package/test/storage-routes.test.ts +222 -0
- package/test/storage-s3.test.ts +304 -0
- package/test-ast.ts +28 -0
- package/test.ts +6 -0
- package/test_output.txt +1133 -0
- package/tsconfig.json +49 -0
- package/tsconfig.prod.json +20 -0
- package/vite.config.ts +80 -0
|
@@ -0,0 +1,788 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { ApiError, errorHandler } from "../api/errors";
|
|
3
|
+
import { randomBytes, createHash } from "crypto";
|
|
4
|
+
import type { AuthRepository, OAuthProvider } from "./interfaces";
|
|
5
|
+
import { generateAccessToken, generateRefreshToken, hashRefreshToken, getRefreshTokenExpiry, getAccessTokenExpiry } from "./jwt";
|
|
6
|
+
import { hashPassword, verifyPassword, validatePasswordStrength } from "./password";
|
|
7
|
+
import { requireAuth } from "./middleware";
|
|
8
|
+
import { EmailService, EmailConfig } from "../email";
|
|
9
|
+
import { getPasswordResetTemplate, getEmailVerificationTemplate, getWelcomeEmailTemplate } from "../email/templates";
|
|
10
|
+
import { HonoEnv } from "../api/types";
|
|
11
|
+
import { defaultAuthLimiter, strictAuthLimiter } from "./rate-limiter";
|
|
12
|
+
import { z } from "zod";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Shared configuration for auth and admin route factories.
|
|
16
|
+
*/
|
|
17
|
+
export interface AuthModuleConfig {
|
|
18
|
+
authRepo: AuthRepository;
|
|
19
|
+
emailService?: EmailService;
|
|
20
|
+
emailConfig?: EmailConfig;
|
|
21
|
+
/** Allow new user registration (default: false). */
|
|
22
|
+
allowRegistration?: boolean;
|
|
23
|
+
/** Default role ID to assign to new users (default: none). Must NOT be "admin". */
|
|
24
|
+
defaultRole?: string;
|
|
25
|
+
/** Optional array of OAuth providers */
|
|
26
|
+
oauthProviders?: OAuthProvider[];
|
|
27
|
+
/** When true, blocks all self-registration regardless of `allowRegistration`. */
|
|
28
|
+
disableSelfRegistration?: boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Callback that checks if bootstrap has already been completed.
|
|
31
|
+
* Used by GET /auth/config to report `needsSetup` status.
|
|
32
|
+
* When not provided, falls back to checking if any users exist.
|
|
33
|
+
*/
|
|
34
|
+
isBootstrapCompleted?: () => Promise<boolean>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Helper to build standard auth response output
|
|
39
|
+
*/
|
|
40
|
+
function buildAuthResponse(
|
|
41
|
+
user: { id: string; email: string; displayName?: string | null; photoUrl?: string | null },
|
|
42
|
+
roleIds: string[],
|
|
43
|
+
accessToken: string,
|
|
44
|
+
refreshToken: string
|
|
45
|
+
) {
|
|
46
|
+
return {
|
|
47
|
+
user: {
|
|
48
|
+
uid: user.id,
|
|
49
|
+
email: user.email,
|
|
50
|
+
displayName: user.displayName ?? null,
|
|
51
|
+
photoURL: user.photoUrl ?? null,
|
|
52
|
+
roles: roleIds
|
|
53
|
+
},
|
|
54
|
+
tokens: {
|
|
55
|
+
accessToken,
|
|
56
|
+
refreshToken,
|
|
57
|
+
accessTokenExpiresAt: getAccessTokenExpiry()
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Generate a secure random token
|
|
64
|
+
*/
|
|
65
|
+
function generateSecureToken(): string {
|
|
66
|
+
return randomBytes(40).toString("hex");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Hash a token for database storage
|
|
71
|
+
*/
|
|
72
|
+
function hashToken(token: string): string {
|
|
73
|
+
return createHash("sha256").update(token).digest("hex");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Get password reset token expiry (1 hour from now)
|
|
78
|
+
*/
|
|
79
|
+
function getPasswordResetExpiry(): Date {
|
|
80
|
+
return new Date(Date.now() + 60 * 60 * 1000); // 1 hour
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function createAuthRoutes(config: AuthModuleConfig): Hono<HonoEnv> {
|
|
84
|
+
if (config.defaultRole === "admin") {
|
|
85
|
+
throw new Error("CRITICAL SECURITY ERROR: defaultRole cannot be 'admin'. Administrative privilege escalation via registration is strictly forbidden. Use the POST /admin/bootstrap endpoint to promote the initial administrator.");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const router = new Hono<HonoEnv>();
|
|
89
|
+
|
|
90
|
+
// Attach Rebase error handler to ensure ApiError exceptions are correctly
|
|
91
|
+
// formatted instead of caught by Hono's default error handler.
|
|
92
|
+
// Hono's onError does NOT propagate from parent to child routers.
|
|
93
|
+
router.onError(errorHandler);
|
|
94
|
+
|
|
95
|
+
const authRepo = config.authRepo;
|
|
96
|
+
const { emailService, emailConfig, allowRegistration = false } = config;
|
|
97
|
+
|
|
98
|
+
// ── Zod input schemas ──────────────────────────────────────────────
|
|
99
|
+
const registerSchema = z.object({
|
|
100
|
+
email: z.string().email("Invalid email address").max(255),
|
|
101
|
+
password: z.string().min(1, "Password is required").max(128),
|
|
102
|
+
displayName: z.string().max(255).optional()
|
|
103
|
+
});
|
|
104
|
+
const loginSchema = z.object({
|
|
105
|
+
email: z.string().email("Invalid email address").max(255),
|
|
106
|
+
password: z.string().min(1, "Password is required").max(128)
|
|
107
|
+
});
|
|
108
|
+
const forgotPasswordSchema = z.object({
|
|
109
|
+
email: z.string().email("Invalid email address").max(255)
|
|
110
|
+
});
|
|
111
|
+
const resetPasswordSchema = z.object({
|
|
112
|
+
token: z.string().min(1, "Token is required"),
|
|
113
|
+
password: z.string().min(1, "Password is required").max(128)
|
|
114
|
+
});
|
|
115
|
+
const changePasswordSchema = z.object({
|
|
116
|
+
oldPassword: z.string().min(1, "Old password is required").max(128),
|
|
117
|
+
newPassword: z.string().min(1, "New password is required").max(128)
|
|
118
|
+
});
|
|
119
|
+
const refreshSchema = z.object({
|
|
120
|
+
refreshToken: z.string().min(1, "Refresh token is required")
|
|
121
|
+
});
|
|
122
|
+
const logoutSchema = z.object({
|
|
123
|
+
refreshToken: z.string().optional()
|
|
124
|
+
});
|
|
125
|
+
const updateProfileSchema = z.object({
|
|
126
|
+
displayName: z.string().max(255).optional(),
|
|
127
|
+
photoURL: z.string().url().max(2048).optional()
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
/** Parse a Zod schema against the request body, throwing ApiError on failure */
|
|
131
|
+
function parseBody<T>(schema: z.ZodSchema<T>, body: unknown): T {
|
|
132
|
+
const result = schema.safeParse(body);
|
|
133
|
+
if (!result.success) {
|
|
134
|
+
const messages = result.error.errors.map(e => `${e.path.join(".")}: ${e.message}`).join(". ");
|
|
135
|
+
throw ApiError.badRequest(messages, "INVALID_INPUT");
|
|
136
|
+
}
|
|
137
|
+
return result.data;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Check if email service is configured
|
|
142
|
+
*/
|
|
143
|
+
function isEmailConfigured(): boolean {
|
|
144
|
+
return !!(emailService && emailService.isConfigured());
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Check if registration is allowed.
|
|
149
|
+
* Registration is only allowed when explicitly enabled via `allowRegistration`.
|
|
150
|
+
* First-user bootstrap must use POST /admin/bootstrap instead.
|
|
151
|
+
*/
|
|
152
|
+
function isRegistrationAllowed(): boolean {
|
|
153
|
+
if (config.disableSelfRegistration) return false;
|
|
154
|
+
return !!allowRegistration;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Send welcome email to a newly registered user (fire-and-forget).
|
|
159
|
+
*/
|
|
160
|
+
function sendWelcomeEmail(user: { email: string; displayName?: string | null }) {
|
|
161
|
+
if (!isEmailConfigured()) return;
|
|
162
|
+
const appName = emailConfig?.appName || "Rebase";
|
|
163
|
+
const loginUrl = emailConfig?.resetPasswordUrl || ""; // reuse base URL → the login / app page
|
|
164
|
+
const templateFn = emailConfig?.templates?.welcomeEmail;
|
|
165
|
+
const emailContent = templateFn
|
|
166
|
+
? templateFn(user, appName)
|
|
167
|
+
: getWelcomeEmailTemplate(user, appName, loginUrl ? `${loginUrl}/app` : undefined);
|
|
168
|
+
|
|
169
|
+
emailService!.send({
|
|
170
|
+
to: user.email,
|
|
171
|
+
subject: emailContent.subject,
|
|
172
|
+
html: emailContent.html,
|
|
173
|
+
text: emailContent.text
|
|
174
|
+
}).catch(err => {
|
|
175
|
+
console.error("Failed to send welcome email:", err instanceof Error ? err.message : err);
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Helper to generate and store session tokens
|
|
181
|
+
*/
|
|
182
|
+
async function createSessionAndTokens(userId: string, userAgent: string, ipAddress: string) {
|
|
183
|
+
const roles = await authRepo.getUserRoles(userId);
|
|
184
|
+
const roleIds = roles.map(r => r.id);
|
|
185
|
+
const accessToken = generateAccessToken(userId, roleIds);
|
|
186
|
+
const refreshToken = generateRefreshToken();
|
|
187
|
+
|
|
188
|
+
await authRepo.createRefreshToken(
|
|
189
|
+
userId,
|
|
190
|
+
hashRefreshToken(refreshToken),
|
|
191
|
+
getRefreshTokenExpiry(),
|
|
192
|
+
userAgent,
|
|
193
|
+
ipAddress
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
return { roleIds, accessToken, refreshToken };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* POST /auth/register
|
|
201
|
+
* Create a new account with email/password
|
|
202
|
+
*/
|
|
203
|
+
router.post("/register", defaultAuthLimiter, async (c) => {
|
|
204
|
+
const { email, password, displayName } = parseBody(registerSchema, await c.req.json());
|
|
205
|
+
|
|
206
|
+
// Hard kill switch — blocks registration regardless of allowRegistration
|
|
207
|
+
if (config.disableSelfRegistration) {
|
|
208
|
+
throw ApiError.forbidden("Registration is disabled", "REGISTRATION_DISABLED");
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Check if registration is allowed (no bypass for empty databases)
|
|
212
|
+
if (!isRegistrationAllowed()) {
|
|
213
|
+
throw ApiError.forbidden("Registration is disabled", "REGISTRATION_DISABLED");
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Validate password strength
|
|
217
|
+
const passwordValidation = validatePasswordStrength(password);
|
|
218
|
+
if (!passwordValidation.valid) {
|
|
219
|
+
throw ApiError.badRequest(passwordValidation.errors.join(". "), "WEAK_PASSWORD");
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Check if email already exists
|
|
223
|
+
const existingUser = await authRepo.getUserByEmail(email);
|
|
224
|
+
if (existingUser) {
|
|
225
|
+
throw ApiError.conflict("Email already registered", "EMAIL_EXISTS");
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Create user
|
|
229
|
+
const passwordHash = await hashPassword(password);
|
|
230
|
+
const user = await authRepo.createUser({
|
|
231
|
+
email: email.toLowerCase(),
|
|
232
|
+
passwordHash,
|
|
233
|
+
displayName: displayName || undefined
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// Assign configured default role (never auto-assign admin via registration)
|
|
237
|
+
if (config.defaultRole) {
|
|
238
|
+
await authRepo.assignDefaultRole(user.id, config.defaultRole);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const { roleIds, accessToken, refreshToken } = await createSessionAndTokens(
|
|
242
|
+
user.id,
|
|
243
|
+
c.req.header("user-agent") || "unknown",
|
|
244
|
+
c.req.header("x-forwarded-for") || "unknown"
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
// Send welcome email (fire-and-forget, don't block registration)
|
|
248
|
+
sendWelcomeEmail({ email: user.email,
|
|
249
|
+
displayName: user.displayName });
|
|
250
|
+
|
|
251
|
+
return c.json(buildAuthResponse(user, roleIds, accessToken, refreshToken), 201);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* POST /auth/login
|
|
256
|
+
* Login with email/password
|
|
257
|
+
*/
|
|
258
|
+
router.post("/login", defaultAuthLimiter, async (c) => {
|
|
259
|
+
const { email, password } = parseBody(loginSchema, await c.req.json());
|
|
260
|
+
|
|
261
|
+
const user = await authRepo.getUserByEmail(email);
|
|
262
|
+
if (!user) {
|
|
263
|
+
throw ApiError.unauthorized("Invalid email or password", "INVALID_CREDENTIALS");
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (!user.passwordHash) {
|
|
267
|
+
throw ApiError.unauthorized("Invalid email or password", "INVALID_CREDENTIALS");
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const isValidPassword = await verifyPassword(password, user.passwordHash);
|
|
271
|
+
if (!isValidPassword) {
|
|
272
|
+
throw ApiError.unauthorized("Invalid email or password", "INVALID_CREDENTIALS");
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const { roleIds, accessToken, refreshToken } = await createSessionAndTokens(
|
|
276
|
+
user.id,
|
|
277
|
+
c.req.header("user-agent") || "unknown",
|
|
278
|
+
c.req.header("x-forwarded-for") || "unknown"
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
return c.json(buildAuthResponse(user, roleIds, accessToken, refreshToken));
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Dynamically mount OAuth provider routes
|
|
286
|
+
*/
|
|
287
|
+
if (config.oauthProviders && config.oauthProviders.length > 0) {
|
|
288
|
+
for (const provider of config.oauthProviders) {
|
|
289
|
+
router.post(`/${provider.id}`, defaultAuthLimiter, async (c) => {
|
|
290
|
+
const payload = parseBody(provider.schema, await c.req.json());
|
|
291
|
+
|
|
292
|
+
const externalUser = await provider.verify(payload);
|
|
293
|
+
if (!externalUser) {
|
|
294
|
+
throw ApiError.unauthorized(`Invalid ${provider.id} credentials`, "INVALID_TOKEN");
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Find or create user
|
|
298
|
+
let user = await authRepo.getUserByIdentity(provider.id, externalUser.providerId);
|
|
299
|
+
|
|
300
|
+
if (!user) {
|
|
301
|
+
// Check if email exists (link accounts)
|
|
302
|
+
user = await authRepo.getUserByEmail(externalUser.email);
|
|
303
|
+
|
|
304
|
+
if (user) {
|
|
305
|
+
// Link Provider to existing account
|
|
306
|
+
await authRepo.linkUserIdentity(user.id, provider.id, externalUser.providerId, { email: externalUser.email });
|
|
307
|
+
|
|
308
|
+
// Optional: Update profile info from external provider if empty
|
|
309
|
+
await authRepo.updateUser(user.id, {
|
|
310
|
+
displayName: user.displayName || externalUser.displayName || undefined,
|
|
311
|
+
photoUrl: user.photoUrl || externalUser.photoUrl || undefined
|
|
312
|
+
});
|
|
313
|
+
} else {
|
|
314
|
+
// Create new user
|
|
315
|
+
user = await authRepo.createUser({
|
|
316
|
+
email: externalUser.email.toLowerCase(),
|
|
317
|
+
displayName: externalUser.displayName || undefined,
|
|
318
|
+
photoUrl: externalUser.photoUrl || undefined
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
await authRepo.linkUserIdentity(user.id, provider.id, externalUser.providerId, { email: externalUser.email });
|
|
322
|
+
|
|
323
|
+
// Assign configured default role (never auto-assign admin via registration)
|
|
324
|
+
if (config.defaultRole) {
|
|
325
|
+
await authRepo.assignDefaultRole(user.id, config.defaultRole);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Send welcome email for new OAuth users (fire-and-forget)
|
|
329
|
+
sendWelcomeEmail({ email: user.email,
|
|
330
|
+
displayName: user.displayName });
|
|
331
|
+
}
|
|
332
|
+
} else {
|
|
333
|
+
// Update profile info from external provider
|
|
334
|
+
await authRepo.updateUser(user.id, {
|
|
335
|
+
displayName: externalUser.displayName || user.displayName || undefined,
|
|
336
|
+
photoUrl: externalUser.photoUrl || user.photoUrl || undefined
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const { roleIds, accessToken, refreshToken } = await createSessionAndTokens(
|
|
341
|
+
user.id,
|
|
342
|
+
c.req.header("user-agent") || "unknown",
|
|
343
|
+
c.req.header("x-forwarded-for") || "unknown"
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
return c.json(buildAuthResponse(user, roleIds, accessToken, refreshToken));
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* POST /auth/forgot-password
|
|
353
|
+
* Request password reset email
|
|
354
|
+
*/
|
|
355
|
+
router.post("/forgot-password", strictAuthLimiter, async (c) => {
|
|
356
|
+
const { email } = parseBody(forgotPasswordSchema, await c.req.json());
|
|
357
|
+
|
|
358
|
+
// Check if email service is configured
|
|
359
|
+
if (!isEmailConfigured()) {
|
|
360
|
+
throw ApiError.serviceUnavailable("Email service not configured. Password reset is not available.", "EMAIL_NOT_CONFIGURED");
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Always return success (security: don't reveal if email exists)
|
|
364
|
+
// But only send email if user exists
|
|
365
|
+
const user = await authRepo.getUserByEmail(email);
|
|
366
|
+
|
|
367
|
+
if (user) {
|
|
368
|
+
// Generate reset token
|
|
369
|
+
const token = generateSecureToken();
|
|
370
|
+
const tokenHash = hashToken(token);
|
|
371
|
+
const expiresAt = getPasswordResetExpiry();
|
|
372
|
+
|
|
373
|
+
await authRepo.createPasswordResetToken(user.id, tokenHash, expiresAt);
|
|
374
|
+
|
|
375
|
+
// Build reset URL
|
|
376
|
+
const baseUrl = emailConfig?.resetPasswordUrl || "";
|
|
377
|
+
const resetUrl = `${baseUrl}/reset-password?token=${token}`;
|
|
378
|
+
|
|
379
|
+
// Get email template
|
|
380
|
+
const appName = emailConfig?.appName || "Rebase";
|
|
381
|
+
const templateFn = emailConfig?.templates?.passwordReset;
|
|
382
|
+
const emailContent = templateFn
|
|
383
|
+
? templateFn(resetUrl, { email: user.email,
|
|
384
|
+
displayName: user.displayName })
|
|
385
|
+
: getPasswordResetTemplate(resetUrl, { email: user.email,
|
|
386
|
+
displayName: user.displayName }, appName);
|
|
387
|
+
|
|
388
|
+
// Send email
|
|
389
|
+
try {
|
|
390
|
+
await emailService!.send({
|
|
391
|
+
to: user.email,
|
|
392
|
+
subject: emailContent.subject,
|
|
393
|
+
html: emailContent.html,
|
|
394
|
+
text: emailContent.text
|
|
395
|
+
});
|
|
396
|
+
} catch (emailError: unknown) {
|
|
397
|
+
console.error("Failed to send password reset email:", emailError instanceof Error ? emailError.message : emailError);
|
|
398
|
+
// Don't reveal email sending failure to client
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Always return success
|
|
403
|
+
return c.json({
|
|
404
|
+
success: true,
|
|
405
|
+
message: "If an account with that email exists, a password reset link has been sent."
|
|
406
|
+
});
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* POST /auth/reset-password
|
|
411
|
+
* Reset password using token
|
|
412
|
+
*/
|
|
413
|
+
router.post("/reset-password", strictAuthLimiter, async (c) => {
|
|
414
|
+
const { token, password } = parseBody(resetPasswordSchema, await c.req.json());
|
|
415
|
+
|
|
416
|
+
// Validate password strength
|
|
417
|
+
const passwordValidation = validatePasswordStrength(password);
|
|
418
|
+
if (!passwordValidation.valid) {
|
|
419
|
+
throw ApiError.badRequest(passwordValidation.errors.join(". "), "WEAK_PASSWORD");
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Find valid token
|
|
423
|
+
const tokenHash = hashToken(token);
|
|
424
|
+
const storedToken = await authRepo.findValidPasswordResetToken(tokenHash);
|
|
425
|
+
|
|
426
|
+
if (!storedToken) {
|
|
427
|
+
throw ApiError.badRequest("Invalid or expired reset token", "INVALID_TOKEN");
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Update password
|
|
431
|
+
const passwordHash = await hashPassword(password);
|
|
432
|
+
await authRepo.updatePassword(storedToken.userId, passwordHash);
|
|
433
|
+
|
|
434
|
+
// Mark token as used
|
|
435
|
+
await authRepo.markPasswordResetTokenUsed(tokenHash);
|
|
436
|
+
|
|
437
|
+
// Invalidate all refresh tokens (security: log out all sessions)
|
|
438
|
+
await authRepo.deleteAllRefreshTokensForUser(storedToken.userId);
|
|
439
|
+
|
|
440
|
+
return c.json({ success: true,
|
|
441
|
+
message: "Password has been reset successfully" });
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* POST /auth/change-password
|
|
446
|
+
* Change password for authenticated user
|
|
447
|
+
*/
|
|
448
|
+
router.post("/change-password", requireAuth, async (c) => {
|
|
449
|
+
const userCtx = c.get("user") as { userId: string; roles?: string[] } | undefined;
|
|
450
|
+
if (!userCtx) {
|
|
451
|
+
throw ApiError.unauthorized("Not authenticated");
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const { oldPassword, newPassword } = parseBody(changePasswordSchema, await c.req.json());
|
|
455
|
+
|
|
456
|
+
// Get user
|
|
457
|
+
const user = await authRepo.getUserById(userCtx.userId);
|
|
458
|
+
if (!user || !user.passwordHash) {
|
|
459
|
+
throw ApiError.badRequest("Cannot change password for this account", "INVALID_ACCOUNT");
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Verify old password
|
|
463
|
+
const isValidOldPassword = await verifyPassword(oldPassword, user.passwordHash);
|
|
464
|
+
if (!isValidOldPassword) {
|
|
465
|
+
throw ApiError.unauthorized("Current password is incorrect", "INVALID_CREDENTIALS");
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// Validate new password strength
|
|
469
|
+
const passwordValidation = validatePasswordStrength(newPassword);
|
|
470
|
+
if (!passwordValidation.valid) {
|
|
471
|
+
throw ApiError.badRequest(passwordValidation.errors.join(". "), "WEAK_PASSWORD");
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Update password
|
|
475
|
+
const passwordHash = await hashPassword(newPassword);
|
|
476
|
+
await authRepo.updatePassword(user.id, passwordHash);
|
|
477
|
+
|
|
478
|
+
// Invalidate all refresh tokens (security: log out all sessions)
|
|
479
|
+
await authRepo.deleteAllRefreshTokensForUser(user.id);
|
|
480
|
+
|
|
481
|
+
return c.json({ success: true,
|
|
482
|
+
message: "Password has been changed successfully" });
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* POST /auth/send-verification
|
|
487
|
+
* Send email verification link (authenticated)
|
|
488
|
+
*/
|
|
489
|
+
router.post("/send-verification", requireAuth, async (c) => {
|
|
490
|
+
const userCtx = c.get("user") as { userId: string; roles?: string[] } | undefined;
|
|
491
|
+
if (!userCtx) {
|
|
492
|
+
throw ApiError.unauthorized("Not authenticated");
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Check if email service is configured
|
|
496
|
+
if (!isEmailConfigured()) {
|
|
497
|
+
throw ApiError.serviceUnavailable("Email service not configured. Email verification is not available.", "EMAIL_NOT_CONFIGURED");
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const user = await authRepo.getUserById(userCtx.userId);
|
|
501
|
+
if (!user) {
|
|
502
|
+
throw ApiError.notFound("User not found");
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
if (user.emailVerified) {
|
|
506
|
+
throw ApiError.badRequest("Email is already verified", "ALREADY_VERIFIED");
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// Generate verification token
|
|
510
|
+
const token = generateSecureToken();
|
|
511
|
+
|
|
512
|
+
// Store hashed token in user record (raw token goes in the email URL)
|
|
513
|
+
await authRepo.setVerificationToken(user.id, hashToken(token));
|
|
514
|
+
|
|
515
|
+
// Build verification URL
|
|
516
|
+
const baseUrl = emailConfig?.verifyEmailUrl || "";
|
|
517
|
+
const verifyUrl = `${baseUrl}/verify-email?token=${token}`;
|
|
518
|
+
|
|
519
|
+
// Get email template
|
|
520
|
+
const appName = emailConfig?.appName || "Rebase";
|
|
521
|
+
const templateFn = emailConfig?.templates?.emailVerification;
|
|
522
|
+
const emailContent = templateFn
|
|
523
|
+
? templateFn(verifyUrl, { email: user.email,
|
|
524
|
+
displayName: user.displayName })
|
|
525
|
+
: getEmailVerificationTemplate(verifyUrl, { email: user.email,
|
|
526
|
+
displayName: user.displayName }, appName);
|
|
527
|
+
|
|
528
|
+
// Send email
|
|
529
|
+
await emailService!.send({
|
|
530
|
+
to: user.email,
|
|
531
|
+
subject: emailContent.subject,
|
|
532
|
+
html: emailContent.html,
|
|
533
|
+
text: emailContent.text
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
return c.json({ success: true,
|
|
537
|
+
message: "Verification email sent" });
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* GET /auth/verify-email
|
|
542
|
+
* Verify email address using token
|
|
543
|
+
*/
|
|
544
|
+
router.get("/verify-email", async (c) => {
|
|
545
|
+
const token = c.req.query("token");
|
|
546
|
+
|
|
547
|
+
if (!token) {
|
|
548
|
+
throw ApiError.badRequest("Verification token is required", "INVALID_INPUT");
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// Find user by hashed verification token
|
|
552
|
+
const user = await authRepo.getUserByVerificationToken(hashToken(token));
|
|
553
|
+
if (!user) {
|
|
554
|
+
throw ApiError.badRequest("Invalid or expired verification token", "INVALID_TOKEN");
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// Mark email as verified
|
|
558
|
+
await authRepo.setEmailVerified(user.id, true);
|
|
559
|
+
|
|
560
|
+
return c.json({ success: true,
|
|
561
|
+
message: "Email verified successfully" });
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* POST /auth/refresh
|
|
566
|
+
* Refresh access token using refresh token
|
|
567
|
+
*/
|
|
568
|
+
router.post("/refresh", async (c) => {
|
|
569
|
+
const { refreshToken } = parseBody(refreshSchema, await c.req.json());
|
|
570
|
+
|
|
571
|
+
const tokenHash = hashRefreshToken(refreshToken);
|
|
572
|
+
const storedToken = await authRepo.findRefreshTokenByHash(tokenHash);
|
|
573
|
+
|
|
574
|
+
if (!storedToken) {
|
|
575
|
+
throw ApiError.unauthorized("Invalid refresh token", "INVALID_TOKEN");
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
if (new Date() > storedToken.expiresAt) {
|
|
579
|
+
await authRepo.deleteRefreshToken(tokenHash);
|
|
580
|
+
throw ApiError.unauthorized("Refresh token expired", "TOKEN_EXPIRED");
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// Generate new tokens
|
|
584
|
+
const roles = await authRepo.getUserRoles(storedToken.userId);
|
|
585
|
+
const roleIds = roles.map(r => r.id);
|
|
586
|
+
|
|
587
|
+
const newAccessToken = generateAccessToken(storedToken.userId, roleIds);
|
|
588
|
+
const newRefreshToken = generateRefreshToken();
|
|
589
|
+
|
|
590
|
+
// Rotate refresh token (delete old, create new)
|
|
591
|
+
const userAgent = c.req.header("user-agent") || "unknown";
|
|
592
|
+
const ipAddress = c.req.header("x-forwarded-for") || "unknown";
|
|
593
|
+
|
|
594
|
+
await authRepo.deleteRefreshToken(tokenHash);
|
|
595
|
+
await authRepo.createRefreshToken(
|
|
596
|
+
storedToken.userId,
|
|
597
|
+
hashRefreshToken(newRefreshToken),
|
|
598
|
+
getRefreshTokenExpiry(),
|
|
599
|
+
userAgent,
|
|
600
|
+
ipAddress
|
|
601
|
+
);
|
|
602
|
+
|
|
603
|
+
return c.json({
|
|
604
|
+
tokens: {
|
|
605
|
+
accessToken: newAccessToken,
|
|
606
|
+
refreshToken: newRefreshToken,
|
|
607
|
+
accessTokenExpiresAt: getAccessTokenExpiry()
|
|
608
|
+
}
|
|
609
|
+
});
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* POST /auth/logout
|
|
614
|
+
* Invalidate refresh token
|
|
615
|
+
*/
|
|
616
|
+
router.post("/logout", async (c) => {
|
|
617
|
+
const { refreshToken } = parseBody(logoutSchema, await c.req.json());
|
|
618
|
+
|
|
619
|
+
if (refreshToken) {
|
|
620
|
+
const tokenHash = hashRefreshToken(refreshToken);
|
|
621
|
+
await authRepo.deleteRefreshToken(tokenHash);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
return c.json({ success: true });
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* GET /auth/sessions
|
|
629
|
+
* Get active refresh tokens (sessions) for the current user
|
|
630
|
+
*/
|
|
631
|
+
router.get("/sessions", requireAuth, async (c) => {
|
|
632
|
+
const userCtx = c.get("user") as { userId: string; roles?: string[] } | undefined;
|
|
633
|
+
if (!userCtx) {
|
|
634
|
+
throw ApiError.unauthorized("Not authenticated");
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
const currentRefreshToken = c.req.header("x-refresh-token") as string;
|
|
638
|
+
const currentTokenHash = currentRefreshToken ? hashRefreshToken(currentRefreshToken) : null;
|
|
639
|
+
|
|
640
|
+
const sessions = await authRepo.listRefreshTokensForUser(userCtx.userId);
|
|
641
|
+
|
|
642
|
+
const mappedSessions = sessions.map(s => ({
|
|
643
|
+
id: s.id,
|
|
644
|
+
userAgent: s.userAgent,
|
|
645
|
+
ipAddress: s.ipAddress,
|
|
646
|
+
createdAt: s.createdAt,
|
|
647
|
+
isCurrentSession: currentTokenHash ? s.tokenHash === currentTokenHash : false
|
|
648
|
+
}));
|
|
649
|
+
|
|
650
|
+
return c.json({ sessions: mappedSessions });
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* DELETE /auth/sessions
|
|
655
|
+
* Delete all refresh tokens for the current user (remote logout every device)
|
|
656
|
+
*/
|
|
657
|
+
router.delete("/sessions", requireAuth, async (c) => {
|
|
658
|
+
const userCtx = c.get("user") as { userId: string; roles?: string[] } | undefined;
|
|
659
|
+
if (!userCtx) {
|
|
660
|
+
throw ApiError.unauthorized("Not authenticated");
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
await authRepo.deleteAllRefreshTokensForUser(userCtx.userId);
|
|
664
|
+
return c.json({ success: true,
|
|
665
|
+
message: "All sessions revoked successfully" });
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* DELETE /auth/sessions/:id
|
|
670
|
+
* Delete a specific refresh token (remote logout)
|
|
671
|
+
*/
|
|
672
|
+
router.delete("/sessions/:id", requireAuth, async (c) => {
|
|
673
|
+
const userCtx = c.get("user") as { userId: string; roles?: string[] } | undefined;
|
|
674
|
+
if (!userCtx) {
|
|
675
|
+
throw ApiError.unauthorized("Not authenticated");
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
const id = c.req.param("id");
|
|
679
|
+
if (!id) {
|
|
680
|
+
throw ApiError.badRequest("Session ID is required", "INVALID_INPUT");
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
await authRepo.deleteRefreshTokenById(id, userCtx.userId);
|
|
684
|
+
return c.json({ success: true,
|
|
685
|
+
message: "Session revoked successfully" });
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* GET /auth/me
|
|
690
|
+
* Get current authenticated user
|
|
691
|
+
*/
|
|
692
|
+
router.get("/me", requireAuth, async (c) => {
|
|
693
|
+
const userCtx = c.get("user") as { userId: string; roles?: string[] } | undefined;
|
|
694
|
+
if (!userCtx) {
|
|
695
|
+
throw ApiError.unauthorized("Not authenticated");
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
const result = await authRepo.getUserWithRoles(userCtx.userId);
|
|
699
|
+
if (!result) {
|
|
700
|
+
throw ApiError.notFound("User not found");
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
return c.json({
|
|
704
|
+
user: {
|
|
705
|
+
uid: result.user.id,
|
|
706
|
+
email: result.user.email,
|
|
707
|
+
displayName: result.user.displayName,
|
|
708
|
+
photoURL: result.user.photoUrl,
|
|
709
|
+
emailVerified: result.user.emailVerified,
|
|
710
|
+
roles: result.roles.map(r => r.id)
|
|
711
|
+
}
|
|
712
|
+
});
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
/**
|
|
716
|
+
* PATCH /auth/me
|
|
717
|
+
* Update current authenticated user profile
|
|
718
|
+
*/
|
|
719
|
+
router.patch("/me", requireAuth, async (c) => {
|
|
720
|
+
const userCtx = c.get("user") as { userId: string; roles?: string[] } | undefined;
|
|
721
|
+
if (!userCtx) {
|
|
722
|
+
throw ApiError.unauthorized("Not authenticated");
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
const { displayName, photoURL } = parseBody(updateProfileSchema, await c.req.json());
|
|
726
|
+
|
|
727
|
+
const updatedUser = await authRepo.updateUser(userCtx.userId, {
|
|
728
|
+
displayName: displayName !== undefined ? displayName : undefined,
|
|
729
|
+
photoUrl: photoURL !== undefined ? photoURL : undefined
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
if (!updatedUser) {
|
|
733
|
+
throw ApiError.notFound("User not found");
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
const result = await authRepo.getUserWithRoles(userCtx.userId);
|
|
737
|
+
if (!result) {
|
|
738
|
+
throw ApiError.notFound("User not found");
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
return c.json({
|
|
742
|
+
user: {
|
|
743
|
+
uid: result.user.id,
|
|
744
|
+
email: result.user.email,
|
|
745
|
+
displayName: result.user.displayName,
|
|
746
|
+
photoURL: result.user.photoUrl,
|
|
747
|
+
emailVerified: result.user.emailVerified,
|
|
748
|
+
roles: result.roles.map(r => r.id)
|
|
749
|
+
}
|
|
750
|
+
});
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
/**
|
|
754
|
+
* GET /auth/config
|
|
755
|
+
* Get public auth configuration (for frontend to know what's available)
|
|
756
|
+
*/
|
|
757
|
+
router.get("/config", defaultAuthLimiter, async (c) => {
|
|
758
|
+
// Determine if setup is needed using the persistent bootstrap flag
|
|
759
|
+
// when available, falling back to user-count check for backward compat.
|
|
760
|
+
let needsSetup: boolean;
|
|
761
|
+
if (config.isBootstrapCompleted) {
|
|
762
|
+
needsSetup = !(await config.isBootstrapCompleted());
|
|
763
|
+
} else {
|
|
764
|
+
const allUsers = await authRepo.listUsers();
|
|
765
|
+
needsSetup = allUsers.length === 0;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// Registration is allowed when explicitly enabled OR during initial setup
|
|
769
|
+
const registrationAllowed = needsSetup || !!allowRegistration;
|
|
770
|
+
|
|
771
|
+
// Build a dynamic map of enabled providers for frontend discovery.
|
|
772
|
+
// Also maintain legacy boolean fields for backward compatibility.
|
|
773
|
+
const enabledProviders = (config.oauthProviders || []).map(p => p.id);
|
|
774
|
+
|
|
775
|
+
return c.json({
|
|
776
|
+
needsSetup,
|
|
777
|
+
registrationEnabled: registrationAllowed,
|
|
778
|
+
// Legacy fields (kept for backward compat)
|
|
779
|
+
googleEnabled: enabledProviders.includes("google"),
|
|
780
|
+
linkedinEnabled: enabledProviders.includes("linkedin"),
|
|
781
|
+
emailServiceEnabled: isEmailConfigured(),
|
|
782
|
+
// New: complete list of available OAuth providers
|
|
783
|
+
enabledProviders
|
|
784
|
+
});
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
return router;
|
|
788
|
+
}
|