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