@simple-auth-kit/cli 1.4.2 → 1.5.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.
Files changed (133) hide show
  1. package/package.json +1 -1
  2. package/registry/combos/express-drizzle/shared/src/{auth-core-error.middleware.ts → middleware/auth-core-error.middleware.ts} +17 -4
  3. package/registry/combos/express-drizzle/shared/src/{auth.middleware.ts → middleware/auth.middleware.ts} +23 -7
  4. package/registry/combos/{express-prisma/shared/src → express-drizzle/shared/src/middleware}/response-envelope.middleware.ts +15 -3
  5. package/registry/combos/express-drizzle/shared/src/{oauth.repository.ts → repositories/oauth.repository.ts} +32 -9
  6. package/registry/combos/express-drizzle/shared/src/repositories/password-reset.repository.ts +59 -0
  7. package/registry/combos/express-drizzle/shared/src/{session.repository.ts → repositories/session.repository.ts} +52 -14
  8. package/registry/combos/express-drizzle/shared/src/{two-factor.repository.ts → repositories/two-factor.repository.ts} +20 -6
  9. package/registry/combos/express-drizzle/shared/src/request-context.ts +1 -1
  10. package/registry/combos/express-drizzle/shared/src/routers/auth.router.ts +395 -0
  11. package/registry/combos/express-drizzle/variants/base/src/create-auth-app.ts +52 -19
  12. package/registry/combos/express-drizzle/variants/base/src/{authz.middleware.ts → middleware/authz.middleware.ts} +15 -7
  13. package/registry/combos/express-drizzle/variants/base/src/{audit-log.repository.ts → repositories/audit-log.repository.ts} +28 -10
  14. package/registry/combos/express-drizzle/variants/base/src/{rbac.repository.ts → repositories/rbac.repository.ts} +239 -48
  15. package/registry/combos/express-drizzle/variants/base/src/routers/admin.router.ts +497 -0
  16. package/registry/combos/express-drizzle/variants/base/src/{auth.service.ts → services/auth.service.ts} +480 -109
  17. package/registry/combos/express-drizzle/variants/workspaces/src/create-auth-app.ts +69 -23
  18. package/registry/combos/express-drizzle/variants/workspaces/src/{authz.middleware.ts → middleware/authz.middleware.ts} +32 -12
  19. package/registry/combos/express-drizzle/variants/workspaces/src/openapi-workspace.ts +147 -28
  20. package/registry/combos/express-drizzle/variants/workspaces/src/{audit-log.repository.ts → repositories/audit-log.repository.ts} +30 -11
  21. package/registry/combos/express-drizzle/variants/workspaces/src/{rbac.repository.ts → repositories/rbac.repository.ts} +319 -70
  22. package/registry/combos/express-drizzle/variants/workspaces/src/{workspace.repository.ts → repositories/workspace.repository.ts} +127 -30
  23. package/registry/combos/express-drizzle/variants/workspaces/src/routers/admin.router.ts +534 -0
  24. package/registry/combos/express-drizzle/variants/workspaces/src/routers/workspace.router.ts +194 -0
  25. package/registry/combos/express-drizzle/variants/workspaces/src/{auth.service.ts → services/auth.service.ts} +544 -122
  26. package/registry/combos/express-prisma/shared/src/{auth-core-error.middleware.ts → middleware/auth-core-error.middleware.ts} +17 -4
  27. package/registry/combos/express-prisma/shared/src/{auth.middleware.ts → middleware/auth.middleware.ts} +19 -6
  28. package/registry/combos/{express-drizzle/shared/src → express-prisma/shared/src/middleware}/response-envelope.middleware.ts +15 -3
  29. package/registry/combos/express-prisma/shared/src/{oauth.repository.ts → repositories/oauth.repository.ts} +27 -9
  30. package/registry/combos/express-prisma/shared/src/repositories/password-reset.repository.ts +55 -0
  31. package/registry/combos/express-prisma/shared/src/{session.repository.ts → repositories/session.repository.ts} +30 -9
  32. package/registry/combos/express-prisma/shared/src/{two-factor.repository.ts → repositories/two-factor.repository.ts} +11 -4
  33. package/registry/combos/express-prisma/shared/src/request-context.ts +1 -1
  34. package/registry/combos/express-prisma/shared/src/routers/auth.router.ts +398 -0
  35. package/registry/combos/express-prisma/variants/base/src/create-auth-app.ts +57 -22
  36. package/registry/combos/express-prisma/variants/base/src/{authz.middleware.ts → middleware/authz.middleware.ts} +15 -7
  37. package/registry/combos/express-prisma/variants/base/src/rbac.defaults.ts +67 -19
  38. package/registry/combos/express-prisma/variants/base/src/{audit-log.repository.ts → repositories/audit-log.repository.ts} +16 -5
  39. package/registry/combos/express-prisma/variants/base/src/{rbac.repository.ts → repositories/rbac.repository.ts} +199 -48
  40. package/registry/combos/express-prisma/variants/base/src/routers/admin.router.ts +508 -0
  41. package/registry/combos/express-prisma/variants/base/src/seed.ts +78 -20
  42. package/registry/combos/express-prisma/variants/base/src/{auth.service.ts → services/auth.service.ts} +454 -105
  43. package/registry/combos/express-prisma/variants/workspaces/src/create-auth-app.ts +81 -26
  44. package/registry/combos/express-prisma/variants/workspaces/src/{authz.middleware.ts → middleware/authz.middleware.ts} +32 -12
  45. package/registry/combos/express-prisma/variants/workspaces/src/openapi-workspace.ts +147 -28
  46. package/registry/combos/express-prisma/variants/workspaces/src/rbac.defaults.ts +73 -21
  47. package/registry/combos/express-prisma/variants/workspaces/src/{audit-log.repository.ts → repositories/audit-log.repository.ts} +16 -5
  48. package/registry/combos/express-prisma/variants/workspaces/src/{rbac.repository.ts → repositories/rbac.repository.ts} +272 -64
  49. package/registry/combos/express-prisma/variants/workspaces/src/{workspace.repository.ts → repositories/workspace.repository.ts} +86 -23
  50. package/registry/combos/express-prisma/variants/workspaces/src/routers/admin.router.ts +545 -0
  51. package/registry/combos/express-prisma/variants/workspaces/src/routers/workspace.router.ts +194 -0
  52. package/registry/combos/express-prisma/variants/workspaces/src/seed.ts +109 -29
  53. package/registry/combos/express-prisma/variants/workspaces/src/{auth.service.ts → services/auth.service.ts} +518 -119
  54. package/registry/combos/nestjs-drizzle/shared/src/{auth.controller.ts → controllers/auth.controller.ts} +6 -6
  55. package/registry/combos/nestjs-drizzle/shared/src/{auth-core-error.filter.ts → filters/auth-core-error.filter.ts} +29 -5
  56. package/registry/combos/nestjs-drizzle/shared/src/{ability.guard.ts → guards/ability.guard.ts} +20 -7
  57. package/registry/combos/nestjs-drizzle/shared/src/{auth.guard.ts → guards/auth.guard.ts} +19 -7
  58. package/registry/combos/{nestjs-prisma/shared/src → nestjs-drizzle/shared/src/interceptors}/response.interceptor.ts +10 -2
  59. package/registry/combos/nestjs-drizzle/shared/src/repositories/oauth.repository.ts +63 -0
  60. package/registry/combos/nestjs-drizzle/shared/src/repositories/password-reset.repository.ts +59 -0
  61. package/registry/combos/nestjs-drizzle/shared/src/{session.repository.ts → repositories/session.repository.ts} +52 -14
  62. package/registry/combos/nestjs-drizzle/shared/src/{two-factor.repository.ts → repositories/two-factor.repository.ts} +21 -7
  63. package/registry/combos/nestjs-drizzle/shared/src/request-context.ts +1 -1
  64. package/registry/combos/nestjs-drizzle/test/prove-cycle.ts +1097 -307
  65. package/registry/combos/nestjs-drizzle/variants/base/src/auth.module.ts +44 -20
  66. package/registry/combos/nestjs-drizzle/variants/base/src/{admin.controller.ts → controllers/admin.controller.ts} +228 -62
  67. package/registry/combos/nestjs-drizzle/variants/base/src/{authz.guard.ts → guards/authz.guard.ts} +12 -5
  68. package/registry/combos/nestjs-drizzle/variants/base/src/{audit-log.repository.ts → repositories/audit-log.repository.ts} +27 -10
  69. package/registry/combos/nestjs-drizzle/variants/base/src/{rbac.repository.ts → repositories/rbac.repository.ts} +249 -51
  70. package/registry/combos/nestjs-drizzle/variants/base/src/{auth.service.ts → services/auth.service.ts} +499 -116
  71. package/registry/combos/nestjs-drizzle/variants/workspaces/src/auth.module.ts +48 -22
  72. package/registry/combos/nestjs-drizzle/variants/workspaces/src/{admin.controller.ts → controllers/admin.controller.ts} +269 -70
  73. package/registry/combos/nestjs-drizzle/variants/workspaces/src/{workspace.controller.ts → controllers/workspace.controller.ts} +93 -26
  74. package/registry/combos/nestjs-drizzle/variants/workspaces/src/{authz.guard.ts → guards/authz.guard.ts} +26 -8
  75. package/registry/combos/nestjs-drizzle/variants/workspaces/src/{audit-log.repository.ts → repositories/audit-log.repository.ts} +29 -11
  76. package/registry/combos/nestjs-drizzle/variants/workspaces/src/{rbac.repository.ts → repositories/rbac.repository.ts} +323 -71
  77. package/registry/combos/nestjs-drizzle/variants/workspaces/src/{workspace.repository.ts → repositories/workspace.repository.ts} +131 -30
  78. package/registry/combos/nestjs-drizzle/variants/workspaces/src/services/auth.service.ts +988 -0
  79. package/registry/combos/nestjs-prisma/shared/src/{auth.controller.ts → controllers/auth.controller.ts} +6 -6
  80. package/registry/combos/nestjs-prisma/shared/src/{auth-core-error.filter.ts → filters/auth-core-error.filter.ts} +29 -5
  81. package/registry/combos/nestjs-prisma/shared/src/{ability.guard.ts → guards/ability.guard.ts} +20 -7
  82. package/registry/combos/nestjs-prisma/shared/src/{auth.guard.ts → guards/auth.guard.ts} +19 -7
  83. package/registry/combos/{nestjs-drizzle/shared/src → nestjs-prisma/shared/src/interceptors}/response.interceptor.ts +10 -2
  84. package/registry/combos/nestjs-prisma/shared/src/{oauth.repository.ts → repositories/oauth.repository.ts} +25 -8
  85. package/registry/combos/nestjs-prisma/shared/src/repositories/password-reset.repository.ts +55 -0
  86. package/registry/combos/nestjs-prisma/shared/src/{session.repository.ts → repositories/session.repository.ts} +30 -9
  87. package/registry/combos/nestjs-prisma/shared/src/{two-factor.repository.ts → repositories/two-factor.repository.ts} +11 -4
  88. package/registry/combos/nestjs-prisma/shared/src/request-context.ts +1 -1
  89. package/registry/combos/nestjs-prisma/test/prove-cycle.ts +1097 -307
  90. package/registry/combos/nestjs-prisma/variants/base/src/auth.module.ts +59 -28
  91. package/registry/combos/nestjs-prisma/variants/base/src/{admin.controller.ts → controllers/admin.controller.ts} +469 -110
  92. package/registry/combos/nestjs-prisma/variants/base/src/{audit-log.gateway.ts → gateways/audit-log.gateway.ts} +22 -6
  93. package/registry/combos/nestjs-prisma/variants/base/src/{authz.guard.ts → guards/authz.guard.ts} +12 -5
  94. package/registry/combos/nestjs-prisma/variants/base/src/rbac.defaults.ts +98 -27
  95. package/registry/combos/nestjs-prisma/variants/base/src/{audit-log.repository.ts → repositories/audit-log.repository.ts} +16 -5
  96. package/registry/combos/nestjs-prisma/variants/base/src/{country.repository.ts → repositories/country.repository.ts} +98 -25
  97. package/registry/combos/nestjs-prisma/variants/base/src/{customer.repository.ts → repositories/customer.repository.ts} +106 -27
  98. package/registry/combos/nestjs-prisma/variants/base/src/{language.repository.ts → repositories/language.repository.ts} +93 -25
  99. package/registry/combos/nestjs-prisma/variants/base/src/{rbac.repository.ts → repositories/rbac.repository.ts} +229 -55
  100. package/registry/combos/nestjs-prisma/variants/base/src/seed.ts +78 -20
  101. package/registry/combos/nestjs-prisma/variants/base/src/services/auth.service.ts +1064 -0
  102. package/registry/combos/nestjs-prisma/variants/workspaces/src/auth.module.ts +55 -27
  103. package/registry/combos/nestjs-prisma/variants/workspaces/src/{admin.controller.ts → controllers/admin.controller.ts} +544 -128
  104. package/registry/combos/nestjs-prisma/variants/workspaces/src/{workspace.controller.ts → controllers/workspace.controller.ts} +93 -26
  105. package/registry/combos/nestjs-prisma/variants/workspaces/src/{authz.guard.ts → guards/authz.guard.ts} +26 -8
  106. package/registry/combos/nestjs-prisma/variants/workspaces/src/rbac.defaults.ts +104 -29
  107. package/registry/combos/nestjs-prisma/variants/workspaces/src/{audit-log.repository.ts → repositories/audit-log.repository.ts} +16 -5
  108. package/registry/combos/nestjs-prisma/variants/workspaces/src/{country.repository.ts → repositories/country.repository.ts} +127 -26
  109. package/registry/combos/nestjs-prisma/variants/workspaces/src/{customer.repository.ts → repositories/customer.repository.ts} +137 -28
  110. package/registry/combos/nestjs-prisma/variants/workspaces/src/repositories/language.repository.ts +243 -0
  111. package/registry/combos/nestjs-prisma/variants/workspaces/src/{rbac.repository.ts → repositories/rbac.repository.ts} +314 -74
  112. package/registry/combos/nestjs-prisma/variants/workspaces/src/{workspace.repository.ts → repositories/workspace.repository.ts} +94 -24
  113. package/registry/combos/nestjs-prisma/variants/workspaces/src/seed.ts +109 -29
  114. package/registry/combos/nestjs-prisma/variants/workspaces/src/services/auth.service.ts +961 -0
  115. package/registry.json +6 -4
  116. package/simple-auth-kit.ts +582 -124
  117. package/registry/combos/express-drizzle/shared/src/auth.router.ts +0 -266
  118. package/registry/combos/express-drizzle/shared/src/password-reset.repository.ts +0 -35
  119. package/registry/combos/express-drizzle/variants/base/src/admin.router.ts +0 -307
  120. package/registry/combos/express-drizzle/variants/workspaces/src/admin.router.ts +0 -323
  121. package/registry/combos/express-drizzle/variants/workspaces/src/workspace.router.ts +0 -123
  122. package/registry/combos/express-prisma/shared/src/auth.router.ts +0 -269
  123. package/registry/combos/express-prisma/shared/src/password-reset.repository.ts +0 -29
  124. package/registry/combos/express-prisma/variants/base/src/admin.router.ts +0 -314
  125. package/registry/combos/express-prisma/variants/workspaces/src/admin.router.ts +0 -330
  126. package/registry/combos/express-prisma/variants/workspaces/src/workspace.router.ts +0 -123
  127. package/registry/combos/nestjs-drizzle/shared/src/oauth.repository.ts +0 -36
  128. package/registry/combos/nestjs-drizzle/shared/src/password-reset.repository.ts +0 -29
  129. package/registry/combos/nestjs-drizzle/variants/workspaces/src/auth.service.ts +0 -553
  130. package/registry/combos/nestjs-prisma/shared/src/password-reset.repository.ts +0 -29
  131. package/registry/combos/nestjs-prisma/variants/base/src/auth.service.ts +0 -618
  132. package/registry/combos/nestjs-prisma/variants/workspaces/src/auth.service.ts +0 -509
  133. package/registry/combos/nestjs-prisma/variants/workspaces/src/language.repository.ts +0 -143
@@ -1,553 +0,0 @@
1
- import { randomUUID } from "node:crypto";
2
- import { BadRequestException, ConflictException, HttpException, HttpStatus, Inject, Injectable, NotFoundException, UnauthorizedException } from "@nestjs/common";
3
- import { and, desc, eq, gt, or } from "drizzle-orm";
4
- import { hashPassword, verifyPassword } from "@/lib/auth/core/crypto.js";
5
- import {
6
- buildAuthorizationUrl,
7
- APPLE_OIDC_PROVIDER,
8
- completeOAuthLogin,
9
- exchangeCodeForTokens,
10
- GOOGLE_OIDC_PROVIDER,
11
- OAuthProviderDescriptor,
12
- signAppleClientSecret,
13
- verifyIdTokenAndExtractProfile,
14
- } from "@/lib/auth/core/oauth.js";
15
- import { requestPasswordReset as coreRequestPasswordReset, resetPassword as coreResetPassword } from "@/lib/auth/core/password-reset.js";
16
- import { checkRateLimit, type RateLimitDeps } from "@/lib/auth/core/rate-limit.js";
17
- import {
18
- blockUser,
19
- createSession,
20
- deactivateUser,
21
- revokeAccessToken,
22
- revokeAllSessionsForUser,
23
- revokeOtherSessionsForUser,
24
- revokeSession,
25
- rotateRefreshToken,
26
- } from "@/lib/auth/core/session-policy.js";
27
- import { signAccessToken, signRefreshToken, signTwoFactorChallengeToken, verifyRefreshToken, verifyTwoFactorChallengeToken } from "@/lib/auth/core/token-service.js";
28
- import { buildTotpProvisioningUri, generateBackupCodes, generateTotpSecret, verifyTotpCode } from "@/lib/auth/core/two-factor.js";
29
- import { AUTH_CONFIG, AuthConfig } from "./auth.config.js";
30
- import { AuditLogEntry, AuditLogListFilter, AuditLogRepository, toAuditLogEntry } from "./audit-log.repository.js";
31
- import type { Paginated } from "./pagination.js";
32
- import { DRIZZLE_DB, type Database } from "./db.js";
33
- import { KeyProviderService } from "./key-provider.js";
34
- import { OAuthRepository } from "./oauth.repository.js";
35
- import { PasswordResetRepository } from "./password-reset.repository.js";
36
- import { RATE_LIMIT_STORE } from "./rate-limit.store.js";
37
- import type { Revoker } from "@/lib/auth/core/types.js";
38
- import type { AuthzContext } from "./authz.guard.js";
39
- import { MemberListFilter, MemberListResult, MemberSummary, PermissionInput, PermissionSummary, RbacRepository, RoleSummary, toMemberSummary } from "./rbac.repository.js";
40
- import { sessions, users } from "./schema.js";
41
- import { SessionRepository } from "./session.repository.js";
42
- import { TwoFactorRepository } from "./two-factor.repository.js";
43
- import { toId, toIdOrNull } from "./id.helper.js";
44
-
45
- export interface AuthTokens {
46
- accessToken: string;
47
- refreshToken: string;
48
- sessionId: string;
49
- }
50
-
51
- export interface TwoFactorChallenge {
52
- twoFactorRequired: true;
53
- challengeToken: string;
54
- }
55
-
56
- /** `PATCH /auth/me`'s return shape — the same across every combo, workspace-scoped or not, since a profile isn't a workspace concept. */
57
- export interface SelfProfile {
58
- id: string;
59
- email: string;
60
- firstName: string | null;
61
- lastName: string | null;
62
- displayName: string | null;
63
- phone: string | null;
64
- username: string | null;
65
- photo: string | null;
66
- createdAt: string;
67
- }
68
-
69
- @Injectable()
70
- export class AuthService {
71
- constructor(
72
- @Inject(DRIZZLE_DB) private readonly db: Database,
73
- @Inject(SessionRepository) private readonly sessions: SessionRepository,
74
- @Inject(KeyProviderService) private readonly keys: KeyProviderService,
75
- @Inject(RATE_LIMIT_STORE) private readonly rateLimit: RateLimitDeps,
76
- @Inject(AuditLogRepository) private readonly auditLog: AuditLogRepository,
77
- @Inject(RbacRepository) private readonly rbac: RbacRepository,
78
- @Inject(TwoFactorRepository) private readonly twoFactor: TwoFactorRepository,
79
- @Inject(OAuthRepository) private readonly oauth: OAuthRepository,
80
- @Inject(PasswordResetRepository) private readonly passwordReset: PasswordResetRepository,
81
- @Inject(AUTH_CONFIG) private readonly config: AuthConfig,
82
- ) {}
83
-
84
- /** Creates the user and nothing else. Joining or creating a workspace is a separate, explicit call — see WorkspaceController. */
85
- async signup(input: {
86
- email: string;
87
- password: string;
88
- firstName?: string;
89
- lastName?: string;
90
- displayName?: string;
91
- phone?: string;
92
- username?: string;
93
- userAgent?: string;
94
- ip?: string;
95
- }): Promise<AuthTokens> {
96
- const [existing] = await this.db.select().from(users).where(eq(users.email, input.email)).limit(1);
97
- if (existing) throw new ConflictException("email already registered");
98
-
99
- const passwordHash = await hashPassword(input.password);
100
- const [user] = await this.db
101
- .insert(users)
102
- .values({
103
- email: input.email,
104
- passwordHash,
105
- firstName: input.firstName,
106
- lastName: input.lastName,
107
- displayName: input.displayName,
108
- phone: input.phone,
109
- username: input.username,
110
- })
111
- .returning();
112
- return this.issueSessionTokens(user, { userAgent: input.userAgent, ip: input.ip });
113
- }
114
-
115
- /** Returns full tokens directly, or a short-lived challenge if the account has 2FA enabled — see `loginTwoFactor`. */
116
- async login(input: { identifier: string; password: string; userAgent?: string; ip?: string }): Promise<AuthTokens | TwoFactorChallenge> {
117
- const { allowed } = await checkRateLimit(this.rateLimit, "login", input.identifier);
118
- if (!allowed) throw new HttpException("too many login attempts", HttpStatus.TOO_MANY_REQUESTS);
119
-
120
- const [user] = await this.db
121
- .select()
122
- .from(users)
123
- .where(or(eq(users.email, input.identifier), eq(users.username, input.identifier), eq(users.phone, input.identifier)))
124
- .limit(1);
125
- if (!user || user.blocked || !user.isActive || user.isDeleted || !user.passwordHash) throw new UnauthorizedException("invalid credentials");
126
-
127
- const valid = await verifyPassword(user.passwordHash, input.password);
128
- if (!valid) throw new UnauthorizedException("invalid credentials");
129
-
130
- if (user.twoFactorEnabled) {
131
- const key = await this.keys.getActiveKey();
132
- const { token } = await signTwoFactorChallengeToken({ activeKey: key }, user.id.toString()); // core-facing: sub must be string
133
- return { twoFactorRequired: true, challengeToken: token };
134
- }
135
-
136
- return this.issueSessionTokens(user, { userAgent: input.userAgent, ip: input.ip });
137
- }
138
-
139
- /** Completes the challenge `login()` returned when 2FA is enabled — a TOTP code or an unused backup code. */
140
- async loginTwoFactor(input: { challengeToken: string; code: string; userAgent?: string; ip?: string }): Promise<AuthTokens> {
141
- const { sub } = await verifyTwoFactorChallengeToken({ secret: this.keys.secret }, input.challengeToken);
142
-
143
- const [user] = await this.db.select().from(users).where(eq(users.id, toId(sub))).limit(1);
144
- if (!user || user.blocked || !user.isActive || user.isDeleted || !user.twoFactorEnabled || !user.twoFactorSecret) throw new UnauthorizedException("invalid credentials");
145
-
146
- const validTotp = verifyTotpCode(user.twoFactorSecret, input.code);
147
- const validBackup = !validTotp && (await this.twoFactor.consumeBackupCode(user.id, input.code));
148
- if (!validTotp && !validBackup) {
149
- await this.sessions.appendAuditEvent({ type: "two_factor_challenge_failed", userId: user.id.toString() }); // core-facing: string
150
- throw new UnauthorizedException("invalid two-factor code");
151
- }
152
-
153
- return this.issueSessionTokens(user, { userAgent: input.userAgent, ip: input.ip });
154
- }
155
-
156
- async enrollTwoFactor(userId: string): Promise<{ secret: string; provisioningUri: string }> {
157
- const idBig = toId(userId);
158
- const user = await this.getUserOrThrow(userId);
159
- const secret = generateTotpSecret();
160
- // Not enabled yet — confirmTwoFactor() flips that, so an abandoned enrollment never locks the account out.
161
- await this.db.update(users).set({ twoFactorSecret: secret, twoFactorEnabled: false }).where(eq(users.id, idBig));
162
- return { secret, provisioningUri: buildTotpProvisioningUri({ secret, accountName: user.email, issuer: this.config.twoFactorIssuer }) };
163
- }
164
-
165
- async confirmTwoFactor(userId: string, code: string): Promise<{ backupCodes: string[] }> {
166
- const idBig = toId(userId);
167
- const user = await this.getUserOrThrow(userId);
168
- if (!user.twoFactorSecret) throw new BadRequestException("call enrollTwoFactor first");
169
- if (!verifyTotpCode(user.twoFactorSecret, code)) throw new UnauthorizedException("invalid two-factor code");
170
-
171
- const backupCodes = generateBackupCodes();
172
- await this.twoFactor.saveBackupCodes(idBig, backupCodes.map((c) => c.hash));
173
- await this.db.update(users).set({ twoFactorEnabled: true }).where(eq(users.id, idBig));
174
- await this.sessions.appendAuditEvent({ type: "two_factor_enabled", userId });
175
- return { backupCodes: backupCodes.map((c) => c.code) };
176
- }
177
-
178
- async disableTwoFactor(userId: string, code: string): Promise<void> {
179
- const idBig = toId(userId);
180
- const user = await this.getUserOrThrow(userId);
181
- if (!user.twoFactorEnabled || !user.twoFactorSecret) throw new BadRequestException("two-factor is not enabled");
182
-
183
- const validTotp = verifyTotpCode(user.twoFactorSecret, code);
184
- const validBackup = !validTotp && (await this.twoFactor.consumeBackupCode(idBig, code));
185
- if (!validTotp && !validBackup) throw new UnauthorizedException("invalid two-factor code");
186
-
187
- await this.db.update(users).set({ twoFactorEnabled: false, twoFactorSecret: null }).where(eq(users.id, idBig));
188
- await this.twoFactor.clearBackupCodes(idBig);
189
- await this.sessions.appendAuditEvent({ type: "two_factor_disabled", userId });
190
- }
191
-
192
- /** Always succeeds from the caller's point of view — an unknown email or a throttled request looks identical, to avoid enumeration. */
193
- async requestPasswordReset(email: string): Promise<void> {
194
- const [user] = await this.db.select().from(users).where(eq(users.email, email)).limit(1);
195
- if (!user || user.isDeleted) return;
196
-
197
- const { allowed } = await checkRateLimit(this.rateLimit, "password-reset", email);
198
- if (!allowed) return;
199
-
200
- const { token } = await coreRequestPasswordReset(this.passwordReset, user.id.toString());
201
- if (this.config.sendPasswordResetEmail) await this.config.sendPasswordResetEmail(email, token);
202
- }
203
-
204
- async resetPassword(token: string, newPassword: string): Promise<void> {
205
- const { userId } = await coreResetPassword(this.passwordReset, token, newPassword);
206
- // The old password is no longer trusted, so neither are its sessions. The revoker is the user
207
- // themselves — they proved control of the account by consuming the reset token.
208
- await revokeAllSessionsForUser(this.sessions, userId, { userId });
209
- }
210
-
211
- /** Builds the redirect URL for `provider`. `state` is an opaque anti-CSRF nonce the provider echoes back verbatim. */
212
- async startOAuth(provider: string): Promise<{ url: string }> {
213
- const state = Buffer.from(JSON.stringify({ nonce: randomUUID() })).toString("base64url");
214
-
215
- if (provider === "google") {
216
- const creds = this.config.oauthProviders.google;
217
- if (!creds) throw new BadRequestException("google OAuth is not configured");
218
- return { url: buildAuthorizationUrl(GOOGLE_OIDC_PROVIDER, { clientId: creds.clientId, redirectUri: creds.redirectUri, state }) };
219
- }
220
- if (provider === "apple") {
221
- const creds = this.config.oauthProviders.apple;
222
- if (!creds) throw new BadRequestException("apple OAuth is not configured");
223
- return { url: buildAuthorizationUrl(APPLE_OIDC_PROVIDER, { clientId: creds.clientId, redirectUri: creds.redirectUri, state }) };
224
- }
225
- throw new BadRequestException(`unknown OAuth provider "${provider}"`);
226
- }
227
-
228
- async completeOAuthCallback(provider: string, code: string, _state: string): Promise<AuthTokens> {
229
- let providerDescriptor: OAuthProviderDescriptor;
230
- let clientId: string;
231
- let idToken: string;
232
-
233
- if (provider === "google") {
234
- const creds = this.config.oauthProviders.google;
235
- if (!creds) throw new BadRequestException("google OAuth is not configured");
236
- providerDescriptor = GOOGLE_OIDC_PROVIDER;
237
- clientId = creds.clientId;
238
- idToken = (
239
- await exchangeCodeForTokens(GOOGLE_OIDC_PROVIDER, { clientId: creds.clientId, clientSecret: creds.clientSecret, redirectUri: creds.redirectUri, code })
240
- ).idToken;
241
- } else if (provider === "apple") {
242
- const creds = this.config.oauthProviders.apple;
243
- if (!creds) throw new BadRequestException("apple OAuth is not configured");
244
- providerDescriptor = APPLE_OIDC_PROVIDER;
245
- clientId = creds.clientId;
246
- const clientSecret = await signAppleClientSecret({ teamId: creds.teamId, clientId: creds.clientId, keyId: creds.keyId, privateKeyPem: creds.privateKey });
247
- idToken = (await exchangeCodeForTokens(APPLE_OIDC_PROVIDER, { clientId: creds.clientId, clientSecret, redirectUri: creds.redirectUri, code })).idToken;
248
- } else {
249
- throw new BadRequestException(`unknown OAuth provider "${provider}"`);
250
- }
251
-
252
- const profile = await verifyIdTokenAndExtractProfile(providerDescriptor, { clientId, idToken });
253
- const { userId } = await completeOAuthLogin(this.oauth, { provider, profile });
254
-
255
- const user = await this.getUserOrThrow(userId);
256
- if (user.blocked || !user.isActive || user.isDeleted) throw new UnauthorizedException("account is blocked");
257
- return this.issueSessionTokens(user, { provider });
258
- }
259
-
260
- /** Pinned to the caller's workspace — the filter argument cannot widen it. */
261
- async listAuditLog(ctx: AuthzContext, filter: AuditLogListFilter): Promise<Paginated<AuditLogEntry>> {
262
- const { items, meta } = await this.auditLog.list({ ...filter, workspaceId: ctx.workspaceId });
263
- return { items: items.map(toAuditLogEntry), meta };
264
- }
265
-
266
- /** `req.auth` (the JWT claims) never carries 2FA status, so `/auth/me` fetches it fresh — the one bit of the response that isn't just echoing the token. */
267
- async getTwoFactorStatus(userId: string): Promise<{ twoFactorEnabled: boolean }> {
268
- const [user] = await this.db.select({ twoFactorEnabled: users.twoFactorEnabled }).from(users).where(eq(users.id, toId(userId))).limit(1);
269
- if (!user) throw new NotFoundException(`user "${userId}" not found`);
270
- return { twoFactorEnabled: user.twoFactorEnabled };
271
- }
272
-
273
- async listActiveSessions(userId: string): Promise<Array<{ id: string; createdAt: string; expiresAt: string; provider?: string; userAgent?: string; ip?: string }>> {
274
- // "Active" is now two conditions, not one: not revoked, and not past its own absolute expiry.
275
- // `sessions_user_active_idx` is (user_id, is_revoked, expires_at) for exactly this read.
276
- const rows = await this.db
277
- .select()
278
- .from(sessions)
279
- .where(and(eq(sessions.userId, toId(userId)), eq(sessions.isRevoked, false), gt(sessions.expiresAt, new Date())))
280
- .orderBy(desc(sessions.createdAt));
281
- return rows.map((row) => ({
282
- id: row.id.toString(),
283
- createdAt: row.createdAt.toISOString(),
284
- expiresAt: row.expiresAt.toISOString(),
285
- provider: row.provider ?? undefined,
286
- userAgent: row.userAgent ?? undefined,
287
- ip: row.ip ?? undefined,
288
- }));
289
- }
290
-
291
- // ---- workspace-scoped administration ----
292
- //
293
- // Every method below takes the caller's AuthzContext, and every one of them reaches the
294
- // database through a workspace-scoped query. That is the security property this model rests on:
295
- // an admin of one workspace has no expressible way to name a row in another.
296
-
297
- async listUsers(ctx: AuthzContext, filter: MemberListFilter): Promise<MemberListResult> {
298
- const { items, meta } = await this.rbac.listMembers(ctx.workspaceId, filter);
299
- return { items: items.map(toMemberSummary), meta };
300
- }
301
-
302
- async getUser(ctx: AuthzContext, userId: string): Promise<MemberSummary> {
303
- return this.rbac.getMember(ctx.workspaceId, userId);
304
- }
305
-
306
- async updateUser(
307
- ctx: AuthzContext,
308
- userId: string,
309
- input: { firstName?: string | null; lastName?: string | null; displayName?: string | null; phone?: string | null; username?: string | null; photo?: string | null },
310
- actorUserId: string | null,
311
- ): Promise<MemberSummary> {
312
- return this.rbac.updateMember(ctx.workspaceId, userId, input, actorUserId);
313
- }
314
-
315
- async deleteUser(ctx: AuthzContext, userId: string, actorUserId: string | null, reason?: string): Promise<void> {
316
- await this.rbac.deleteMember(ctx.workspaceId, userId, actorUserId, reason);
317
- }
318
-
319
- /**
320
- * The whole authorization vocabulary of the deployment, for a console's permission matrix. The
321
- * catalog is global; what is workspace-scoped is which of this workspace's roles point at it.
322
- */
323
- async listPermissions(): Promise<{ permissions: PermissionSummary[] }> {
324
- return { permissions: await this.rbac.listPermissions() };
325
- }
326
-
327
- /** Defines a permission, or edits one — including deactivating it. The write path for "the catalog is editable in the database". */
328
- async definePermission(input: PermissionInput, actorUserId: string | null): Promise<PermissionSummary> {
329
- return this.rbac.upsertPermission(input, actorUserId);
330
- }
331
-
332
- async listRoles(ctx: AuthzContext): Promise<{ roles: RoleSummary[] }> {
333
- return { roles: await this.rbac.listRoles(ctx.workspaceId) };
334
- }
335
-
336
- async createRole(
337
- ctx: AuthzContext,
338
- input: { slug: string; name?: string; displayName?: string; description?: string | null },
339
- actorUserId: string | null,
340
- ): Promise<RoleSummary> {
341
- return this.rbac.createRole(ctx.workspaceId, input, actorUserId);
342
- }
343
-
344
- async updateRole(
345
- ctx: AuthzContext,
346
- roleId: string,
347
- input: { name?: string; displayName?: string; description?: string | null; isActive?: boolean },
348
- actorUserId: string | null,
349
- ): Promise<RoleSummary> {
350
- return this.rbac.updateRole(ctx.workspaceId, roleId, input, actorUserId);
351
- }
352
-
353
- async deleteRole(ctx: AuthzContext, roleId: string, actorUserId: string | null, reason?: string): Promise<void> {
354
- await this.rbac.deleteRole(ctx.workspaceId, roleId, actorUserId, reason);
355
- }
356
-
357
- async attachPermissionToRole(ctx: AuthzContext, roleId: string, permissionSlug: string, actorUserId: string | null): Promise<void> {
358
- await this.rbac.attachPermissionToRole(ctx.workspaceId, roleId, permissionSlug, actorUserId);
359
- }
360
-
361
- async assignRole(ctx: AuthzContext, userId: string, roleSlug: string): Promise<void> {
362
- await this.rbac.assignRoleToMember(ctx.workspaceId, userId, roleSlug);
363
- await this.auditLog.append({ type: "role_assigned", userId, role: roleSlug }, { workspaceId: ctx.workspaceId });
364
- }
365
-
366
- async revokeRole(ctx: AuthzContext, userId: string, roleSlug: string): Promise<void> {
367
- await this.rbac.revokeRoleFromMember(ctx.workspaceId, userId, roleSlug);
368
- await this.auditLog.append({ type: "role_revoked", userId, role: roleSlug }, { workspaceId: ctx.workspaceId });
369
- }
370
-
371
- async grantPermission(ctx: AuthzContext, userId: string, permissionSlug: string, actorUserId: string | null): Promise<void> {
372
- await this.rbac.grantPermissionToMember(ctx.workspaceId, userId, permissionSlug, actorUserId);
373
- await this.auditLog.append({ type: "permission_granted", userId, permission: permissionSlug }, { workspaceId: ctx.workspaceId });
374
- }
375
-
376
- async revokePermission(ctx: AuthzContext, userId: string, permissionSlug: string): Promise<void> {
377
- await this.rbac.revokePermissionFromMember(ctx.workspaceId, userId, permissionSlug);
378
- await this.auditLog.append({ type: "permission_revoked", userId, permission: permissionSlug }, { workspaceId: ctx.workspaceId });
379
- }
380
-
381
- async refresh(refreshToken: string): Promise<Omit<AuthTokens, "sessionId">> {
382
- const key = await this.keys.getActiveKey();
383
- const presented = await verifyRefreshToken({ secret: this.keys.secret }, refreshToken);
384
- const { session, nextJti } = await rotateRefreshToken(this.sessions, presented);
385
-
386
- const [user] = await this.db.select().from(users).where(eq(users.id, toId(session.userId))).limit(1);
387
- if (!user || user.blocked || !user.isActive || user.isDeleted) throw new UnauthorizedException("invalid credentials");
388
-
389
- // Identity only. Authorization is resolved from the database on every request (AuthzGuard),
390
- // so a token issued before a grant is as authoritative as one issued after it.
391
- const access = await signAccessToken(
392
- { activeKey: key },
393
- { sub: user.id.toString(), sessionId: session.id },
394
- { ttlSeconds: this.config.accessTokenTtlSeconds },
395
- );
396
- const refresh = await signRefreshToken(
397
- { activeKey: key },
398
- { sub: user.id.toString(), sessionId: session.id, sv: session.sessionVersion },
399
- { jti: nextJti, ttlSeconds: this.config.refreshTokenTtlSeconds },
400
- );
401
- return { accessToken: access.token, refreshToken: refresh.token };
402
- }
403
-
404
- async logout(sessionId: string, accessJti: string, accessRemainingTtlSeconds: number, revoker?: Revoker): Promise<void> {
405
- await revokeSession(this.sessions, sessionId, revoker);
406
- await revokeAccessToken(this.sessions, accessJti, accessRemainingTtlSeconds);
407
- }
408
-
409
- async logoutAll(userId: string, revoker?: Revoker): Promise<void> {
410
- await revokeAllSessionsForUser(this.sessions, userId, revoker);
411
- }
412
-
413
- async logoutOthers(userId: string, keepSessionId: string, revoker?: Revoker): Promise<void> {
414
- await revokeOtherSessionsForUser(this.sessions, userId, keepSessionId, revoker);
415
- }
416
-
417
- async changePassword(userId: string, currentSessionId: string, input: { currentPassword: string; newPassword: string }): Promise<void> {
418
- const userIdBig = toId(userId);
419
- const [user] = await this.db.select().from(users).where(eq(users.id, userIdBig)).limit(1);
420
- if (!user || user.blocked || !user.isActive || user.isDeleted || !user.passwordHash) throw new UnauthorizedException("invalid credentials");
421
-
422
- const valid = await verifyPassword(user.passwordHash, input.currentPassword);
423
- if (!valid) throw new UnauthorizedException("invalid credentials");
424
-
425
- const passwordHash = await hashPassword(input.newPassword);
426
- await this.db.update(users).set({ passwordHash, updatedBy: userIdBig }).where(eq(users.id, userIdBig));
427
- // The old password is no longer trusted everywhere else it's signed in — but leave the
428
- // session making this very call alone, the same courtesy `logoutOthers` extends.
429
- await revokeOtherSessionsForUser(this.sessions, userId, currentSessionId, { userId });
430
- }
431
-
432
- /** Backs both `GET`- and `PATCH /auth/me` — deliberately not workspace-scoped, see `updateProfile`. */
433
- private toSelfProfile(user: typeof users.$inferSelect): SelfProfile {
434
- return {
435
- id: user.id.toString(),
436
- email: user.email,
437
- firstName: user.firstName,
438
- lastName: user.lastName,
439
- displayName: user.displayName,
440
- phone: user.phone,
441
- username: user.username,
442
- photo: user.photo,
443
- createdAt: user.createdAt.toISOString(),
444
- };
445
- }
446
-
447
- /** Self-service — no `users:manage` permission required, callable by anyone on their own row, and deliberately not workspace-scoped, see `updateProfile`. */
448
- async getProfile(userId: string): Promise<SelfProfile> {
449
- const userIdBig = toId(userId);
450
- const [user] = await this.db
451
- .select()
452
- .from(users)
453
- .where(and(eq(users.id, userIdBig), eq(users.isDeleted, false)))
454
- .limit(1);
455
- if (!user) throw new UnauthorizedException("invalid credentials");
456
- return this.toSelfProfile(user);
457
- }
458
-
459
- /**
460
- * Self-service — no `users:manage` permission required, the caller's own row only, and
461
- * deliberately not workspace-scoped: a profile belongs to the account, not to any one
462
- * membership, so this updates the same `users` row `updateMember` would, without requiring an
463
- * `X-Workspace-Id` or a membership in it.
464
- */
465
- async updateProfile(
466
- userId: string,
467
- input: { firstName?: string | null; lastName?: string | null; displayName?: string | null; phone?: string | null; username?: string | null; photo?: string | null },
468
- ): Promise<SelfProfile> {
469
- const userIdBig = toId(userId);
470
- const [existing] = await this.db
471
- .select({ id: users.id })
472
- .from(users)
473
- .where(and(eq(users.id, userIdBig), eq(users.isDeleted, false)))
474
- .limit(1);
475
- if (!existing) throw new UnauthorizedException("invalid credentials");
476
-
477
- const changed = Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined));
478
- const [user] = await this.db
479
- .update(users)
480
- .set({ ...changed, updatedBy: userIdBig })
481
- .where(eq(users.id, userIdBig))
482
- .returning();
483
- return this.toSelfProfile(user);
484
- }
485
-
486
- /**
487
- * Blocking is an account-level action, so it is gated on the target being a member of the
488
- * caller's workspace — otherwise an admin of one workspace could disable an account they
489
- * have no relationship with.
490
- */
491
- async block(ctx: AuthzContext, userId: string, revoker?: Revoker): Promise<void> {
492
- await this.rbac.requireMember(ctx.workspaceId, userId);
493
- await this.db.update(users).set({ blocked: true, updatedBy: toIdOrNull(revoker?.userId) }).where(eq(users.id, toId(userId)));
494
- // The administrator, not the blocked user, is what lands in `sessions.revoked_by`.
495
- await blockUser(this.sessions, userId, revoker);
496
- // Belt and braces. The block is enforced on the authentication path — login and refresh both
497
- // refuse a blocked user, and AuthGuard never consults the permission cache — so a warm entry
498
- // cannot defeat it. Dropping the entry anyway means nothing about a blocked account is being
499
- // served from memory.
500
- await this.rbac.invalidateMember(userId, ctx.workspaceId);
501
- }
502
-
503
- async unblock(ctx: AuthzContext, userId: string, revoker?: Revoker): Promise<void> {
504
- await this.rbac.requireMember(ctx.workspaceId, userId);
505
- await this.db.update(users).set({ blocked: false, updatedBy: toIdOrNull(revoker?.userId) }).where(eq(users.id, toId(userId)));
506
- }
507
-
508
- /**
509
- * A routine administrative on/off toggle — distinct from `block`/`unblock`, which is a
510
- * security/moderation action. Both independently deny login; see the note on the `users` table.
511
- */
512
- async deactivate(ctx: AuthzContext, userId: string, revoker?: Revoker): Promise<void> {
513
- await this.rbac.requireMember(ctx.workspaceId, userId);
514
- await this.db.update(users).set({ isActive: false, updatedBy: toIdOrNull(revoker?.userId) }).where(eq(users.id, toId(userId)));
515
- await deactivateUser(this.sessions, userId, revoker);
516
- await this.rbac.invalidateMember(userId, ctx.workspaceId);
517
- }
518
-
519
- async activate(ctx: AuthzContext, userId: string, revoker?: Revoker): Promise<void> {
520
- await this.rbac.requireMember(ctx.workspaceId, userId);
521
- await this.db.update(users).set({ isActive: true, updatedBy: toIdOrNull(revoker?.userId) }).where(eq(users.id, toId(userId)));
522
- }
523
-
524
- private async getUserOrThrow(userId: string): Promise<typeof users.$inferSelect> {
525
- const [user] = await this.db.select().from(users).where(eq(users.id, toId(userId))).limit(1);
526
- if (!user) throw new NotFoundException(`user "${userId}" not found`);
527
- return user;
528
- }
529
-
530
- /**
531
- * The access token identifies the user and the session, and carries no authorization: roles
532
- * and permissions belong to a workspace membership, and this token is valid in all of them.
533
- * Each request resolves its own — see AuthzGuard. Takes the raw database user row (bigint id)
534
- * — every call site already has one in hand.
535
- */
536
- private async issueSessionTokens(user: { id: bigint }, meta: { userAgent?: string; ip?: string; provider?: string }): Promise<AuthTokens> {
537
- const session = await createSession(this.sessions, { userId: user.id.toString(), ...meta });
538
- const key = await this.keys.getActiveKey();
539
-
540
- const access = await signAccessToken(
541
- { activeKey: key },
542
- { sub: user.id.toString(), sessionId: session.id },
543
- { ttlSeconds: this.config.accessTokenTtlSeconds },
544
- );
545
- const refresh = await signRefreshToken(
546
- { activeKey: key },
547
- { sub: user.id.toString(), sessionId: session.id, sv: session.sessionVersion },
548
- { jti: session.currentRefreshJti, ttlSeconds: this.config.refreshTokenTtlSeconds },
549
- );
550
-
551
- return { accessToken: access.token, refreshToken: refresh.token, sessionId: session.id };
552
- }
553
- }
@@ -1,29 +0,0 @@
1
- import { Inject, Injectable } from "@nestjs/common";
2
- import type { PasswordResetStoreDeps } from "@/lib/auth/core/password-reset.js";
3
- import { PrismaClient } from "../generated/prisma/client.js";
4
- import { toId } from "./id.helper.js";
5
-
6
- @Injectable()
7
- export class PasswordResetRepository implements PasswordResetStoreDeps {
8
- constructor(@Inject(PrismaClient) private readonly prisma: PrismaClient) {}
9
-
10
- async saveResetToken(input: { userId: string; tokenHash: string; expiresAt: string }): Promise<void> {
11
- await this.prisma.passwordResetToken.create({
12
- data: { userId: toId(input.userId), tokenHash: input.tokenHash, expiresAt: new Date(input.expiresAt) },
13
- });
14
- }
15
-
16
- async findValidResetToken(tokenHash: string): Promise<{ userId: string; expiresAt: string; consumedAt: string | null } | null> {
17
- const row = await this.prisma.passwordResetToken.findUnique({ where: { tokenHash } });
18
- if (!row) return null;
19
- return { userId: row.userId.toString(), expiresAt: row.expiresAt.toISOString(), consumedAt: row.consumedAt?.toISOString() ?? null };
20
- }
21
-
22
- async consumeResetToken(tokenHash: string): Promise<void> {
23
- await this.prisma.passwordResetToken.update({ where: { tokenHash }, data: { consumedAt: new Date() } });
24
- }
25
-
26
- async setPasswordHash(userId: string, passwordHash: string): Promise<void> {
27
- await this.prisma.user.update({ where: { id: toId(userId) }, data: { passwordHash } });
28
- }
29
- }