@shipfox/api-auth 10.2.0 → 12.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 +1 -1
- package/CHANGELOG.md +40 -0
- package/README.md +48 -32
- package/dist/core/auth.d.ts.map +1 -1
- package/dist/core/auth.js +16 -8
- package/dist/core/auth.js.map +1 -1
- package/dist/core/job-lease-token.d.ts +1 -1
- package/dist/core/job-lease-token.d.ts.map +1 -1
- package/dist/core/job-lease-token.js +1 -1
- package/dist/core/job-lease-token.js.map +1 -1
- package/dist/core/jwt.d.ts +10 -0
- package/dist/core/jwt.d.ts.map +1 -1
- package/dist/core/jwt.js +3 -2
- package/dist/core/jwt.js.map +1 -1
- package/dist/core/runner-session-token.d.ts +1 -1
- package/dist/core/runner-session-token.d.ts.map +1 -1
- package/dist/core/runner-session-token.js +1 -1
- package/dist/core/runner-session-token.js.map +1 -1
- package/dist/db/rate-limits.d.ts +6 -15
- package/dist/db/rate-limits.d.ts.map +1 -1
- package/dist/db/rate-limits.js +17 -18
- package/dist/db/rate-limits.js.map +1 -1
- package/dist/db/refresh-tokens.d.ts +3 -7
- package/dist/db/refresh-tokens.d.ts.map +1 -1
- package/dist/db/refresh-tokens.js +3 -9
- package/dist/db/refresh-tokens.js.map +1 -1
- package/dist/presentation/auth/jwt-auth.d.ts +1 -4
- package/dist/presentation/auth/jwt-auth.d.ts.map +1 -1
- package/dist/presentation/auth/jwt-auth.js +5 -29
- package/dist/presentation/auth/jwt-auth.js.map +1 -1
- package/dist/presentation/auth/lease-token-auth.d.ts +1 -1
- package/dist/presentation/auth/lease-token-auth.js +1 -1
- package/dist/presentation/auth/lease-token-auth.js.map +1 -1
- package/dist/presentation/routes/rate-limit.d.ts.map +1 -1
- package/dist/presentation/routes/rate-limit.js +32 -53
- package/dist/presentation/routes/rate-limit.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +13 -13
- package/src/core/auth.test.ts +29 -10
- package/src/core/auth.ts +27 -12
- package/src/core/job-lease-token.ts +1 -1
- package/src/core/jwt.test.ts +20 -4
- package/src/core/jwt.ts +2 -1
- package/src/core/runner-session-token.ts +1 -1
- package/src/db/rate-limits.ts +28 -42
- package/src/db/refresh-tokens.test.ts +0 -19
- package/src/db/refresh-tokens.ts +3 -26
- package/src/presentation/auth/jwt-auth.test.ts +33 -8
- package/src/presentation/auth/jwt-auth.ts +3 -26
- package/src/presentation/auth/lease-token-auth.ts +1 -1
- package/src/presentation/routes/administration.test.ts +9 -1
- package/src/presentation/routes/password/change-password.test.ts +6 -0
- package/src/presentation/routes/password/password-reset-confirm.test.ts +14 -0
- package/src/presentation/routes/rate-limit.ts +31 -64
- package/src/presentation/routes/registration/signup.test.ts +2 -2
- package/src/presentation/routes/session/logout.test.ts +6 -0
- package/src/presentation/routes/session/refresh.test.ts +11 -0
- package/tsconfig.build.tsbuildinfo +1 -1
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shipfox/api-auth",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "
|
|
4
|
+
"version": "12.0.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/ShipfoxHQ/shipfox.git",
|
|
@@ -26,24 +26,24 @@
|
|
|
26
26
|
"@node-rs/argon2": "^2.0.2",
|
|
27
27
|
"drizzle-orm": "^0.45.2",
|
|
28
28
|
"zod": "^4.4.3",
|
|
29
|
-
"@shipfox/api-common-dto": "
|
|
30
|
-
"@shipfox/api-auth-context": "
|
|
31
|
-
"@shipfox/api-auth-dto": "
|
|
32
|
-
"@shipfox/api-email-challenges": "1.1.
|
|
33
|
-
"@shipfox/api-workspaces-dto": "
|
|
34
|
-
"@shipfox/inter-module": "0.2.
|
|
29
|
+
"@shipfox/api-common-dto": "12.0.0",
|
|
30
|
+
"@shipfox/api-auth-context": "12.0.0",
|
|
31
|
+
"@shipfox/api-auth-dto": "12.0.0",
|
|
32
|
+
"@shipfox/api-email-challenges": "1.1.7",
|
|
33
|
+
"@shipfox/api-workspaces-dto": "12.0.0",
|
|
34
|
+
"@shipfox/inter-module": "0.2.3",
|
|
35
35
|
"@shipfox/config": "1.2.4",
|
|
36
|
-
"@shipfox/node-drizzle": "0.3.
|
|
36
|
+
"@shipfox/node-drizzle": "0.3.5",
|
|
37
37
|
"@shipfox/node-auth-root-key": "0.2.3",
|
|
38
|
-
"@shipfox/node-fastify": "0.4.
|
|
39
|
-
"@shipfox/node-jwt": "0.
|
|
38
|
+
"@shipfox/node-fastify": "0.4.1",
|
|
39
|
+
"@shipfox/node-jwt": "0.4.0",
|
|
40
40
|
"@shipfox/node-email": "0.3.4",
|
|
41
41
|
"@shipfox/node-mailer": "0.2.4",
|
|
42
|
-
"@shipfox/node-module": "1.0.
|
|
42
|
+
"@shipfox/node-module": "1.0.5",
|
|
43
43
|
"@shipfox/node-opentelemetry": "0.6.3",
|
|
44
44
|
"@shipfox/node-outbox": "0.2.6",
|
|
45
|
-
"@shipfox/node-postgres": "0.
|
|
46
|
-
"@shipfox/node-rate-limit": "0.
|
|
45
|
+
"@shipfox/node-postgres": "0.5.0",
|
|
46
|
+
"@shipfox/node-rate-limit": "0.4.0",
|
|
47
47
|
"@shipfox/node-tokens": "0.3.2"
|
|
48
48
|
},
|
|
49
49
|
"imports": {
|
package/src/core/auth.test.ts
CHANGED
|
@@ -73,7 +73,13 @@ vi.mock('#config.js', () => ({
|
|
|
73
73
|
vi.mock('@shipfox/node-mailer', () => ({mailer: testConfig.mailer}));
|
|
74
74
|
|
|
75
75
|
const listMembershipsByUserMock = vi.fn(() =>
|
|
76
|
-
Promise.resolve({
|
|
76
|
+
Promise.resolve({
|
|
77
|
+
memberships: [] as Array<{
|
|
78
|
+
workspaceId: string;
|
|
79
|
+
role: 'admin';
|
|
80
|
+
workspaceStatus: 'active' | 'suspended' | 'deleted';
|
|
81
|
+
}>,
|
|
82
|
+
}),
|
|
77
83
|
);
|
|
78
84
|
const workspaces = {
|
|
79
85
|
listMembershipsForTokenClaims: listMembershipsByUserMock,
|
|
@@ -492,7 +498,6 @@ describe('auth core', () => {
|
|
|
492
498
|
test('refreshAccessToken rejects a lost rotation claim when the token was revoked', async () => {
|
|
493
499
|
const user = await userFactory.create({emailVerifiedAt: new Date()});
|
|
494
500
|
const loginResult = await login({email: user.email, password: user.plainPassword});
|
|
495
|
-
listMembershipsByUserMock.mockClear();
|
|
496
501
|
vi.spyOn(refreshTokenDb, 'rotateRefreshToken').mockImplementationOnce(async () => {
|
|
497
502
|
await logout({refreshToken: loginResult.refreshToken});
|
|
498
503
|
return undefined;
|
|
@@ -501,7 +506,9 @@ describe('auth core', () => {
|
|
|
501
506
|
const raced = refreshAccessToken({refreshToken: loginResult.refreshToken});
|
|
502
507
|
|
|
503
508
|
await expect(raced).rejects.toBeInstanceOf(TokenInvalidError);
|
|
504
|
-
expect(
|
|
509
|
+
await expect(
|
|
510
|
+
verifyUserToken({token: loginResult.token, secret: userAccessTokenKey()}),
|
|
511
|
+
).resolves.toMatchObject({sub: user.id});
|
|
505
512
|
});
|
|
506
513
|
|
|
507
514
|
test('refreshAccessToken rejects reuse past the grace window and revokes the session', async () => {
|
|
@@ -524,6 +531,9 @@ describe('auth core', () => {
|
|
|
524
531
|
})
|
|
525
532
|
: undefined;
|
|
526
533
|
expect(successor).toBeUndefined();
|
|
534
|
+
await expect(
|
|
535
|
+
verifyUserToken({token: refreshed.token, secret: userAccessTokenKey()}),
|
|
536
|
+
).resolves.toMatchObject({sub: user.id});
|
|
527
537
|
});
|
|
528
538
|
|
|
529
539
|
test('refreshAccessToken rejects refresh tokens for inactive users', async () => {
|
|
@@ -597,6 +607,9 @@ describe('auth core', () => {
|
|
|
597
607
|
|
|
598
608
|
expect(currentRefresh.user.id).toBe(user.id);
|
|
599
609
|
await expect(otherRefresh).rejects.toBeInstanceOf(TokenInvalidError);
|
|
610
|
+
await expect(
|
|
611
|
+
verifyUserToken({token: loginResult.token, secret: userAccessTokenKey()}),
|
|
612
|
+
).resolves.toMatchObject({sub: user.id});
|
|
600
613
|
});
|
|
601
614
|
|
|
602
615
|
test('changePassword rejects unknown users and invalid current passwords', async () => {
|
|
@@ -696,8 +709,8 @@ describe('auth core', () => {
|
|
|
696
709
|
const workspaceB = crypto.randomUUID();
|
|
697
710
|
listMembershipsByUserMock.mockResolvedValueOnce({
|
|
698
711
|
memberships: [
|
|
699
|
-
{workspaceId: workspaceA, role: 'admin'},
|
|
700
|
-
{workspaceId: workspaceB, role: 'admin'},
|
|
712
|
+
{workspaceId: workspaceA, role: 'admin', workspaceStatus: 'active'},
|
|
713
|
+
{workspaceId: workspaceB, role: 'admin', workspaceStatus: 'suspended'},
|
|
701
714
|
],
|
|
702
715
|
});
|
|
703
716
|
|
|
@@ -706,8 +719,8 @@ describe('auth core', () => {
|
|
|
706
719
|
const claims = await verifyUserToken({token: result.token, secret: userAccessTokenKey()});
|
|
707
720
|
expect(claims.name).toBe(user.name);
|
|
708
721
|
expect(claims.memberships).toEqual([
|
|
709
|
-
{workspaceId: workspaceA, role: 'admin'},
|
|
710
|
-
{workspaceId: workspaceB, role: 'admin'},
|
|
722
|
+
{workspaceId: workspaceA, role: 'admin', workspaceStatus: 'active'},
|
|
723
|
+
{workspaceId: workspaceB, role: 'admin', workspaceStatus: 'suspended'},
|
|
711
724
|
]);
|
|
712
725
|
});
|
|
713
726
|
|
|
@@ -718,7 +731,7 @@ describe('auth core', () => {
|
|
|
718
731
|
|
|
719
732
|
const newWorkspaceId = crypto.randomUUID();
|
|
720
733
|
listMembershipsByUserMock.mockResolvedValueOnce({
|
|
721
|
-
memberships: [{workspaceId: newWorkspaceId, role: 'admin'}],
|
|
734
|
+
memberships: [{workspaceId: newWorkspaceId, role: 'admin', workspaceStatus: 'active'}],
|
|
722
735
|
});
|
|
723
736
|
const refreshed = await refreshAccessToken({refreshToken: loginResult.refreshToken});
|
|
724
737
|
|
|
@@ -726,7 +739,9 @@ describe('auth core', () => {
|
|
|
726
739
|
token: refreshed.token,
|
|
727
740
|
secret: userAccessTokenKey(),
|
|
728
741
|
});
|
|
729
|
-
expect(refreshedClaims.memberships).toEqual([
|
|
742
|
+
expect(refreshedClaims.memberships).toEqual([
|
|
743
|
+
{workspaceId: newWorkspaceId, role: 'admin', workspaceStatus: 'active'},
|
|
744
|
+
]);
|
|
730
745
|
});
|
|
731
746
|
|
|
732
747
|
test('login fails closed when listMembershipsByUser throws', async () => {
|
|
@@ -738,7 +753,7 @@ describe('auth core', () => {
|
|
|
738
753
|
await expect(promise).rejects.toBeInstanceOf(AuthDependencyUnavailableError);
|
|
739
754
|
});
|
|
740
755
|
|
|
741
|
-
test('refresh
|
|
756
|
+
test('does not rotate a refresh token when listMembershipsByUser throws', async () => {
|
|
742
757
|
const user = await userFactory.create({emailVerifiedAt: new Date()});
|
|
743
758
|
listMembershipsByUserMock.mockResolvedValueOnce({memberships: []});
|
|
744
759
|
const loginResult = await login({email: user.email, password: user.plainPassword});
|
|
@@ -747,5 +762,9 @@ describe('auth core', () => {
|
|
|
747
762
|
const promise = refreshAccessToken({refreshToken: loginResult.refreshToken});
|
|
748
763
|
|
|
749
764
|
await expect(promise).rejects.toBeInstanceOf(AuthDependencyUnavailableError);
|
|
765
|
+
|
|
766
|
+
const retry = await refreshAccessToken({refreshToken: loginResult.refreshToken});
|
|
767
|
+
expect(retry.token).toEqual(expect.any(String));
|
|
768
|
+
expect(retry.refreshToken).toEqual(expect.any(String));
|
|
750
769
|
});
|
|
751
770
|
});
|
package/src/core/auth.ts
CHANGED
|
@@ -46,7 +46,7 @@ import {
|
|
|
46
46
|
TokenInvalidError,
|
|
47
47
|
UserNotFoundError,
|
|
48
48
|
} from './errors.js';
|
|
49
|
-
import {signUserToken} from './jwt.js';
|
|
49
|
+
import {signUserToken, type TokenMembership} from './jwt.js';
|
|
50
50
|
import {hashPassword, verifyPassword} from './password.js';
|
|
51
51
|
import type {SignupPolicy} from './ports.js';
|
|
52
52
|
import {createEnvironmentSignupPolicy} from './signup-policy.js';
|
|
@@ -95,21 +95,30 @@ function recordRefreshOutcome(outcome: AuthTokenRefreshOutcome): void {
|
|
|
95
95
|
recordTokenRefreshed(outcome);
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
|
|
99
|
-
|
|
98
|
+
type TokenMemberships = TokenMembership[];
|
|
99
|
+
|
|
100
|
+
async function loadTokenMemberships(
|
|
101
|
+
userId: string,
|
|
100
102
|
workspaces: WorkspacesInterModuleClient,
|
|
101
|
-
|
|
102
|
-
): Promise<string> {
|
|
103
|
+
): Promise<TokenMemberships> {
|
|
103
104
|
const memberships = await workspaces
|
|
104
|
-
.listMembershipsForTokenClaims({userId
|
|
105
|
+
.listMembershipsForTokenClaims({userId})
|
|
105
106
|
.catch((error: unknown) => {
|
|
106
107
|
throw new AuthDependencyUnavailableError('workspaces', error);
|
|
107
108
|
});
|
|
109
|
+
return memberships.memberships;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function signAccessToken(
|
|
113
|
+
user: User,
|
|
114
|
+
memberships: TokenMemberships,
|
|
115
|
+
refreshSessionId: string,
|
|
116
|
+
): Promise<string> {
|
|
108
117
|
return await signUserToken({
|
|
109
118
|
userId: user.id,
|
|
110
119
|
email: user.email,
|
|
111
120
|
name: user.name,
|
|
112
|
-
memberships
|
|
121
|
+
memberships,
|
|
113
122
|
refreshSessionId,
|
|
114
123
|
secret: userAccessTokenKey(),
|
|
115
124
|
expiresIn: config.AUTH_JWT_EXPIRES_IN,
|
|
@@ -142,7 +151,8 @@ async function createSessionTokens(
|
|
|
142
151
|
workspaces: WorkspacesInterModuleClient,
|
|
143
152
|
): Promise<{token: string; refreshToken: string; adminRole: AdminRole | null}> {
|
|
144
153
|
const refreshSessionId = crypto.randomUUID();
|
|
145
|
-
const
|
|
154
|
+
const memberships = await loadTokenMemberships(user.id, workspaces);
|
|
155
|
+
const token = await signAccessToken(user, memberships, refreshSessionId);
|
|
146
156
|
const adminRole = await getCurrentAdminRole({userId: user.id});
|
|
147
157
|
const {refreshToken} = await createRefreshSession(user, refreshSessionId);
|
|
148
158
|
return {token, refreshToken, adminRole};
|
|
@@ -335,7 +345,7 @@ export async function signupWithInvitation(
|
|
|
335
345
|
}
|
|
336
346
|
}
|
|
337
347
|
|
|
338
|
-
// Step 4: Issue session.
|
|
348
|
+
// Step 4: Issue session. createSessionTokens reads memberships through the
|
|
339
349
|
// workspaces module API, so a successful accept is reflected in the JWT.
|
|
340
350
|
const {token, refreshToken, adminRole} = await createSessionTokens(user, params.workspaces);
|
|
341
351
|
|
|
@@ -479,7 +489,8 @@ export async function refreshAccessToken(params: {
|
|
|
479
489
|
// second tab); past it, reuse of a retired token means a compromised session.
|
|
480
490
|
if (current.rotatedAt) {
|
|
481
491
|
if (isWithinRotationGrace(current)) {
|
|
482
|
-
const
|
|
492
|
+
const memberships = await loadTokenMemberships(user.id, params.workspaces);
|
|
493
|
+
const token = await signAccessToken(user, memberships, current.sessionId);
|
|
483
494
|
recordRefreshOutcome('grace');
|
|
484
495
|
return {token, refreshToken: undefined, user, adminRole};
|
|
485
496
|
}
|
|
@@ -488,6 +499,10 @@ export async function refreshAccessToken(params: {
|
|
|
488
499
|
throw new TokenInvalidError('Refresh token reused after rotation');
|
|
489
500
|
}
|
|
490
501
|
|
|
502
|
+
// Keep the external membership snapshot ahead of rotation so an outage
|
|
503
|
+
// cannot retire a refresh token that the caller can still retry.
|
|
504
|
+
const memberships = await loadTokenMemberships(user.id, params.workspaces);
|
|
505
|
+
|
|
491
506
|
// Losing the CAS requires a state check: another request may have rotated,
|
|
492
507
|
// revoked, or expired the token before this refresh could claim it.
|
|
493
508
|
const nextRefreshToken = generateOpaqueToken('refreshToken');
|
|
@@ -509,12 +524,12 @@ export async function refreshAccessToken(params: {
|
|
|
509
524
|
recordRefreshOutcome('rejected');
|
|
510
525
|
throw new TokenInvalidError('Refresh token is invalid or expired');
|
|
511
526
|
}
|
|
512
|
-
const token = await signAccessToken(user,
|
|
527
|
+
const token = await signAccessToken(user, memberships, latest.sessionId);
|
|
513
528
|
recordRefreshOutcome('grace');
|
|
514
529
|
return {token, refreshToken: undefined, user, adminRole};
|
|
515
530
|
}
|
|
516
531
|
|
|
517
|
-
const token = await signAccessToken(user,
|
|
532
|
+
const token = await signAccessToken(user, memberships, current.sessionId);
|
|
518
533
|
recordRefreshOutcome('rotated');
|
|
519
534
|
return {token, refreshToken: nextRefreshToken, user, adminRole};
|
|
520
535
|
}
|
|
@@ -66,7 +66,7 @@ export async function issueJobLeaseToken(claims: IssueJobLeaseTokenParams): Prom
|
|
|
66
66
|
return token;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
/** Returns the claims on success, or `null` for any invalid input
|
|
69
|
+
/** Returns the claims on success, or `null` for any invalid input. Never throws. */
|
|
70
70
|
export async function verifyJobLeaseToken(token: string): Promise<JobLeaseTokenClaims | null> {
|
|
71
71
|
try {
|
|
72
72
|
const claims = await verifyHs256({
|
package/src/core/jwt.test.ts
CHANGED
|
@@ -12,8 +12,8 @@ describe('jwt', () => {
|
|
|
12
12
|
const userId = crypto.randomUUID();
|
|
13
13
|
const email = `jwt-${crypto.randomUUID()}@example.com`;
|
|
14
14
|
const memberships: TokenMembership[] = [
|
|
15
|
-
{workspaceId: crypto.randomUUID(), role: 'admin'},
|
|
16
|
-
{workspaceId: crypto.randomUUID(), role: 'admin'},
|
|
15
|
+
{workspaceId: crypto.randomUUID(), role: 'admin', workspaceStatus: 'active'},
|
|
16
|
+
{workspaceId: crypto.randomUUID(), role: 'admin', workspaceStatus: 'suspended'},
|
|
17
17
|
];
|
|
18
18
|
|
|
19
19
|
const token = await signUserToken({
|
|
@@ -140,7 +140,7 @@ describe('jwt', () => {
|
|
|
140
140
|
test('rejects a token with non-UUID workspaceId in memberships', async () => {
|
|
141
141
|
const token = await new SignJWT({
|
|
142
142
|
email: `jwt-${crypto.randomUUID()}@example.com`,
|
|
143
|
-
memberships: [{workspaceId: 'not-a-uuid', role: 'admin'}],
|
|
143
|
+
memberships: [{workspaceId: 'not-a-uuid', role: 'admin', workspaceStatus: 'active'}],
|
|
144
144
|
})
|
|
145
145
|
.setProtectedHeader({alg: 'HS256'})
|
|
146
146
|
.setSubject(crypto.randomUUID())
|
|
@@ -151,10 +151,26 @@ describe('jwt', () => {
|
|
|
151
151
|
await expect(verifyUserToken({token, secret: SECRET})).rejects.toThrow();
|
|
152
152
|
});
|
|
153
153
|
|
|
154
|
+
test('accepts legacy tokens with missing workspace status as active', async () => {
|
|
155
|
+
const token = await new SignJWT({
|
|
156
|
+
email: `jwt-${crypto.randomUUID()}@example.com`,
|
|
157
|
+
memberships: [{workspaceId: crypto.randomUUID(), role: 'admin'}],
|
|
158
|
+
})
|
|
159
|
+
.setProtectedHeader({alg: 'HS256'})
|
|
160
|
+
.setSubject(crypto.randomUUID())
|
|
161
|
+
.setIssuedAt()
|
|
162
|
+
.setExpirationTime('7d')
|
|
163
|
+
.sign(encodeSecret(SECRET));
|
|
164
|
+
|
|
165
|
+
await expect(verifyUserToken({token, secret: SECRET})).resolves.toMatchObject({
|
|
166
|
+
memberships: [{workspaceId: expect.any(String), role: 'admin', workspaceStatus: 'active'}],
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
154
170
|
test('rejects a token with unknown role value', async () => {
|
|
155
171
|
const token = await new SignJWT({
|
|
156
172
|
email: `jwt-${crypto.randomUUID()}@example.com`,
|
|
157
|
-
memberships: [{workspaceId: crypto.randomUUID(), role: 'owner'}],
|
|
173
|
+
memberships: [{workspaceId: crypto.randomUUID(), role: 'owner', workspaceStatus: 'active'}],
|
|
158
174
|
})
|
|
159
175
|
.setProtectedHeader({alg: 'HS256'})
|
|
160
176
|
.setSubject(crypto.randomUUID())
|
package/src/core/jwt.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {workspaceRoleSchema} from '@shipfox/api-workspaces-dto';
|
|
1
|
+
import {workspaceRoleSchema, workspaceStatusSchema} from '@shipfox/api-workspaces-dto';
|
|
2
2
|
import {signHs256, verifyHs256} from '@shipfox/node-jwt';
|
|
3
3
|
import {z} from 'zod';
|
|
4
4
|
import {recordTokenIssued, recordTokenVerified} from '#metrics/index.js';
|
|
@@ -6,6 +6,7 @@ import {recordTokenIssued, recordTokenVerified} from '#metrics/index.js';
|
|
|
6
6
|
export const tokenMembershipSchema = z.object({
|
|
7
7
|
workspaceId: z.string().uuid(),
|
|
8
8
|
role: workspaceRoleSchema,
|
|
9
|
+
workspaceStatus: workspaceStatusSchema.default('active'),
|
|
9
10
|
});
|
|
10
11
|
|
|
11
12
|
export type TokenMembership = z.infer<typeof tokenMembershipSchema>;
|
|
@@ -30,7 +30,7 @@ export async function issueRunnerSessionToken(
|
|
|
30
30
|
return token;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
/** Returns the claims on success, or `null` for any invalid input
|
|
33
|
+
/** Returns the claims on success, or `null` for any invalid input. Never throws. */
|
|
34
34
|
export async function verifyRunnerSessionToken(
|
|
35
35
|
token: string,
|
|
36
36
|
): Promise<RunnerSessionTokenClaims | null> {
|
package/src/db/rate-limits.ts
CHANGED
|
@@ -1,28 +1,23 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ConsumeRateLimitParams,
|
|
3
|
+
type ConsumeRateLimitResult,
|
|
4
|
+
createRateLimitPersistence,
|
|
5
|
+
} from '@shipfox/node-rate-limit';
|
|
1
6
|
import {lt, sql} from 'drizzle-orm';
|
|
2
7
|
import {db} from './db.js';
|
|
3
8
|
import {authRateLimits} from './schema/rate-limits.js';
|
|
4
9
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
expiresAt: Date;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export async function consumeAuthRateLimit(
|
|
20
|
-
params: ConsumeAuthRateLimitParams,
|
|
21
|
-
): Promise<ConsumeAuthRateLimitResult> {
|
|
22
|
-
return await db().transaction(async (tx) => {
|
|
23
|
-
await tx.execute(sql`select set_config('statement_timeout', ${`${params.timeoutMs}ms`}, true)`);
|
|
24
|
-
|
|
25
|
-
const rows = await tx
|
|
10
|
+
type AuthRateLimitTransaction = Parameters<Parameters<ReturnType<typeof db>['transaction']>[0]>[0];
|
|
11
|
+
|
|
12
|
+
const persistence = createRateLimitPersistence<AuthRateLimitTransaction>({
|
|
13
|
+
transaction: (callback) => db().transaction(callback),
|
|
14
|
+
setStatementTimeout: async (transaction, timeoutMs) => {
|
|
15
|
+
await transaction.execute(
|
|
16
|
+
sql`select set_config('statement_timeout', ${`${timeoutMs}ms`}, true)`,
|
|
17
|
+
);
|
|
18
|
+
},
|
|
19
|
+
consume: async (transaction, params) => {
|
|
20
|
+
const rows = await transaction
|
|
26
21
|
.insert(authRateLimits)
|
|
27
22
|
.values({
|
|
28
23
|
action: params.action,
|
|
@@ -46,24 +41,15 @@ export async function consumeAuthRateLimit(
|
|
|
46
41
|
})
|
|
47
42
|
.returning({count: authRateLimits.count, expiresAt: authRateLimits.expiresAt});
|
|
48
43
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
export
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
const minIntervalMs = params.minIntervalMs ?? 60_000;
|
|
62
|
-
if (minIntervalMs > 0 && now.getTime() < nextPruneAt) return undefined;
|
|
63
|
-
|
|
64
|
-
nextPruneAt = now.getTime() + minIntervalMs;
|
|
65
|
-
|
|
66
|
-
const result = await db().delete(authRateLimits).where(lt(authRateLimits.expiresAt, now));
|
|
67
|
-
|
|
68
|
-
return result.rowCount ?? 0;
|
|
69
|
-
}
|
|
44
|
+
return rows[0];
|
|
45
|
+
},
|
|
46
|
+
prune: async (now) => {
|
|
47
|
+
const result = await db().delete(authRateLimits).where(lt(authRateLimits.expiresAt, now));
|
|
48
|
+
return result.rowCount ?? 0;
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
export type ConsumeAuthRateLimitParams = ConsumeRateLimitParams<string, string>;
|
|
53
|
+
export type ConsumeAuthRateLimitResult = ConsumeRateLimitResult;
|
|
54
|
+
export const consumeAuthRateLimit = persistence.consume;
|
|
55
|
+
export const pruneExpiredAuthRateLimits = persistence.prune;
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import {hashOpaqueToken} from '@shipfox/node-tokens';
|
|
2
2
|
import {
|
|
3
3
|
createRefreshToken,
|
|
4
|
-
findActiveRefreshSession,
|
|
5
4
|
findActiveRefreshTokenByHash,
|
|
6
5
|
findRefreshTokenByHash,
|
|
7
6
|
revokeRefreshSession,
|
|
@@ -45,24 +44,6 @@ describe('refresh-tokens db', () => {
|
|
|
45
44
|
expect(found).toBeUndefined();
|
|
46
45
|
});
|
|
47
46
|
|
|
48
|
-
test('finds only the active session owned by the requested user', async () => {
|
|
49
|
-
const user = await createUser({email: emailFor('rt-session'), hashedPassword: 'h'});
|
|
50
|
-
const otherUser = await createUser({email: emailFor('rt-session-other'), hashedPassword: 'h'});
|
|
51
|
-
const sessionId = crypto.randomUUID();
|
|
52
|
-
await createRefreshToken({
|
|
53
|
-
userId: user.id,
|
|
54
|
-
sessionId,
|
|
55
|
-
hashedToken: hashOpaqueToken(`active-${crypto.randomUUID()}`),
|
|
56
|
-
expiresAt: new Date(Date.now() + 60_000),
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
const found = await findActiveRefreshSession({sessionId, userId: user.id});
|
|
60
|
-
const otherUserFound = await findActiveRefreshSession({sessionId, userId: otherUser.id});
|
|
61
|
-
|
|
62
|
-
expect(found?.sessionId).toBe(sessionId);
|
|
63
|
-
expect(otherUserFound).toBeUndefined();
|
|
64
|
-
});
|
|
65
|
-
|
|
66
47
|
test('rotates a token once, creates the successor, and only the first caller wins', async () => {
|
|
67
48
|
const user = await createUser({email: emailFor('rt-rotate'), hashedPassword: 'h'});
|
|
68
49
|
const currentHashedToken = hashOpaqueToken(`current-${crypto.randomUUID()}`);
|
package/src/db/refresh-tokens.ts
CHANGED
|
@@ -61,7 +61,7 @@ export async function createRefreshTokenForActiveUser(
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
/**
|
|
64
|
-
* Looks up the live session token by hash
|
|
64
|
+
* Looks up the live session token by hash. It can still authenticate as
|
|
65
65
|
* the current session. Returns `undefined` for revoked, expired, or already
|
|
66
66
|
* rotated tokens; rotated rows survive in the table only for the grace window
|
|
67
67
|
* and are not "active".
|
|
@@ -87,34 +87,11 @@ export async function findActiveRefreshTokenByHash(params: {
|
|
|
87
87
|
return toRefreshToken(row);
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
export async function findActiveRefreshSession(params: {
|
|
91
|
-
sessionId: string;
|
|
92
|
-
userId: string;
|
|
93
|
-
}): Promise<RefreshToken | undefined> {
|
|
94
|
-
const rows = await db()
|
|
95
|
-
.select()
|
|
96
|
-
.from(refreshTokens)
|
|
97
|
-
.where(
|
|
98
|
-
and(
|
|
99
|
-
eq(refreshTokens.sessionId, params.sessionId),
|
|
100
|
-
eq(refreshTokens.userId, params.userId),
|
|
101
|
-
isNull(refreshTokens.revokedAt),
|
|
102
|
-
isNull(refreshTokens.rotatedAt),
|
|
103
|
-
gt(refreshTokens.expiresAt, sql`now()`),
|
|
104
|
-
),
|
|
105
|
-
)
|
|
106
|
-
.limit(1);
|
|
107
|
-
|
|
108
|
-
const row = rows[0];
|
|
109
|
-
if (!row) return undefined;
|
|
110
|
-
return toRefreshToken(row);
|
|
111
|
-
}
|
|
112
|
-
|
|
113
90
|
/**
|
|
114
91
|
* Looks up a non-revoked, non-expired token by hash, including ones already
|
|
115
92
|
* rotated. Rotation reads {@link RefreshToken.rotatedAt} to decide whether to
|
|
116
|
-
* rotate, honour the grace window, or reject reuse, so
|
|
117
|
-
* {@link findActiveRefreshTokenByHash}
|
|
93
|
+
* rotate, honour the grace window, or reject reuse, so, unlike
|
|
94
|
+
* {@link findActiveRefreshTokenByHash}, it must still see rotated rows.
|
|
118
95
|
*/
|
|
119
96
|
export async function findRefreshTokenByHash(params: {
|
|
120
97
|
hashedToken: string;
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
revokeRefreshTokenByHash,
|
|
10
10
|
rotateRefreshToken,
|
|
11
11
|
} from '#db/refresh-tokens.js';
|
|
12
|
-
import {createUser
|
|
12
|
+
import {createUser} from '#db/users.js';
|
|
13
13
|
import {createJwtAuthMethod, getAuthenticatedSessionContext, getClientContext} from './jwt-auth.js';
|
|
14
14
|
|
|
15
15
|
const SECRET = userAccessTokenKey();
|
|
@@ -138,7 +138,35 @@ describe('jwt-auth', () => {
|
|
|
138
138
|
expect(res.json().client.email).toBe(email);
|
|
139
139
|
});
|
|
140
140
|
|
|
141
|
-
test('
|
|
141
|
+
test('verifies a token from claims when its user and refresh session are missing', async () => {
|
|
142
|
+
const userId = crypto.randomUUID();
|
|
143
|
+
const refreshSessionId = crypto.randomUUID();
|
|
144
|
+
const token = await signUserToken({
|
|
145
|
+
userId,
|
|
146
|
+
email: emailFor('jwt-missing-user'),
|
|
147
|
+
memberships: [],
|
|
148
|
+
refreshSessionId,
|
|
149
|
+
secret: SECRET,
|
|
150
|
+
expiresIn: '7d',
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const protectedResponse = await app.inject({
|
|
154
|
+
method: 'GET',
|
|
155
|
+
url: '/protected',
|
|
156
|
+
headers: {authorization: `Bearer ${token}`},
|
|
157
|
+
});
|
|
158
|
+
const sessionResponse = await app.inject({
|
|
159
|
+
method: 'GET',
|
|
160
|
+
url: '/session',
|
|
161
|
+
headers: {authorization: `Bearer ${token}`},
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
expect(protectedResponse.statusCode).toBe(200);
|
|
165
|
+
expect(sessionResponse.statusCode).toBe(200);
|
|
166
|
+
expect(sessionResponse.json()).toEqual({userId, refreshSessionId});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('returns the refresh-session identity from an authenticated request', async () => {
|
|
142
170
|
const user = await createUser({email: emailFor('jwt-session'), hashedPassword: 'h'});
|
|
143
171
|
const rawRefreshToken = `refresh-${crypto.randomUUID()}`;
|
|
144
172
|
const refreshToken = await createRefreshToken({
|
|
@@ -186,7 +214,7 @@ describe('jwt-auth', () => {
|
|
|
186
214
|
expect(refreshed.json()).toEqual(initial.json());
|
|
187
215
|
});
|
|
188
216
|
|
|
189
|
-
test('
|
|
217
|
+
test('keeps an authenticated token valid after its refresh session is revoked', async () => {
|
|
190
218
|
const user = await createUser({email: emailFor('jwt-session-revoked'), hashedPassword: 'h'});
|
|
191
219
|
const rawRefreshToken = `refresh-${crypto.randomUUID()}`;
|
|
192
220
|
const refreshToken = await createRefreshToken({
|
|
@@ -210,7 +238,8 @@ describe('jwt-auth', () => {
|
|
|
210
238
|
headers: {authorization: `Bearer ${token}`},
|
|
211
239
|
});
|
|
212
240
|
|
|
213
|
-
expect(res.statusCode).toBe(
|
|
241
|
+
expect(res.statusCode).toBe(200);
|
|
242
|
+
expect(res.json()).toEqual({userId: user.id, refreshSessionId: refreshToken.sessionId});
|
|
214
243
|
});
|
|
215
244
|
|
|
216
245
|
test('rejects an authenticated token without a refresh-session claim', async () => {
|
|
@@ -231,8 +260,4 @@ describe('jwt-auth', () => {
|
|
|
231
260
|
|
|
232
261
|
expect(res.statusCode).toBe(401);
|
|
233
262
|
});
|
|
234
|
-
|
|
235
|
-
test('helpers remain available', () => {
|
|
236
|
-
expect(typeof findUserById).toBe('function');
|
|
237
|
-
});
|
|
238
263
|
});
|
|
@@ -12,8 +12,6 @@ import type {RefreshToken} from '#core/entities/refresh-token.js';
|
|
|
12
12
|
import type {User} from '#core/entities/user.js';
|
|
13
13
|
import type {UserTokenClaims} from '#core/jwt.js';
|
|
14
14
|
import {verifyUserToken} from '#core/jwt.js';
|
|
15
|
-
import {findActiveRefreshSession} from '#db/refresh-tokens.js';
|
|
16
|
-
import {findUserById} from '#db/users.js';
|
|
17
15
|
import {createBearerTokenAuthMethod} from './bearer-token-auth.js';
|
|
18
16
|
|
|
19
17
|
const AUTHENTICATED_SESSION_CONTEXT_KEY = Symbol.for('@shipfox/api-auth/session');
|
|
@@ -61,10 +59,7 @@ function getAuthenticatedSessionTokenContext(request: FastifyRequest): {
|
|
|
61
59
|
);
|
|
62
60
|
}
|
|
63
61
|
|
|
64
|
-
/**
|
|
65
|
-
* Resolves the active refresh session backing an authenticated user request.
|
|
66
|
-
* The session check intentionally happens only for consumers that need browser-session binding.
|
|
67
|
-
*/
|
|
62
|
+
/** Returns the refresh-session metadata carried by a verified access token. */
|
|
68
63
|
export async function getAuthenticatedSessionContext(
|
|
69
64
|
request: FastifyRequest,
|
|
70
65
|
): Promise<AuthenticatedSessionContext> {
|
|
@@ -73,13 +68,7 @@ export async function getAuthenticatedSessionContext(
|
|
|
73
68
|
if (!client || !token || token.userId !== client.userId || !token.refreshSessionId)
|
|
74
69
|
throw new ClientError('Invalid or expired token', 'unauthorized', {status: 401});
|
|
75
70
|
|
|
76
|
-
|
|
77
|
-
sessionId: token.refreshSessionId,
|
|
78
|
-
userId: client.userId,
|
|
79
|
-
});
|
|
80
|
-
if (!session) throw new ClientError('Invalid or expired token', 'unauthorized', {status: 401});
|
|
81
|
-
|
|
82
|
-
return {userId: client.userId, refreshSessionId: session.sessionId};
|
|
71
|
+
return await Promise.resolve({userId: client.userId, refreshSessionId: token.refreshSessionId});
|
|
83
72
|
}
|
|
84
73
|
|
|
85
74
|
export function createJwtAuthMethod(): AuthMethod {
|
|
@@ -92,19 +81,7 @@ export function createJwtAuthMethod(): AuthMethod {
|
|
|
92
81
|
} catch {
|
|
93
82
|
throw new InvalidJwtTokenError();
|
|
94
83
|
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
// Legacy access tokens without a session claim remain verifiable for the
|
|
98
|
-
// migration window; known suspended or deleted users are still rejected.
|
|
99
|
-
if (user && user.status !== 'active') return null;
|
|
100
|
-
if (!claims.refreshSessionId) return claims;
|
|
101
|
-
if (!user) return null;
|
|
102
|
-
|
|
103
|
-
const session = await findActiveRefreshSession({
|
|
104
|
-
sessionId: claims.refreshSessionId,
|
|
105
|
-
userId: claims.sub,
|
|
106
|
-
});
|
|
107
|
-
return session ? claims : null;
|
|
84
|
+
return claims;
|
|
108
85
|
},
|
|
109
86
|
isInvalidTokenError: (error) => error instanceof InvalidJwtTokenError,
|
|
110
87
|
invalidTokenError: {message: 'Invalid or expired token', code: 'unauthorized'},
|
|
@@ -4,7 +4,7 @@ import {verifyJobLeaseToken} from '#core/job-lease-token.js';
|
|
|
4
4
|
import {createBearerTokenAuthMethod} from './bearer-token-auth.js';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* Trust boundary: the signed token is the sole authority
|
|
7
|
+
* Trust boundary: the signed token is the sole authority. `claims.jobId` scopes
|
|
8
8
|
* every runner request to exactly one job. `workflowRunId`/`workflowRunAttemptId`/`workspaceId`
|
|
9
9
|
* are carried for consumers but are NOT verified against the database here.
|
|
10
10
|
*
|