@simple-auth-kit/cli 1.4.3 → 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.
- package/package.json +1 -1
- package/registry/combos/express-drizzle/shared/src/{auth-core-error.middleware.ts → middleware/auth-core-error.middleware.ts} +17 -4
- package/registry/combos/express-drizzle/shared/src/{auth.middleware.ts → middleware/auth.middleware.ts} +23 -7
- package/registry/combos/{express-prisma/shared/src → express-drizzle/shared/src/middleware}/response-envelope.middleware.ts +15 -3
- package/registry/combos/express-drizzle/shared/src/{oauth.repository.ts → repositories/oauth.repository.ts} +32 -9
- package/registry/combos/express-drizzle/shared/src/repositories/password-reset.repository.ts +59 -0
- package/registry/combos/express-drizzle/shared/src/{session.repository.ts → repositories/session.repository.ts} +52 -14
- package/registry/combos/express-drizzle/shared/src/{two-factor.repository.ts → repositories/two-factor.repository.ts} +20 -6
- package/registry/combos/express-drizzle/shared/src/request-context.ts +1 -1
- package/registry/combos/express-drizzle/shared/src/routers/auth.router.ts +395 -0
- package/registry/combos/express-drizzle/variants/base/src/create-auth-app.ts +52 -19
- package/registry/combos/express-drizzle/variants/base/src/{authz.middleware.ts → middleware/authz.middleware.ts} +15 -7
- package/registry/combos/express-drizzle/variants/base/src/{audit-log.repository.ts → repositories/audit-log.repository.ts} +28 -10
- package/registry/combos/express-drizzle/variants/base/src/{rbac.repository.ts → repositories/rbac.repository.ts} +239 -48
- package/registry/combos/express-drizzle/variants/base/src/routers/admin.router.ts +497 -0
- package/registry/combos/express-drizzle/variants/base/src/{auth.service.ts → services/auth.service.ts} +480 -109
- package/registry/combos/express-drizzle/variants/workspaces/src/create-auth-app.ts +69 -23
- package/registry/combos/express-drizzle/variants/workspaces/src/{authz.middleware.ts → middleware/authz.middleware.ts} +32 -12
- package/registry/combos/express-drizzle/variants/workspaces/src/openapi-workspace.ts +147 -28
- package/registry/combos/express-drizzle/variants/workspaces/src/{audit-log.repository.ts → repositories/audit-log.repository.ts} +30 -11
- package/registry/combos/express-drizzle/variants/workspaces/src/{rbac.repository.ts → repositories/rbac.repository.ts} +319 -70
- package/registry/combos/express-drizzle/variants/workspaces/src/{workspace.repository.ts → repositories/workspace.repository.ts} +127 -30
- package/registry/combos/express-drizzle/variants/workspaces/src/routers/admin.router.ts +534 -0
- package/registry/combos/express-drizzle/variants/workspaces/src/routers/workspace.router.ts +194 -0
- package/registry/combos/express-drizzle/variants/workspaces/src/{auth.service.ts → services/auth.service.ts} +544 -122
- package/registry/combos/express-prisma/shared/src/{auth-core-error.middleware.ts → middleware/auth-core-error.middleware.ts} +17 -4
- package/registry/combos/express-prisma/shared/src/{auth.middleware.ts → middleware/auth.middleware.ts} +19 -6
- package/registry/combos/{express-drizzle/shared/src → express-prisma/shared/src/middleware}/response-envelope.middleware.ts +15 -3
- package/registry/combos/express-prisma/shared/src/{oauth.repository.ts → repositories/oauth.repository.ts} +27 -9
- package/registry/combos/express-prisma/shared/src/repositories/password-reset.repository.ts +55 -0
- package/registry/combos/express-prisma/shared/src/{session.repository.ts → repositories/session.repository.ts} +30 -9
- package/registry/combos/express-prisma/shared/src/{two-factor.repository.ts → repositories/two-factor.repository.ts} +11 -4
- package/registry/combos/express-prisma/shared/src/request-context.ts +1 -1
- package/registry/combos/express-prisma/shared/src/routers/auth.router.ts +398 -0
- package/registry/combos/express-prisma/variants/base/src/create-auth-app.ts +57 -22
- package/registry/combos/express-prisma/variants/base/src/{authz.middleware.ts → middleware/authz.middleware.ts} +15 -7
- package/registry/combos/express-prisma/variants/base/src/rbac.defaults.ts +67 -19
- package/registry/combos/express-prisma/variants/base/src/{audit-log.repository.ts → repositories/audit-log.repository.ts} +16 -5
- package/registry/combos/express-prisma/variants/base/src/{rbac.repository.ts → repositories/rbac.repository.ts} +199 -48
- package/registry/combos/express-prisma/variants/base/src/routers/admin.router.ts +508 -0
- package/registry/combos/express-prisma/variants/base/src/seed.ts +78 -20
- package/registry/combos/express-prisma/variants/base/src/{auth.service.ts → services/auth.service.ts} +454 -105
- package/registry/combos/express-prisma/variants/workspaces/src/create-auth-app.ts +81 -26
- package/registry/combos/express-prisma/variants/workspaces/src/{authz.middleware.ts → middleware/authz.middleware.ts} +32 -12
- package/registry/combos/express-prisma/variants/workspaces/src/openapi-workspace.ts +147 -28
- package/registry/combos/express-prisma/variants/workspaces/src/rbac.defaults.ts +73 -21
- package/registry/combos/express-prisma/variants/workspaces/src/{audit-log.repository.ts → repositories/audit-log.repository.ts} +16 -5
- package/registry/combos/express-prisma/variants/workspaces/src/{rbac.repository.ts → repositories/rbac.repository.ts} +272 -64
- package/registry/combos/express-prisma/variants/workspaces/src/{workspace.repository.ts → repositories/workspace.repository.ts} +86 -23
- package/registry/combos/express-prisma/variants/workspaces/src/routers/admin.router.ts +545 -0
- package/registry/combos/express-prisma/variants/workspaces/src/routers/workspace.router.ts +194 -0
- package/registry/combos/express-prisma/variants/workspaces/src/seed.ts +109 -29
- package/registry/combos/express-prisma/variants/workspaces/src/{auth.service.ts → services/auth.service.ts} +518 -119
- package/registry/combos/nestjs-drizzle/shared/src/{auth.controller.ts → controllers/auth.controller.ts} +6 -6
- package/registry/combos/nestjs-drizzle/shared/src/{auth-core-error.filter.ts → filters/auth-core-error.filter.ts} +29 -5
- package/registry/combos/nestjs-drizzle/shared/src/{ability.guard.ts → guards/ability.guard.ts} +20 -7
- package/registry/combos/nestjs-drizzle/shared/src/{auth.guard.ts → guards/auth.guard.ts} +19 -7
- package/registry/combos/{nestjs-prisma/shared/src → nestjs-drizzle/shared/src/interceptors}/response.interceptor.ts +10 -2
- package/registry/combos/nestjs-drizzle/shared/src/repositories/oauth.repository.ts +63 -0
- package/registry/combos/nestjs-drizzle/shared/src/repositories/password-reset.repository.ts +59 -0
- package/registry/combos/nestjs-drizzle/shared/src/{session.repository.ts → repositories/session.repository.ts} +52 -14
- package/registry/combos/nestjs-drizzle/shared/src/{two-factor.repository.ts → repositories/two-factor.repository.ts} +21 -7
- package/registry/combos/nestjs-drizzle/shared/src/request-context.ts +1 -1
- package/registry/combos/nestjs-drizzle/test/prove-cycle.ts +1097 -307
- package/registry/combos/nestjs-drizzle/variants/base/src/auth.module.ts +44 -20
- package/registry/combos/nestjs-drizzle/variants/base/src/{admin.controller.ts → controllers/admin.controller.ts} +228 -62
- package/registry/combos/nestjs-drizzle/variants/base/src/{authz.guard.ts → guards/authz.guard.ts} +12 -5
- package/registry/combos/nestjs-drizzle/variants/base/src/{audit-log.repository.ts → repositories/audit-log.repository.ts} +27 -10
- package/registry/combos/nestjs-drizzle/variants/base/src/{rbac.repository.ts → repositories/rbac.repository.ts} +249 -51
- package/registry/combos/nestjs-drizzle/variants/base/src/{auth.service.ts → services/auth.service.ts} +499 -116
- package/registry/combos/nestjs-drizzle/variants/workspaces/src/auth.module.ts +48 -22
- package/registry/combos/nestjs-drizzle/variants/workspaces/src/{admin.controller.ts → controllers/admin.controller.ts} +269 -70
- package/registry/combos/nestjs-drizzle/variants/workspaces/src/{workspace.controller.ts → controllers/workspace.controller.ts} +93 -26
- package/registry/combos/nestjs-drizzle/variants/workspaces/src/{authz.guard.ts → guards/authz.guard.ts} +26 -8
- package/registry/combos/nestjs-drizzle/variants/workspaces/src/{audit-log.repository.ts → repositories/audit-log.repository.ts} +29 -11
- package/registry/combos/nestjs-drizzle/variants/workspaces/src/{rbac.repository.ts → repositories/rbac.repository.ts} +323 -71
- package/registry/combos/nestjs-drizzle/variants/workspaces/src/{workspace.repository.ts → repositories/workspace.repository.ts} +131 -30
- package/registry/combos/nestjs-drizzle/variants/workspaces/src/services/auth.service.ts +988 -0
- package/registry/combos/nestjs-prisma/shared/src/{auth.controller.ts → controllers/auth.controller.ts} +6 -6
- package/registry/combos/nestjs-prisma/shared/src/{auth-core-error.filter.ts → filters/auth-core-error.filter.ts} +29 -5
- package/registry/combos/nestjs-prisma/shared/src/{ability.guard.ts → guards/ability.guard.ts} +20 -7
- package/registry/combos/nestjs-prisma/shared/src/{auth.guard.ts → guards/auth.guard.ts} +19 -7
- package/registry/combos/{nestjs-drizzle/shared/src → nestjs-prisma/shared/src/interceptors}/response.interceptor.ts +10 -2
- package/registry/combos/nestjs-prisma/shared/src/{oauth.repository.ts → repositories/oauth.repository.ts} +25 -8
- package/registry/combos/nestjs-prisma/shared/src/repositories/password-reset.repository.ts +55 -0
- package/registry/combos/nestjs-prisma/shared/src/{session.repository.ts → repositories/session.repository.ts} +30 -9
- package/registry/combos/nestjs-prisma/shared/src/{two-factor.repository.ts → repositories/two-factor.repository.ts} +11 -4
- package/registry/combos/nestjs-prisma/shared/src/request-context.ts +1 -1
- package/registry/combos/nestjs-prisma/test/prove-cycle.ts +1097 -307
- package/registry/combos/nestjs-prisma/variants/base/src/auth.module.ts +59 -28
- package/registry/combos/nestjs-prisma/variants/base/src/{admin.controller.ts → controllers/admin.controller.ts} +469 -110
- package/registry/combos/nestjs-prisma/variants/base/src/{audit-log.gateway.ts → gateways/audit-log.gateway.ts} +22 -6
- package/registry/combos/nestjs-prisma/variants/base/src/{authz.guard.ts → guards/authz.guard.ts} +12 -5
- package/registry/combos/nestjs-prisma/variants/base/src/rbac.defaults.ts +98 -27
- package/registry/combos/nestjs-prisma/variants/base/src/{audit-log.repository.ts → repositories/audit-log.repository.ts} +16 -5
- package/registry/combos/nestjs-prisma/variants/base/src/{country.repository.ts → repositories/country.repository.ts} +98 -25
- package/registry/combos/nestjs-prisma/variants/base/src/{customer.repository.ts → repositories/customer.repository.ts} +106 -27
- package/registry/combos/nestjs-prisma/variants/base/src/{language.repository.ts → repositories/language.repository.ts} +93 -25
- package/registry/combos/nestjs-prisma/variants/base/src/{rbac.repository.ts → repositories/rbac.repository.ts} +229 -55
- package/registry/combos/nestjs-prisma/variants/base/src/seed.ts +78 -20
- package/registry/combos/nestjs-prisma/variants/base/src/services/auth.service.ts +1064 -0
- package/registry/combos/nestjs-prisma/variants/workspaces/src/auth.module.ts +55 -27
- package/registry/combos/nestjs-prisma/variants/workspaces/src/{admin.controller.ts → controllers/admin.controller.ts} +544 -128
- package/registry/combos/nestjs-prisma/variants/workspaces/src/{workspace.controller.ts → controllers/workspace.controller.ts} +93 -26
- package/registry/combos/nestjs-prisma/variants/workspaces/src/{authz.guard.ts → guards/authz.guard.ts} +26 -8
- package/registry/combos/nestjs-prisma/variants/workspaces/src/rbac.defaults.ts +104 -29
- package/registry/combos/nestjs-prisma/variants/workspaces/src/{audit-log.repository.ts → repositories/audit-log.repository.ts} +16 -5
- package/registry/combos/nestjs-prisma/variants/workspaces/src/{country.repository.ts → repositories/country.repository.ts} +127 -26
- package/registry/combos/nestjs-prisma/variants/workspaces/src/{customer.repository.ts → repositories/customer.repository.ts} +137 -28
- package/registry/combos/nestjs-prisma/variants/workspaces/src/repositories/language.repository.ts +243 -0
- package/registry/combos/nestjs-prisma/variants/workspaces/src/{rbac.repository.ts → repositories/rbac.repository.ts} +314 -74
- package/registry/combos/nestjs-prisma/variants/workspaces/src/{workspace.repository.ts → repositories/workspace.repository.ts} +94 -24
- package/registry/combos/nestjs-prisma/variants/workspaces/src/seed.ts +109 -29
- package/registry/combos/nestjs-prisma/variants/workspaces/src/services/auth.service.ts +961 -0
- package/registry.json +4 -4
- package/simple-auth-kit.ts +29 -2
- package/registry/combos/express-drizzle/shared/src/auth.router.ts +0 -266
- package/registry/combos/express-drizzle/shared/src/password-reset.repository.ts +0 -35
- package/registry/combos/express-drizzle/variants/base/src/admin.router.ts +0 -307
- package/registry/combos/express-drizzle/variants/workspaces/src/admin.router.ts +0 -323
- package/registry/combos/express-drizzle/variants/workspaces/src/workspace.router.ts +0 -123
- package/registry/combos/express-prisma/shared/src/auth.router.ts +0 -269
- package/registry/combos/express-prisma/shared/src/password-reset.repository.ts +0 -29
- package/registry/combos/express-prisma/variants/base/src/admin.router.ts +0 -314
- package/registry/combos/express-prisma/variants/workspaces/src/admin.router.ts +0 -330
- package/registry/combos/express-prisma/variants/workspaces/src/workspace.router.ts +0 -123
- package/registry/combos/nestjs-drizzle/shared/src/oauth.repository.ts +0 -36
- package/registry/combos/nestjs-drizzle/shared/src/password-reset.repository.ts +0 -29
- package/registry/combos/nestjs-drizzle/variants/workspaces/src/auth.service.ts +0 -553
- package/registry/combos/nestjs-prisma/shared/src/password-reset.repository.ts +0 -29
- package/registry/combos/nestjs-prisma/variants/base/src/auth.service.ts +0 -618
- package/registry/combos/nestjs-prisma/variants/workspaces/src/auth.service.ts +0 -509
- package/registry/combos/nestjs-prisma/variants/workspaces/src/language.repository.ts +0 -143
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
BadRequestException,
|
|
4
|
+
ConflictException,
|
|
5
|
+
HttpException,
|
|
6
|
+
HttpStatus,
|
|
7
|
+
Inject,
|
|
8
|
+
Injectable,
|
|
9
|
+
NotFoundException,
|
|
10
|
+
UnauthorizedException,
|
|
11
|
+
} from "@nestjs/common";
|
|
3
12
|
import { and, desc, eq, gt, or } from "drizzle-orm";
|
|
4
13
|
import { hashPassword, verifyPassword } from "@/lib/auth/core/crypto.js";
|
|
5
14
|
import {
|
|
@@ -12,8 +21,14 @@ import {
|
|
|
12
21
|
signAppleClientSecret,
|
|
13
22
|
verifyIdTokenAndExtractProfile,
|
|
14
23
|
} from "@/lib/auth/core/oauth.js";
|
|
15
|
-
import {
|
|
16
|
-
|
|
24
|
+
import {
|
|
25
|
+
requestPasswordReset as coreRequestPasswordReset,
|
|
26
|
+
resetPassword as coreResetPassword,
|
|
27
|
+
} from "@/lib/auth/core/password-reset.js";
|
|
28
|
+
import {
|
|
29
|
+
checkRateLimit,
|
|
30
|
+
type RateLimitDeps,
|
|
31
|
+
} from "@/lib/auth/core/rate-limit.js";
|
|
17
32
|
import {
|
|
18
33
|
blockUser,
|
|
19
34
|
createSession,
|
|
@@ -24,22 +39,47 @@ import {
|
|
|
24
39
|
revokeSession,
|
|
25
40
|
rotateRefreshToken,
|
|
26
41
|
} from "@/lib/auth/core/session-policy.js";
|
|
27
|
-
import {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
import {
|
|
35
|
-
|
|
36
|
-
|
|
42
|
+
import {
|
|
43
|
+
signAccessToken,
|
|
44
|
+
signRefreshToken,
|
|
45
|
+
signTwoFactorChallengeToken,
|
|
46
|
+
verifyRefreshToken,
|
|
47
|
+
verifyTwoFactorChallengeToken,
|
|
48
|
+
} from "@/lib/auth/core/token-service.js";
|
|
49
|
+
import {
|
|
50
|
+
buildTotpProvisioningUri,
|
|
51
|
+
generateBackupCodes,
|
|
52
|
+
generateTotpSecret,
|
|
53
|
+
verifyTotpCode,
|
|
54
|
+
} from "@/lib/auth/core/two-factor.js";
|
|
55
|
+
import { AUTH_CONFIG, AuthConfig } from "../auth.config.js";
|
|
56
|
+
import {
|
|
57
|
+
AuditLogEntry,
|
|
58
|
+
AuditLogListFilter,
|
|
59
|
+
AuditLogRepository,
|
|
60
|
+
toAuditLogEntry,
|
|
61
|
+
} from "../repositories/audit-log.repository.js";
|
|
62
|
+
import type { Paginated } from "../pagination.js";
|
|
63
|
+
import { DRIZZLE_DB, type Database } from "../db.js";
|
|
64
|
+
import { KeyProviderService } from "../key-provider.js";
|
|
65
|
+
import { OAuthRepository } from "../repositories/oauth.repository.js";
|
|
66
|
+
import { PasswordResetRepository } from "../repositories/password-reset.repository.js";
|
|
67
|
+
import { RATE_LIMIT_STORE } from "../rate-limit.store.js";
|
|
37
68
|
import type { Revoker } from "@/lib/auth/core/types.js";
|
|
38
|
-
import {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
69
|
+
import {
|
|
70
|
+
PermissionInput,
|
|
71
|
+
PermissionSummary,
|
|
72
|
+
RbacRepository,
|
|
73
|
+
RoleSummary,
|
|
74
|
+
toUserSummary,
|
|
75
|
+
UserListFilter,
|
|
76
|
+
UserListResult,
|
|
77
|
+
UserSummary,
|
|
78
|
+
} from "../repositories/rbac.repository.js";
|
|
79
|
+
import { sessions, users } from "../schema.js";
|
|
80
|
+
import { SessionRepository } from "../repositories/session.repository.js";
|
|
81
|
+
import { TwoFactorRepository } from "../repositories/two-factor.repository.js";
|
|
82
|
+
import { toId, toIdOrNull } from "../id.helper.js";
|
|
43
83
|
|
|
44
84
|
export interface AuthTokens {
|
|
45
85
|
accessToken: string;
|
|
@@ -74,9 +114,11 @@ export class AuthService {
|
|
|
74
114
|
@Inject(RATE_LIMIT_STORE) private readonly rateLimit: RateLimitDeps,
|
|
75
115
|
@Inject(AuditLogRepository) private readonly auditLog: AuditLogRepository,
|
|
76
116
|
@Inject(RbacRepository) private readonly rbac: RbacRepository,
|
|
77
|
-
@Inject(TwoFactorRepository)
|
|
117
|
+
@Inject(TwoFactorRepository)
|
|
118
|
+
private readonly twoFactor: TwoFactorRepository,
|
|
78
119
|
@Inject(OAuthRepository) private readonly oauth: OAuthRepository,
|
|
79
|
-
@Inject(PasswordResetRepository)
|
|
120
|
+
@Inject(PasswordResetRepository)
|
|
121
|
+
private readonly passwordReset: PasswordResetRepository,
|
|
80
122
|
@Inject(AUTH_CONFIG) private readonly config: AuthConfig,
|
|
81
123
|
) {}
|
|
82
124
|
|
|
@@ -92,7 +134,11 @@ export class AuthService {
|
|
|
92
134
|
userAgent?: string;
|
|
93
135
|
ip?: string;
|
|
94
136
|
}): Promise<AuthTokens> {
|
|
95
|
-
const [existing] = await this.db
|
|
137
|
+
const [existing] = await this.db
|
|
138
|
+
.select()
|
|
139
|
+
.from(users)
|
|
140
|
+
.where(eq(users.email, input.email))
|
|
141
|
+
.limit(1);
|
|
96
142
|
if (existing) throw new ConflictException("email already registered");
|
|
97
143
|
|
|
98
144
|
const passwordHash = await hashPassword(input.password);
|
|
@@ -111,100 +157,214 @@ export class AuthService {
|
|
|
111
157
|
// The signup default is whichever roles are flagged `isDefault` in the database, not a name
|
|
112
158
|
// spelled in code — see rbac.defaults.ts.
|
|
113
159
|
await this.rbac.assignDefaultRoles(user.id);
|
|
114
|
-
return this.issueSessionTokens(user, {
|
|
160
|
+
return this.issueSessionTokens(user, {
|
|
161
|
+
userAgent: input.userAgent,
|
|
162
|
+
ip: input.ip,
|
|
163
|
+
});
|
|
115
164
|
}
|
|
116
165
|
|
|
117
166
|
/** Returns full tokens directly, or a short-lived challenge if the account has 2FA enabled — see `loginTwoFactor`. */
|
|
118
|
-
async login(input: {
|
|
119
|
-
|
|
120
|
-
|
|
167
|
+
async login(input: {
|
|
168
|
+
identifier: string;
|
|
169
|
+
password: string;
|
|
170
|
+
userAgent?: string;
|
|
171
|
+
ip?: string;
|
|
172
|
+
}): Promise<AuthTokens | TwoFactorChallenge> {
|
|
173
|
+
const { allowed } = await checkRateLimit(
|
|
174
|
+
this.rateLimit,
|
|
175
|
+
"login",
|
|
176
|
+
input.identifier,
|
|
177
|
+
);
|
|
178
|
+
if (!allowed)
|
|
179
|
+
throw new HttpException(
|
|
180
|
+
"too many login attempts",
|
|
181
|
+
HttpStatus.TOO_MANY_REQUESTS,
|
|
182
|
+
);
|
|
121
183
|
|
|
122
184
|
const [user] = await this.db
|
|
123
185
|
.select()
|
|
124
186
|
.from(users)
|
|
125
|
-
.where(
|
|
187
|
+
.where(
|
|
188
|
+
or(
|
|
189
|
+
eq(users.email, input.identifier),
|
|
190
|
+
eq(users.username, input.identifier),
|
|
191
|
+
eq(users.phone, input.identifier),
|
|
192
|
+
),
|
|
193
|
+
)
|
|
126
194
|
.limit(1);
|
|
127
|
-
if (
|
|
195
|
+
if (
|
|
196
|
+
!user ||
|
|
197
|
+
user.blocked ||
|
|
198
|
+
!user.isActive ||
|
|
199
|
+
user.isDeleted ||
|
|
200
|
+
!user.passwordHash
|
|
201
|
+
)
|
|
202
|
+
throw new UnauthorizedException("invalid credentials");
|
|
128
203
|
|
|
129
204
|
const valid = await verifyPassword(user.passwordHash, input.password);
|
|
130
205
|
if (!valid) throw new UnauthorizedException("invalid credentials");
|
|
131
206
|
|
|
132
207
|
if (user.twoFactorEnabled) {
|
|
133
208
|
const key = await this.keys.getActiveKey();
|
|
134
|
-
const { token } = await signTwoFactorChallengeToken(
|
|
209
|
+
const { token } = await signTwoFactorChallengeToken(
|
|
210
|
+
{ activeKey: key },
|
|
211
|
+
user.id.toString(),
|
|
212
|
+
); // core-facing: sub must be string
|
|
135
213
|
return { twoFactorRequired: true, challengeToken: token };
|
|
136
214
|
}
|
|
137
215
|
|
|
138
|
-
return this.issueSessionTokens(user, {
|
|
216
|
+
return this.issueSessionTokens(user, {
|
|
217
|
+
userAgent: input.userAgent,
|
|
218
|
+
ip: input.ip,
|
|
219
|
+
});
|
|
139
220
|
}
|
|
140
221
|
|
|
141
222
|
/** Completes the challenge `login()` returned when 2FA is enabled — a TOTP code or an unused backup code. */
|
|
142
|
-
async loginTwoFactor(input: {
|
|
143
|
-
|
|
223
|
+
async loginTwoFactor(input: {
|
|
224
|
+
challengeToken: string;
|
|
225
|
+
code: string;
|
|
226
|
+
userAgent?: string;
|
|
227
|
+
ip?: string;
|
|
228
|
+
}): Promise<AuthTokens> {
|
|
229
|
+
const { sub } = await verifyTwoFactorChallengeToken(
|
|
230
|
+
{ secret: this.keys.secret },
|
|
231
|
+
input.challengeToken,
|
|
232
|
+
);
|
|
144
233
|
|
|
145
|
-
const [user] = await this.db
|
|
146
|
-
|
|
234
|
+
const [user] = await this.db
|
|
235
|
+
.select()
|
|
236
|
+
.from(users)
|
|
237
|
+
.where(eq(users.id, toId(sub)))
|
|
238
|
+
.limit(1);
|
|
239
|
+
if (
|
|
240
|
+
!user ||
|
|
241
|
+
user.blocked ||
|
|
242
|
+
!user.isActive ||
|
|
243
|
+
user.isDeleted ||
|
|
244
|
+
!user.twoFactorEnabled ||
|
|
245
|
+
!user.twoFactorSecret
|
|
246
|
+
)
|
|
247
|
+
throw new UnauthorizedException("invalid credentials");
|
|
147
248
|
|
|
148
249
|
const validTotp = verifyTotpCode(user.twoFactorSecret, input.code);
|
|
149
|
-
const validBackup =
|
|
250
|
+
const validBackup =
|
|
251
|
+
!validTotp &&
|
|
252
|
+
(await this.twoFactor.consumeBackupCode(user.id, input.code));
|
|
150
253
|
if (!validTotp && !validBackup) {
|
|
151
|
-
await this.sessions.appendAuditEvent({
|
|
254
|
+
await this.sessions.appendAuditEvent({
|
|
255
|
+
type: "two_factor_challenge_failed",
|
|
256
|
+
userId: user.id.toString(),
|
|
257
|
+
}); // core-facing: string
|
|
152
258
|
throw new UnauthorizedException("invalid two-factor code");
|
|
153
259
|
}
|
|
154
260
|
|
|
155
|
-
return this.issueSessionTokens(user, {
|
|
261
|
+
return this.issueSessionTokens(user, {
|
|
262
|
+
userAgent: input.userAgent,
|
|
263
|
+
ip: input.ip,
|
|
264
|
+
});
|
|
156
265
|
}
|
|
157
266
|
|
|
158
|
-
async enrollTwoFactor(
|
|
267
|
+
async enrollTwoFactor(
|
|
268
|
+
userId: string,
|
|
269
|
+
): Promise<{ secret: string; provisioningUri: string }> {
|
|
159
270
|
const idBig = toId(userId);
|
|
160
271
|
const user = await this.getUserOrThrow(userId);
|
|
161
272
|
const secret = generateTotpSecret();
|
|
162
273
|
// Not enabled yet — confirmTwoFactor() flips that, so an abandoned enrollment never locks the account out.
|
|
163
|
-
await this.db
|
|
164
|
-
|
|
274
|
+
await this.db
|
|
275
|
+
.update(users)
|
|
276
|
+
.set({ twoFactorSecret: secret, twoFactorEnabled: false })
|
|
277
|
+
.where(eq(users.id, idBig));
|
|
278
|
+
return {
|
|
279
|
+
secret,
|
|
280
|
+
provisioningUri: buildTotpProvisioningUri({
|
|
281
|
+
secret,
|
|
282
|
+
accountName: user.email,
|
|
283
|
+
issuer: this.config.twoFactorIssuer,
|
|
284
|
+
}),
|
|
285
|
+
};
|
|
165
286
|
}
|
|
166
287
|
|
|
167
|
-
async confirmTwoFactor(
|
|
288
|
+
async confirmTwoFactor(
|
|
289
|
+
userId: string,
|
|
290
|
+
code: string,
|
|
291
|
+
): Promise<{ backupCodes: string[] }> {
|
|
168
292
|
const idBig = toId(userId);
|
|
169
293
|
const user = await this.getUserOrThrow(userId);
|
|
170
|
-
if (!user.twoFactorSecret)
|
|
171
|
-
|
|
294
|
+
if (!user.twoFactorSecret)
|
|
295
|
+
throw new BadRequestException("call enrollTwoFactor first");
|
|
296
|
+
if (!verifyTotpCode(user.twoFactorSecret, code))
|
|
297
|
+
throw new UnauthorizedException("invalid two-factor code");
|
|
172
298
|
|
|
173
299
|
const backupCodes = generateBackupCodes();
|
|
174
|
-
await this.twoFactor.saveBackupCodes(
|
|
175
|
-
|
|
176
|
-
|
|
300
|
+
await this.twoFactor.saveBackupCodes(
|
|
301
|
+
idBig,
|
|
302
|
+
backupCodes.map((c) => c.hash),
|
|
303
|
+
);
|
|
304
|
+
await this.db
|
|
305
|
+
.update(users)
|
|
306
|
+
.set({ twoFactorEnabled: true })
|
|
307
|
+
.where(eq(users.id, idBig));
|
|
308
|
+
await this.sessions.appendAuditEvent({
|
|
309
|
+
type: "two_factor_enabled",
|
|
310
|
+
userId,
|
|
311
|
+
});
|
|
177
312
|
return { backupCodes: backupCodes.map((c) => c.code) };
|
|
178
313
|
}
|
|
179
314
|
|
|
180
315
|
async disableTwoFactor(userId: string, code: string): Promise<void> {
|
|
181
316
|
const idBig = toId(userId);
|
|
182
317
|
const user = await this.getUserOrThrow(userId);
|
|
183
|
-
if (!user.twoFactorEnabled || !user.twoFactorSecret)
|
|
318
|
+
if (!user.twoFactorEnabled || !user.twoFactorSecret)
|
|
319
|
+
throw new BadRequestException("two-factor is not enabled");
|
|
184
320
|
|
|
185
321
|
const validTotp = verifyTotpCode(user.twoFactorSecret, code);
|
|
186
|
-
const validBackup =
|
|
187
|
-
|
|
322
|
+
const validBackup =
|
|
323
|
+
!validTotp && (await this.twoFactor.consumeBackupCode(idBig, code));
|
|
324
|
+
if (!validTotp && !validBackup)
|
|
325
|
+
throw new UnauthorizedException("invalid two-factor code");
|
|
188
326
|
|
|
189
|
-
await this.db
|
|
327
|
+
await this.db
|
|
328
|
+
.update(users)
|
|
329
|
+
.set({ twoFactorEnabled: false, twoFactorSecret: null })
|
|
330
|
+
.where(eq(users.id, idBig));
|
|
190
331
|
await this.twoFactor.clearBackupCodes(idBig);
|
|
191
|
-
await this.sessions.appendAuditEvent({
|
|
332
|
+
await this.sessions.appendAuditEvent({
|
|
333
|
+
type: "two_factor_disabled",
|
|
334
|
+
userId,
|
|
335
|
+
});
|
|
192
336
|
}
|
|
193
337
|
|
|
194
338
|
/** Always succeeds from the caller's point of view — an unknown email or a throttled request looks identical, to avoid enumeration. */
|
|
195
339
|
async requestPasswordReset(email: string): Promise<void> {
|
|
196
|
-
const [user] = await this.db
|
|
340
|
+
const [user] = await this.db
|
|
341
|
+
.select()
|
|
342
|
+
.from(users)
|
|
343
|
+
.where(eq(users.email, email))
|
|
344
|
+
.limit(1);
|
|
197
345
|
if (!user || user.isDeleted) return;
|
|
198
346
|
|
|
199
|
-
const { allowed } = await checkRateLimit(
|
|
347
|
+
const { allowed } = await checkRateLimit(
|
|
348
|
+
this.rateLimit,
|
|
349
|
+
"password-reset",
|
|
350
|
+
email,
|
|
351
|
+
);
|
|
200
352
|
if (!allowed) return;
|
|
201
353
|
|
|
202
|
-
const { token } = await coreRequestPasswordReset(
|
|
203
|
-
|
|
354
|
+
const { token } = await coreRequestPasswordReset(
|
|
355
|
+
this.passwordReset,
|
|
356
|
+
user.id.toString(),
|
|
357
|
+
);
|
|
358
|
+
if (this.config.sendPasswordResetEmail)
|
|
359
|
+
await this.config.sendPasswordResetEmail(email, token);
|
|
204
360
|
}
|
|
205
361
|
|
|
206
362
|
async resetPassword(token: string, newPassword: string): Promise<void> {
|
|
207
|
-
const { userId } = await coreResetPassword(
|
|
363
|
+
const { userId } = await coreResetPassword(
|
|
364
|
+
this.passwordReset,
|
|
365
|
+
token,
|
|
366
|
+
newPassword,
|
|
367
|
+
);
|
|
208
368
|
// The old password is no longer trusted, so neither are its sessions. The revoker is the user
|
|
209
369
|
// themselves — they proved control of the account by consuming the reset token.
|
|
210
370
|
await revokeAllSessionsForUser(this.sessions, userId, { userId });
|
|
@@ -212,76 +372,148 @@ export class AuthService {
|
|
|
212
372
|
|
|
213
373
|
/** Builds the redirect URL for `provider`. `state` is an opaque anti-CSRF nonce the provider echoes back verbatim. */
|
|
214
374
|
async startOAuth(provider: string): Promise<{ url: string }> {
|
|
215
|
-
const state = Buffer.from(JSON.stringify({ nonce: randomUUID() })).toString(
|
|
375
|
+
const state = Buffer.from(JSON.stringify({ nonce: randomUUID() })).toString(
|
|
376
|
+
"base64url",
|
|
377
|
+
);
|
|
216
378
|
|
|
217
379
|
if (provider === "google") {
|
|
218
380
|
const creds = this.config.oauthProviders.google;
|
|
219
|
-
if (!creds)
|
|
220
|
-
|
|
381
|
+
if (!creds)
|
|
382
|
+
throw new BadRequestException("google OAuth is not configured");
|
|
383
|
+
return {
|
|
384
|
+
url: buildAuthorizationUrl(GOOGLE_OIDC_PROVIDER, {
|
|
385
|
+
clientId: creds.clientId,
|
|
386
|
+
redirectUri: creds.redirectUri,
|
|
387
|
+
state,
|
|
388
|
+
}),
|
|
389
|
+
};
|
|
221
390
|
}
|
|
222
391
|
if (provider === "apple") {
|
|
223
392
|
const creds = this.config.oauthProviders.apple;
|
|
224
|
-
if (!creds)
|
|
225
|
-
|
|
393
|
+
if (!creds)
|
|
394
|
+
throw new BadRequestException("apple OAuth is not configured");
|
|
395
|
+
return {
|
|
396
|
+
url: buildAuthorizationUrl(APPLE_OIDC_PROVIDER, {
|
|
397
|
+
clientId: creds.clientId,
|
|
398
|
+
redirectUri: creds.redirectUri,
|
|
399
|
+
state,
|
|
400
|
+
}),
|
|
401
|
+
};
|
|
226
402
|
}
|
|
227
403
|
throw new BadRequestException(`unknown OAuth provider "${provider}"`);
|
|
228
404
|
}
|
|
229
405
|
|
|
230
|
-
async completeOAuthCallback(
|
|
406
|
+
async completeOAuthCallback(
|
|
407
|
+
provider: string,
|
|
408
|
+
code: string,
|
|
409
|
+
_state: string,
|
|
410
|
+
): Promise<AuthTokens> {
|
|
231
411
|
let providerDescriptor: OAuthProviderDescriptor;
|
|
232
412
|
let clientId: string;
|
|
233
413
|
let idToken: string;
|
|
234
414
|
|
|
235
415
|
if (provider === "google") {
|
|
236
416
|
const creds = this.config.oauthProviders.google;
|
|
237
|
-
if (!creds)
|
|
417
|
+
if (!creds)
|
|
418
|
+
throw new BadRequestException("google OAuth is not configured");
|
|
238
419
|
providerDescriptor = GOOGLE_OIDC_PROVIDER;
|
|
239
420
|
clientId = creds.clientId;
|
|
240
421
|
idToken = (
|
|
241
|
-
await exchangeCodeForTokens(GOOGLE_OIDC_PROVIDER, {
|
|
422
|
+
await exchangeCodeForTokens(GOOGLE_OIDC_PROVIDER, {
|
|
423
|
+
clientId: creds.clientId,
|
|
424
|
+
clientSecret: creds.clientSecret,
|
|
425
|
+
redirectUri: creds.redirectUri,
|
|
426
|
+
code,
|
|
427
|
+
})
|
|
242
428
|
).idToken;
|
|
243
429
|
} else if (provider === "apple") {
|
|
244
430
|
const creds = this.config.oauthProviders.apple;
|
|
245
|
-
if (!creds)
|
|
431
|
+
if (!creds)
|
|
432
|
+
throw new BadRequestException("apple OAuth is not configured");
|
|
246
433
|
providerDescriptor = APPLE_OIDC_PROVIDER;
|
|
247
434
|
clientId = creds.clientId;
|
|
248
|
-
const clientSecret = await signAppleClientSecret({
|
|
249
|
-
|
|
435
|
+
const clientSecret = await signAppleClientSecret({
|
|
436
|
+
teamId: creds.teamId,
|
|
437
|
+
clientId: creds.clientId,
|
|
438
|
+
keyId: creds.keyId,
|
|
439
|
+
privateKeyPem: creds.privateKey,
|
|
440
|
+
});
|
|
441
|
+
idToken = (
|
|
442
|
+
await exchangeCodeForTokens(APPLE_OIDC_PROVIDER, {
|
|
443
|
+
clientId: creds.clientId,
|
|
444
|
+
clientSecret,
|
|
445
|
+
redirectUri: creds.redirectUri,
|
|
446
|
+
code,
|
|
447
|
+
})
|
|
448
|
+
).idToken;
|
|
250
449
|
} else {
|
|
251
450
|
throw new BadRequestException(`unknown OAuth provider "${provider}"`);
|
|
252
451
|
}
|
|
253
452
|
|
|
254
|
-
const profile = await verifyIdTokenAndExtractProfile(providerDescriptor, {
|
|
255
|
-
|
|
453
|
+
const profile = await verifyIdTokenAndExtractProfile(providerDescriptor, {
|
|
454
|
+
clientId,
|
|
455
|
+
idToken,
|
|
456
|
+
});
|
|
457
|
+
const { userId } = await completeOAuthLogin(this.oauth, {
|
|
458
|
+
provider,
|
|
459
|
+
profile,
|
|
460
|
+
});
|
|
256
461
|
|
|
257
462
|
const user = await this.getUserOrThrow(userId);
|
|
258
|
-
if (user.blocked || !user.isActive || user.isDeleted)
|
|
463
|
+
if (user.blocked || !user.isActive || user.isDeleted)
|
|
464
|
+
throw new UnauthorizedException("account is blocked");
|
|
259
465
|
// A user this login just created holds no roles at all; give them the defaults. Guarded on
|
|
260
466
|
// "holds none" rather than "was created", so it can never re-add a role an administrator
|
|
261
467
|
// revoked from someone who has been logging in for months.
|
|
262
|
-
if (!(await this.rbac.hasAnyRole(user.id)))
|
|
468
|
+
if (!(await this.rbac.hasAnyRole(user.id)))
|
|
469
|
+
await this.rbac.assignDefaultRoles(user.id);
|
|
263
470
|
return this.issueSessionTokens(user, { provider });
|
|
264
471
|
}
|
|
265
472
|
|
|
266
|
-
async listAuditLog(
|
|
473
|
+
async listAuditLog(
|
|
474
|
+
filter: AuditLogListFilter,
|
|
475
|
+
): Promise<Paginated<AuditLogEntry>> {
|
|
267
476
|
const { items, meta } = await this.auditLog.list(filter);
|
|
268
477
|
return { items: items.map(toAuditLogEntry), meta };
|
|
269
478
|
}
|
|
270
479
|
|
|
271
480
|
/** `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. */
|
|
272
|
-
async getTwoFactorStatus(
|
|
273
|
-
|
|
481
|
+
async getTwoFactorStatus(
|
|
482
|
+
userId: string,
|
|
483
|
+
): Promise<{ twoFactorEnabled: boolean }> {
|
|
484
|
+
const [user] = await this.db
|
|
485
|
+
.select({ twoFactorEnabled: users.twoFactorEnabled })
|
|
486
|
+
.from(users)
|
|
487
|
+
.where(eq(users.id, toId(userId)))
|
|
488
|
+
.limit(1);
|
|
274
489
|
if (!user) throw new NotFoundException(`user "${userId}" not found`);
|
|
275
490
|
return { twoFactorEnabled: user.twoFactorEnabled };
|
|
276
491
|
}
|
|
277
492
|
|
|
278
|
-
async listActiveSessions(
|
|
493
|
+
async listActiveSessions(
|
|
494
|
+
userId: string,
|
|
495
|
+
): Promise<
|
|
496
|
+
Array<{
|
|
497
|
+
id: string;
|
|
498
|
+
createdAt: string;
|
|
499
|
+
expiresAt: string;
|
|
500
|
+
provider?: string;
|
|
501
|
+
userAgent?: string;
|
|
502
|
+
ip?: string;
|
|
503
|
+
}>
|
|
504
|
+
> {
|
|
279
505
|
// "Active" is now two conditions, not one: not revoked, and not past its own absolute expiry.
|
|
280
506
|
// `sessions_user_active_idx` is (user_id, is_revoked, expires_at) for exactly this read.
|
|
281
507
|
const rows = await this.db
|
|
282
508
|
.select()
|
|
283
509
|
.from(sessions)
|
|
284
|
-
.where(
|
|
510
|
+
.where(
|
|
511
|
+
and(
|
|
512
|
+
eq(sessions.userId, toId(userId)),
|
|
513
|
+
eq(sessions.isRevoked, false),
|
|
514
|
+
gt(sessions.expiresAt, new Date()),
|
|
515
|
+
),
|
|
516
|
+
)
|
|
285
517
|
.orderBy(desc(sessions.createdAt));
|
|
286
518
|
return rows.map((row) => ({
|
|
287
519
|
id: row.id.toString(),
|
|
@@ -341,7 +573,11 @@ export class AuthService {
|
|
|
341
573
|
return this.rbac.updateUser(userId, input, actorUserId);
|
|
342
574
|
}
|
|
343
575
|
|
|
344
|
-
async deleteUser(
|
|
576
|
+
async deleteUser(
|
|
577
|
+
userId: string,
|
|
578
|
+
actorUserId: string | null,
|
|
579
|
+
reason?: string,
|
|
580
|
+
): Promise<void> {
|
|
345
581
|
await this.rbac.deleteUser(userId, actorUserId, reason);
|
|
346
582
|
}
|
|
347
583
|
|
|
@@ -351,7 +587,10 @@ export class AuthService {
|
|
|
351
587
|
}
|
|
352
588
|
|
|
353
589
|
/** Defines a permission, or edits one — including deactivating it. The write path for "the catalog is editable in the database". */
|
|
354
|
-
async definePermission(
|
|
590
|
+
async definePermission(
|
|
591
|
+
input: PermissionInput,
|
|
592
|
+
actorUserId: string | null,
|
|
593
|
+
): Promise<PermissionSummary> {
|
|
355
594
|
return this.rbac.upsertPermission(input, actorUserId);
|
|
356
595
|
}
|
|
357
596
|
|
|
@@ -360,7 +599,14 @@ export class AuthService {
|
|
|
360
599
|
}
|
|
361
600
|
|
|
362
601
|
async createRole(
|
|
363
|
-
input: {
|
|
602
|
+
input: {
|
|
603
|
+
slug: string;
|
|
604
|
+
name?: string;
|
|
605
|
+
displayName?: string;
|
|
606
|
+
description?: string | null;
|
|
607
|
+
isDefault?: boolean;
|
|
608
|
+
isActive?: boolean;
|
|
609
|
+
},
|
|
364
610
|
actorUserId: string | null,
|
|
365
611
|
): Promise<RoleSummary> {
|
|
366
612
|
return this.rbac.createRole(input, actorUserId);
|
|
@@ -368,47 +614,95 @@ export class AuthService {
|
|
|
368
614
|
|
|
369
615
|
async updateRole(
|
|
370
616
|
roleId: string,
|
|
371
|
-
input: {
|
|
617
|
+
input: {
|
|
618
|
+
name?: string;
|
|
619
|
+
displayName?: string;
|
|
620
|
+
description?: string | null;
|
|
621
|
+
isDefault?: boolean;
|
|
622
|
+
isActive?: boolean;
|
|
623
|
+
},
|
|
372
624
|
actorUserId: string | null,
|
|
373
625
|
): Promise<RoleSummary> {
|
|
374
626
|
return this.rbac.updateRole(roleId, input, actorUserId);
|
|
375
627
|
}
|
|
376
628
|
|
|
377
|
-
async deleteRole(
|
|
629
|
+
async deleteRole(
|
|
630
|
+
roleId: string,
|
|
631
|
+
actorUserId: string | null,
|
|
632
|
+
reason?: string,
|
|
633
|
+
): Promise<void> {
|
|
378
634
|
await this.rbac.deleteRole(roleId, actorUserId, reason);
|
|
379
635
|
}
|
|
380
636
|
|
|
381
|
-
async attachPermissionToRole(
|
|
637
|
+
async attachPermissionToRole(
|
|
638
|
+
roleId: string,
|
|
639
|
+
permissionSlug: string,
|
|
640
|
+
actorUserId: string | null,
|
|
641
|
+
): Promise<void> {
|
|
382
642
|
await this.rbac.attachPermissionToRole(roleId, permissionSlug, actorUserId);
|
|
383
643
|
}
|
|
384
644
|
|
|
385
645
|
async assignRole(userId: string, roleSlug: string): Promise<void> {
|
|
386
646
|
await this.rbac.assignRoleToUser(userId, roleSlug);
|
|
387
|
-
await this.sessions.appendAuditEvent({
|
|
647
|
+
await this.sessions.appendAuditEvent({
|
|
648
|
+
type: "role_assigned",
|
|
649
|
+
userId,
|
|
650
|
+
role: roleSlug,
|
|
651
|
+
});
|
|
388
652
|
}
|
|
389
653
|
|
|
390
654
|
async revokeRole(userId: string, roleSlug: string): Promise<void> {
|
|
391
655
|
await this.rbac.revokeRoleFromUser(userId, roleSlug);
|
|
392
|
-
await this.sessions.appendAuditEvent({
|
|
656
|
+
await this.sessions.appendAuditEvent({
|
|
657
|
+
type: "role_revoked",
|
|
658
|
+
userId,
|
|
659
|
+
role: roleSlug,
|
|
660
|
+
});
|
|
393
661
|
}
|
|
394
662
|
|
|
395
|
-
async grantPermission(
|
|
663
|
+
async grantPermission(
|
|
664
|
+
userId: string,
|
|
665
|
+
permissionSlug: string,
|
|
666
|
+
actorUserId: string | null,
|
|
667
|
+
): Promise<void> {
|
|
396
668
|
await this.rbac.grantPermissionToUser(userId, permissionSlug, actorUserId);
|
|
397
|
-
await this.sessions.appendAuditEvent({
|
|
669
|
+
await this.sessions.appendAuditEvent({
|
|
670
|
+
type: "permission_granted",
|
|
671
|
+
userId,
|
|
672
|
+
permission: permissionSlug,
|
|
673
|
+
});
|
|
398
674
|
}
|
|
399
675
|
|
|
400
|
-
async revokePermission(
|
|
676
|
+
async revokePermission(
|
|
677
|
+
userId: string,
|
|
678
|
+
permissionSlug: string,
|
|
679
|
+
): Promise<void> {
|
|
401
680
|
await this.rbac.revokePermissionFromUser(userId, permissionSlug);
|
|
402
|
-
await this.sessions.appendAuditEvent({
|
|
681
|
+
await this.sessions.appendAuditEvent({
|
|
682
|
+
type: "permission_revoked",
|
|
683
|
+
userId,
|
|
684
|
+
permission: permissionSlug,
|
|
685
|
+
});
|
|
403
686
|
}
|
|
404
687
|
|
|
405
688
|
async refresh(refreshToken: string): Promise<Omit<AuthTokens, "sessionId">> {
|
|
406
689
|
const key = await this.keys.getActiveKey();
|
|
407
|
-
const presented = await verifyRefreshToken(
|
|
408
|
-
|
|
690
|
+
const presented = await verifyRefreshToken(
|
|
691
|
+
{ secret: this.keys.secret },
|
|
692
|
+
refreshToken,
|
|
693
|
+
);
|
|
694
|
+
const { session, nextJti } = await rotateRefreshToken(
|
|
695
|
+
this.sessions,
|
|
696
|
+
presented,
|
|
697
|
+
);
|
|
409
698
|
|
|
410
|
-
const [user] = await this.db
|
|
411
|
-
|
|
699
|
+
const [user] = await this.db
|
|
700
|
+
.select()
|
|
701
|
+
.from(users)
|
|
702
|
+
.where(eq(users.id, toId(session.userId)))
|
|
703
|
+
.limit(1);
|
|
704
|
+
if (!user || user.blocked || !user.isActive || user.isDeleted)
|
|
705
|
+
throw new UnauthorizedException("invalid credentials");
|
|
412
706
|
|
|
413
707
|
// Identity only. Authorization is resolved from the database on every request (AuthzGuard),
|
|
414
708
|
// so a token issued before a grant is as authoritative as one issued after it.
|
|
@@ -419,38 +713,83 @@ export class AuthService {
|
|
|
419
713
|
);
|
|
420
714
|
const refresh = await signRefreshToken(
|
|
421
715
|
{ activeKey: key },
|
|
422
|
-
{
|
|
716
|
+
{
|
|
717
|
+
sub: user.id.toString(),
|
|
718
|
+
sessionId: session.id,
|
|
719
|
+
sv: session.sessionVersion,
|
|
720
|
+
},
|
|
423
721
|
{ jti: nextJti, ttlSeconds: this.config.refreshTokenTtlSeconds },
|
|
424
722
|
);
|
|
425
723
|
return { accessToken: access.token, refreshToken: refresh.token };
|
|
426
724
|
}
|
|
427
725
|
|
|
428
|
-
async logout(
|
|
726
|
+
async logout(
|
|
727
|
+
sessionId: string,
|
|
728
|
+
accessJti: string,
|
|
729
|
+
accessRemainingTtlSeconds: number,
|
|
730
|
+
revoker?: Revoker,
|
|
731
|
+
): Promise<void> {
|
|
429
732
|
await revokeSession(this.sessions, sessionId, revoker);
|
|
430
|
-
await revokeAccessToken(
|
|
733
|
+
await revokeAccessToken(
|
|
734
|
+
this.sessions,
|
|
735
|
+
accessJti,
|
|
736
|
+
accessRemainingTtlSeconds,
|
|
737
|
+
);
|
|
431
738
|
}
|
|
432
739
|
|
|
433
740
|
async logoutAll(userId: string, revoker?: Revoker): Promise<void> {
|
|
434
741
|
await revokeAllSessionsForUser(this.sessions, userId, revoker);
|
|
435
742
|
}
|
|
436
743
|
|
|
437
|
-
async logoutOthers(
|
|
438
|
-
|
|
744
|
+
async logoutOthers(
|
|
745
|
+
userId: string,
|
|
746
|
+
keepSessionId: string,
|
|
747
|
+
revoker?: Revoker,
|
|
748
|
+
): Promise<void> {
|
|
749
|
+
await revokeOtherSessionsForUser(
|
|
750
|
+
this.sessions,
|
|
751
|
+
userId,
|
|
752
|
+
keepSessionId,
|
|
753
|
+
revoker,
|
|
754
|
+
);
|
|
439
755
|
}
|
|
440
756
|
|
|
441
|
-
async changePassword(
|
|
757
|
+
async changePassword(
|
|
758
|
+
userId: string,
|
|
759
|
+
currentSessionId: string,
|
|
760
|
+
input: { currentPassword: string; newPassword: string },
|
|
761
|
+
): Promise<void> {
|
|
442
762
|
const userIdBig = toId(userId);
|
|
443
|
-
const [user] = await this.db
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
763
|
+
const [user] = await this.db
|
|
764
|
+
.select()
|
|
765
|
+
.from(users)
|
|
766
|
+
.where(eq(users.id, userIdBig))
|
|
767
|
+
.limit(1);
|
|
768
|
+
if (
|
|
769
|
+
!user ||
|
|
770
|
+
user.blocked ||
|
|
771
|
+
!user.isActive ||
|
|
772
|
+
user.isDeleted ||
|
|
773
|
+
!user.passwordHash
|
|
774
|
+
)
|
|
775
|
+
throw new UnauthorizedException("invalid credentials");
|
|
776
|
+
|
|
777
|
+
const valid = await verifyPassword(
|
|
778
|
+
user.passwordHash,
|
|
779
|
+
input.currentPassword,
|
|
780
|
+
);
|
|
447
781
|
if (!valid) throw new UnauthorizedException("invalid credentials");
|
|
448
782
|
|
|
449
783
|
const passwordHash = await hashPassword(input.newPassword);
|
|
450
|
-
await this.db
|
|
784
|
+
await this.db
|
|
785
|
+
.update(users)
|
|
786
|
+
.set({ passwordHash, updatedBy: userIdBig })
|
|
787
|
+
.where(eq(users.id, userIdBig));
|
|
451
788
|
// The old password is no longer trusted everywhere else it's signed in — but leave the
|
|
452
789
|
// session making this very call alone, the same courtesy `logoutOthers` extends.
|
|
453
|
-
await revokeOtherSessionsForUser(this.sessions, userId, currentSessionId, {
|
|
790
|
+
await revokeOtherSessionsForUser(this.sessions, userId, currentSessionId, {
|
|
791
|
+
userId,
|
|
792
|
+
});
|
|
454
793
|
}
|
|
455
794
|
|
|
456
795
|
/** Backs both `GET`- and `PATCH /auth/me` — roles/blocked/lastLogin are an admin's business, not this endpoint's. */
|
|
@@ -480,14 +819,26 @@ export class AuthService {
|
|
|
480
819
|
*/
|
|
481
820
|
async updateProfile(
|
|
482
821
|
userId: string,
|
|
483
|
-
input: {
|
|
822
|
+
input: {
|
|
823
|
+
firstName?: string | null;
|
|
824
|
+
lastName?: string | null;
|
|
825
|
+
displayName?: string | null;
|
|
826
|
+
phone?: string | null;
|
|
827
|
+
username?: string | null;
|
|
828
|
+
photo?: string | null;
|
|
829
|
+
},
|
|
484
830
|
): Promise<SelfProfile> {
|
|
485
|
-
return this.toSelfProfile(
|
|
831
|
+
return this.toSelfProfile(
|
|
832
|
+
await this.rbac.updateUser(userId, input, userId),
|
|
833
|
+
);
|
|
486
834
|
}
|
|
487
835
|
|
|
488
836
|
async block(userId: string, revoker?: Revoker): Promise<void> {
|
|
489
837
|
const idBig = toId(userId);
|
|
490
|
-
await this.db
|
|
838
|
+
await this.db
|
|
839
|
+
.update(users)
|
|
840
|
+
.set({ blocked: true, updatedBy: toIdOrNull(revoker?.userId) })
|
|
841
|
+
.where(eq(users.id, idBig));
|
|
491
842
|
// The administrator, not the blocked user, is what lands in `sessions.revoked_by`.
|
|
492
843
|
await blockUser(this.sessions, userId, revoker);
|
|
493
844
|
// Belt and braces. The block is enforced on the authentication path — login and refresh both
|
|
@@ -498,7 +849,10 @@ export class AuthService {
|
|
|
498
849
|
}
|
|
499
850
|
|
|
500
851
|
async unblock(userId: string, revoker?: Revoker): Promise<void> {
|
|
501
|
-
await this.db
|
|
852
|
+
await this.db
|
|
853
|
+
.update(users)
|
|
854
|
+
.set({ blocked: false, updatedBy: toIdOrNull(revoker?.userId) })
|
|
855
|
+
.where(eq(users.id, toId(userId)));
|
|
502
856
|
}
|
|
503
857
|
|
|
504
858
|
/**
|
|
@@ -506,17 +860,29 @@ export class AuthService {
|
|
|
506
860
|
* security/moderation action. Both independently deny login; see the note on the `users` table.
|
|
507
861
|
*/
|
|
508
862
|
async deactivate(userId: string, revoker?: Revoker): Promise<void> {
|
|
509
|
-
await this.db
|
|
863
|
+
await this.db
|
|
864
|
+
.update(users)
|
|
865
|
+
.set({ isActive: false, updatedBy: toIdOrNull(revoker?.userId) })
|
|
866
|
+
.where(eq(users.id, toId(userId)));
|
|
510
867
|
await deactivateUser(this.sessions, userId, revoker);
|
|
511
868
|
await this.rbac.invalidateUser(userId);
|
|
512
869
|
}
|
|
513
870
|
|
|
514
871
|
async activate(userId: string, revoker?: Revoker): Promise<void> {
|
|
515
|
-
await this.db
|
|
872
|
+
await this.db
|
|
873
|
+
.update(users)
|
|
874
|
+
.set({ isActive: true, updatedBy: toIdOrNull(revoker?.userId) })
|
|
875
|
+
.where(eq(users.id, toId(userId)));
|
|
516
876
|
}
|
|
517
877
|
|
|
518
|
-
private async getUserOrThrow(
|
|
519
|
-
|
|
878
|
+
private async getUserOrThrow(
|
|
879
|
+
userId: string,
|
|
880
|
+
): Promise<typeof users.$inferSelect> {
|
|
881
|
+
const [user] = await this.db
|
|
882
|
+
.select()
|
|
883
|
+
.from(users)
|
|
884
|
+
.where(eq(users.id, toId(userId)))
|
|
885
|
+
.limit(1);
|
|
520
886
|
if (!user) throw new NotFoundException(`user "${userId}" not found`);
|
|
521
887
|
return user;
|
|
522
888
|
}
|
|
@@ -524,8 +890,14 @@ export class AuthService {
|
|
|
524
890
|
/** The access token identifies the user and the session and carries no authorization at all
|
|
525
891
|
* — see AuthzGuard. Takes the raw database user row (bigint id) — every call site already has
|
|
526
892
|
* one in hand. */
|
|
527
|
-
private async issueSessionTokens(
|
|
528
|
-
|
|
893
|
+
private async issueSessionTokens(
|
|
894
|
+
user: { id: bigint },
|
|
895
|
+
meta: { userAgent?: string; ip?: string; provider?: string },
|
|
896
|
+
): Promise<AuthTokens> {
|
|
897
|
+
const session = await createSession(this.sessions, {
|
|
898
|
+
userId: user.id.toString(),
|
|
899
|
+
...meta,
|
|
900
|
+
});
|
|
529
901
|
const key = await this.keys.getActiveKey();
|
|
530
902
|
|
|
531
903
|
const access = await signAccessToken(
|
|
@@ -535,10 +907,21 @@ export class AuthService {
|
|
|
535
907
|
);
|
|
536
908
|
const refresh = await signRefreshToken(
|
|
537
909
|
{ activeKey: key },
|
|
538
|
-
{
|
|
539
|
-
|
|
910
|
+
{
|
|
911
|
+
sub: user.id.toString(),
|
|
912
|
+
sessionId: session.id,
|
|
913
|
+
sv: session.sessionVersion,
|
|
914
|
+
},
|
|
915
|
+
{
|
|
916
|
+
jti: session.currentRefreshJti,
|
|
917
|
+
ttlSeconds: this.config.refreshTokenTtlSeconds,
|
|
918
|
+
},
|
|
540
919
|
);
|
|
541
920
|
|
|
542
|
-
return {
|
|
921
|
+
return {
|
|
922
|
+
accessToken: access.token,
|
|
923
|
+
refreshToken: refresh.token,
|
|
924
|
+
sessionId: session.id,
|
|
925
|
+
};
|
|
543
926
|
}
|
|
544
927
|
}
|