@shipfox/api-auth 2.0.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/.turbo/turbo-build.log +2 -0
- package/.turbo/turbo-type$colon$emit.log +1 -0
- package/.turbo/turbo-type.log +1 -0
- package/CHANGELOG.md +116 -0
- package/LICENSE +21 -0
- package/README.md +336 -0
- package/dist/config.d.ts +22 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +95 -0
- package/dist/config.js.map +1 -0
- package/dist/core/auth.d.ts +108 -0
- package/dist/core/auth.d.ts.map +1 -0
- package/dist/core/auth.js +476 -0
- package/dist/core/auth.js.map +1 -0
- package/dist/core/entities/email-verification.d.ts +9 -0
- package/dist/core/entities/email-verification.d.ts.map +1 -0
- package/dist/core/entities/email-verification.js +3 -0
- package/dist/core/entities/email-verification.js.map +1 -0
- package/dist/core/entities/password-reset.d.ts +9 -0
- package/dist/core/entities/password-reset.d.ts.map +1 -0
- package/dist/core/entities/password-reset.js +3 -0
- package/dist/core/entities/password-reset.js.map +1 -0
- package/dist/core/entities/refresh-token.d.ts +12 -0
- package/dist/core/entities/refresh-token.d.ts.map +1 -0
- package/dist/core/entities/refresh-token.js +3 -0
- package/dist/core/entities/refresh-token.js.map +1 -0
- package/dist/core/entities/user.d.ts +12 -0
- package/dist/core/entities/user.d.ts.map +1 -0
- package/dist/core/entities/user.js +3 -0
- package/dist/core/entities/user.js.map +1 -0
- package/dist/core/errors.d.ts +54 -0
- package/dist/core/errors.d.ts.map +1 -0
- package/dist/core/errors.js +106 -0
- package/dist/core/errors.js.map +1 -0
- package/dist/core/job-lease-token.d.ts +12 -0
- package/dist/core/job-lease-token.d.ts.map +1 -0
- package/dist/core/job-lease-token.js +58 -0
- package/dist/core/job-lease-token.js.map +1 -0
- package/dist/core/jwt.d.ts +37 -0
- package/dist/core/jwt.d.ts.map +1 -0
- package/dist/core/jwt.js +46 -0
- package/dist/core/jwt.js.map +1 -0
- package/dist/core/password.d.ts +10 -0
- package/dist/core/password.d.ts.map +1 -0
- package/dist/core/password.js +13 -0
- package/dist/core/password.js.map +1 -0
- package/dist/core/rate-limit.d.ts +34 -0
- package/dist/core/rate-limit.d.ts.map +1 -0
- package/dist/core/rate-limit.js +76 -0
- package/dist/core/rate-limit.js.map +1 -0
- package/dist/core/runner-session-token.d.ts +6 -0
- package/dist/core/runner-session-token.d.ts.map +1 -0
- package/dist/core/runner-session-token.js +37 -0
- package/dist/core/runner-session-token.js.map +1 -0
- package/dist/db/db.d.ts +1723 -0
- package/dist/db/db.d.ts.map +1 -0
- package/dist/db/db.js +28 -0
- package/dist/db/db.js.map +1 -0
- package/dist/db/email-verifications.d.ts +39 -0
- package/dist/db/email-verifications.d.ts.map +1 -0
- package/dist/db/email-verifications.js +95 -0
- package/dist/db/email-verifications.js.map +1 -0
- package/dist/db/migrations.d.ts +2 -0
- package/dist/db/migrations.d.ts.map +1 -0
- package/dist/db/migrations.js +5 -0
- package/dist/db/migrations.js.map +1 -0
- package/dist/db/password-resets.d.ts +23 -0
- package/dist/db/password-resets.d.ts.map +1 -0
- package/dist/db/password-resets.js +37 -0
- package/dist/db/password-resets.js.map +1 -0
- package/dist/db/rate-limits.d.ts +18 -0
- package/dist/db/rate-limits.d.ts.map +1 -0
- package/dist/db/rate-limits.js +44 -0
- package/dist/db/rate-limits.js.map +1 -0
- package/dist/db/refresh-tokens.d.ts +44 -0
- package/dist/db/refresh-tokens.d.ts.map +1 -0
- package/dist/db/refresh-tokens.js +71 -0
- package/dist/db/refresh-tokens.js.map +1 -0
- package/dist/db/schema/common.d.ts +2 -0
- package/dist/db/schema/common.d.ts.map +1 -0
- package/dist/db/schema/common.js +4 -0
- package/dist/db/schema/common.js.map +1 -0
- package/dist/db/schema/email-verifications.d.ts +114 -0
- package/dist/db/schema/email-verifications.d.ts.map +1 -0
- package/dist/db/schema/email-verifications.js +35 -0
- package/dist/db/schema/email-verifications.js.map +1 -0
- package/dist/db/schema/outbox.d.ts +195 -0
- package/dist/db/schema/outbox.d.ts.map +1 -0
- package/dist/db/schema/outbox.js +5 -0
- package/dist/db/schema/outbox.js.map +1 -0
- package/dist/db/schema/password-resets.d.ts +114 -0
- package/dist/db/schema/password-resets.d.ts.map +1 -0
- package/dist/db/schema/password-resets.js +35 -0
- package/dist/db/schema/password-resets.js.map +1 -0
- package/dist/db/schema/rate-limits.d.ts +145 -0
- package/dist/db/schema/rate-limits.d.ts.map +1 -0
- package/dist/db/schema/rate-limits.js +25 -0
- package/dist/db/schema/rate-limits.js.map +1 -0
- package/dist/db/schema/refresh-tokens.d.ts +165 -0
- package/dist/db/schema/refresh-tokens.d.ts.map +1 -0
- package/dist/db/schema/refresh-tokens.js +47 -0
- package/dist/db/schema/refresh-tokens.js.map +1 -0
- package/dist/db/schema/users.d.ts +149 -0
- package/dist/db/schema/users.d.ts.map +1 -0
- package/dist/db/schema/users.js +40 -0
- package/dist/db/schema/users.js.map +1 -0
- package/dist/db/users.d.ts +28 -0
- package/dist/db/users.d.ts.map +1 -0
- package/dist/db/users.js +83 -0
- package/dist/db/users.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +52 -0
- package/dist/index.js.map +1 -0
- package/dist/metrics/index.d.ts +2 -0
- package/dist/metrics/index.d.ts.map +1 -0
- package/dist/metrics/index.js +3 -0
- package/dist/metrics/index.js.map +1 -0
- package/dist/metrics/instance.d.ts +16 -0
- package/dist/metrics/instance.d.ts.map +1 -0
- package/dist/metrics/instance.js +52 -0
- package/dist/metrics/instance.js.map +1 -0
- package/dist/presentation/auth/bearer-token-auth.d.ts +15 -0
- package/dist/presentation/auth/bearer-token-auth.d.ts.map +1 -0
- package/dist/presentation/auth/bearer-token-auth.js +31 -0
- package/dist/presentation/auth/bearer-token-auth.js.map +1 -0
- package/dist/presentation/auth/jwt-auth.d.ts +10 -0
- package/dist/presentation/auth/jwt-auth.d.ts.map +1 -0
- package/dist/presentation/auth/jwt-auth.js +31 -0
- package/dist/presentation/auth/jwt-auth.js.map +1 -0
- package/dist/presentation/auth/lease-token-auth.d.ts +15 -0
- package/dist/presentation/auth/lease-token-auth.d.ts.map +1 -0
- package/dist/presentation/auth/lease-token-auth.js +27 -0
- package/dist/presentation/auth/lease-token-auth.js.map +1 -0
- package/dist/presentation/auth/refresh-cookie.d.ts +7 -0
- package/dist/presentation/auth/refresh-cookie.d.ts.map +1 -0
- package/dist/presentation/auth/refresh-cookie.js +29 -0
- package/dist/presentation/auth/refresh-cookie.js.map +1 -0
- package/dist/presentation/auth/runner-session-auth.d.ts +3 -0
- package/dist/presentation/auth/runner-session-auth.d.ts.map +1 -0
- package/dist/presentation/auth/runner-session-auth.js +16 -0
- package/dist/presentation/auth/runner-session-auth.js.map +1 -0
- package/dist/presentation/dto/user.d.ts +8 -0
- package/dist/presentation/dto/user.d.ts.map +1 -0
- package/dist/presentation/dto/user.js +19 -0
- package/dist/presentation/dto/user.js.map +1 -0
- package/dist/presentation/e2eRoutes/create-session.d.ts +2 -0
- package/dist/presentation/e2eRoutes/create-session.d.ts.map +1 -0
- package/dist/presentation/e2eRoutes/create-session.js +49 -0
- package/dist/presentation/e2eRoutes/create-session.js.map +1 -0
- package/dist/presentation/e2eRoutes/create-user.d.ts +2 -0
- package/dist/presentation/e2eRoutes/create-user.d.ts.map +1 -0
- package/dist/presentation/e2eRoutes/create-user.js +42 -0
- package/dist/presentation/e2eRoutes/create-user.js.map +1 -0
- package/dist/presentation/e2eRoutes/index.d.ts +3 -0
- package/dist/presentation/e2eRoutes/index.d.ts.map +1 -0
- package/dist/presentation/e2eRoutes/index.js +15 -0
- package/dist/presentation/e2eRoutes/index.js.map +1 -0
- package/dist/presentation/routes/email-verification/verify-email-confirm.d.ts +2 -0
- package/dist/presentation/routes/email-verification/verify-email-confirm.d.ts.map +1 -0
- package/dist/presentation/routes/email-verification/verify-email-confirm.js +35 -0
- package/dist/presentation/routes/email-verification/verify-email-confirm.js.map +1 -0
- package/dist/presentation/routes/email-verification/verify-email-resend.d.ts +2 -0
- package/dist/presentation/routes/email-verification/verify-email-resend.d.ts.map +1 -0
- package/dist/presentation/routes/email-verification/verify-email-resend.js +27 -0
- package/dist/presentation/routes/email-verification/verify-email-resend.js.map +1 -0
- package/dist/presentation/routes/index.d.ts +3 -0
- package/dist/presentation/routes/index.d.ts.map +1 -0
- package/dist/presentation/routes/index.js +31 -0
- package/dist/presentation/routes/index.js.map +1 -0
- package/dist/presentation/routes/password/change-password.d.ts +2 -0
- package/dist/presentation/routes/password/change-password.d.ts.map +1 -0
- package/dist/presentation/routes/password/change-password.js +50 -0
- package/dist/presentation/routes/password/change-password.js.map +1 -0
- package/dist/presentation/routes/password/password-reset-confirm.d.ts +2 -0
- package/dist/presentation/routes/password/password-reset-confirm.d.ts.map +1 -0
- package/dist/presentation/routes/password/password-reset-confirm.js +36 -0
- package/dist/presentation/routes/password/password-reset-confirm.js.map +1 -0
- package/dist/presentation/routes/password/password-reset-request.d.ts +2 -0
- package/dist/presentation/routes/password/password-reset-request.d.ts.map +1 -0
- package/dist/presentation/routes/password/password-reset-request.js +26 -0
- package/dist/presentation/routes/password/password-reset-request.js.map +1 -0
- package/dist/presentation/routes/rate-limit.d.ts +10 -0
- package/dist/presentation/routes/rate-limit.d.ts.map +1 -0
- package/dist/presentation/routes/rate-limit.js +101 -0
- package/dist/presentation/routes/rate-limit.js.map +1 -0
- package/dist/presentation/routes/registration/signup.d.ts +2 -0
- package/dist/presentation/routes/registration/signup.d.ts.map +1 -0
- package/dist/presentation/routes/registration/signup.js +82 -0
- package/dist/presentation/routes/registration/signup.js.map +1 -0
- package/dist/presentation/routes/session/login.d.ts +2 -0
- package/dist/presentation/routes/session/login.d.ts.map +1 -0
- package/dist/presentation/routes/session/login.js +48 -0
- package/dist/presentation/routes/session/login.js.map +1 -0
- package/dist/presentation/routes/session/logout.d.ts +2 -0
- package/dist/presentation/routes/session/logout.d.ts.map +1 -0
- package/dist/presentation/routes/session/logout.js +25 -0
- package/dist/presentation/routes/session/logout.js.map +1 -0
- package/dist/presentation/routes/session/me.d.ts +2 -0
- package/dist/presentation/routes/session/me.d.ts.map +1 -0
- package/dist/presentation/routes/session/me.js +42 -0
- package/dist/presentation/routes/session/me.js.map +1 -0
- package/dist/presentation/routes/session/refresh.d.ts +2 -0
- package/dist/presentation/routes/session/refresh.d.ts.map +1 -0
- package/dist/presentation/routes/session/refresh.js +49 -0
- package/dist/presentation/routes/session/refresh.js.map +1 -0
- package/dist/presentation/subscribers/index.d.ts +3 -0
- package/dist/presentation/subscribers/index.d.ts.map +1 -0
- package/dist/presentation/subscribers/index.js +4 -0
- package/dist/presentation/subscribers/index.js.map +1 -0
- package/dist/presentation/subscribers/on-email-verification-send-requested.d.ts +3 -0
- package/dist/presentation/subscribers/on-email-verification-send-requested.d.ts.map +1 -0
- package/dist/presentation/subscribers/on-email-verification-send-requested.js +13 -0
- package/dist/presentation/subscribers/on-email-verification-send-requested.js.map +1 -0
- package/dist/presentation/subscribers/on-password-reset-send-requested.d.ts +3 -0
- package/dist/presentation/subscribers/on-password-reset-send-requested.d.ts.map +1 -0
- package/dist/presentation/subscribers/on-password-reset-send-requested.js +14 -0
- package/dist/presentation/subscribers/on-password-reset-send-requested.js.map +1 -0
- package/dist/tsconfig.test.tsbuildinfo +1 -0
- package/drizzle/0000_initial.sql +81 -0
- package/drizzle/meta/0000_snapshot.json +643 -0
- package/drizzle/meta/_journal.json +13 -0
- package/drizzle.config.ts +7 -0
- package/package.json +88 -0
- package/src/config.ts +88 -0
- package/src/core/auth.test.ts +675 -0
- package/src/core/auth.ts +560 -0
- package/src/core/entities/email-verification.ts +8 -0
- package/src/core/entities/password-reset.ts +8 -0
- package/src/core/entities/refresh-token.ts +11 -0
- package/src/core/entities/user.ts +12 -0
- package/src/core/errors.ts +123 -0
- package/src/core/job-lease-token.test.ts +201 -0
- package/src/core/job-lease-token.ts +83 -0
- package/src/core/jwt.test.ts +180 -0
- package/src/core/jwt.ts +66 -0
- package/src/core/password.test.ts +28 -0
- package/src/core/password.ts +21 -0
- package/src/core/rate-limit.test.ts +462 -0
- package/src/core/rate-limit.ts +130 -0
- package/src/core/runner-session-token.test.ts +126 -0
- package/src/core/runner-session-token.ts +49 -0
- package/src/db/db.ts +28 -0
- package/src/db/email-verifications.test.ts +61 -0
- package/src/db/email-verifications.ts +160 -0
- package/src/db/migrations.ts +4 -0
- package/src/db/password-resets.test.ts +61 -0
- package/src/db/password-resets.ts +69 -0
- package/src/db/rate-limits.ts +69 -0
- package/src/db/refresh-tokens.test.ts +164 -0
- package/src/db/refresh-tokens.ts +149 -0
- package/src/db/schema/common.ts +3 -0
- package/src/db/schema/email-verifications.ts +37 -0
- package/src/db/schema/outbox.ts +4 -0
- package/src/db/schema/password-resets.ts +37 -0
- package/src/db/schema/rate-limits.ts +27 -0
- package/src/db/schema/refresh-tokens.ts +43 -0
- package/src/db/schema/users.ts +37 -0
- package/src/db/users.test.ts +75 -0
- package/src/db/users.ts +121 -0
- package/src/index.test.ts +39 -0
- package/src/index.ts +69 -0
- package/src/metrics/index.ts +13 -0
- package/src/metrics/instance.test.ts +69 -0
- package/src/metrics/instance.ts +77 -0
- package/src/presentation/auth/bearer-token-auth.test.ts +99 -0
- package/src/presentation/auth/bearer-token-auth.ts +51 -0
- package/src/presentation/auth/jwt-auth.test.ts +136 -0
- package/src/presentation/auth/jwt-auth.ts +39 -0
- package/src/presentation/auth/lease-token-auth.test.ts +56 -0
- package/src/presentation/auth/lease-token-auth.ts +25 -0
- package/src/presentation/auth/refresh-cookie.test.ts +39 -0
- package/src/presentation/auth/refresh-cookie.ts +34 -0
- package/src/presentation/auth/runner-session-auth.test.ts +54 -0
- package/src/presentation/auth/runner-session-auth.ts +13 -0
- package/src/presentation/dto/user.ts +21 -0
- package/src/presentation/e2eRoutes/create-session.ts +39 -0
- package/src/presentation/e2eRoutes/create-user.ts +38 -0
- package/src/presentation/e2eRoutes/index.test.ts +102 -0
- package/src/presentation/e2eRoutes/index.ts +10 -0
- package/src/presentation/routes/email-verification/verify-email-confirm.test.ts +66 -0
- package/src/presentation/routes/email-verification/verify-email-confirm.ts +37 -0
- package/src/presentation/routes/email-verification/verify-email-resend.test.ts +100 -0
- package/src/presentation/routes/email-verification/verify-email-resend.ts +26 -0
- package/src/presentation/routes/index.ts +29 -0
- package/src/presentation/routes/password/change-password.test.ts +106 -0
- package/src/presentation/routes/password/change-password.ts +45 -0
- package/src/presentation/routes/password/password-reset-confirm.test.ts +63 -0
- package/src/presentation/routes/password/password-reset-confirm.ts +35 -0
- package/src/presentation/routes/password/password-reset-request.test.ts +56 -0
- package/src/presentation/routes/password/password-reset-request.ts +25 -0
- package/src/presentation/routes/rate-limit.test.ts +341 -0
- package/src/presentation/routes/rate-limit.ts +119 -0
- package/src/presentation/routes/registration/signup.test.ts +284 -0
- package/src/presentation/routes/registration/signup.ts +81 -0
- package/src/presentation/routes/session/login.test.ts +77 -0
- package/src/presentation/routes/session/login.ts +50 -0
- package/src/presentation/routes/session/logout.test.ts +55 -0
- package/src/presentation/routes/session/logout.ts +23 -0
- package/src/presentation/routes/session/me.test.ts +56 -0
- package/src/presentation/routes/session/me.ts +35 -0
- package/src/presentation/routes/session/refresh.test.ts +89 -0
- package/src/presentation/routes/session/refresh.ts +54 -0
- package/src/presentation/subscribers/email-send-requested.test.ts +77 -0
- package/src/presentation/subscribers/index.ts +2 -0
- package/src/presentation/subscribers/on-email-verification-send-requested.ts +10 -0
- package/src/presentation/subscribers/on-password-reset-send-requested.ts +13 -0
- package/test/env.ts +10 -0
- package/test/factories/index.ts +1 -0
- package/test/factories/user.ts +36 -0
- package/test/globalSetup.ts +18 -0
- package/test/index.ts +1 -0
- package/test/routes.ts +275 -0
- package/test/setup.ts +17 -0
- package/tsconfig.build.json +9 -0
- package/tsconfig.build.tsbuildinfo +1 -0
- package/tsconfig.json +4 -0
- package/tsconfig.test.json +8 -0
- package/vitest.config.ts +11 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
export class WorkspaceNotFoundError extends Error {
|
|
2
|
+
constructor(workspaceId: string) {
|
|
3
|
+
super(`Workspace not found: ${workspaceId}`);
|
|
4
|
+
this.name = 'WorkspaceNotFoundError';
|
|
5
|
+
}
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export class ApiKeyNotFoundError extends Error {
|
|
9
|
+
constructor(id: string) {
|
|
10
|
+
super(`API key not found: ${id}`);
|
|
11
|
+
this.name = 'ApiKeyNotFoundError';
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class InvitationNotFoundError extends Error {
|
|
16
|
+
constructor(id: string) {
|
|
17
|
+
super(`Invitation not found: ${id}`);
|
|
18
|
+
this.name = 'InvitationNotFoundError';
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class InvitationWorkspaceMismatchError extends Error {
|
|
23
|
+
constructor() {
|
|
24
|
+
super('Invitation does not belong to this workspace');
|
|
25
|
+
this.name = 'InvitationWorkspaceMismatchError';
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class MembershipNotFoundError extends Error {
|
|
30
|
+
constructor(userId: string, workspaceId: string) {
|
|
31
|
+
super(`Membership not found for user ${userId} in workspace ${workspaceId}`);
|
|
32
|
+
this.name = 'MembershipNotFoundError';
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class UserNotFoundError extends Error {
|
|
37
|
+
constructor(idOrEmail: string) {
|
|
38
|
+
super(`User not found: ${idOrEmail}`);
|
|
39
|
+
this.name = 'UserNotFoundError';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class EmailTakenError extends Error {
|
|
44
|
+
constructor(email: string) {
|
|
45
|
+
super(`Email already registered: ${email}`);
|
|
46
|
+
this.name = 'EmailTakenError';
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class InvalidCredentialsError extends Error {
|
|
51
|
+
constructor() {
|
|
52
|
+
super('Invalid credentials');
|
|
53
|
+
this.name = 'InvalidCredentialsError';
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export class EmailNotVerifiedError extends Error {
|
|
58
|
+
constructor() {
|
|
59
|
+
super('Email not verified');
|
|
60
|
+
this.name = 'EmailNotVerifiedError';
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export class AuthDependencyUnavailableError extends Error {
|
|
65
|
+
readonly dependency: string;
|
|
66
|
+
override readonly cause: unknown;
|
|
67
|
+
|
|
68
|
+
constructor(dependency: string, cause: unknown) {
|
|
69
|
+
super(`Authentication dependency unavailable: ${dependency}`);
|
|
70
|
+
this.name = 'AuthDependencyUnavailableError';
|
|
71
|
+
this.dependency = dependency;
|
|
72
|
+
this.cause = cause;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export class TokenInvalidError extends Error {
|
|
77
|
+
constructor(reason?: string) {
|
|
78
|
+
super(reason ? `Invalid token: ${reason}` : 'Invalid token');
|
|
79
|
+
this.name = 'TokenInvalidError';
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export class TokenExpiredError extends Error {
|
|
84
|
+
constructor() {
|
|
85
|
+
super('Token has expired');
|
|
86
|
+
this.name = 'TokenExpiredError';
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export class TokenAlreadyUsedError extends Error {
|
|
91
|
+
constructor() {
|
|
92
|
+
super('Token has already been used');
|
|
93
|
+
this.name = 'TokenAlreadyUsedError';
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export class MembershipRequiredError extends Error {
|
|
98
|
+
constructor(workspaceId: string) {
|
|
99
|
+
super(`Membership required for workspace: ${workspaceId}`);
|
|
100
|
+
this.name = 'MembershipRequiredError';
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export class LastMemberError extends Error {
|
|
105
|
+
constructor(workspaceId: string) {
|
|
106
|
+
super(`Cannot remove the last member of workspace: ${workspaceId}`);
|
|
107
|
+
this.name = 'LastMemberError';
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export class InvitationEmailMismatchError extends Error {
|
|
112
|
+
constructor() {
|
|
113
|
+
super('Invitation email does not match authenticated user');
|
|
114
|
+
this.name = 'InvitationEmailMismatchError';
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export class OpenInvitationExistsError extends Error {
|
|
119
|
+
constructor(email: string) {
|
|
120
|
+
super(`An open invitation already exists for: ${email}`);
|
|
121
|
+
this.name = 'OpenInvitationExistsError';
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import {JOB_LEASE_TOKEN_AUDIENCE} from '@shipfox/api-auth-dto';
|
|
2
|
+
import {signHs256} from '@shipfox/node-jwt';
|
|
3
|
+
import {issueJobLeaseToken, verifyJobLeaseToken} from './job-lease-token.js';
|
|
4
|
+
|
|
5
|
+
// Matches test/env.ts; the codec reads this same value from config.
|
|
6
|
+
const SECRET = process.env.AUTH_JOB_LEASE_TOKEN_SECRET ?? 'test-lease-secret';
|
|
7
|
+
|
|
8
|
+
function claims() {
|
|
9
|
+
return {
|
|
10
|
+
workflowRunId: crypto.randomUUID(),
|
|
11
|
+
workflowRunAttemptId: crypto.randomUUID(),
|
|
12
|
+
jobId: crypto.randomUUID(),
|
|
13
|
+
jobExecutionId: crypto.randomUUID(),
|
|
14
|
+
projectId: crypto.randomUUID(),
|
|
15
|
+
workspaceId: crypto.randomUUID(),
|
|
16
|
+
runnerSessionId: crypto.randomUUID(),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('job-lease-token', () => {
|
|
21
|
+
test('issues a token that verifies round-trip', async () => {
|
|
22
|
+
const input = claims();
|
|
23
|
+
|
|
24
|
+
const token = await issueJobLeaseToken(input);
|
|
25
|
+
const verified = await verifyJobLeaseToken(token);
|
|
26
|
+
|
|
27
|
+
expect(verified).not.toBeNull();
|
|
28
|
+
expect(verified?.workflowRunId).toBe(input.workflowRunId);
|
|
29
|
+
expect(verified?.workflowRunAttemptId).toBe(input.workflowRunAttemptId);
|
|
30
|
+
expect(verified?.jobId).toBe(input.jobId);
|
|
31
|
+
expect(verified?.projectId).toBe(input.projectId);
|
|
32
|
+
expect(verified?.workspaceId).toBe(input.workspaceId);
|
|
33
|
+
expect(verified?.runnerSessionId).toBe(input.runnerSessionId);
|
|
34
|
+
expect(verified?.aud).toBe(JOB_LEASE_TOKEN_AUDIENCE);
|
|
35
|
+
expect(verified?.iat).toBeTypeOf('number');
|
|
36
|
+
expect(verified?.exp).toBeGreaterThan(verified?.iat ?? 0);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('omits step scope when issuing a job-scoped token', async () => {
|
|
40
|
+
const input = claims();
|
|
41
|
+
|
|
42
|
+
const token = await issueJobLeaseToken(input);
|
|
43
|
+
const verified = await verifyJobLeaseToken(token);
|
|
44
|
+
|
|
45
|
+
expect(verified?.currentStepId).toBeUndefined();
|
|
46
|
+
expect(verified?.currentStepAttempt).toBeUndefined();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('round-trips the current step scope when present', async () => {
|
|
50
|
+
const input = {
|
|
51
|
+
...claims(),
|
|
52
|
+
currentStepId: crypto.randomUUID(),
|
|
53
|
+
currentStepAttempt: 2,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const token = await issueJobLeaseToken(input);
|
|
57
|
+
const verified = await verifyJobLeaseToken(token);
|
|
58
|
+
|
|
59
|
+
expect(verified?.currentStepId).toBe(input.currentStepId);
|
|
60
|
+
expect(verified?.currentStepAttempt).toBe(input.currentStepAttempt);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('returns claims when metric recording fails after successful verification', async () => {
|
|
64
|
+
vi.resetModules();
|
|
65
|
+
vi.doMock('@shipfox/node-opentelemetry', () => ({
|
|
66
|
+
instanceMetrics: {
|
|
67
|
+
getMeter: () => ({
|
|
68
|
+
createCounter: () => ({
|
|
69
|
+
add: () => {
|
|
70
|
+
throw new Error('metrics unavailable');
|
|
71
|
+
},
|
|
72
|
+
}),
|
|
73
|
+
}),
|
|
74
|
+
},
|
|
75
|
+
}));
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
const tokenModule = await import('./job-lease-token.js');
|
|
79
|
+
const input = claims();
|
|
80
|
+
|
|
81
|
+
const token = await tokenModule.issueJobLeaseToken(input);
|
|
82
|
+
const verified = await tokenModule.verifyJobLeaseToken(token);
|
|
83
|
+
|
|
84
|
+
expect(verified?.jobId).toBe(input.jobId);
|
|
85
|
+
} finally {
|
|
86
|
+
vi.doUnmock('@shipfox/node-opentelemetry');
|
|
87
|
+
vi.resetModules();
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('accepts UUIDv7 ids (job/run primary keys are uuidv7)', async () => {
|
|
92
|
+
const input = {
|
|
93
|
+
workflowRunId: '018f6b1e-7e2a-7b3c-8d4e-5f6a7b8c9d0d',
|
|
94
|
+
workflowRunAttemptId: '018f6b1e-7e2a-7b3c-8d4e-5f6a7b8c9d0f',
|
|
95
|
+
jobId: '018f6b1e-7e2a-7b3c-8d4e-5f6a7b8c9d0e',
|
|
96
|
+
jobExecutionId: '018f6b1e-7e2a-7b3c-8d4e-5f6a7b8c9d10',
|
|
97
|
+
projectId: crypto.randomUUID(),
|
|
98
|
+
workspaceId: crypto.randomUUID(),
|
|
99
|
+
runnerSessionId: crypto.randomUUID(),
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const token = await issueJobLeaseToken(input);
|
|
103
|
+
const verified = await verifyJobLeaseToken(token);
|
|
104
|
+
|
|
105
|
+
expect(verified?.workflowRunId).toBe(input.workflowRunId);
|
|
106
|
+
expect(verified?.workflowRunAttemptId).toBe(input.workflowRunAttemptId);
|
|
107
|
+
expect(verified?.jobId).toBe(input.jobId);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('returns null for a tampered token', async () => {
|
|
111
|
+
const token = await issueJobLeaseToken(claims());
|
|
112
|
+
const tampered = `${token.slice(0, -4)}xxxx`;
|
|
113
|
+
|
|
114
|
+
const verified = await verifyJobLeaseToken(tampered);
|
|
115
|
+
|
|
116
|
+
expect(verified).toBeNull();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('returns null for a token signed with a different secret', async () => {
|
|
120
|
+
const token = await signHs256({
|
|
121
|
+
payload: claims(),
|
|
122
|
+
secret: 'a-different-secret',
|
|
123
|
+
expiresIn: '90m',
|
|
124
|
+
audience: JOB_LEASE_TOKEN_AUDIENCE,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const verified = await verifyJobLeaseToken(token);
|
|
128
|
+
|
|
129
|
+
expect(verified).toBeNull();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('returns null for an expired token', async () => {
|
|
133
|
+
const token = await signHs256({
|
|
134
|
+
payload: claims(),
|
|
135
|
+
secret: SECRET,
|
|
136
|
+
expiresIn: '-1s',
|
|
137
|
+
audience: JOB_LEASE_TOKEN_AUDIENCE,
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const verified = await verifyJobLeaseToken(token);
|
|
141
|
+
|
|
142
|
+
expect(verified).toBeNull();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test.each(['not.a.token', '', 'a.b.c'])('returns null for invalid input %j', async (input) => {
|
|
146
|
+
const verified = await verifyJobLeaseToken(input);
|
|
147
|
+
|
|
148
|
+
expect(verified).toBeNull();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('returns null for a token with the wrong audience', async () => {
|
|
152
|
+
const token = await signHs256({
|
|
153
|
+
payload: claims(),
|
|
154
|
+
secret: SECRET,
|
|
155
|
+
expiresIn: '90m',
|
|
156
|
+
audience: 'some-other-audience',
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const verified = await verifyJobLeaseToken(token);
|
|
160
|
+
|
|
161
|
+
expect(verified).toBeNull();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('returns null for a token with no audience claim', async () => {
|
|
165
|
+
const token = await signHs256({
|
|
166
|
+
payload: claims(),
|
|
167
|
+
secret: SECRET,
|
|
168
|
+
expiresIn: '90m',
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const verified = await verifyJobLeaseToken(token);
|
|
172
|
+
|
|
173
|
+
expect(verified).toBeNull();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('returns null for a token whose claims fail the schema (non-uuid jobId)', async () => {
|
|
177
|
+
const token = await signHs256({
|
|
178
|
+
payload: {...claims(), jobId: 'not-a-uuid'},
|
|
179
|
+
secret: SECRET,
|
|
180
|
+
expiresIn: '90m',
|
|
181
|
+
audience: JOB_LEASE_TOKEN_AUDIENCE,
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const verified = await verifyJobLeaseToken(token);
|
|
185
|
+
|
|
186
|
+
expect(verified).toBeNull();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('returns null for a token whose claims fail the schema (non-uuid projectId)', async () => {
|
|
190
|
+
const token = await signHs256({
|
|
191
|
+
payload: {...claims(), projectId: 'not-a-uuid'},
|
|
192
|
+
secret: SECRET,
|
|
193
|
+
expiresIn: '90m',
|
|
194
|
+
audience: JOB_LEASE_TOKEN_AUDIENCE,
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
const verified = await verifyJobLeaseToken(token);
|
|
198
|
+
|
|
199
|
+
expect(verified).toBeNull();
|
|
200
|
+
});
|
|
201
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import {
|
|
2
|
+
JOB_LEASE_TOKEN_AUDIENCE,
|
|
3
|
+
type JobLeaseTokenClaims,
|
|
4
|
+
jobLeaseTokenClaimsSchema,
|
|
5
|
+
} from '@shipfox/api-auth-dto';
|
|
6
|
+
import {signHs256, verifyHs256} from '@shipfox/node-jwt';
|
|
7
|
+
import {config} from '#config.js';
|
|
8
|
+
import {recordTokenIssued, recordTokenVerified} from '#metrics/index.js';
|
|
9
|
+
|
|
10
|
+
// `aud`, `iat` and `exp` are set by the codec (jose); callers supply the business ids only.
|
|
11
|
+
export type IssueJobLeaseTokenParams = Omit<JobLeaseTokenClaims, 'aud' | 'iat' | 'exp'>;
|
|
12
|
+
|
|
13
|
+
type JobLeaseParamSource = Pick<
|
|
14
|
+
JobLeaseTokenClaims,
|
|
15
|
+
| 'workflowRunId'
|
|
16
|
+
| 'workflowRunAttemptId'
|
|
17
|
+
| 'jobId'
|
|
18
|
+
| 'jobExecutionId'
|
|
19
|
+
| 'projectId'
|
|
20
|
+
| 'workspaceId'
|
|
21
|
+
| 'runnerSessionId'
|
|
22
|
+
>;
|
|
23
|
+
|
|
24
|
+
export function jobLeaseParamsFrom(
|
|
25
|
+
source: JobLeaseParamSource,
|
|
26
|
+
stepScope?: {currentStepId: string; currentStepAttempt: number},
|
|
27
|
+
): IssueJobLeaseTokenParams {
|
|
28
|
+
return {
|
|
29
|
+
workflowRunId: source.workflowRunId,
|
|
30
|
+
workflowRunAttemptId: source.workflowRunAttemptId,
|
|
31
|
+
jobId: source.jobId,
|
|
32
|
+
jobExecutionId: source.jobExecutionId,
|
|
33
|
+
projectId: source.projectId,
|
|
34
|
+
workspaceId: source.workspaceId,
|
|
35
|
+
runnerSessionId: source.runnerSessionId,
|
|
36
|
+
...(stepScope ? stepScope : {}),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function issueJobLeaseToken(claims: IssueJobLeaseTokenParams): Promise<string> {
|
|
41
|
+
const token = await signHs256({
|
|
42
|
+
payload: {
|
|
43
|
+
workflowRunId: claims.workflowRunId,
|
|
44
|
+
...(claims.workflowRunAttempt === undefined
|
|
45
|
+
? {}
|
|
46
|
+
: {workflowRunAttempt: claims.workflowRunAttempt}),
|
|
47
|
+
workflowRunAttemptId: claims.workflowRunAttemptId,
|
|
48
|
+
jobId: claims.jobId,
|
|
49
|
+
jobExecutionId: claims.jobExecutionId,
|
|
50
|
+
projectId: claims.projectId,
|
|
51
|
+
workspaceId: claims.workspaceId,
|
|
52
|
+
runnerSessionId: claims.runnerSessionId,
|
|
53
|
+
...(claims.currentStepId && claims.currentStepAttempt !== undefined
|
|
54
|
+
? {
|
|
55
|
+
currentStepId: claims.currentStepId,
|
|
56
|
+
currentStepAttempt: claims.currentStepAttempt,
|
|
57
|
+
}
|
|
58
|
+
: {}),
|
|
59
|
+
},
|
|
60
|
+
secret: config.AUTH_JOB_LEASE_TOKEN_SECRET,
|
|
61
|
+
expiresIn: config.AUTH_JOB_LEASE_TOKEN_EXPIRES_IN,
|
|
62
|
+
audience: JOB_LEASE_TOKEN_AUDIENCE,
|
|
63
|
+
});
|
|
64
|
+
recordTokenIssued('job_lease');
|
|
65
|
+
return token;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Returns the claims on success, or `null` for any invalid input — never throws. */
|
|
69
|
+
export async function verifyJobLeaseToken(token: string): Promise<JobLeaseTokenClaims | null> {
|
|
70
|
+
try {
|
|
71
|
+
const claims = await verifyHs256({
|
|
72
|
+
token,
|
|
73
|
+
secret: config.AUTH_JOB_LEASE_TOKEN_SECRET,
|
|
74
|
+
schema: jobLeaseTokenClaimsSchema,
|
|
75
|
+
audience: JOB_LEASE_TOKEN_AUDIENCE,
|
|
76
|
+
});
|
|
77
|
+
recordTokenVerified('job_lease', 'ok');
|
|
78
|
+
return claims;
|
|
79
|
+
} catch {
|
|
80
|
+
recordTokenVerified('job_lease', 'rejected');
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import {SignJWT} from 'jose';
|
|
2
|
+
import {signUserToken, type TokenMembership, verifyUserToken} from './jwt.js';
|
|
3
|
+
|
|
4
|
+
const SECRET = 'test-secret-do-not-use-in-prod';
|
|
5
|
+
|
|
6
|
+
function encodeSecret(secret: string): Uint8Array {
|
|
7
|
+
return new TextEncoder().encode(secret);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe('jwt', () => {
|
|
11
|
+
test('signs a token with memberships and verifies it round-trip', async () => {
|
|
12
|
+
const userId = crypto.randomUUID();
|
|
13
|
+
const email = `jwt-${crypto.randomUUID()}@example.com`;
|
|
14
|
+
const memberships: TokenMembership[] = [
|
|
15
|
+
{workspaceId: crypto.randomUUID(), role: 'admin'},
|
|
16
|
+
{workspaceId: crypto.randomUUID(), role: 'admin'},
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
const token = await signUserToken({
|
|
20
|
+
userId,
|
|
21
|
+
email,
|
|
22
|
+
name: 'Token User',
|
|
23
|
+
memberships,
|
|
24
|
+
secret: SECRET,
|
|
25
|
+
expiresIn: '7d',
|
|
26
|
+
});
|
|
27
|
+
const claims = await verifyUserToken({token, secret: SECRET});
|
|
28
|
+
|
|
29
|
+
expect(claims.sub).toBe(userId);
|
|
30
|
+
expect(claims.email).toBe(email);
|
|
31
|
+
expect(claims.name).toBe('Token User');
|
|
32
|
+
expect(claims.memberships).toEqual(memberships);
|
|
33
|
+
expect(claims.iat).toBeTypeOf('number');
|
|
34
|
+
expect(claims.exp).toBeGreaterThan(claims.iat);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('signs and verifies a token with empty memberships', async () => {
|
|
38
|
+
const userId = crypto.randomUUID();
|
|
39
|
+
const email = `jwt-${crypto.randomUUID()}@example.com`;
|
|
40
|
+
|
|
41
|
+
const token = await signUserToken({
|
|
42
|
+
userId,
|
|
43
|
+
email,
|
|
44
|
+
memberships: [],
|
|
45
|
+
secret: SECRET,
|
|
46
|
+
expiresIn: '7d',
|
|
47
|
+
});
|
|
48
|
+
const claims = await verifyUserToken({token, secret: SECRET});
|
|
49
|
+
|
|
50
|
+
expect(claims.memberships).toEqual([]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('verifies legacy tokens without a name claim', async () => {
|
|
54
|
+
const userId = crypto.randomUUID();
|
|
55
|
+
const email = `jwt-${crypto.randomUUID()}@example.com`;
|
|
56
|
+
const token = await new SignJWT({email, memberships: []})
|
|
57
|
+
.setProtectedHeader({alg: 'HS256'})
|
|
58
|
+
.setSubject(userId)
|
|
59
|
+
.setIssuedAt()
|
|
60
|
+
.setExpirationTime('7d')
|
|
61
|
+
.sign(encodeSecret(SECRET));
|
|
62
|
+
|
|
63
|
+
const claims = await verifyUserToken({token, secret: SECRET});
|
|
64
|
+
|
|
65
|
+
expect(claims.sub).toBe(userId);
|
|
66
|
+
expect(claims.email).toBe(email);
|
|
67
|
+
expect(claims.name).toBeUndefined();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('rejects expired token', async () => {
|
|
71
|
+
const userId = crypto.randomUUID();
|
|
72
|
+
const token = await signUserToken({
|
|
73
|
+
userId,
|
|
74
|
+
email: `jwt-${crypto.randomUUID()}@example.com`,
|
|
75
|
+
memberships: [],
|
|
76
|
+
secret: SECRET,
|
|
77
|
+
expiresIn: '-1s',
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
await expect(verifyUserToken({token, secret: SECRET})).rejects.toThrow();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('rejects tampered signature', async () => {
|
|
84
|
+
const userId = crypto.randomUUID();
|
|
85
|
+
const token = await signUserToken({
|
|
86
|
+
userId,
|
|
87
|
+
email: `jwt-${crypto.randomUUID()}@example.com`,
|
|
88
|
+
memberships: [],
|
|
89
|
+
secret: SECRET,
|
|
90
|
+
expiresIn: '7d',
|
|
91
|
+
});
|
|
92
|
+
const tampered = `${token.slice(0, -4)}xxxx`;
|
|
93
|
+
|
|
94
|
+
await expect(verifyUserToken({token: tampered, secret: SECRET})).rejects.toThrow();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('rejects malformed token', async () => {
|
|
98
|
+
await expect(verifyUserToken({token: 'not.a.token', secret: SECRET})).rejects.toThrow();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('rejects token signed with different secret', async () => {
|
|
102
|
+
const userId = crypto.randomUUID();
|
|
103
|
+
const token = await signUserToken({
|
|
104
|
+
userId,
|
|
105
|
+
email: `jwt-${crypto.randomUUID()}@example.com`,
|
|
106
|
+
memberships: [],
|
|
107
|
+
secret: SECRET,
|
|
108
|
+
expiresIn: '7d',
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
await expect(verifyUserToken({token, secret: 'different-secret'})).rejects.toThrow();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('rejects a token with missing memberships claim', async () => {
|
|
115
|
+
const token = await new SignJWT({email: `jwt-${crypto.randomUUID()}@example.com`})
|
|
116
|
+
.setProtectedHeader({alg: 'HS256'})
|
|
117
|
+
.setSubject(crypto.randomUUID())
|
|
118
|
+
.setIssuedAt()
|
|
119
|
+
.setExpirationTime('7d')
|
|
120
|
+
.sign(encodeSecret(SECRET));
|
|
121
|
+
|
|
122
|
+
await expect(verifyUserToken({token, secret: SECRET})).rejects.toThrow();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('rejects a token whose memberships is not an array', async () => {
|
|
126
|
+
const token = await new SignJWT({
|
|
127
|
+
email: `jwt-${crypto.randomUUID()}@example.com`,
|
|
128
|
+
memberships: 'not-an-array',
|
|
129
|
+
})
|
|
130
|
+
.setProtectedHeader({alg: 'HS256'})
|
|
131
|
+
.setSubject(crypto.randomUUID())
|
|
132
|
+
.setIssuedAt()
|
|
133
|
+
.setExpirationTime('7d')
|
|
134
|
+
.sign(encodeSecret(SECRET));
|
|
135
|
+
|
|
136
|
+
await expect(verifyUserToken({token, secret: SECRET})).rejects.toThrow();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('rejects a token with non-UUID workspaceId in memberships', async () => {
|
|
140
|
+
const token = await new SignJWT({
|
|
141
|
+
email: `jwt-${crypto.randomUUID()}@example.com`,
|
|
142
|
+
memberships: [{workspaceId: 'not-a-uuid', role: 'admin'}],
|
|
143
|
+
})
|
|
144
|
+
.setProtectedHeader({alg: 'HS256'})
|
|
145
|
+
.setSubject(crypto.randomUUID())
|
|
146
|
+
.setIssuedAt()
|
|
147
|
+
.setExpirationTime('7d')
|
|
148
|
+
.sign(encodeSecret(SECRET));
|
|
149
|
+
|
|
150
|
+
await expect(verifyUserToken({token, secret: SECRET})).rejects.toThrow();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('rejects a token with unknown role value', async () => {
|
|
154
|
+
const token = await new SignJWT({
|
|
155
|
+
email: `jwt-${crypto.randomUUID()}@example.com`,
|
|
156
|
+
memberships: [{workspaceId: crypto.randomUUID(), role: 'owner'}],
|
|
157
|
+
})
|
|
158
|
+
.setProtectedHeader({alg: 'HS256'})
|
|
159
|
+
.setSubject(crypto.randomUUID())
|
|
160
|
+
.setIssuedAt()
|
|
161
|
+
.setExpirationTime('7d')
|
|
162
|
+
.sign(encodeSecret(SECRET));
|
|
163
|
+
|
|
164
|
+
await expect(verifyUserToken({token, secret: SECRET})).rejects.toThrow();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('rejects a token with non-UUID sub', async () => {
|
|
168
|
+
const token = await new SignJWT({
|
|
169
|
+
email: `jwt-${crypto.randomUUID()}@example.com`,
|
|
170
|
+
memberships: [],
|
|
171
|
+
})
|
|
172
|
+
.setProtectedHeader({alg: 'HS256'})
|
|
173
|
+
.setSubject('not-a-uuid')
|
|
174
|
+
.setIssuedAt()
|
|
175
|
+
.setExpirationTime('7d')
|
|
176
|
+
.sign(encodeSecret(SECRET));
|
|
177
|
+
|
|
178
|
+
await expect(verifyUserToken({token, secret: SECRET})).rejects.toThrow();
|
|
179
|
+
});
|
|
180
|
+
});
|
package/src/core/jwt.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import {workspaceRoleSchema} from '@shipfox/api-workspaces-dto';
|
|
2
|
+
import {signHs256, verifyHs256} from '@shipfox/node-jwt';
|
|
3
|
+
import {z} from 'zod';
|
|
4
|
+
import {recordTokenIssued, recordTokenVerified} from '#metrics/index.js';
|
|
5
|
+
|
|
6
|
+
export const tokenMembershipSchema = z.object({
|
|
7
|
+
workspaceId: z.string().uuid(),
|
|
8
|
+
role: workspaceRoleSchema,
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
export type TokenMembership = z.infer<typeof tokenMembershipSchema>;
|
|
12
|
+
|
|
13
|
+
export const userTokenClaimsSchema = z.object({
|
|
14
|
+
sub: z.string().uuid(),
|
|
15
|
+
email: z.string().email(),
|
|
16
|
+
name: z.string().nullable().optional(),
|
|
17
|
+
memberships: z.array(tokenMembershipSchema),
|
|
18
|
+
iat: z.number().int(),
|
|
19
|
+
exp: z.number().int(),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export type UserTokenClaims = z.infer<typeof userTokenClaimsSchema>;
|
|
23
|
+
|
|
24
|
+
export interface SignUserTokenParams {
|
|
25
|
+
userId: string;
|
|
26
|
+
email: string;
|
|
27
|
+
name?: string | null | undefined;
|
|
28
|
+
memberships: TokenMembership[];
|
|
29
|
+
secret: string;
|
|
30
|
+
expiresIn: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface VerifyUserTokenParams {
|
|
34
|
+
token: string;
|
|
35
|
+
secret: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function signUserToken(params: SignUserTokenParams): Promise<string> {
|
|
39
|
+
const token = await signHs256({
|
|
40
|
+
payload: {
|
|
41
|
+
email: params.email,
|
|
42
|
+
name: params.name ?? null,
|
|
43
|
+
memberships: params.memberships,
|
|
44
|
+
},
|
|
45
|
+
secret: params.secret,
|
|
46
|
+
expiresIn: params.expiresIn,
|
|
47
|
+
subject: params.userId,
|
|
48
|
+
});
|
|
49
|
+
recordTokenIssued('session');
|
|
50
|
+
return token;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function verifyUserToken(params: VerifyUserTokenParams): Promise<UserTokenClaims> {
|
|
54
|
+
try {
|
|
55
|
+
const claims = await verifyHs256({
|
|
56
|
+
token: params.token,
|
|
57
|
+
secret: params.secret,
|
|
58
|
+
schema: userTokenClaimsSchema,
|
|
59
|
+
});
|
|
60
|
+
recordTokenVerified('session', 'ok');
|
|
61
|
+
return claims;
|
|
62
|
+
} catch (error) {
|
|
63
|
+
recordTokenVerified('session', 'rejected');
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import {hashPassword, verifyPassword} from './password.js';
|
|
2
|
+
|
|
3
|
+
describe('password', () => {
|
|
4
|
+
test('hashes a password and verifies it (roundtrip)', async () => {
|
|
5
|
+
const password = 'correct horse battery staple';
|
|
6
|
+
|
|
7
|
+
const hash = await hashPassword({password});
|
|
8
|
+
const ok = await verifyPassword({password, hash});
|
|
9
|
+
|
|
10
|
+
expect(hash).not.toBe(password);
|
|
11
|
+
expect(hash.startsWith('$argon2')).toBe(true);
|
|
12
|
+
expect(ok).toBe(true);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test('rejects wrong password', async () => {
|
|
16
|
+
const hash = await hashPassword({password: 'correct horse battery staple'});
|
|
17
|
+
|
|
18
|
+
const ok = await verifyPassword({password: 'wrong password', hash});
|
|
19
|
+
|
|
20
|
+
expect(ok).toBe(false);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('throws on a corrupt hash string', async () => {
|
|
24
|
+
await expect(
|
|
25
|
+
verifyPassword({password: 'anything', hash: 'not-an-argon2-hash'}),
|
|
26
|
+
).rejects.toThrow();
|
|
27
|
+
});
|
|
28
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import {hash, verify} from '@node-rs/argon2';
|
|
2
|
+
|
|
3
|
+
export interface HashPasswordParams {
|
|
4
|
+
password: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface VerifyPasswordParams {
|
|
8
|
+
password: string;
|
|
9
|
+
hash: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Algorithm.Argon2id = 2; using literal here to avoid const-enum import under verbatimModuleSyntax.
|
|
13
|
+
const HASH_OPTIONS = {algorithm: 2} as const;
|
|
14
|
+
|
|
15
|
+
export function hashPassword(params: HashPasswordParams): Promise<string> {
|
|
16
|
+
return hash(params.password, HASH_OPTIONS);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function verifyPassword(params: VerifyPasswordParams): Promise<boolean> {
|
|
20
|
+
return verify(params.hash, params.password);
|
|
21
|
+
}
|