@simple-auth-kit/cli 1.4.2 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +6 -4
- package/simple-auth-kit.ts +582 -124
- 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,23 +29,48 @@ 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 type { AuthzContext } from "
|
|
38
|
-
import {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
59
|
+
import type { AuthzContext } from "../middleware/authz.middleware.js";
|
|
60
|
+
import {
|
|
61
|
+
MemberListFilter,
|
|
62
|
+
MemberListResult,
|
|
63
|
+
MemberSummary,
|
|
64
|
+
PermissionInput,
|
|
65
|
+
PermissionSummary,
|
|
66
|
+
RbacRepository,
|
|
67
|
+
RoleSummary,
|
|
68
|
+
toMemberSummary,
|
|
69
|
+
} from "../repositories/rbac.repository.js";
|
|
70
|
+
import { sessions, users } from "../schema.js";
|
|
71
|
+
import { SessionRepository } from "../repositories/session.repository.js";
|
|
72
|
+
import { TwoFactorRepository } from "../repositories/two-factor.repository.js";
|
|
73
|
+
import { toId, toIdOrNull } from "../id.helper.js";
|
|
43
74
|
|
|
44
75
|
export interface AuthTokens {
|
|
45
76
|
accessToken: string;
|
|
@@ -94,7 +125,11 @@ export class AuthService {
|
|
|
94
125
|
userAgent?: string;
|
|
95
126
|
ip?: string;
|
|
96
127
|
}): Promise<AuthTokens> {
|
|
97
|
-
const [existing] = await this.db
|
|
128
|
+
const [existing] = await this.db
|
|
129
|
+
.select()
|
|
130
|
+
.from(users)
|
|
131
|
+
.where(eq(users.email, input.email))
|
|
132
|
+
.limit(1);
|
|
98
133
|
if (existing) throw new HttpError(409, "email already registered");
|
|
99
134
|
|
|
100
135
|
const passwordHash = await hashPassword(input.password);
|
|
@@ -110,100 +145,210 @@ export class AuthService {
|
|
|
110
145
|
username: input.username,
|
|
111
146
|
})
|
|
112
147
|
.returning();
|
|
113
|
-
return this.issueSessionTokens(user, {
|
|
148
|
+
return this.issueSessionTokens(user, {
|
|
149
|
+
userAgent: input.userAgent,
|
|
150
|
+
ip: input.ip,
|
|
151
|
+
});
|
|
114
152
|
}
|
|
115
153
|
|
|
116
154
|
/** Returns full tokens directly, or a short-lived challenge if the account has 2FA enabled — see `loginTwoFactor`. */
|
|
117
|
-
async login(input: {
|
|
118
|
-
|
|
155
|
+
async login(input: {
|
|
156
|
+
identifier: string;
|
|
157
|
+
password: string;
|
|
158
|
+
userAgent?: string;
|
|
159
|
+
ip?: string;
|
|
160
|
+
}): Promise<AuthTokens | TwoFactorChallenge> {
|
|
161
|
+
const { allowed } = await checkRateLimit(
|
|
162
|
+
this.rateLimit,
|
|
163
|
+
"login",
|
|
164
|
+
input.identifier,
|
|
165
|
+
);
|
|
119
166
|
if (!allowed) throw new HttpError(429, "too many login attempts");
|
|
120
167
|
|
|
121
168
|
const [user] = await this.db
|
|
122
169
|
.select()
|
|
123
170
|
.from(users)
|
|
124
|
-
.where(
|
|
171
|
+
.where(
|
|
172
|
+
or(
|
|
173
|
+
eq(users.email, input.identifier),
|
|
174
|
+
eq(users.username, input.identifier),
|
|
175
|
+
eq(users.phone, input.identifier),
|
|
176
|
+
),
|
|
177
|
+
)
|
|
125
178
|
.limit(1);
|
|
126
|
-
if (
|
|
179
|
+
if (
|
|
180
|
+
!user ||
|
|
181
|
+
user.blocked ||
|
|
182
|
+
!user.isActive ||
|
|
183
|
+
user.isDeleted ||
|
|
184
|
+
!user.passwordHash
|
|
185
|
+
)
|
|
186
|
+
throw new HttpError(401, "invalid credentials");
|
|
127
187
|
|
|
128
188
|
const valid = await verifyPassword(user.passwordHash, input.password);
|
|
129
189
|
if (!valid) throw new HttpError(401, "invalid credentials");
|
|
130
190
|
|
|
131
191
|
if (user.twoFactorEnabled) {
|
|
132
192
|
const key = await this.keys.getActiveKey();
|
|
133
|
-
const { token } = await signTwoFactorChallengeToken(
|
|
193
|
+
const { token } = await signTwoFactorChallengeToken(
|
|
194
|
+
{ activeKey: key },
|
|
195
|
+
user.id.toString(),
|
|
196
|
+
); // core-facing: sub must be string
|
|
134
197
|
return { twoFactorRequired: true, challengeToken: token };
|
|
135
198
|
}
|
|
136
199
|
|
|
137
|
-
return this.issueSessionTokens(user, {
|
|
200
|
+
return this.issueSessionTokens(user, {
|
|
201
|
+
userAgent: input.userAgent,
|
|
202
|
+
ip: input.ip,
|
|
203
|
+
});
|
|
138
204
|
}
|
|
139
205
|
|
|
140
206
|
/** Completes the challenge `login()` returned when 2FA is enabled — a TOTP code or an unused backup code. */
|
|
141
|
-
async loginTwoFactor(input: {
|
|
142
|
-
|
|
207
|
+
async loginTwoFactor(input: {
|
|
208
|
+
challengeToken: string;
|
|
209
|
+
code: string;
|
|
210
|
+
userAgent?: string;
|
|
211
|
+
ip?: string;
|
|
212
|
+
}): Promise<AuthTokens> {
|
|
213
|
+
const { sub } = await verifyTwoFactorChallengeToken(
|
|
214
|
+
{ secret: this.keys.secret },
|
|
215
|
+
input.challengeToken,
|
|
216
|
+
);
|
|
143
217
|
|
|
144
|
-
const [user] = await this.db
|
|
145
|
-
|
|
218
|
+
const [user] = await this.db
|
|
219
|
+
.select()
|
|
220
|
+
.from(users)
|
|
221
|
+
.where(eq(users.id, toId(sub)))
|
|
222
|
+
.limit(1);
|
|
223
|
+
if (
|
|
224
|
+
!user ||
|
|
225
|
+
user.blocked ||
|
|
226
|
+
!user.isActive ||
|
|
227
|
+
user.isDeleted ||
|
|
228
|
+
!user.twoFactorEnabled ||
|
|
229
|
+
!user.twoFactorSecret
|
|
230
|
+
)
|
|
231
|
+
throw new HttpError(401, "invalid credentials");
|
|
146
232
|
|
|
147
233
|
const validTotp = verifyTotpCode(user.twoFactorSecret, input.code);
|
|
148
|
-
const validBackup =
|
|
234
|
+
const validBackup =
|
|
235
|
+
!validTotp &&
|
|
236
|
+
(await this.twoFactor.consumeBackupCode(user.id, input.code));
|
|
149
237
|
if (!validTotp && !validBackup) {
|
|
150
|
-
await this.sessions.appendAuditEvent({
|
|
238
|
+
await this.sessions.appendAuditEvent({
|
|
239
|
+
type: "two_factor_challenge_failed",
|
|
240
|
+
userId: user.id.toString(),
|
|
241
|
+
}); // core-facing: string
|
|
151
242
|
throw new HttpError(401, "invalid two-factor code");
|
|
152
243
|
}
|
|
153
244
|
|
|
154
|
-
return this.issueSessionTokens(user, {
|
|
245
|
+
return this.issueSessionTokens(user, {
|
|
246
|
+
userAgent: input.userAgent,
|
|
247
|
+
ip: input.ip,
|
|
248
|
+
});
|
|
155
249
|
}
|
|
156
250
|
|
|
157
|
-
async enrollTwoFactor(
|
|
251
|
+
async enrollTwoFactor(
|
|
252
|
+
userId: string,
|
|
253
|
+
): Promise<{ secret: string; provisioningUri: string }> {
|
|
158
254
|
const idBig = toId(userId);
|
|
159
255
|
const user = await this.getUserOrThrow(userId);
|
|
160
256
|
const secret = generateTotpSecret();
|
|
161
257
|
// Not enabled yet — confirmTwoFactor() flips that, so an abandoned enrollment never locks the account out.
|
|
162
|
-
await this.db
|
|
163
|
-
|
|
258
|
+
await this.db
|
|
259
|
+
.update(users)
|
|
260
|
+
.set({ twoFactorSecret: secret, twoFactorEnabled: false })
|
|
261
|
+
.where(eq(users.id, idBig));
|
|
262
|
+
return {
|
|
263
|
+
secret,
|
|
264
|
+
provisioningUri: buildTotpProvisioningUri({
|
|
265
|
+
secret,
|
|
266
|
+
accountName: user.email,
|
|
267
|
+
issuer: this.config.twoFactorIssuer,
|
|
268
|
+
}),
|
|
269
|
+
};
|
|
164
270
|
}
|
|
165
271
|
|
|
166
|
-
async confirmTwoFactor(
|
|
272
|
+
async confirmTwoFactor(
|
|
273
|
+
userId: string,
|
|
274
|
+
code: string,
|
|
275
|
+
): Promise<{ backupCodes: string[] }> {
|
|
167
276
|
const idBig = toId(userId);
|
|
168
277
|
const user = await this.getUserOrThrow(userId);
|
|
169
|
-
if (!user.twoFactorSecret)
|
|
170
|
-
|
|
278
|
+
if (!user.twoFactorSecret)
|
|
279
|
+
throw new HttpError(400, "call enrollTwoFactor first");
|
|
280
|
+
if (!verifyTotpCode(user.twoFactorSecret, code))
|
|
281
|
+
throw new HttpError(401, "invalid two-factor code");
|
|
171
282
|
|
|
172
283
|
const backupCodes = generateBackupCodes();
|
|
173
|
-
await this.twoFactor.saveBackupCodes(
|
|
174
|
-
|
|
175
|
-
|
|
284
|
+
await this.twoFactor.saveBackupCodes(
|
|
285
|
+
idBig,
|
|
286
|
+
backupCodes.map((c) => c.hash),
|
|
287
|
+
);
|
|
288
|
+
await this.db
|
|
289
|
+
.update(users)
|
|
290
|
+
.set({ twoFactorEnabled: true })
|
|
291
|
+
.where(eq(users.id, idBig));
|
|
292
|
+
await this.sessions.appendAuditEvent({
|
|
293
|
+
type: "two_factor_enabled",
|
|
294
|
+
userId,
|
|
295
|
+
});
|
|
176
296
|
return { backupCodes: backupCodes.map((c) => c.code) };
|
|
177
297
|
}
|
|
178
298
|
|
|
179
299
|
async disableTwoFactor(userId: string, code: string): Promise<void> {
|
|
180
300
|
const idBig = toId(userId);
|
|
181
301
|
const user = await this.getUserOrThrow(userId);
|
|
182
|
-
if (!user.twoFactorEnabled || !user.twoFactorSecret)
|
|
302
|
+
if (!user.twoFactorEnabled || !user.twoFactorSecret)
|
|
303
|
+
throw new HttpError(400, "two-factor is not enabled");
|
|
183
304
|
|
|
184
305
|
const validTotp = verifyTotpCode(user.twoFactorSecret, code);
|
|
185
|
-
const validBackup =
|
|
186
|
-
|
|
306
|
+
const validBackup =
|
|
307
|
+
!validTotp && (await this.twoFactor.consumeBackupCode(idBig, code));
|
|
308
|
+
if (!validTotp && !validBackup)
|
|
309
|
+
throw new HttpError(401, "invalid two-factor code");
|
|
187
310
|
|
|
188
|
-
await this.db
|
|
311
|
+
await this.db
|
|
312
|
+
.update(users)
|
|
313
|
+
.set({ twoFactorEnabled: false, twoFactorSecret: null })
|
|
314
|
+
.where(eq(users.id, idBig));
|
|
189
315
|
await this.twoFactor.clearBackupCodes(idBig);
|
|
190
|
-
await this.sessions.appendAuditEvent({
|
|
316
|
+
await this.sessions.appendAuditEvent({
|
|
317
|
+
type: "two_factor_disabled",
|
|
318
|
+
userId,
|
|
319
|
+
});
|
|
191
320
|
}
|
|
192
321
|
|
|
193
322
|
/** Always succeeds from the caller's point of view — an unknown email or a throttled request looks identical, to avoid enumeration. */
|
|
194
323
|
async requestPasswordReset(email: string): Promise<void> {
|
|
195
|
-
const [user] = await this.db
|
|
324
|
+
const [user] = await this.db
|
|
325
|
+
.select()
|
|
326
|
+
.from(users)
|
|
327
|
+
.where(eq(users.email, email))
|
|
328
|
+
.limit(1);
|
|
196
329
|
if (!user || user.isDeleted) return;
|
|
197
330
|
|
|
198
|
-
const { allowed } = await checkRateLimit(
|
|
331
|
+
const { allowed } = await checkRateLimit(
|
|
332
|
+
this.rateLimit,
|
|
333
|
+
"password-reset",
|
|
334
|
+
email,
|
|
335
|
+
);
|
|
199
336
|
if (!allowed) return;
|
|
200
337
|
|
|
201
|
-
const { token } = await coreRequestPasswordReset(
|
|
202
|
-
|
|
338
|
+
const { token } = await coreRequestPasswordReset(
|
|
339
|
+
this.passwordReset,
|
|
340
|
+
user.id.toString(),
|
|
341
|
+
);
|
|
342
|
+
if (this.config.sendPasswordResetEmail)
|
|
343
|
+
await this.config.sendPasswordResetEmail(email, token);
|
|
203
344
|
}
|
|
204
345
|
|
|
205
346
|
async resetPassword(token: string, newPassword: string): Promise<void> {
|
|
206
|
-
const { userId } = await coreResetPassword(
|
|
347
|
+
const { userId } = await coreResetPassword(
|
|
348
|
+
this.passwordReset,
|
|
349
|
+
token,
|
|
350
|
+
newPassword,
|
|
351
|
+
);
|
|
207
352
|
// The old password is no longer trusted, so neither are its sessions. The revoker is the user
|
|
208
353
|
// themselves — they proved control of the account by consuming the reset token.
|
|
209
354
|
await revokeAllSessionsForUser(this.sessions, userId, { userId });
|
|
@@ -211,22 +356,40 @@ export class AuthService {
|
|
|
211
356
|
|
|
212
357
|
/** Builds the redirect URL for `provider`. `state` is an opaque anti-CSRF nonce the provider echoes back verbatim. */
|
|
213
358
|
async startOAuth(provider: string): Promise<{ url: string }> {
|
|
214
|
-
const state = Buffer.from(JSON.stringify({ nonce: randomUUID() })).toString(
|
|
359
|
+
const state = Buffer.from(JSON.stringify({ nonce: randomUUID() })).toString(
|
|
360
|
+
"base64url",
|
|
361
|
+
);
|
|
215
362
|
|
|
216
363
|
if (provider === "google") {
|
|
217
364
|
const creds = this.config.oauthProviders.google;
|
|
218
365
|
if (!creds) throw new HttpError(400, "google OAuth is not configured");
|
|
219
|
-
return {
|
|
366
|
+
return {
|
|
367
|
+
url: buildAuthorizationUrl(GOOGLE_OIDC_PROVIDER, {
|
|
368
|
+
clientId: creds.clientId,
|
|
369
|
+
redirectUri: creds.redirectUri,
|
|
370
|
+
state,
|
|
371
|
+
}),
|
|
372
|
+
};
|
|
220
373
|
}
|
|
221
374
|
if (provider === "apple") {
|
|
222
375
|
const creds = this.config.oauthProviders.apple;
|
|
223
376
|
if (!creds) throw new HttpError(400, "apple OAuth is not configured");
|
|
224
|
-
return {
|
|
377
|
+
return {
|
|
378
|
+
url: buildAuthorizationUrl(APPLE_OIDC_PROVIDER, {
|
|
379
|
+
clientId: creds.clientId,
|
|
380
|
+
redirectUri: creds.redirectUri,
|
|
381
|
+
state,
|
|
382
|
+
}),
|
|
383
|
+
};
|
|
225
384
|
}
|
|
226
385
|
throw new HttpError(400, `unknown OAuth provider "${provider}"`);
|
|
227
386
|
}
|
|
228
387
|
|
|
229
|
-
async completeOAuthCallback(
|
|
388
|
+
async completeOAuthCallback(
|
|
389
|
+
provider: string,
|
|
390
|
+
code: string,
|
|
391
|
+
_state: string,
|
|
392
|
+
): Promise<AuthTokens> {
|
|
230
393
|
let providerDescriptor: OAuthProviderDescriptor;
|
|
231
394
|
let clientId: string;
|
|
232
395
|
let idToken: string;
|
|
@@ -237,47 +400,100 @@ export class AuthService {
|
|
|
237
400
|
providerDescriptor = GOOGLE_OIDC_PROVIDER;
|
|
238
401
|
clientId = creds.clientId;
|
|
239
402
|
idToken = (
|
|
240
|
-
await exchangeCodeForTokens(GOOGLE_OIDC_PROVIDER, {
|
|
403
|
+
await exchangeCodeForTokens(GOOGLE_OIDC_PROVIDER, {
|
|
404
|
+
clientId: creds.clientId,
|
|
405
|
+
clientSecret: creds.clientSecret,
|
|
406
|
+
redirectUri: creds.redirectUri,
|
|
407
|
+
code,
|
|
408
|
+
})
|
|
241
409
|
).idToken;
|
|
242
410
|
} else if (provider === "apple") {
|
|
243
411
|
const creds = this.config.oauthProviders.apple;
|
|
244
412
|
if (!creds) throw new HttpError(400, "apple OAuth is not configured");
|
|
245
413
|
providerDescriptor = APPLE_OIDC_PROVIDER;
|
|
246
414
|
clientId = creds.clientId;
|
|
247
|
-
const clientSecret = await signAppleClientSecret({
|
|
248
|
-
|
|
415
|
+
const clientSecret = await signAppleClientSecret({
|
|
416
|
+
teamId: creds.teamId,
|
|
417
|
+
clientId: creds.clientId,
|
|
418
|
+
keyId: creds.keyId,
|
|
419
|
+
privateKeyPem: creds.privateKey,
|
|
420
|
+
});
|
|
421
|
+
idToken = (
|
|
422
|
+
await exchangeCodeForTokens(APPLE_OIDC_PROVIDER, {
|
|
423
|
+
clientId: creds.clientId,
|
|
424
|
+
clientSecret,
|
|
425
|
+
redirectUri: creds.redirectUri,
|
|
426
|
+
code,
|
|
427
|
+
})
|
|
428
|
+
).idToken;
|
|
249
429
|
} else {
|
|
250
430
|
throw new HttpError(400, `unknown OAuth provider "${provider}"`);
|
|
251
431
|
}
|
|
252
432
|
|
|
253
|
-
const profile = await verifyIdTokenAndExtractProfile(providerDescriptor, {
|
|
254
|
-
|
|
433
|
+
const profile = await verifyIdTokenAndExtractProfile(providerDescriptor, {
|
|
434
|
+
clientId,
|
|
435
|
+
idToken,
|
|
436
|
+
});
|
|
437
|
+
const { userId } = await completeOAuthLogin(this.oauth, {
|
|
438
|
+
provider,
|
|
439
|
+
profile,
|
|
440
|
+
});
|
|
255
441
|
|
|
256
442
|
const user = await this.getUserOrThrow(userId);
|
|
257
|
-
if (user.blocked || !user.isActive || user.isDeleted)
|
|
443
|
+
if (user.blocked || !user.isActive || user.isDeleted)
|
|
444
|
+
throw new HttpError(401, "account is blocked");
|
|
258
445
|
return this.issueSessionTokens(user, { provider });
|
|
259
446
|
}
|
|
260
447
|
|
|
261
448
|
/** Pinned to the caller's workspace — the filter argument cannot widen it. */
|
|
262
|
-
async listAuditLog(
|
|
263
|
-
|
|
449
|
+
async listAuditLog(
|
|
450
|
+
ctx: AuthzContext,
|
|
451
|
+
filter: AuditLogListFilter,
|
|
452
|
+
): Promise<Paginated<AuditLogEntry>> {
|
|
453
|
+
const { items, meta } = await this.auditLog.list({
|
|
454
|
+
...filter,
|
|
455
|
+
workspaceId: ctx.workspaceId,
|
|
456
|
+
});
|
|
264
457
|
return { items: items.map(toAuditLogEntry), meta };
|
|
265
458
|
}
|
|
266
459
|
|
|
267
460
|
/** `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. */
|
|
268
|
-
async getTwoFactorStatus(
|
|
269
|
-
|
|
461
|
+
async getTwoFactorStatus(
|
|
462
|
+
userId: string,
|
|
463
|
+
): Promise<{ twoFactorEnabled: boolean }> {
|
|
464
|
+
const [user] = await this.db
|
|
465
|
+
.select({ twoFactorEnabled: users.twoFactorEnabled })
|
|
466
|
+
.from(users)
|
|
467
|
+
.where(eq(users.id, toId(userId)))
|
|
468
|
+
.limit(1);
|
|
270
469
|
if (!user) throw new HttpError(404, `user "${userId}" not found`);
|
|
271
470
|
return { twoFactorEnabled: user.twoFactorEnabled };
|
|
272
471
|
}
|
|
273
472
|
|
|
274
|
-
async listActiveSessions(
|
|
473
|
+
async listActiveSessions(
|
|
474
|
+
userId: string,
|
|
475
|
+
): Promise<
|
|
476
|
+
Array<{
|
|
477
|
+
id: string;
|
|
478
|
+
createdAt: string;
|
|
479
|
+
expiresAt: string;
|
|
480
|
+
provider?: string;
|
|
481
|
+
userAgent?: string;
|
|
482
|
+
ip?: string;
|
|
483
|
+
}>
|
|
484
|
+
> {
|
|
275
485
|
// "Active" is now two conditions, not one: not revoked, and not past its own absolute expiry.
|
|
276
486
|
// `sessions_user_active_idx` is (user_id, is_revoked, expires_at) for exactly this read.
|
|
277
487
|
const rows = await this.db
|
|
278
488
|
.select()
|
|
279
489
|
.from(sessions)
|
|
280
|
-
.where(
|
|
490
|
+
.where(
|
|
491
|
+
and(
|
|
492
|
+
eq(sessions.userId, toId(userId)),
|
|
493
|
+
eq(sessions.isRevoked, false),
|
|
494
|
+
gt(sessions.expiresAt, new Date()),
|
|
495
|
+
),
|
|
496
|
+
)
|
|
281
497
|
.orderBy(desc(sessions.createdAt));
|
|
282
498
|
return rows.map((row) => ({
|
|
283
499
|
id: row.id.toString(),
|
|
@@ -295,8 +511,14 @@ export class AuthService {
|
|
|
295
511
|
// database through a workspace-scoped query. That is the security property this model rests on:
|
|
296
512
|
// an admin of one workspace has no expressible way to name a row in another.
|
|
297
513
|
|
|
298
|
-
async listUsers(
|
|
299
|
-
|
|
514
|
+
async listUsers(
|
|
515
|
+
ctx: AuthzContext,
|
|
516
|
+
filter: MemberListFilter,
|
|
517
|
+
): Promise<MemberListResult> {
|
|
518
|
+
const { items, meta } = await this.rbac.listMembers(
|
|
519
|
+
ctx.workspaceId,
|
|
520
|
+
filter,
|
|
521
|
+
);
|
|
300
522
|
return { items: items.map(toMemberSummary), meta };
|
|
301
523
|
}
|
|
302
524
|
|
|
@@ -307,13 +529,25 @@ export class AuthService {
|
|
|
307
529
|
async updateUser(
|
|
308
530
|
ctx: AuthzContext,
|
|
309
531
|
userId: string,
|
|
310
|
-
input: {
|
|
532
|
+
input: {
|
|
533
|
+
firstName?: string | null;
|
|
534
|
+
lastName?: string | null;
|
|
535
|
+
displayName?: string | null;
|
|
536
|
+
phone?: string | null;
|
|
537
|
+
username?: string | null;
|
|
538
|
+
photo?: string | null;
|
|
539
|
+
},
|
|
311
540
|
actorUserId: string | null,
|
|
312
541
|
): Promise<MemberSummary> {
|
|
313
542
|
return this.rbac.updateMember(ctx.workspaceId, userId, input, actorUserId);
|
|
314
543
|
}
|
|
315
544
|
|
|
316
|
-
async deleteUser(
|
|
545
|
+
async deleteUser(
|
|
546
|
+
ctx: AuthzContext,
|
|
547
|
+
userId: string,
|
|
548
|
+
actorUserId: string | null,
|
|
549
|
+
reason?: string,
|
|
550
|
+
): Promise<void> {
|
|
317
551
|
await this.rbac.deleteMember(ctx.workspaceId, userId, actorUserId, reason);
|
|
318
552
|
}
|
|
319
553
|
|
|
@@ -326,7 +560,10 @@ export class AuthService {
|
|
|
326
560
|
}
|
|
327
561
|
|
|
328
562
|
/** Defines a permission, or edits one — including deactivating it. The write path for "the catalog is editable in the database". */
|
|
329
|
-
async definePermission(
|
|
563
|
+
async definePermission(
|
|
564
|
+
input: PermissionInput,
|
|
565
|
+
actorUserId: string | null,
|
|
566
|
+
): Promise<PermissionSummary> {
|
|
330
567
|
return this.rbac.upsertPermission(input, actorUserId);
|
|
331
568
|
}
|
|
332
569
|
|
|
@@ -336,7 +573,12 @@ export class AuthService {
|
|
|
336
573
|
|
|
337
574
|
async createRole(
|
|
338
575
|
ctx: AuthzContext,
|
|
339
|
-
input: {
|
|
576
|
+
input: {
|
|
577
|
+
slug: string;
|
|
578
|
+
name?: string;
|
|
579
|
+
displayName?: string;
|
|
580
|
+
description?: string | null;
|
|
581
|
+
},
|
|
340
582
|
actorUserId: string | null,
|
|
341
583
|
): Promise<RoleSummary> {
|
|
342
584
|
return this.rbac.createRole(ctx.workspaceId, input, actorUserId);
|
|
@@ -345,47 +587,116 @@ export class AuthService {
|
|
|
345
587
|
async updateRole(
|
|
346
588
|
ctx: AuthzContext,
|
|
347
589
|
roleId: string,
|
|
348
|
-
input: {
|
|
590
|
+
input: {
|
|
591
|
+
name?: string;
|
|
592
|
+
displayName?: string;
|
|
593
|
+
description?: string | null;
|
|
594
|
+
isActive?: boolean;
|
|
595
|
+
},
|
|
349
596
|
actorUserId: string | null,
|
|
350
597
|
): Promise<RoleSummary> {
|
|
351
598
|
return this.rbac.updateRole(ctx.workspaceId, roleId, input, actorUserId);
|
|
352
599
|
}
|
|
353
600
|
|
|
354
|
-
async deleteRole(
|
|
601
|
+
async deleteRole(
|
|
602
|
+
ctx: AuthzContext,
|
|
603
|
+
roleId: string,
|
|
604
|
+
actorUserId: string | null,
|
|
605
|
+
reason?: string,
|
|
606
|
+
): Promise<void> {
|
|
355
607
|
await this.rbac.deleteRole(ctx.workspaceId, roleId, actorUserId, reason);
|
|
356
608
|
}
|
|
357
609
|
|
|
358
|
-
async attachPermissionToRole(
|
|
359
|
-
|
|
610
|
+
async attachPermissionToRole(
|
|
611
|
+
ctx: AuthzContext,
|
|
612
|
+
roleId: string,
|
|
613
|
+
permissionSlug: string,
|
|
614
|
+
actorUserId: string | null,
|
|
615
|
+
): Promise<void> {
|
|
616
|
+
await this.rbac.attachPermissionToRole(
|
|
617
|
+
ctx.workspaceId,
|
|
618
|
+
roleId,
|
|
619
|
+
permissionSlug,
|
|
620
|
+
actorUserId,
|
|
621
|
+
);
|
|
360
622
|
}
|
|
361
623
|
|
|
362
|
-
async assignRole(
|
|
624
|
+
async assignRole(
|
|
625
|
+
ctx: AuthzContext,
|
|
626
|
+
userId: string,
|
|
627
|
+
roleSlug: string,
|
|
628
|
+
): Promise<void> {
|
|
363
629
|
await this.rbac.assignRoleToMember(ctx.workspaceId, userId, roleSlug);
|
|
364
|
-
await this.auditLog.append(
|
|
630
|
+
await this.auditLog.append(
|
|
631
|
+
{ type: "role_assigned", userId, role: roleSlug },
|
|
632
|
+
{ workspaceId: ctx.workspaceId },
|
|
633
|
+
);
|
|
365
634
|
}
|
|
366
635
|
|
|
367
|
-
async revokeRole(
|
|
636
|
+
async revokeRole(
|
|
637
|
+
ctx: AuthzContext,
|
|
638
|
+
userId: string,
|
|
639
|
+
roleSlug: string,
|
|
640
|
+
): Promise<void> {
|
|
368
641
|
await this.rbac.revokeRoleFromMember(ctx.workspaceId, userId, roleSlug);
|
|
369
|
-
await this.auditLog.append(
|
|
642
|
+
await this.auditLog.append(
|
|
643
|
+
{ type: "role_revoked", userId, role: roleSlug },
|
|
644
|
+
{ workspaceId: ctx.workspaceId },
|
|
645
|
+
);
|
|
370
646
|
}
|
|
371
647
|
|
|
372
|
-
async grantPermission(
|
|
373
|
-
|
|
374
|
-
|
|
648
|
+
async grantPermission(
|
|
649
|
+
ctx: AuthzContext,
|
|
650
|
+
userId: string,
|
|
651
|
+
permissionSlug: string,
|
|
652
|
+
actorUserId: string | null,
|
|
653
|
+
): Promise<void> {
|
|
654
|
+
await this.rbac.grantPermissionToMember(
|
|
655
|
+
ctx.workspaceId,
|
|
656
|
+
userId,
|
|
657
|
+
permissionSlug,
|
|
658
|
+
actorUserId,
|
|
659
|
+
);
|
|
660
|
+
await this.auditLog.append(
|
|
661
|
+
{ type: "permission_granted", userId, permission: permissionSlug },
|
|
662
|
+
{ workspaceId: ctx.workspaceId },
|
|
663
|
+
);
|
|
375
664
|
}
|
|
376
665
|
|
|
377
|
-
async revokePermission(
|
|
378
|
-
|
|
379
|
-
|
|
666
|
+
async revokePermission(
|
|
667
|
+
ctx: AuthzContext,
|
|
668
|
+
userId: string,
|
|
669
|
+
permissionSlug: string,
|
|
670
|
+
): Promise<void> {
|
|
671
|
+
await this.rbac.revokePermissionFromMember(
|
|
672
|
+
ctx.workspaceId,
|
|
673
|
+
userId,
|
|
674
|
+
permissionSlug,
|
|
675
|
+
);
|
|
676
|
+
await this.auditLog.append(
|
|
677
|
+
{ type: "permission_revoked", userId, permission: permissionSlug },
|
|
678
|
+
{ workspaceId: ctx.workspaceId },
|
|
679
|
+
);
|
|
380
680
|
}
|
|
381
681
|
|
|
382
682
|
async refresh(refreshToken: string): Promise<Omit<AuthTokens, "sessionId">> {
|
|
383
683
|
const key = await this.keys.getActiveKey();
|
|
384
|
-
const presented = await verifyRefreshToken(
|
|
385
|
-
|
|
684
|
+
const presented = await verifyRefreshToken(
|
|
685
|
+
{ secret: this.keys.secret },
|
|
686
|
+
refreshToken,
|
|
687
|
+
);
|
|
688
|
+
const { session, nextJti } = await rotateRefreshToken(
|
|
689
|
+
this.sessions,
|
|
690
|
+
presented,
|
|
691
|
+
);
|
|
386
692
|
|
|
387
|
-
const [user] = await this.db
|
|
388
|
-
|
|
693
|
+
const [user] = await this.db
|
|
694
|
+
.select()
|
|
695
|
+
.from(users)
|
|
696
|
+
.where(eq(users.id, toId(session.userId)))
|
|
697
|
+
.limit(1);
|
|
698
|
+
if (!user || user.blocked || !user.isActive || user.isDeleted)
|
|
699
|
+
throw new HttpError(401, "invalid credentials");
|
|
389
700
|
|
|
390
701
|
// Identity only. Authorization is resolved from the database on every request (the authz middleware),
|
|
391
702
|
// so a token issued before a grant is as authoritative as one issued after it.
|
|
@@ -396,38 +707,83 @@ export class AuthService {
|
|
|
396
707
|
);
|
|
397
708
|
const refresh = await signRefreshToken(
|
|
398
709
|
{ activeKey: key },
|
|
399
|
-
{
|
|
710
|
+
{
|
|
711
|
+
sub: user.id.toString(),
|
|
712
|
+
sessionId: session.id,
|
|
713
|
+
sv: session.sessionVersion,
|
|
714
|
+
},
|
|
400
715
|
{ jti: nextJti, ttlSeconds: this.config.refreshTokenTtlSeconds },
|
|
401
716
|
);
|
|
402
717
|
return { accessToken: access.token, refreshToken: refresh.token };
|
|
403
718
|
}
|
|
404
719
|
|
|
405
|
-
async logout(
|
|
720
|
+
async logout(
|
|
721
|
+
sessionId: string,
|
|
722
|
+
accessJti: string,
|
|
723
|
+
accessRemainingTtlSeconds: number,
|
|
724
|
+
revoker?: Revoker,
|
|
725
|
+
): Promise<void> {
|
|
406
726
|
await revokeSession(this.sessions, sessionId, revoker);
|
|
407
|
-
await revokeAccessToken(
|
|
727
|
+
await revokeAccessToken(
|
|
728
|
+
this.sessions,
|
|
729
|
+
accessJti,
|
|
730
|
+
accessRemainingTtlSeconds,
|
|
731
|
+
);
|
|
408
732
|
}
|
|
409
733
|
|
|
410
734
|
async logoutAll(userId: string, revoker?: Revoker): Promise<void> {
|
|
411
735
|
await revokeAllSessionsForUser(this.sessions, userId, revoker);
|
|
412
736
|
}
|
|
413
737
|
|
|
414
|
-
async logoutOthers(
|
|
415
|
-
|
|
738
|
+
async logoutOthers(
|
|
739
|
+
userId: string,
|
|
740
|
+
keepSessionId: string,
|
|
741
|
+
revoker?: Revoker,
|
|
742
|
+
): Promise<void> {
|
|
743
|
+
await revokeOtherSessionsForUser(
|
|
744
|
+
this.sessions,
|
|
745
|
+
userId,
|
|
746
|
+
keepSessionId,
|
|
747
|
+
revoker,
|
|
748
|
+
);
|
|
416
749
|
}
|
|
417
750
|
|
|
418
|
-
async changePassword(
|
|
751
|
+
async changePassword(
|
|
752
|
+
userId: string,
|
|
753
|
+
currentSessionId: string,
|
|
754
|
+
input: { currentPassword: string; newPassword: string },
|
|
755
|
+
): Promise<void> {
|
|
419
756
|
const idBig = toId(userId);
|
|
420
|
-
const [user] = await this.db
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
757
|
+
const [user] = await this.db
|
|
758
|
+
.select()
|
|
759
|
+
.from(users)
|
|
760
|
+
.where(eq(users.id, idBig))
|
|
761
|
+
.limit(1);
|
|
762
|
+
if (
|
|
763
|
+
!user ||
|
|
764
|
+
user.blocked ||
|
|
765
|
+
!user.isActive ||
|
|
766
|
+
user.isDeleted ||
|
|
767
|
+
!user.passwordHash
|
|
768
|
+
)
|
|
769
|
+
throw new HttpError(401, "invalid credentials");
|
|
770
|
+
|
|
771
|
+
const valid = await verifyPassword(
|
|
772
|
+
user.passwordHash,
|
|
773
|
+
input.currentPassword,
|
|
774
|
+
);
|
|
424
775
|
if (!valid) throw new HttpError(401, "invalid credentials");
|
|
425
776
|
|
|
426
777
|
const passwordHash = await hashPassword(input.newPassword);
|
|
427
|
-
await this.db
|
|
778
|
+
await this.db
|
|
779
|
+
.update(users)
|
|
780
|
+
.set({ passwordHash, updatedBy: idBig })
|
|
781
|
+
.where(eq(users.id, idBig));
|
|
428
782
|
// The old password is no longer trusted everywhere else it's signed in — but leave the
|
|
429
783
|
// session making this very call alone, the same courtesy `logoutOthers` extends.
|
|
430
|
-
await revokeOtherSessionsForUser(this.sessions, userId, currentSessionId, {
|
|
784
|
+
await revokeOtherSessionsForUser(this.sessions, userId, currentSessionId, {
|
|
785
|
+
userId,
|
|
786
|
+
});
|
|
431
787
|
}
|
|
432
788
|
|
|
433
789
|
/** Backs both `GET`- and `PATCH /auth/me` — deliberately not workspace-scoped, see `updateProfile`. */
|
|
@@ -448,7 +804,11 @@ export class AuthService {
|
|
|
448
804
|
/** Self-service — no `users:manage` permission required, callable by anyone on their own row, and deliberately not workspace-scoped, see `updateProfile`. */
|
|
449
805
|
async getProfile(userId: string): Promise<SelfProfile> {
|
|
450
806
|
const idBig = toId(userId);
|
|
451
|
-
const [user] = await this.db
|
|
807
|
+
const [user] = await this.db
|
|
808
|
+
.select()
|
|
809
|
+
.from(users)
|
|
810
|
+
.where(and(eq(users.id, idBig), eq(users.isDeleted, false)))
|
|
811
|
+
.limit(1);
|
|
452
812
|
if (!user) throw new HttpError(401, "invalid credentials");
|
|
453
813
|
return this.toSelfProfile(user);
|
|
454
814
|
}
|
|
@@ -462,10 +822,21 @@ export class AuthService {
|
|
|
462
822
|
*/
|
|
463
823
|
async updateProfile(
|
|
464
824
|
userId: string,
|
|
465
|
-
input: {
|
|
825
|
+
input: {
|
|
826
|
+
firstName?: string | null;
|
|
827
|
+
lastName?: string | null;
|
|
828
|
+
displayName?: string | null;
|
|
829
|
+
phone?: string | null;
|
|
830
|
+
username?: string | null;
|
|
831
|
+
photo?: string | null;
|
|
832
|
+
},
|
|
466
833
|
): Promise<SelfProfile> {
|
|
467
834
|
const idBig = toId(userId);
|
|
468
|
-
const [existing] = await this.db
|
|
835
|
+
const [existing] = await this.db
|
|
836
|
+
.select({ id: users.id })
|
|
837
|
+
.from(users)
|
|
838
|
+
.where(and(eq(users.id, idBig), eq(users.isDeleted, false)))
|
|
839
|
+
.limit(1);
|
|
469
840
|
if (!existing) throw new HttpError(401, "invalid credentials");
|
|
470
841
|
const [user] = await this.db
|
|
471
842
|
.update(users)
|
|
@@ -480,9 +851,16 @@ export class AuthService {
|
|
|
480
851
|
* caller's workspace — otherwise an admin of one workspace could disable an account they
|
|
481
852
|
* have no relationship with.
|
|
482
853
|
*/
|
|
483
|
-
async block(
|
|
854
|
+
async block(
|
|
855
|
+
ctx: AuthzContext,
|
|
856
|
+
userId: string,
|
|
857
|
+
revoker?: Revoker,
|
|
858
|
+
): Promise<void> {
|
|
484
859
|
await this.rbac.requireMember(ctx.workspaceId, userId);
|
|
485
|
-
await this.db
|
|
860
|
+
await this.db
|
|
861
|
+
.update(users)
|
|
862
|
+
.set({ blocked: true, updatedBy: toIdOrNull(revoker?.userId) })
|
|
863
|
+
.where(eq(users.id, toId(userId)));
|
|
486
864
|
// The administrator, not the blocked user, is what lands in `sessions.revoked_by`.
|
|
487
865
|
await blockUser(this.sessions, userId, revoker);
|
|
488
866
|
// Belt and braces. The block is enforced on the authentication path — login and refresh both
|
|
@@ -492,29 +870,56 @@ export class AuthService {
|
|
|
492
870
|
await this.rbac.invalidateMember(userId, ctx.workspaceId);
|
|
493
871
|
}
|
|
494
872
|
|
|
495
|
-
async unblock(
|
|
873
|
+
async unblock(
|
|
874
|
+
ctx: AuthzContext,
|
|
875
|
+
userId: string,
|
|
876
|
+
revoker?: Revoker,
|
|
877
|
+
): Promise<void> {
|
|
496
878
|
await this.rbac.requireMember(ctx.workspaceId, userId);
|
|
497
|
-
await this.db
|
|
879
|
+
await this.db
|
|
880
|
+
.update(users)
|
|
881
|
+
.set({ blocked: false, updatedBy: toIdOrNull(revoker?.userId) })
|
|
882
|
+
.where(eq(users.id, toId(userId)));
|
|
498
883
|
}
|
|
499
884
|
|
|
500
885
|
/**
|
|
501
886
|
* A routine administrative on/off toggle — distinct from `block`/`unblock`, which is a
|
|
502
887
|
* security/moderation action. Both independently deny login; see the note on the `users` table.
|
|
503
888
|
*/
|
|
504
|
-
async deactivate(
|
|
889
|
+
async deactivate(
|
|
890
|
+
ctx: AuthzContext,
|
|
891
|
+
userId: string,
|
|
892
|
+
revoker?: Revoker,
|
|
893
|
+
): Promise<void> {
|
|
505
894
|
await this.rbac.requireMember(ctx.workspaceId, userId);
|
|
506
|
-
await this.db
|
|
895
|
+
await this.db
|
|
896
|
+
.update(users)
|
|
897
|
+
.set({ isActive: false, updatedBy: toIdOrNull(revoker?.userId) })
|
|
898
|
+
.where(eq(users.id, toId(userId)));
|
|
507
899
|
await deactivateUser(this.sessions, userId, revoker);
|
|
508
900
|
await this.rbac.invalidateMember(userId, ctx.workspaceId);
|
|
509
901
|
}
|
|
510
902
|
|
|
511
|
-
async activate(
|
|
903
|
+
async activate(
|
|
904
|
+
ctx: AuthzContext,
|
|
905
|
+
userId: string,
|
|
906
|
+
revoker?: Revoker,
|
|
907
|
+
): Promise<void> {
|
|
512
908
|
await this.rbac.requireMember(ctx.workspaceId, userId);
|
|
513
|
-
await this.db
|
|
909
|
+
await this.db
|
|
910
|
+
.update(users)
|
|
911
|
+
.set({ isActive: true, updatedBy: toIdOrNull(revoker?.userId) })
|
|
912
|
+
.where(eq(users.id, toId(userId)));
|
|
514
913
|
}
|
|
515
914
|
|
|
516
|
-
private async getUserOrThrow(
|
|
517
|
-
|
|
915
|
+
private async getUserOrThrow(
|
|
916
|
+
userId: string,
|
|
917
|
+
): Promise<typeof users.$inferSelect> {
|
|
918
|
+
const [user] = await this.db
|
|
919
|
+
.select()
|
|
920
|
+
.from(users)
|
|
921
|
+
.where(eq(users.id, toId(userId)))
|
|
922
|
+
.limit(1);
|
|
518
923
|
if (!user) throw new HttpError(404, `user "${userId}" not found`);
|
|
519
924
|
return user;
|
|
520
925
|
}
|
|
@@ -525,8 +930,14 @@ export class AuthService {
|
|
|
525
930
|
* Each request resolves its own — see AuthzGuard. Takes the raw database user row (bigint id)
|
|
526
931
|
* — every call site already has one in hand.
|
|
527
932
|
*/
|
|
528
|
-
private async issueSessionTokens(
|
|
529
|
-
|
|
933
|
+
private async issueSessionTokens(
|
|
934
|
+
user: { id: bigint },
|
|
935
|
+
meta: { userAgent?: string; ip?: string; provider?: string },
|
|
936
|
+
): Promise<AuthTokens> {
|
|
937
|
+
const session = await createSession(this.sessions, {
|
|
938
|
+
userId: user.id.toString(),
|
|
939
|
+
...meta,
|
|
940
|
+
});
|
|
530
941
|
const key = await this.keys.getActiveKey();
|
|
531
942
|
|
|
532
943
|
const access = await signAccessToken(
|
|
@@ -536,10 +947,21 @@ export class AuthService {
|
|
|
536
947
|
);
|
|
537
948
|
const refresh = await signRefreshToken(
|
|
538
949
|
{ activeKey: key },
|
|
539
|
-
{
|
|
540
|
-
|
|
950
|
+
{
|
|
951
|
+
sub: user.id.toString(),
|
|
952
|
+
sessionId: session.id,
|
|
953
|
+
sv: session.sessionVersion,
|
|
954
|
+
},
|
|
955
|
+
{
|
|
956
|
+
jti: session.currentRefreshJti,
|
|
957
|
+
ttlSeconds: this.config.refreshTokenTtlSeconds,
|
|
958
|
+
},
|
|
541
959
|
);
|
|
542
960
|
|
|
543
|
-
return {
|
|
961
|
+
return {
|
|
962
|
+
accessToken: access.token,
|
|
963
|
+
refreshToken: refresh.token,
|
|
964
|
+
sessionId: session.id,
|
|
965
|
+
};
|
|
544
966
|
}
|
|
545
967
|
}
|