@shipfox/api-auth 7.0.2 → 9.0.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/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +73 -0
- package/README.md +29 -8
- package/dist/core/auth.d.ts.map +1 -1
- package/dist/core/auth.js +38 -21
- package/dist/core/auth.js.map +1 -1
- package/dist/core/entities/refresh-token.d.ts +1 -0
- package/dist/core/entities/refresh-token.d.ts.map +1 -1
- package/dist/core/entities/refresh-token.js.map +1 -1
- package/dist/core/jwt.d.ts +2 -0
- package/dist/core/jwt.d.ts.map +1 -1
- package/dist/core/jwt.js +3 -1
- package/dist/core/jwt.js.map +1 -1
- package/dist/db/db.d.ts +34 -0
- package/dist/db/db.d.ts.map +1 -1
- package/dist/db/refresh-tokens.d.ts +9 -0
- package/dist/db/refresh-tokens.d.ts.map +1 -1
- package/dist/db/refresh-tokens.js +14 -0
- package/dist/db/refresh-tokens.js.map +1 -1
- package/dist/db/schema/refresh-tokens.d.ts +17 -0
- package/dist/db/schema/refresh-tokens.d.ts.map +1 -1
- package/dist/db/schema/refresh-tokens.js +4 -0
- package/dist/db/schema/refresh-tokens.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/presentation/auth/jwt-auth.d.ts +14 -1
- package/dist/presentation/auth/jwt-auth.d.ts.map +1 -1
- package/dist/presentation/auth/jwt-auth.js +34 -0
- package/dist/presentation/auth/jwt-auth.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/drizzle/0000_initial.sql +2 -0
- package/drizzle/meta/0000_snapshot.json +29 -0
- package/package.json +22 -23
- package/src/core/auth.test.ts +42 -0
- package/src/core/auth.ts +36 -19
- package/src/core/entities/refresh-token.ts +1 -0
- package/src/core/jwt.ts +3 -0
- package/src/db/refresh-tokens.test.ts +42 -0
- package/src/db/refresh-tokens.ts +42 -0
- package/src/db/schema/refresh-tokens.ts +6 -0
- package/src/index.test.ts +1 -0
- package/src/index.ts +8 -2
- package/src/presentation/auth/jwt-auth.test.ts +102 -1
- package/src/presentation/auth/jwt-auth.ts +55 -1
- package/src/presentation/e2eRoutes/index.test.ts +1 -0
- package/src/presentation/routes/index.test.ts +1 -0
- package/test/globalSetup.ts +3 -10
- package/test/routes.ts +1 -0
- package/tsconfig.build.tsbuildinfo +1 -1
package/drizzle/0000_initial.sql
CHANGED
|
@@ -35,6 +35,7 @@ CREATE TABLE "auth_rate_limits" (
|
|
|
35
35
|
--> statement-breakpoint
|
|
36
36
|
CREATE TABLE "auth_refresh_tokens" (
|
|
37
37
|
"id" uuid PRIMARY KEY DEFAULT uuidv7() NOT NULL,
|
|
38
|
+
"session_id" uuid DEFAULT uuidv7() NOT NULL,
|
|
38
39
|
"user_id" uuid NOT NULL,
|
|
39
40
|
"hashed_token" text NOT NULL,
|
|
40
41
|
"expires_at" timestamp with time zone NOT NULL,
|
|
@@ -65,5 +66,6 @@ CREATE INDEX "auth_password_resets_user_id_idx" ON "auth_password_resets" USING
|
|
|
65
66
|
CREATE UNIQUE INDEX "auth_rate_limits_window_unique" ON "auth_rate_limits" USING btree ("action","scope","identifier_hmac","window_start");--> statement-breakpoint
|
|
66
67
|
CREATE INDEX "auth_rate_limits_expires_at_idx" ON "auth_rate_limits" USING btree ("expires_at");--> statement-breakpoint
|
|
67
68
|
CREATE UNIQUE INDEX "auth_refresh_tokens_hashed_token_unique" ON "auth_refresh_tokens" USING btree ("hashed_token");--> statement-breakpoint
|
|
69
|
+
CREATE UNIQUE INDEX "auth_refresh_tokens_active_session_unique" ON "auth_refresh_tokens" USING btree ("user_id","session_id") WHERE "auth_refresh_tokens"."revoked_at" IS NULL AND "auth_refresh_tokens"."rotated_at" IS NULL;--> statement-breakpoint
|
|
68
70
|
CREATE INDEX "auth_refresh_tokens_user_id_idx" ON "auth_refresh_tokens" USING btree ("user_id");--> statement-breakpoint
|
|
69
71
|
CREATE UNIQUE INDEX "auth_users_email_unique" ON "auth_users" USING btree ("email");
|
|
@@ -143,6 +143,13 @@
|
|
|
143
143
|
"notNull": true,
|
|
144
144
|
"default": "uuidv7()"
|
|
145
145
|
},
|
|
146
|
+
"session_id": {
|
|
147
|
+
"name": "session_id",
|
|
148
|
+
"type": "uuid",
|
|
149
|
+
"primaryKey": false,
|
|
150
|
+
"notNull": true,
|
|
151
|
+
"default": "uuidv7()"
|
|
152
|
+
},
|
|
146
153
|
"user_id": {
|
|
147
154
|
"name": "user_id",
|
|
148
155
|
"type": "uuid",
|
|
@@ -415,6 +422,28 @@
|
|
|
415
422
|
"method": "btree",
|
|
416
423
|
"with": {}
|
|
417
424
|
},
|
|
425
|
+
"auth_refresh_tokens_active_session_unique": {
|
|
426
|
+
"name": "auth_refresh_tokens_active_session_unique",
|
|
427
|
+
"columns": [
|
|
428
|
+
{
|
|
429
|
+
"expression": "user_id",
|
|
430
|
+
"isExpression": false,
|
|
431
|
+
"asc": true,
|
|
432
|
+
"nulls": "last"
|
|
433
|
+
},
|
|
434
|
+
{
|
|
435
|
+
"expression": "session_id",
|
|
436
|
+
"isExpression": false,
|
|
437
|
+
"asc": true,
|
|
438
|
+
"nulls": "last"
|
|
439
|
+
}
|
|
440
|
+
],
|
|
441
|
+
"isUnique": true,
|
|
442
|
+
"where": "\"auth_refresh_tokens\".\"revoked_at\" IS NULL AND \"auth_refresh_tokens\".\"rotated_at\" IS NULL",
|
|
443
|
+
"concurrently": false,
|
|
444
|
+
"method": "btree",
|
|
445
|
+
"with": {}
|
|
446
|
+
},
|
|
418
447
|
"auth_refresh_tokens_user_id_idx": {
|
|
419
448
|
"name": "auth_refresh_tokens_user_id_idx",
|
|
420
449
|
"columns": [
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shipfox/api-auth",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "
|
|
4
|
+
"version": "9.0.1",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/ShipfoxHQ/shipfox.git",
|
|
@@ -11,10 +11,6 @@
|
|
|
11
11
|
"type": "module",
|
|
12
12
|
"main": "dist/index.js",
|
|
13
13
|
"types": "dist/index.d.ts",
|
|
14
|
-
"imports": {
|
|
15
|
-
"#test/*": "./test/*",
|
|
16
|
-
"#*": "./dist/*"
|
|
17
|
-
},
|
|
18
14
|
"exports": {
|
|
19
15
|
".": {
|
|
20
16
|
"types": "./dist/index.d.ts",
|
|
@@ -30,24 +26,27 @@
|
|
|
30
26
|
"@node-rs/argon2": "^2.0.2",
|
|
31
27
|
"drizzle-orm": "^0.45.2",
|
|
32
28
|
"zod": "^4.4.3",
|
|
33
|
-
"@shipfox/api-auth-context": "
|
|
34
|
-
"@shipfox/api-auth-dto": "
|
|
35
|
-
"@shipfox/api-email-challenges": "
|
|
36
|
-
"@shipfox/api-workspaces-dto": "
|
|
37
|
-
"@shipfox/inter-module": "0.2.
|
|
38
|
-
"@shipfox/config": "1.2.
|
|
39
|
-
"@shipfox/node-drizzle": "0.3.
|
|
40
|
-
"@shipfox/node-auth-root-key": "0.2.
|
|
41
|
-
"@shipfox/node-
|
|
42
|
-
"@shipfox/node-
|
|
43
|
-
"@shipfox/node-email": "0.3.
|
|
44
|
-
"@shipfox/node-mailer": "0.2.
|
|
45
|
-
"@shipfox/node-module": "0.
|
|
46
|
-
"@shipfox/node-opentelemetry": "0.
|
|
47
|
-
"@shipfox/node-outbox": "0.2.
|
|
48
|
-
"@shipfox/node-postgres": "0.4.
|
|
49
|
-
"@shipfox/node-rate-limit": "0.3.
|
|
50
|
-
"@shipfox/node-tokens": "0.3.
|
|
29
|
+
"@shipfox/api-auth-context": "9.0.1",
|
|
30
|
+
"@shipfox/api-auth-dto": "9.0.1",
|
|
31
|
+
"@shipfox/api-email-challenges": "1.1.0",
|
|
32
|
+
"@shipfox/api-workspaces-dto": "9.0.1",
|
|
33
|
+
"@shipfox/inter-module": "0.2.1",
|
|
34
|
+
"@shipfox/config": "1.2.3",
|
|
35
|
+
"@shipfox/node-drizzle": "0.3.3",
|
|
36
|
+
"@shipfox/node-auth-root-key": "0.2.2",
|
|
37
|
+
"@shipfox/node-jwt": "0.3.1",
|
|
38
|
+
"@shipfox/node-fastify": "0.3.1",
|
|
39
|
+
"@shipfox/node-email": "0.3.2",
|
|
40
|
+
"@shipfox/node-mailer": "0.2.2",
|
|
41
|
+
"@shipfox/node-module": "1.0.0",
|
|
42
|
+
"@shipfox/node-opentelemetry": "0.6.1",
|
|
43
|
+
"@shipfox/node-outbox": "0.2.5",
|
|
44
|
+
"@shipfox/node-postgres": "0.4.3",
|
|
45
|
+
"@shipfox/node-rate-limit": "0.3.1",
|
|
46
|
+
"@shipfox/node-tokens": "0.3.1"
|
|
47
|
+
},
|
|
48
|
+
"imports": {
|
|
49
|
+
"#*": "./dist/*"
|
|
51
50
|
},
|
|
52
51
|
"scripts": {
|
|
53
52
|
"build": "shipfox-swc",
|
package/src/core/auth.test.ts
CHANGED
|
@@ -73,6 +73,7 @@ const listMembershipsByUserMock = vi.fn(() =>
|
|
|
73
73
|
);
|
|
74
74
|
const workspaces = {
|
|
75
75
|
listMembershipsForTokenClaims: listMembershipsByUserMock,
|
|
76
|
+
getWorkspaceCreator: vi.fn(),
|
|
76
77
|
preflightInvitationAcceptance: vi.fn(),
|
|
77
78
|
acceptInvitation: vi.fn(),
|
|
78
79
|
requireActiveMembership: vi.fn(),
|
|
@@ -334,6 +335,32 @@ describe('auth core', () => {
|
|
|
334
335
|
expect(refreshed.user.id).toBe(user.id);
|
|
335
336
|
});
|
|
336
337
|
|
|
338
|
+
test('keeps the refresh-session identity stable across a token refresh', async () => {
|
|
339
|
+
const user = await userFactory.create({emailVerifiedAt: new Date()});
|
|
340
|
+
const initial = await login({email: user.email, password: user.plainPassword});
|
|
341
|
+
const refreshed = await refreshAccessToken({refreshToken: initial.refreshToken});
|
|
342
|
+
const initialClaims = await verifyUserToken({
|
|
343
|
+
token: initial.token,
|
|
344
|
+
secret: userAccessTokenKey(),
|
|
345
|
+
});
|
|
346
|
+
const refreshedClaims = await verifyUserToken({
|
|
347
|
+
token: refreshed.token,
|
|
348
|
+
secret: userAccessTokenKey(),
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
expect(refreshedClaims.refreshSessionId).toBe(initialClaims.refreshSessionId);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
test('issues different refresh-session identities for separate sessions of one user', async () => {
|
|
355
|
+
const user = await userFactory.create({emailVerifiedAt: new Date()});
|
|
356
|
+
const first = await login({email: user.email, password: user.plainPassword});
|
|
357
|
+
const second = await login({email: user.email, password: user.plainPassword});
|
|
358
|
+
const firstClaims = await verifyUserToken({token: first.token, secret: userAccessTokenKey()});
|
|
359
|
+
const secondClaims = await verifyUserToken({token: second.token, secret: userAccessTokenKey()});
|
|
360
|
+
|
|
361
|
+
expect(secondClaims.refreshSessionId).not.toBe(firstClaims.refreshSessionId);
|
|
362
|
+
});
|
|
363
|
+
|
|
337
364
|
test('refreshAccessToken tolerates a concurrent reuse within the grace window', async () => {
|
|
338
365
|
const user = await userFactory.create({emailVerifiedAt: new Date()});
|
|
339
366
|
const loginResult = await login({email: user.email, password: user.plainPassword});
|
|
@@ -487,6 +514,21 @@ describe('auth core', () => {
|
|
|
487
514
|
expect(active).toBeUndefined();
|
|
488
515
|
});
|
|
489
516
|
|
|
517
|
+
test('logout revokes the active successor when presented a rotated refresh token', async () => {
|
|
518
|
+
const user = await userFactory.create({emailVerifiedAt: new Date()});
|
|
519
|
+
const loginResult = await login({email: user.email, password: user.plainPassword});
|
|
520
|
+
const refreshed = await refreshAccessToken({refreshToken: loginResult.refreshToken});
|
|
521
|
+
|
|
522
|
+
await logout({refreshToken: loginResult.refreshToken});
|
|
523
|
+
|
|
524
|
+
const successor = refreshed.refreshToken
|
|
525
|
+
? await refreshTokenDb.findActiveRefreshTokenByHash({
|
|
526
|
+
hashedToken: hashOpaqueToken(refreshed.refreshToken),
|
|
527
|
+
})
|
|
528
|
+
: undefined;
|
|
529
|
+
expect(successor).toBeUndefined();
|
|
530
|
+
});
|
|
531
|
+
|
|
490
532
|
test('getCurrentUser returns the user', async () => {
|
|
491
533
|
const user = await userFactory.create({emailVerifiedAt: new Date()});
|
|
492
534
|
|
package/src/core/auth.ts
CHANGED
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
createRefreshToken,
|
|
19
19
|
findActiveRefreshTokenByHash,
|
|
20
20
|
findRefreshTokenByHash,
|
|
21
|
-
|
|
21
|
+
revokeRefreshSession,
|
|
22
22
|
revokeRefreshTokensForUser,
|
|
23
23
|
rotateRefreshToken,
|
|
24
24
|
} from '#db/refresh-tokens.js';
|
|
@@ -73,6 +73,7 @@ function recordRefreshOutcome(outcome: AuthTokenRefreshOutcome): void {
|
|
|
73
73
|
async function signAccessToken(
|
|
74
74
|
user: User,
|
|
75
75
|
workspaces: WorkspacesInterModuleClient,
|
|
76
|
+
refreshSessionId: string,
|
|
76
77
|
): Promise<string> {
|
|
77
78
|
const memberships = await workspaces
|
|
78
79
|
.listMembershipsForTokenClaims({userId: user.id})
|
|
@@ -84,6 +85,7 @@ async function signAccessToken(
|
|
|
84
85
|
email: user.email,
|
|
85
86
|
name: user.name,
|
|
86
87
|
memberships: memberships.memberships,
|
|
88
|
+
refreshSessionId,
|
|
87
89
|
secret: userAccessTokenKey(),
|
|
88
90
|
expiresIn: config.AUTH_JWT_EXPIRES_IN,
|
|
89
91
|
});
|
|
@@ -95,14 +97,28 @@ function isWithinRotationGrace(refreshToken: RefreshToken): boolean {
|
|
|
95
97
|
return Date.now() - refreshToken.rotatedAt.getTime() <= graceMs;
|
|
96
98
|
}
|
|
97
99
|
|
|
98
|
-
async function createRefreshSession(
|
|
100
|
+
async function createRefreshSession(
|
|
101
|
+
user: User,
|
|
102
|
+
refreshSessionId: string,
|
|
103
|
+
): Promise<{refreshToken: string; refreshSessionId: string}> {
|
|
99
104
|
const refreshToken = generateOpaqueToken('refreshToken');
|
|
100
|
-
await createRefreshToken({
|
|
105
|
+
const session = await createRefreshToken({
|
|
106
|
+
sessionId: refreshSessionId,
|
|
101
107
|
userId: user.id,
|
|
102
108
|
hashedToken: hashOpaqueToken(refreshToken),
|
|
103
109
|
expiresAt: daysFromNow(config.AUTH_REFRESH_TOKEN_EXPIRES_IN_DAYS),
|
|
104
110
|
});
|
|
105
|
-
return refreshToken;
|
|
111
|
+
return {refreshToken, refreshSessionId: session.sessionId};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function createSessionTokens(
|
|
115
|
+
user: User,
|
|
116
|
+
workspaces: WorkspacesInterModuleClient,
|
|
117
|
+
): Promise<{token: string; refreshToken: string}> {
|
|
118
|
+
const refreshSessionId = crypto.randomUUID();
|
|
119
|
+
const token = await signAccessToken(user, workspaces, refreshSessionId);
|
|
120
|
+
const {refreshToken} = await createRefreshSession(user, refreshSessionId);
|
|
121
|
+
return {token, refreshToken};
|
|
106
122
|
}
|
|
107
123
|
|
|
108
124
|
function passwordResetLink(rawToken: string): string {
|
|
@@ -175,6 +191,7 @@ export async function signup(params: SignupParams & {sourceIp?: string}): Promis
|
|
|
175
191
|
email: existing.email,
|
|
176
192
|
purpose: PASSWORD_VERIFICATION_PURPOSE,
|
|
177
193
|
continuation: existing.id,
|
|
194
|
+
idempotencyKey: existing.id,
|
|
178
195
|
sourceIp: params.sourceIp ?? '0.0.0.0',
|
|
179
196
|
});
|
|
180
197
|
return {...existing, emailChallenge};
|
|
@@ -194,6 +211,7 @@ export async function signup(params: SignupParams & {sourceIp?: string}): Promis
|
|
|
194
211
|
email: user.email,
|
|
195
212
|
purpose: PASSWORD_VERIFICATION_PURPOSE,
|
|
196
213
|
continuation: user.id,
|
|
214
|
+
idempotencyKey: user.id,
|
|
197
215
|
sourceIp: params.sourceIp ?? '0.0.0.0',
|
|
198
216
|
});
|
|
199
217
|
|
|
@@ -271,8 +289,7 @@ export async function signupWithInvitation(
|
|
|
271
289
|
|
|
272
290
|
// Step 4: Issue session. signAccessToken reads memberships through the
|
|
273
291
|
// workspaces module API, so a successful accept is reflected in the JWT.
|
|
274
|
-
const token = await
|
|
275
|
-
const refreshToken = await createRefreshSession(user);
|
|
292
|
+
const {token, refreshToken} = await createSessionTokens(user, params.workspaces);
|
|
276
293
|
|
|
277
294
|
if (acceptError) {
|
|
278
295
|
return {token, refreshToken, user, membership, acceptError};
|
|
@@ -334,8 +351,7 @@ export async function login(params: LoginParams): Promise<LoginResult> {
|
|
|
334
351
|
throw new EmailNotVerifiedError();
|
|
335
352
|
}
|
|
336
353
|
|
|
337
|
-
const token = await
|
|
338
|
-
const refreshToken = await createRefreshSession(user);
|
|
354
|
+
const {token, refreshToken} = await createSessionTokens(user, params.workspaces);
|
|
339
355
|
|
|
340
356
|
return {token, refreshToken, user};
|
|
341
357
|
}
|
|
@@ -377,8 +393,7 @@ export async function createSessionForUser(
|
|
|
377
393
|
throw new InvalidCredentialsError();
|
|
378
394
|
}
|
|
379
395
|
|
|
380
|
-
const token = await
|
|
381
|
-
const refreshToken = await createRefreshSession(user);
|
|
396
|
+
const {token, refreshToken} = await createSessionTokens(user, params.workspaces);
|
|
382
397
|
|
|
383
398
|
return {token, refreshToken, user};
|
|
384
399
|
}
|
|
@@ -403,7 +418,7 @@ export async function refreshAccessToken(params: {
|
|
|
403
418
|
|
|
404
419
|
const user = await findUserById({id: current.userId});
|
|
405
420
|
if (user?.status !== 'active') {
|
|
406
|
-
await
|
|
421
|
+
await revokeRefreshSession({sessionId: current.sessionId, userId: current.userId});
|
|
407
422
|
recordRefreshOutcome('rejected');
|
|
408
423
|
throw new TokenInvalidError('Refresh token is invalid or expired');
|
|
409
424
|
}
|
|
@@ -412,7 +427,7 @@ export async function refreshAccessToken(params: {
|
|
|
412
427
|
// second tab); past it, reuse of a retired token means a compromised session.
|
|
413
428
|
if (current.rotatedAt) {
|
|
414
429
|
if (isWithinRotationGrace(current)) {
|
|
415
|
-
const token = await signAccessToken(user, params.workspaces);
|
|
430
|
+
const token = await signAccessToken(user, params.workspaces, current.sessionId);
|
|
416
431
|
recordRefreshOutcome('grace');
|
|
417
432
|
return {token, refreshToken: undefined, user};
|
|
418
433
|
}
|
|
@@ -436,12 +451,12 @@ export async function refreshAccessToken(params: {
|
|
|
436
451
|
recordRefreshOutcome('rejected');
|
|
437
452
|
throw new TokenInvalidError('Refresh token is invalid or expired');
|
|
438
453
|
}
|
|
439
|
-
const token = await signAccessToken(user, params.workspaces);
|
|
454
|
+
const token = await signAccessToken(user, params.workspaces, latest.sessionId);
|
|
440
455
|
recordRefreshOutcome('grace');
|
|
441
456
|
return {token, refreshToken: undefined, user};
|
|
442
457
|
}
|
|
443
458
|
|
|
444
|
-
const token = await signAccessToken(user, params.workspaces);
|
|
459
|
+
const token = await signAccessToken(user, params.workspaces, current.sessionId);
|
|
445
460
|
recordRefreshOutcome('rotated');
|
|
446
461
|
return {token, refreshToken: nextRefreshToken, user};
|
|
447
462
|
}
|
|
@@ -476,8 +491,7 @@ export async function confirmEmailVerification(params: {
|
|
|
476
491
|
throw new TokenInvalidError('Verification code is invalid or expired');
|
|
477
492
|
}
|
|
478
493
|
|
|
479
|
-
const token = await
|
|
480
|
-
const refreshToken = await createRefreshSession(verifiedUser);
|
|
494
|
+
const {token, refreshToken} = await createSessionTokens(verifiedUser, params.workspaces);
|
|
481
495
|
|
|
482
496
|
return {token, refreshToken, user: verifiedUser};
|
|
483
497
|
}
|
|
@@ -548,8 +562,7 @@ export async function confirmPasswordReset(params: {
|
|
|
548
562
|
|
|
549
563
|
await revokeRefreshTokensForUser({userId: consumed.userId});
|
|
550
564
|
|
|
551
|
-
const token = await
|
|
552
|
-
const refreshToken = await createRefreshSession(user);
|
|
565
|
+
const {token, refreshToken} = await createSessionTokens(user, params.workspaces);
|
|
553
566
|
|
|
554
567
|
return {token, refreshToken, user};
|
|
555
568
|
}
|
|
@@ -587,7 +600,11 @@ export async function changePassword(params: {
|
|
|
587
600
|
|
|
588
601
|
export async function logout(params: {refreshToken?: string | undefined}): Promise<void> {
|
|
589
602
|
if (!params.refreshToken) return;
|
|
590
|
-
await
|
|
603
|
+
const refreshToken = await findRefreshTokenByHash({
|
|
604
|
+
hashedToken: hashOpaqueToken(params.refreshToken),
|
|
605
|
+
});
|
|
606
|
+
if (!refreshToken) return;
|
|
607
|
+
await revokeRefreshSession({sessionId: refreshToken.sessionId, userId: refreshToken.userId});
|
|
591
608
|
}
|
|
592
609
|
|
|
593
610
|
export interface GetCurrentUserResult {
|
package/src/core/jwt.ts
CHANGED
|
@@ -12,6 +12,7 @@ export type TokenMembership = z.infer<typeof tokenMembershipSchema>;
|
|
|
12
12
|
|
|
13
13
|
export const userTokenClaimsSchema = z.object({
|
|
14
14
|
sub: z.string().uuid(),
|
|
15
|
+
refreshSessionId: z.string().uuid().optional(),
|
|
15
16
|
email: z.string().email(),
|
|
16
17
|
name: z.string().nullable().optional(),
|
|
17
18
|
memberships: z.array(tokenMembershipSchema),
|
|
@@ -22,6 +23,7 @@ export const userTokenClaimsSchema = z.object({
|
|
|
22
23
|
export type UserTokenClaims = z.infer<typeof userTokenClaimsSchema>;
|
|
23
24
|
|
|
24
25
|
export interface SignUserTokenParams {
|
|
26
|
+
refreshSessionId?: string | undefined;
|
|
25
27
|
userId: string;
|
|
26
28
|
email: string;
|
|
27
29
|
name?: string | null | undefined;
|
|
@@ -41,6 +43,7 @@ export async function signUserToken(params: SignUserTokenParams): Promise<string
|
|
|
41
43
|
email: params.email,
|
|
42
44
|
name: params.name ?? null,
|
|
43
45
|
memberships: params.memberships,
|
|
46
|
+
refreshSessionId: params.refreshSessionId,
|
|
44
47
|
},
|
|
45
48
|
secret: params.secret,
|
|
46
49
|
expiresIn: params.expiresIn,
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import {hashOpaqueToken} from '@shipfox/node-tokens';
|
|
2
2
|
import {
|
|
3
3
|
createRefreshToken,
|
|
4
|
+
findActiveRefreshSession,
|
|
4
5
|
findActiveRefreshTokenByHash,
|
|
5
6
|
findRefreshTokenByHash,
|
|
7
|
+
revokeRefreshSession,
|
|
6
8
|
revokeRefreshTokenByHash,
|
|
7
9
|
revokeRefreshTokensForUser,
|
|
8
10
|
rotateRefreshToken,
|
|
@@ -43,6 +45,24 @@ describe('refresh-tokens db', () => {
|
|
|
43
45
|
expect(found).toBeUndefined();
|
|
44
46
|
});
|
|
45
47
|
|
|
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
|
+
|
|
46
66
|
test('rotates a token once, creates the successor, and only the first caller wins', async () => {
|
|
47
67
|
const user = await createUser({email: emailFor('rt-rotate'), hashedPassword: 'h'});
|
|
48
68
|
const currentHashedToken = hashOpaqueToken(`current-${crypto.randomUUID()}`);
|
|
@@ -139,6 +159,28 @@ describe('refresh-tokens db', () => {
|
|
|
139
159
|
expect(found).toBeUndefined();
|
|
140
160
|
});
|
|
141
161
|
|
|
162
|
+
test('revokes every token in one refresh-session lineage', async () => {
|
|
163
|
+
const user = await createUser({email: emailFor('rt-revoke-session'), hashedPassword: 'h'});
|
|
164
|
+
const currentHashedToken = hashOpaqueToken(`current-${crypto.randomUUID()}`);
|
|
165
|
+
const nextHashedToken = hashOpaqueToken(`next-${crypto.randomUUID()}`);
|
|
166
|
+
const created = await createRefreshToken({
|
|
167
|
+
userId: user.id,
|
|
168
|
+
hashedToken: currentHashedToken,
|
|
169
|
+
expiresAt: new Date(Date.now() + 60_000),
|
|
170
|
+
});
|
|
171
|
+
await rotateRefreshToken({
|
|
172
|
+
id: created.id,
|
|
173
|
+
currentHashedToken,
|
|
174
|
+
nextHashedToken,
|
|
175
|
+
expiresAt: new Date(Date.now() + 60_000),
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
await revokeRefreshSession({sessionId: created.sessionId, userId: user.id});
|
|
179
|
+
|
|
180
|
+
expect(await findRefreshTokenByHash({hashedToken: currentHashedToken})).toBeUndefined();
|
|
181
|
+
expect(await findActiveRefreshTokenByHash({hashedToken: nextHashedToken})).toBeUndefined();
|
|
182
|
+
});
|
|
183
|
+
|
|
142
184
|
test('revokes all user refresh tokens except the selected session', async () => {
|
|
143
185
|
const user = await createUser({email: emailFor('rt-revoke-user'), hashedPassword: 'h'});
|
|
144
186
|
const keepHash = hashOpaqueToken(`keep-${crypto.randomUUID()}`);
|
package/src/db/refresh-tokens.ts
CHANGED
|
@@ -4,6 +4,7 @@ import {db} from './db.js';
|
|
|
4
4
|
import {refreshTokens, toRefreshToken} from './schema/refresh-tokens.js';
|
|
5
5
|
|
|
6
6
|
export interface CreateRefreshTokenParams {
|
|
7
|
+
sessionId?: string | undefined;
|
|
7
8
|
userId: string;
|
|
8
9
|
hashedToken: string;
|
|
9
10
|
expiresAt: Date;
|
|
@@ -13,6 +14,7 @@ export async function createRefreshToken(params: CreateRefreshTokenParams): Prom
|
|
|
13
14
|
const rows = await db()
|
|
14
15
|
.insert(refreshTokens)
|
|
15
16
|
.values({
|
|
17
|
+
sessionId: params.sessionId,
|
|
16
18
|
userId: params.userId,
|
|
17
19
|
hashedToken: params.hashedToken,
|
|
18
20
|
expiresAt: params.expiresAt,
|
|
@@ -51,6 +53,29 @@ export async function findActiveRefreshTokenByHash(params: {
|
|
|
51
53
|
return toRefreshToken(row);
|
|
52
54
|
}
|
|
53
55
|
|
|
56
|
+
export async function findActiveRefreshSession(params: {
|
|
57
|
+
sessionId: string;
|
|
58
|
+
userId: string;
|
|
59
|
+
}): Promise<RefreshToken | undefined> {
|
|
60
|
+
const rows = await db()
|
|
61
|
+
.select()
|
|
62
|
+
.from(refreshTokens)
|
|
63
|
+
.where(
|
|
64
|
+
and(
|
|
65
|
+
eq(refreshTokens.sessionId, params.sessionId),
|
|
66
|
+
eq(refreshTokens.userId, params.userId),
|
|
67
|
+
isNull(refreshTokens.revokedAt),
|
|
68
|
+
isNull(refreshTokens.rotatedAt),
|
|
69
|
+
gt(refreshTokens.expiresAt, sql`now()`),
|
|
70
|
+
),
|
|
71
|
+
)
|
|
72
|
+
.limit(1);
|
|
73
|
+
|
|
74
|
+
const row = rows[0];
|
|
75
|
+
if (!row) return undefined;
|
|
76
|
+
return toRefreshToken(row);
|
|
77
|
+
}
|
|
78
|
+
|
|
54
79
|
/**
|
|
55
80
|
* Looks up a non-revoked, non-expired token by hash, including ones already
|
|
56
81
|
* rotated. Rotation reads {@link RefreshToken.rotatedAt} to decide whether to
|
|
@@ -113,6 +138,7 @@ export async function rotateRefreshToken(params: {
|
|
|
113
138
|
const successorRows = await tx
|
|
114
139
|
.insert(refreshTokens)
|
|
115
140
|
.values({
|
|
141
|
+
sessionId: rotated.sessionId,
|
|
116
142
|
userId: rotated.userId,
|
|
117
143
|
hashedToken: params.nextHashedToken,
|
|
118
144
|
expiresAt: params.expiresAt,
|
|
@@ -132,6 +158,22 @@ export async function revokeRefreshTokenByHash(params: {hashedToken: string}): P
|
|
|
132
158
|
.where(and(eq(refreshTokens.hashedToken, params.hashedToken), isNull(refreshTokens.revokedAt)));
|
|
133
159
|
}
|
|
134
160
|
|
|
161
|
+
export async function revokeRefreshSession(params: {
|
|
162
|
+
sessionId: string;
|
|
163
|
+
userId: string;
|
|
164
|
+
}): Promise<void> {
|
|
165
|
+
await db()
|
|
166
|
+
.update(refreshTokens)
|
|
167
|
+
.set({revokedAt: sql`now()`, updatedAt: sql`now()`})
|
|
168
|
+
.where(
|
|
169
|
+
and(
|
|
170
|
+
eq(refreshTokens.sessionId, params.sessionId),
|
|
171
|
+
eq(refreshTokens.userId, params.userId),
|
|
172
|
+
isNull(refreshTokens.revokedAt),
|
|
173
|
+
),
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
135
177
|
export async function revokeRefreshTokensForUser(params: {
|
|
136
178
|
userId: string;
|
|
137
179
|
exceptRefreshTokenId?: string | undefined;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {uuidv7PrimaryKey} from '@shipfox/node-drizzle';
|
|
2
|
+
import {sql} from 'drizzle-orm';
|
|
2
3
|
import {index, text, timestamp, uniqueIndex, uuid} from 'drizzle-orm/pg-core';
|
|
3
4
|
import type {RefreshToken} from '#core/entities/refresh-token.js';
|
|
4
5
|
import {pgTable} from './common.js';
|
|
@@ -8,6 +9,7 @@ export const refreshTokens = pgTable(
|
|
|
8
9
|
'refresh_tokens',
|
|
9
10
|
{
|
|
10
11
|
id: uuidv7PrimaryKey(),
|
|
12
|
+
sessionId: uuid('session_id').notNull().default(sql`uuidv7()`),
|
|
11
13
|
userId: uuid('user_id')
|
|
12
14
|
.notNull()
|
|
13
15
|
.references(() => users.id, {onDelete: 'cascade'}),
|
|
@@ -21,6 +23,9 @@ export const refreshTokens = pgTable(
|
|
|
21
23
|
},
|
|
22
24
|
(table) => [
|
|
23
25
|
uniqueIndex('auth_refresh_tokens_hashed_token_unique').on(table.hashedToken),
|
|
26
|
+
uniqueIndex('auth_refresh_tokens_active_session_unique')
|
|
27
|
+
.on(table.userId, table.sessionId)
|
|
28
|
+
.where(sql`${table.revokedAt} IS NULL AND ${table.rotatedAt} IS NULL`),
|
|
24
29
|
index('auth_refresh_tokens_user_id_idx').on(table.userId),
|
|
25
30
|
],
|
|
26
31
|
);
|
|
@@ -31,6 +36,7 @@ export type RefreshTokenCreateDb = typeof refreshTokens.$inferInsert;
|
|
|
31
36
|
export function toRefreshToken(row: RefreshTokenDb): RefreshToken {
|
|
32
37
|
return {
|
|
33
38
|
id: row.id,
|
|
39
|
+
sessionId: row.sessionId,
|
|
34
40
|
userId: row.userId,
|
|
35
41
|
hashedToken: row.hashedToken,
|
|
36
42
|
expiresAt: row.expiresAt,
|
package/src/index.test.ts
CHANGED
|
@@ -26,6 +26,7 @@ describe('authModule', () => {
|
|
|
26
26
|
const authModule = createAuthModule({
|
|
27
27
|
workspaces: {
|
|
28
28
|
listMembershipsForTokenClaims: vi.fn(),
|
|
29
|
+
getWorkspaceCreator: vi.fn(),
|
|
29
30
|
preflightInvitationAcceptance: vi.fn(),
|
|
30
31
|
acceptInvitation: vi.fn(),
|
|
31
32
|
requireActiveMembership: vi.fn(),
|
package/src/index.ts
CHANGED
|
@@ -44,7 +44,13 @@ export {
|
|
|
44
44
|
issueRunnerSessionToken,
|
|
45
45
|
verifyRunnerSessionToken,
|
|
46
46
|
} from '#core/runner-session-token.js';
|
|
47
|
-
export {
|
|
47
|
+
export {
|
|
48
|
+
type AuthenticatedSessionContext,
|
|
49
|
+
createJwtAuthMethod,
|
|
50
|
+
getAuthenticatedSessionContext,
|
|
51
|
+
type RefreshSessionId,
|
|
52
|
+
type UserId,
|
|
53
|
+
} from '#presentation/auth/jwt-auth.js';
|
|
48
54
|
export {createLeaseTokenAuthMethod} from '#presentation/auth/lease-token-auth.js';
|
|
49
55
|
export {
|
|
50
56
|
authCookiePlugin,
|
|
@@ -63,7 +69,7 @@ export function createAuthModule({
|
|
|
63
69
|
}): ShipfoxModule {
|
|
64
70
|
return {
|
|
65
71
|
name: 'auth',
|
|
66
|
-
database: {db, migrationsPath},
|
|
72
|
+
database: {db, migrationsPath, databaseNamespace: 'auth'},
|
|
67
73
|
auth: [createJwtAuthMethod(), createLeaseTokenAuthMethod(), createRunnerSessionAuthMethod()],
|
|
68
74
|
loginMethods: passwordLoginMethods(config.AUTH_PASSWORD_ENABLED),
|
|
69
75
|
routes: [buildAuthRoutes(config.AUTH_PASSWORD_ENABLED, workspaces)],
|