@simple-auth-kit/cli 1.3.2 → 1.4.1
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/lib/copy.ts +67 -10
- package/package.json +1 -1
- package/registry/admin-apps/nextjs/shared/.husky/commit-msg +1 -0
- package/registry/admin-apps/nextjs/shared/.husky/pre-commit +1 -0
- package/registry/admin-apps/nextjs/shared/.lintstagedrc.json +4 -0
- package/registry/admin-apps/nextjs/shared/.prettierrc.json +3 -0
- package/registry/admin-apps/nextjs/shared/commitlint.config.js +5 -0
- package/registry/admin-apps/nextjs/shared/eslint.config.js +28 -0
- package/registry/admin-apps/nextjs/shared/gitignore +11 -0
- package/registry/admin-apps/nextjs/variants/base/package.json +11 -2
- package/registry/admin-apps/nextjs/variants/base/src/app/(console)/audit-log/page.tsx +72 -16
- package/registry/admin-apps/nextjs/variants/base/src/app/(console)/customers/[id]/edit/page.tsx +107 -26
- package/registry/admin-apps/nextjs/variants/base/src/app/(console)/customers/[id]/page.tsx +131 -29
- package/registry/admin-apps/nextjs/variants/base/src/app/(console)/languages/[id]/edit/page.tsx +111 -25
- package/registry/admin-apps/nextjs/variants/base/src/app/(console)/languages/[id]/page.tsx +97 -22
- package/registry/admin-apps/nextjs/variants/base/src/app/(console)/permissions/[id]/edit/page.tsx +129 -46
- package/registry/admin-apps/nextjs/variants/base/src/app/(console)/permissions/[id]/page.tsx +64 -14
- package/registry/admin-apps/nextjs/variants/base/src/app/(console)/roles/[id]/page.tsx +91 -22
- package/registry/admin-apps/nextjs/variants/base/src/app/(console)/users/[id]/edit/page.tsx +94 -23
- package/registry/admin-apps/nextjs/variants/workspaces/package.json +11 -2
- package/registry/admin-apps/nextjs/variants/workspaces/src/app/(console)/account/page.tsx +179 -41
- package/registry/admin-apps/react/shared/.husky/commit-msg +1 -0
- package/registry/admin-apps/react/shared/.husky/pre-commit +1 -0
- package/registry/admin-apps/react/shared/.lintstagedrc.json +4 -0
- package/registry/admin-apps/react/shared/.prettierrc.json +3 -0
- package/registry/admin-apps/react/shared/commitlint.config.js +3 -0
- package/registry/admin-apps/react/shared/eslint.config.js +38 -0
- package/registry/admin-apps/react/shared/gitignore +17 -0
- package/registry/admin-apps/react/variants/base/package.json +15 -2
- package/registry/admin-apps/react/variants/base/src/pages/CountryDetailPage.tsx +76 -19
- package/registry/admin-apps/react/variants/base/src/pages/CustomerDetailPage.tsx +110 -26
- package/registry/admin-apps/react/variants/base/src/pages/EditCountryPage.tsx +115 -29
- package/registry/admin-apps/react/variants/base/src/pages/EditCustomerPage.tsx +123 -31
- package/registry/admin-apps/react/variants/base/src/pages/EditLanguagePage.tsx +100 -24
- package/registry/admin-apps/react/variants/base/src/pages/EditRolePage.tsx +99 -26
- package/registry/admin-apps/react/variants/base/src/pages/EditUserPage.tsx +126 -31
- package/registry/admin-apps/react/variants/base/src/pages/LanguageDetailPage.tsx +75 -19
- package/registry/admin-apps/react/variants/base/src/pages/PermissionDetailPage.tsx +58 -13
- package/registry/admin-apps/react/variants/base/src/pages/PermissionsPage.tsx +132 -29
- package/registry/admin-apps/react/variants/base/src/pages/RoleDetailPage.tsx +70 -17
- package/registry/admin-apps/react/variants/base/src/pages/RolesPage.tsx +277 -59
- package/registry/admin-apps/react/variants/workspaces/package.json +15 -2
- package/registry/combos/express-drizzle/eslint.config.js +18 -0
- package/registry/combos/express-drizzle/package.json +5 -1
- package/registry/combos/express-drizzle/shared/src/request-context.ts +3 -0
- package/registry/combos/express-prisma/eslint.config.js +18 -0
- package/registry/combos/express-prisma/package.json +5 -1
- package/registry/combos/express-prisma/shared/src/request-context.ts +3 -0
- package/registry/combos/nestjs-drizzle/eslint.config.js +18 -0
- package/registry/combos/nestjs-drizzle/package.json +5 -1
- package/registry/combos/nestjs-drizzle/shared/src/auth.controller.ts +138 -33
- package/registry/combos/nestjs-drizzle/shared/src/route-tiers.ts +71 -18
- package/registry/combos/nestjs-prisma/eslint.config.js +18 -0
- package/registry/combos/nestjs-prisma/package.json +5 -1
- package/registry/combos/nestjs-prisma/shared/src/auth.controller.ts +148 -35
- package/registry/combos/nestjs-prisma/shared/src/route-tiers.ts +72 -18
- package/registry/mobile-apps/bare-rn/shared/.husky/commit-msg +1 -0
- package/registry/mobile-apps/bare-rn/shared/.husky/pre-commit +1 -0
- package/registry/mobile-apps/bare-rn/shared/.lintstagedrc.json +4 -0
- package/registry/mobile-apps/bare-rn/shared/.prettierrc.json +5 -0
- package/registry/mobile-apps/bare-rn/shared/commitlint.config.js +3 -0
- package/registry/mobile-apps/bare-rn/shared/eslint.config.js +19 -0
- package/registry/mobile-apps/bare-rn/shared/gitignore +78 -0
- package/registry/mobile-apps/bare-rn/variants/base/package.json +17 -12
- package/registry/mobile-apps/bare-rn/variants/workspaces/package.json +17 -12
- package/registry/mobile-apps/expo/shared/.husky/commit-msg +1 -0
- package/registry/mobile-apps/expo/shared/.husky/pre-commit +1 -0
- package/registry/mobile-apps/expo/shared/.lintstagedrc.json +4 -0
- package/registry/mobile-apps/expo/shared/.prettierrc.json +5 -0
- package/registry/mobile-apps/expo/shared/commitlint.config.js +3 -0
- package/registry/mobile-apps/expo/shared/eslint.config.js +9 -0
- package/registry/mobile-apps/expo/shared/gitignore +44 -0
- package/registry/mobile-apps/expo/shared/src/screens/SessionsScreen.tsx +6 -17
- package/registry/mobile-apps/expo/shared/src/store/sessionsStore.ts +35 -0
- package/registry/mobile-apps/expo/variants/base/package.json +12 -3
- package/registry/mobile-apps/expo/variants/workspaces/package.json +12 -3
- package/registry/mobile-apps/expo/variants/workspaces/src/screens/WorkspaceSelectScreen.tsx +1 -1
- package/registry.json +13 -36
- package/registry/mobile-apps/bare-rn/shared/.eslintrc.js +0 -4
- package/registry/mobile-apps/bare-rn/shared/.prettierrc.js +0 -5
|
@@ -1,8 +1,33 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
BadRequestException,
|
|
3
|
+
Body,
|
|
4
|
+
Controller,
|
|
5
|
+
Get,
|
|
6
|
+
Inject,
|
|
7
|
+
Ip,
|
|
8
|
+
Param,
|
|
9
|
+
Patch,
|
|
10
|
+
Post,
|
|
11
|
+
Query,
|
|
12
|
+
Req,
|
|
13
|
+
UseGuards,
|
|
14
|
+
} from "@nestjs/common";
|
|
15
|
+
import {
|
|
16
|
+
ApiBearerAuth,
|
|
17
|
+
ApiBody,
|
|
18
|
+
ApiOperation,
|
|
19
|
+
ApiParam,
|
|
20
|
+
ApiQuery,
|
|
21
|
+
ApiResponse,
|
|
22
|
+
ApiTags,
|
|
23
|
+
} from "@nestjs/swagger";
|
|
3
24
|
import { SkipThrottle } from "@nestjs/throttler";
|
|
4
25
|
import type { Request } from "express";
|
|
5
|
-
import {
|
|
26
|
+
import {
|
|
27
|
+
AUTH_CONFIG,
|
|
28
|
+
AuthConfig,
|
|
29
|
+
defaultThrottleBuckets,
|
|
30
|
+
} from "./auth.config.js";
|
|
6
31
|
import { AuthGuard } from "./auth.guard.js";
|
|
7
32
|
import { AuthService } from "./auth.service.js";
|
|
8
33
|
import { AuthzGuard } from "./authz.guard.js";
|
|
@@ -24,17 +49,18 @@ import {
|
|
|
24
49
|
SelfProfileDto,
|
|
25
50
|
SessionSummaryDto,
|
|
26
51
|
SignupDto,
|
|
27
|
-
TwoFactorChallengeDto,
|
|
28
52
|
TwoFactorCodeDto,
|
|
29
53
|
UpdateUserDto,
|
|
30
54
|
} from "./dto/auth.dto.js";
|
|
31
55
|
|
|
32
56
|
function requireString(value: unknown, field: string): string {
|
|
33
|
-
if (typeof value !== "string" || value.length === 0)
|
|
57
|
+
if (typeof value !== "string" || value.length === 0)
|
|
58
|
+
throw new BadRequestException(`${field} is required`);
|
|
34
59
|
return value;
|
|
35
60
|
}
|
|
36
61
|
|
|
37
|
-
const optionalString = (value: unknown): string | undefined =>
|
|
62
|
+
const optionalString = (value: unknown): string | undefined =>
|
|
63
|
+
typeof value === "string" ? value : undefined;
|
|
38
64
|
|
|
39
65
|
/**
|
|
40
66
|
* Identity endpoints — everything that is about *a user*, never about a group of them.
|
|
@@ -53,7 +79,11 @@ export class AuthController {
|
|
|
53
79
|
@ApiOperation({ summary: "Create a user and issue a session" })
|
|
54
80
|
@ApiBody({ type: SignupDto })
|
|
55
81
|
@ApiResponse({ status: 201, type: AuthTokensDto })
|
|
56
|
-
async signup(
|
|
82
|
+
async signup(
|
|
83
|
+
@Body() body: Record<string, unknown>,
|
|
84
|
+
@Req() req: Request,
|
|
85
|
+
@Ip() ip: string,
|
|
86
|
+
) {
|
|
57
87
|
return this.auth.signup({
|
|
58
88
|
email: requireString(body.email, "email"),
|
|
59
89
|
password: requireString(body.password, "password"),
|
|
@@ -71,11 +101,24 @@ export class AuthController {
|
|
|
71
101
|
@Public()
|
|
72
102
|
@ApiOperation({
|
|
73
103
|
summary: "Log in with email, username, or phone + password",
|
|
74
|
-
description:
|
|
104
|
+
description:
|
|
105
|
+
"identifier is matched against email, username, and phone, in that order. Returns tokens directly, or a 2FA challenge if the account has 2FA enabled.",
|
|
75
106
|
})
|
|
76
107
|
@ApiBody({ type: LoginDto })
|
|
77
|
-
@ApiResponse({
|
|
78
|
-
|
|
108
|
+
@ApiResponse({
|
|
109
|
+
status: 201,
|
|
110
|
+
schema: {
|
|
111
|
+
oneOf: [
|
|
112
|
+
{ $ref: "#/components/schemas/AuthTokensDto" },
|
|
113
|
+
{ $ref: "#/components/schemas/TwoFactorChallengeDto" },
|
|
114
|
+
],
|
|
115
|
+
},
|
|
116
|
+
})
|
|
117
|
+
async login(
|
|
118
|
+
@Body() body: Record<string, unknown>,
|
|
119
|
+
@Req() req: Request,
|
|
120
|
+
@Ip() ip: string,
|
|
121
|
+
) {
|
|
79
122
|
return this.auth.login({
|
|
80
123
|
identifier: requireString(body.identifier, "identifier"),
|
|
81
124
|
password: requireString(body.password, "password"),
|
|
@@ -89,7 +132,11 @@ export class AuthController {
|
|
|
89
132
|
@ApiOperation({ summary: "Complete the 2FA challenge from POST /auth/login" })
|
|
90
133
|
@ApiBody({ type: LoginTwoFactorDto })
|
|
91
134
|
@ApiResponse({ status: 201, type: AuthTokensDto })
|
|
92
|
-
async loginTwoFactor(
|
|
135
|
+
async loginTwoFactor(
|
|
136
|
+
@Body() body: Record<string, unknown>,
|
|
137
|
+
@Req() req: Request,
|
|
138
|
+
@Ip() ip: string,
|
|
139
|
+
) {
|
|
93
140
|
return this.auth.loginTwoFactor({
|
|
94
141
|
challengeToken: requireString(body.challengeToken, "challengeToken"),
|
|
95
142
|
code: requireString(body.code, "code"),
|
|
@@ -100,7 +147,9 @@ export class AuthController {
|
|
|
100
147
|
|
|
101
148
|
@Post("refresh")
|
|
102
149
|
@Public()
|
|
103
|
-
@ApiOperation({
|
|
150
|
+
@ApiOperation({
|
|
151
|
+
summary: "Rotate a refresh token for a new access+refresh pair",
|
|
152
|
+
})
|
|
104
153
|
@ApiBody({ type: RefreshDto })
|
|
105
154
|
@ApiResponse({ status: 201, type: RefreshResponseDto })
|
|
106
155
|
async refresh(@Body() body: Record<string, unknown>) {
|
|
@@ -111,7 +160,11 @@ export class AuthController {
|
|
|
111
160
|
@Authenticated()
|
|
112
161
|
// Clients hit this on every navigation to verify the token is still live — identity checks
|
|
113
162
|
// must not consume the abuse budget. Metadata only in variants that don't register the guard.
|
|
114
|
-
@SkipThrottle(
|
|
163
|
+
@SkipThrottle(
|
|
164
|
+
Object.fromEntries(
|
|
165
|
+
defaultThrottleBuckets.map((bucket) => [bucket.name, true]),
|
|
166
|
+
),
|
|
167
|
+
)
|
|
115
168
|
@ApiBearerAuth()
|
|
116
169
|
@UseGuards(AuthGuard, AuthzGuard)
|
|
117
170
|
@ApiOperation({
|
|
@@ -125,7 +178,10 @@ export class AuthController {
|
|
|
125
178
|
@ApiResponse({ status: 200, type: CurrentUserDto })
|
|
126
179
|
async me(@Req() req: Request) {
|
|
127
180
|
const { sub, sessionId } = req.auth!;
|
|
128
|
-
const [{ twoFactorEnabled }, profile] = await Promise.all([
|
|
181
|
+
const [{ twoFactorEnabled }, profile] = await Promise.all([
|
|
182
|
+
this.auth.getTwoFactorStatus(sub),
|
|
183
|
+
this.auth.getProfile(sub),
|
|
184
|
+
]);
|
|
129
185
|
// The same `req.authz` AbilityGuard's ability was built from — not re-resolved here.
|
|
130
186
|
return {
|
|
131
187
|
sub,
|
|
@@ -147,7 +203,9 @@ export class AuthController {
|
|
|
147
203
|
@Authenticated()
|
|
148
204
|
@ApiBearerAuth()
|
|
149
205
|
@UseGuards(AuthGuard)
|
|
150
|
-
@ApiOperation({
|
|
206
|
+
@ApiOperation({
|
|
207
|
+
summary: "List the current user's active (non-revoked) sessions",
|
|
208
|
+
})
|
|
151
209
|
@ApiResponse({ status: 200, type: [SessionSummaryDto] })
|
|
152
210
|
async sessions(@Req() req: Request) {
|
|
153
211
|
return this.auth.listActiveSessions(req.auth!.sub);
|
|
@@ -163,7 +221,10 @@ export class AuthController {
|
|
|
163
221
|
const { sessionId, jti, sub } = req.auth!;
|
|
164
222
|
// The revoker is the caller themselves. Recorded so the row can later be told apart from a
|
|
165
223
|
// session an administrator ended — see `sessions.revoked_by`.
|
|
166
|
-
await this.auth.logout(sessionId, jti, this.config.accessTokenTtlSeconds, {
|
|
224
|
+
await this.auth.logout(sessionId, jti, this.config.accessTokenTtlSeconds, {
|
|
225
|
+
userId: sub,
|
|
226
|
+
ip,
|
|
227
|
+
});
|
|
167
228
|
return { ok: true };
|
|
168
229
|
}
|
|
169
230
|
|
|
@@ -185,7 +246,10 @@ export class AuthController {
|
|
|
185
246
|
@ApiOperation({ summary: "Revoke every session except the current one" })
|
|
186
247
|
@ApiResponse({ status: 201, type: OkResponseDto })
|
|
187
248
|
async logoutOthers(@Req() req: Request, @Ip() ip: string) {
|
|
188
|
-
await this.auth.logoutOthers(req.auth!.sub, req.auth!.sessionId, {
|
|
249
|
+
await this.auth.logoutOthers(req.auth!.sub, req.auth!.sessionId, {
|
|
250
|
+
userId: req.auth!.sub,
|
|
251
|
+
ip,
|
|
252
|
+
});
|
|
189
253
|
return { ok: true };
|
|
190
254
|
}
|
|
191
255
|
|
|
@@ -195,11 +259,15 @@ export class AuthController {
|
|
|
195
259
|
@UseGuards(AuthGuard)
|
|
196
260
|
@ApiOperation({
|
|
197
261
|
summary: "Change the current password",
|
|
198
|
-
description:
|
|
262
|
+
description:
|
|
263
|
+
"Requires the current password. Every other session is revoked; the one making this call is left alone.",
|
|
199
264
|
})
|
|
200
265
|
@ApiBody({ type: ChangePasswordDto })
|
|
201
266
|
@ApiResponse({ status: 201, type: OkResponseDto })
|
|
202
|
-
async changePassword(
|
|
267
|
+
async changePassword(
|
|
268
|
+
@Body() body: Record<string, unknown>,
|
|
269
|
+
@Req() req: Request,
|
|
270
|
+
) {
|
|
203
271
|
await this.auth.changePassword(req.auth!.sub, req.auth!.sessionId, {
|
|
204
272
|
currentPassword: requireString(body.currentPassword, "currentPassword"),
|
|
205
273
|
newPassword: requireString(body.newPassword, "newPassword"),
|
|
@@ -213,15 +281,18 @@ export class AuthController {
|
|
|
213
281
|
@UseGuards(AuthGuard)
|
|
214
282
|
@ApiOperation({
|
|
215
283
|
summary: "Update the current user's own profile",
|
|
216
|
-
description:
|
|
284
|
+
description:
|
|
285
|
+
"Self-service — no admin permission required. Updates the caller's own row directly; unrelated to any workspace.",
|
|
217
286
|
})
|
|
218
287
|
@ApiBody({ type: UpdateUserDto })
|
|
219
288
|
@ApiResponse({ status: 200, type: SelfProfileDto })
|
|
220
289
|
async updateMe(@Body() body: Record<string, unknown>, @Req() req: Request) {
|
|
221
290
|
return this.auth.updateProfile(req.auth!.sub, {
|
|
222
|
-
firstName:
|
|
291
|
+
firstName:
|
|
292
|
+
body.firstName === null ? null : optionalString(body.firstName),
|
|
223
293
|
lastName: body.lastName === null ? null : optionalString(body.lastName),
|
|
224
|
-
displayName:
|
|
294
|
+
displayName:
|
|
295
|
+
body.displayName === null ? null : optionalString(body.displayName),
|
|
225
296
|
phone: body.phone === null ? null : optionalString(body.phone),
|
|
226
297
|
username: body.username === null ? null : optionalString(body.username),
|
|
227
298
|
photo: body.photo === null ? null : optionalString(body.photo),
|
|
@@ -232,7 +303,10 @@ export class AuthController {
|
|
|
232
303
|
@Authenticated()
|
|
233
304
|
@ApiBearerAuth()
|
|
234
305
|
@UseGuards(AuthGuard)
|
|
235
|
-
@ApiOperation({
|
|
306
|
+
@ApiOperation({
|
|
307
|
+
summary: "Begin TOTP enrollment",
|
|
308
|
+
description: "Not active until confirmed via POST /auth/2fa/confirm.",
|
|
309
|
+
})
|
|
236
310
|
@ApiResponse({ status: 201, type: EnrollTwoFactorResponseDto })
|
|
237
311
|
async enrollTwoFactor(@Req() req: Request) {
|
|
238
312
|
return this.auth.enrollTwoFactor(req.auth!.sub);
|
|
@@ -242,28 +316,51 @@ export class AuthController {
|
|
|
242
316
|
@Authenticated()
|
|
243
317
|
@ApiBearerAuth()
|
|
244
318
|
@UseGuards(AuthGuard)
|
|
245
|
-
@ApiOperation({
|
|
319
|
+
@ApiOperation({
|
|
320
|
+
summary: "Confirm TOTP enrollment with a code",
|
|
321
|
+
description:
|
|
322
|
+
"Enables 2FA and returns one-time backup codes, shown only here.",
|
|
323
|
+
})
|
|
246
324
|
@ApiBody({ type: TwoFactorCodeDto })
|
|
247
325
|
@ApiResponse({ status: 201, type: ConfirmTwoFactorResponseDto })
|
|
248
|
-
async confirmTwoFactor(
|
|
249
|
-
|
|
326
|
+
async confirmTwoFactor(
|
|
327
|
+
@Body() body: Record<string, unknown>,
|
|
328
|
+
@Req() req: Request,
|
|
329
|
+
) {
|
|
330
|
+
return this.auth.confirmTwoFactor(
|
|
331
|
+
req.auth!.sub,
|
|
332
|
+
requireString(body.code, "code"),
|
|
333
|
+
);
|
|
250
334
|
}
|
|
251
335
|
|
|
252
336
|
@Post("2fa/disable")
|
|
253
337
|
@Authenticated()
|
|
254
338
|
@ApiBearerAuth()
|
|
255
339
|
@UseGuards(AuthGuard)
|
|
256
|
-
@ApiOperation({
|
|
340
|
+
@ApiOperation({
|
|
341
|
+
summary: "Disable 2FA",
|
|
342
|
+
description: "Requires a valid TOTP or backup code.",
|
|
343
|
+
})
|
|
257
344
|
@ApiBody({ type: TwoFactorCodeDto })
|
|
258
345
|
@ApiResponse({ status: 201, type: OkResponseDto })
|
|
259
|
-
async disableTwoFactor(
|
|
260
|
-
|
|
346
|
+
async disableTwoFactor(
|
|
347
|
+
@Body() body: Record<string, unknown>,
|
|
348
|
+
@Req() req: Request,
|
|
349
|
+
) {
|
|
350
|
+
await this.auth.disableTwoFactor(
|
|
351
|
+
req.auth!.sub,
|
|
352
|
+
requireString(body.code, "code"),
|
|
353
|
+
);
|
|
261
354
|
return { ok: true };
|
|
262
355
|
}
|
|
263
356
|
|
|
264
357
|
@Post("password/forgot")
|
|
265
358
|
@Public()
|
|
266
|
-
@ApiOperation({
|
|
359
|
+
@ApiOperation({
|
|
360
|
+
summary: "Request a password reset",
|
|
361
|
+
description:
|
|
362
|
+
"Always reports success, whether or not the email exists — avoids account enumeration.",
|
|
363
|
+
})
|
|
267
364
|
@ApiBody({ type: ForgotPasswordDto })
|
|
268
365
|
@ApiResponse({ status: 201, type: OkResponseDto })
|
|
269
366
|
async forgotPassword(@Body() body: Record<string, unknown>) {
|
|
@@ -273,11 +370,17 @@ export class AuthController {
|
|
|
273
370
|
|
|
274
371
|
@Post("password/reset")
|
|
275
372
|
@Public()
|
|
276
|
-
@ApiOperation({
|
|
373
|
+
@ApiOperation({
|
|
374
|
+
summary: "Complete a password reset",
|
|
375
|
+
description: "Revokes every existing session for the user on success.",
|
|
376
|
+
})
|
|
277
377
|
@ApiBody({ type: ResetPasswordDto })
|
|
278
378
|
@ApiResponse({ status: 201, type: OkResponseDto })
|
|
279
379
|
async resetPassword(@Body() body: Record<string, unknown>) {
|
|
280
|
-
await this.auth.resetPassword(
|
|
380
|
+
await this.auth.resetPassword(
|
|
381
|
+
requireString(body.token, "token"),
|
|
382
|
+
requireString(body.newPassword, "newPassword"),
|
|
383
|
+
);
|
|
281
384
|
return { ok: true };
|
|
282
385
|
}
|
|
283
386
|
|
|
@@ -292,12 +395,22 @@ export class AuthController {
|
|
|
292
395
|
|
|
293
396
|
@Get("oauth/:provider/callback")
|
|
294
397
|
@Public()
|
|
295
|
-
@ApiOperation({
|
|
398
|
+
@ApiOperation({
|
|
399
|
+
summary: "OAuth provider callback — exchanges the code and completes login",
|
|
400
|
+
})
|
|
296
401
|
@ApiParam({ name: "provider", enum: ["google", "apple"] })
|
|
297
402
|
@ApiQuery({ name: "code", required: true })
|
|
298
403
|
@ApiQuery({ name: "state", required: true })
|
|
299
404
|
@ApiResponse({ status: 200, type: AuthTokensDto })
|
|
300
|
-
async oauthCallback(
|
|
301
|
-
|
|
405
|
+
async oauthCallback(
|
|
406
|
+
@Param("provider") provider: string,
|
|
407
|
+
@Query("code") code: string,
|
|
408
|
+
@Query("state") state: string,
|
|
409
|
+
) {
|
|
410
|
+
return this.auth.completeOAuthCallback(
|
|
411
|
+
provider,
|
|
412
|
+
requireString(code, "code"),
|
|
413
|
+
requireString(state, "state"),
|
|
414
|
+
);
|
|
302
415
|
}
|
|
303
416
|
}
|
|
@@ -5,7 +5,11 @@
|
|
|
5
5
|
// also verifies the guards actually attached match the declared tier, so a route marked
|
|
6
6
|
// @Authenticated() with no AuthGuard can't masquerade as gated.
|
|
7
7
|
import { RequestMethod, SetMetadata } from "@nestjs/common";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
GUARDS_METADATA,
|
|
10
|
+
METHOD_METADATA,
|
|
11
|
+
PATH_METADATA,
|
|
12
|
+
} from "@nestjs/common/constants.js";
|
|
9
13
|
import type { PermissionSlug } from "./rbac.defaults.js";
|
|
10
14
|
|
|
11
15
|
export const ROUTE_TIER_KEY = "routeTier";
|
|
@@ -13,17 +17,22 @@ export const CHECK_ABILITY_KEY = "checkAbility";
|
|
|
13
17
|
|
|
14
18
|
export type RouteTier = "public" | "authenticated" | "ability";
|
|
15
19
|
|
|
16
|
-
export const Public = () =>
|
|
20
|
+
export const Public = () =>
|
|
21
|
+
SetMetadata(ROUTE_TIER_KEY, "public" satisfies RouteTier);
|
|
17
22
|
|
|
18
23
|
// Pair with @UseGuards(AuthGuard) — the startup check refuses a route that claims this tier
|
|
19
24
|
// without an authentication guard behind it.
|
|
20
|
-
export const Authenticated = () =>
|
|
25
|
+
export const Authenticated = () =>
|
|
26
|
+
SetMetadata(ROUTE_TIER_KEY, "authenticated" satisfies RouteTier);
|
|
21
27
|
|
|
22
28
|
// Requires AuthGuard, then the variant's authorization guard, then AbilityGuard. All named
|
|
23
29
|
// slugs must be held — the guard ANDs them. The argument is typed as `PermissionSlug` (the
|
|
24
30
|
// catalog in rbac.defaults.ts) so a route can't be gated on a slug nothing will ever grant.
|
|
25
31
|
export const CheckAbility = (...abilities: PermissionSlug[]) =>
|
|
26
|
-
applyBoth(
|
|
32
|
+
applyBoth(
|
|
33
|
+
SetMetadata(ROUTE_TIER_KEY, "ability" satisfies RouteTier),
|
|
34
|
+
SetMetadata(CHECK_ABILITY_KEY, abilities as string[]),
|
|
35
|
+
);
|
|
27
36
|
|
|
28
37
|
/** `applyDecorators` from @nestjs/common, minus its class-decorator branch — these two are method decorators. */
|
|
29
38
|
function applyBoth(...decorators: MethodDecorator[]): MethodDecorator {
|
|
@@ -33,11 +42,27 @@ function applyBoth(...decorators: MethodDecorator[]): MethodDecorator {
|
|
|
33
42
|
};
|
|
34
43
|
}
|
|
35
44
|
|
|
45
|
+
// `any` here is real type erasure, not laziness: controllers/guards have heterogeneous
|
|
46
|
+
// constructor signatures (AdminController takes an AuthService, AuthGuard takes different deps,
|
|
47
|
+
// etc.), and these types only need to carry a class/method identity through reflection — never
|
|
48
|
+
// construct or invoke anything — so `unknown` params would reject every real, differently-typed
|
|
49
|
+
// class assigned to them.
|
|
50
|
+
/* eslint-disable @typescript-eslint/no-explicit-any -- see comment above */
|
|
51
|
+
/** A NestJS controller/guard class reference — reflected on via metadata, never constructed here. */
|
|
52
|
+
type Ctor = abstract new (...args: any[]) => object;
|
|
53
|
+
|
|
54
|
+
/** A route-handler method — reflected on and invoked by Nest's own pipeline, never called directly here. */
|
|
55
|
+
type HandlerFn = (...args: any[]) => unknown;
|
|
56
|
+
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
57
|
+
|
|
36
58
|
/** A guard entry in `@UseGuards` may be a class or an already-constructed instance; both reduce to a class. */
|
|
37
|
-
type GuardEntry =
|
|
59
|
+
type GuardEntry = Ctor | { constructor: Ctor };
|
|
38
60
|
|
|
39
|
-
const guardClassesOn = (target: object):
|
|
40
|
-
(
|
|
61
|
+
const guardClassesOn = (target: object): Ctor[] =>
|
|
62
|
+
(
|
|
63
|
+
(Reflect.getMetadata(GUARDS_METADATA, target) as
|
|
64
|
+
GuardEntry[] | undefined) ?? []
|
|
65
|
+
).map((g) => (typeof g === "function" ? g : g.constructor));
|
|
41
66
|
|
|
42
67
|
const joinPath = (...parts: unknown[]) =>
|
|
43
68
|
"/" +
|
|
@@ -48,20 +73,31 @@ const joinPath = (...parts: unknown[]) =>
|
|
|
48
73
|
.join("/");
|
|
49
74
|
|
|
50
75
|
/** Own methods carrying Nest's route metadata. Controllers here are flat classes; inherited routes are not a shape this combo uses. */
|
|
51
|
-
function routeHandlersOf(
|
|
76
|
+
function routeHandlersOf(
|
|
77
|
+
controller: Ctor,
|
|
78
|
+
): Array<{ name: string; handler: HandlerFn }> {
|
|
52
79
|
const prototype = controller.prototype as Record<string, unknown>;
|
|
53
80
|
return Object.getOwnPropertyNames(prototype)
|
|
54
81
|
.filter((name) => name !== "constructor")
|
|
55
82
|
.map((name) => ({ name, handler: prototype[name] }))
|
|
56
|
-
.filter(
|
|
57
|
-
|
|
83
|
+
.filter(
|
|
84
|
+
(entry): entry is { name: string; handler: HandlerFn } =>
|
|
85
|
+
typeof entry.handler === "function",
|
|
86
|
+
)
|
|
87
|
+
.filter(
|
|
88
|
+
(entry) =>
|
|
89
|
+
Reflect.getMetadata(PATH_METADATA, entry.handler) !== undefined,
|
|
90
|
+
);
|
|
58
91
|
}
|
|
59
92
|
|
|
60
93
|
// Fail-closed at startup over the whole route table this library owns. Called from
|
|
61
94
|
// `AuthModule.forRoot` with the same array that populates `controllers:`, so nothing can be
|
|
62
95
|
// added to the module and left out of the check. Covers only this combo's own controllers, not
|
|
63
96
|
// the consuming application's.
|
|
64
|
-
export function assertEveryRouteDeclaresATier(
|
|
97
|
+
export function assertEveryRouteDeclaresATier(
|
|
98
|
+
controllers: readonly Ctor[],
|
|
99
|
+
guards: { authentication: Ctor; ability: Ctor },
|
|
100
|
+
): void {
|
|
65
101
|
const offenders: string[] = [];
|
|
66
102
|
|
|
67
103
|
for (const controller of controllers) {
|
|
@@ -69,12 +105,19 @@ export function assertEveryRouteDeclaresATier(controllers: readonly Function[],
|
|
|
69
105
|
const controllerGuards = guardClassesOn(controller);
|
|
70
106
|
|
|
71
107
|
for (const { name, handler } of routeHandlersOf(controller)) {
|
|
72
|
-
const method =
|
|
108
|
+
const method =
|
|
109
|
+
RequestMethod[
|
|
110
|
+
Reflect.getMetadata(METHOD_METADATA, handler) as RequestMethod
|
|
111
|
+
] ?? "?";
|
|
73
112
|
const route = `${method} ${joinPath(controllerPath, Reflect.getMetadata(PATH_METADATA, handler))} (${controller.name}.${name})`;
|
|
74
113
|
|
|
75
|
-
const tier: RouteTier | undefined =
|
|
114
|
+
const tier: RouteTier | undefined =
|
|
115
|
+
Reflect.getMetadata(ROUTE_TIER_KEY, handler) ??
|
|
116
|
+
Reflect.getMetadata(ROUTE_TIER_KEY, controller);
|
|
76
117
|
if (!tier) {
|
|
77
|
-
offenders.push(
|
|
118
|
+
offenders.push(
|
|
119
|
+
`${route} — declares no tier: add @Public(), @Authenticated(), or @CheckAbility(slug)`,
|
|
120
|
+
);
|
|
78
121
|
continue;
|
|
79
122
|
}
|
|
80
123
|
|
|
@@ -84,11 +127,22 @@ export function assertEveryRouteDeclaresATier(controllers: readonly Function[],
|
|
|
84
127
|
const authenticated = attached.includes(guards.authentication);
|
|
85
128
|
const ability = attached.includes(guards.ability);
|
|
86
129
|
|
|
87
|
-
if (tier === "public" && (authenticated || ability))
|
|
88
|
-
|
|
89
|
-
|
|
130
|
+
if (tier === "public" && (authenticated || ability))
|
|
131
|
+
offenders.push(
|
|
132
|
+
`${route} — declares @Public() but is behind ${attached.map((g) => g.name).join(", ")}`,
|
|
133
|
+
);
|
|
134
|
+
if (tier === "authenticated" && !authenticated)
|
|
135
|
+
offenders.push(
|
|
136
|
+
`${route} — declares @Authenticated() but has no ${guards.authentication.name}`,
|
|
137
|
+
);
|
|
138
|
+
if (tier === "authenticated" && ability)
|
|
139
|
+
offenders.push(
|
|
140
|
+
`${route} — declares @Authenticated() but is behind ${guards.ability.name}; use @CheckAbility instead`,
|
|
141
|
+
);
|
|
90
142
|
if (tier === "ability" && !(authenticated && ability))
|
|
91
|
-
offenders.push(
|
|
143
|
+
offenders.push(
|
|
144
|
+
`${route} — declares @CheckAbility but is not behind ${guards.authentication.name} + ${guards.ability.name}`,
|
|
145
|
+
);
|
|
92
146
|
}
|
|
93
147
|
}
|
|
94
148
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
npx --no -- commitlint --edit "$1"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
npx lint-staged
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
const { defineConfig } = require('eslint/config');
|
|
2
|
+
const reactNativeConfig = require('@react-native/eslint-config/flat');
|
|
3
|
+
|
|
4
|
+
module.exports = defineConfig([
|
|
5
|
+
{
|
|
6
|
+
ignores: ['node_modules/**', 'ios/**', 'android/**', 'coverage/**'],
|
|
7
|
+
},
|
|
8
|
+
...reactNativeConfig,
|
|
9
|
+
{
|
|
10
|
+
// This app is TypeScript-only, no Flow — and eslint-plugin-ft-flow (pulled in by
|
|
11
|
+
// @react-native/eslint-config/flat for the .js override) calls the removed
|
|
12
|
+
// context.getAllComments() API under ESLint 9.3x+, crashing the linter on every .js file.
|
|
13
|
+
files: ['**/*.js'],
|
|
14
|
+
rules: {
|
|
15
|
+
'ft-flow/define-flow-type': 'off',
|
|
16
|
+
'ft-flow/use-flow-type': 'off',
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
]);
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# OSX
|
|
2
|
+
#
|
|
3
|
+
.DS_Store
|
|
4
|
+
|
|
5
|
+
# Xcode
|
|
6
|
+
#
|
|
7
|
+
build/
|
|
8
|
+
*.pbxuser
|
|
9
|
+
!default.pbxuser
|
|
10
|
+
*.mode1v3
|
|
11
|
+
!default.mode1v3
|
|
12
|
+
*.mode2v3
|
|
13
|
+
!default.mode2v3
|
|
14
|
+
*.perspectivev3
|
|
15
|
+
!default.perspectivev3
|
|
16
|
+
xcuserdata
|
|
17
|
+
*.xccheckout
|
|
18
|
+
*.moved-aside
|
|
19
|
+
DerivedData
|
|
20
|
+
*.hmap
|
|
21
|
+
*.ipa
|
|
22
|
+
*.xcuserstate
|
|
23
|
+
**/.xcode.env.local
|
|
24
|
+
|
|
25
|
+
# Android/IntelliJ
|
|
26
|
+
#
|
|
27
|
+
build/
|
|
28
|
+
.idea
|
|
29
|
+
.gradle
|
|
30
|
+
local.properties
|
|
31
|
+
*.iml
|
|
32
|
+
*.hprof
|
|
33
|
+
.cxx/
|
|
34
|
+
*.keystore
|
|
35
|
+
!debug.keystore
|
|
36
|
+
.kotlin/
|
|
37
|
+
|
|
38
|
+
# node.js
|
|
39
|
+
#
|
|
40
|
+
node_modules/
|
|
41
|
+
npm-debug.log
|
|
42
|
+
yarn-error.log
|
|
43
|
+
|
|
44
|
+
# fastlane
|
|
45
|
+
#
|
|
46
|
+
# It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the
|
|
47
|
+
# screenshots whenever they are needed.
|
|
48
|
+
# For more information about the recommended setup visit:
|
|
49
|
+
# https://docs.fastlane.tools/best-practices/source-control/
|
|
50
|
+
|
|
51
|
+
**/fastlane/report.xml
|
|
52
|
+
**/fastlane/Preview.html
|
|
53
|
+
**/fastlane/screenshots
|
|
54
|
+
**/fastlane/test_output
|
|
55
|
+
|
|
56
|
+
# Bundle artifact
|
|
57
|
+
*.jsbundle
|
|
58
|
+
|
|
59
|
+
# Ruby / CocoaPods
|
|
60
|
+
**/Pods/
|
|
61
|
+
/vendor/bundle/
|
|
62
|
+
|
|
63
|
+
# Temporary files created by Metro to check the health of the file watcher
|
|
64
|
+
.metro-health-check*
|
|
65
|
+
|
|
66
|
+
# testing
|
|
67
|
+
/coverage
|
|
68
|
+
|
|
69
|
+
# Yarn
|
|
70
|
+
.yarn/*
|
|
71
|
+
!.yarn/patches
|
|
72
|
+
!.yarn/plugins
|
|
73
|
+
!.yarn/releases
|
|
74
|
+
!.yarn/sdks
|
|
75
|
+
!.yarn/versions
|
|
76
|
+
|
|
77
|
+
# husky-generated shim dir (recreated by the "prepare" script on install)
|
|
78
|
+
.husky/_
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
"android": "react-native run-android",
|
|
8
8
|
"ios": "react-native run-ios",
|
|
9
9
|
"lint": "eslint .",
|
|
10
|
+
"prepare": "husky",
|
|
10
11
|
"start": "react-native start",
|
|
11
12
|
"test": "jest",
|
|
12
13
|
"typecheck": "tsc --noEmit"
|
|
@@ -14,12 +15,12 @@
|
|
|
14
15
|
"dependencies": {
|
|
15
16
|
"@simple-auth-kit/auth-client": "^1.0.2",
|
|
16
17
|
"@react-native-async-storage/async-storage": "^3.1.1",
|
|
17
|
-
"@react-native/new-app-screen": "0.86.
|
|
18
|
+
"@react-native/new-app-screen": "0.86.3",
|
|
18
19
|
"@react-navigation/native": "^7.3.16",
|
|
19
20
|
"@react-navigation/native-stack": "^7.18.8",
|
|
20
21
|
"nativewind": "5.0.0-preview.4",
|
|
21
22
|
"react": "19.2.3",
|
|
22
|
-
"react-native": "0.86.
|
|
23
|
+
"react-native": "0.86.3",
|
|
23
24
|
"react-native-config": "^1.6.1",
|
|
24
25
|
"react-native-css": "^3.0.7",
|
|
25
26
|
"react-native-gesture-handler": "^3.1.0",
|
|
@@ -35,20 +36,24 @@
|
|
|
35
36
|
"@babel/preset-env": "^7.25.3",
|
|
36
37
|
"@babel/runtime": "^7.25.0",
|
|
37
38
|
"@expo/metro-config": "^57.0.7",
|
|
38
|
-
"@react-native-community/cli": "20.
|
|
39
|
-
"@react-native-community/cli-platform-android": "20.
|
|
40
|
-
"@react-native-community/cli-platform-ios": "20.
|
|
41
|
-
"@react-native/babel-preset": "0.86.
|
|
42
|
-
"@react-native/eslint-config": "0.86.
|
|
43
|
-
"@react-native/jest-preset": "0.86.
|
|
44
|
-
"@react-native/metro-config": "0.86.
|
|
45
|
-
"@react-native/typescript-config": "0.86.
|
|
39
|
+
"@react-native-community/cli": "20.2.0",
|
|
40
|
+
"@react-native-community/cli-platform-android": "20.2.0",
|
|
41
|
+
"@react-native-community/cli-platform-ios": "20.2.0",
|
|
42
|
+
"@react-native/babel-preset": "0.86.3",
|
|
43
|
+
"@react-native/eslint-config": "0.86.3",
|
|
44
|
+
"@react-native/jest-preset": "0.86.3",
|
|
45
|
+
"@react-native/metro-config": "0.86.3",
|
|
46
|
+
"@react-native/typescript-config": "0.86.3",
|
|
46
47
|
"@types/jest": "^29.5.13",
|
|
47
48
|
"@types/react": "^19.2.0",
|
|
48
49
|
"@types/react-test-renderer": "^19.1.0",
|
|
49
|
-
"
|
|
50
|
+
"@commitlint/cli": "^21.2.2",
|
|
51
|
+
"@commitlint/config-conventional": "^21.2.2",
|
|
52
|
+
"eslint": "^9.39.5",
|
|
53
|
+
"husky": "^9.1.7",
|
|
50
54
|
"jest": "^29.6.3",
|
|
51
|
-
"
|
|
55
|
+
"lint-staged": "^17.5.1",
|
|
56
|
+
"prettier": "^3.9.6",
|
|
52
57
|
"react-test-renderer": "19.2.3",
|
|
53
58
|
"typescript": "^5.8.3"
|
|
54
59
|
},
|