@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.
Files changed (51) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +73 -0
  3. package/README.md +29 -8
  4. package/dist/core/auth.d.ts.map +1 -1
  5. package/dist/core/auth.js +38 -21
  6. package/dist/core/auth.js.map +1 -1
  7. package/dist/core/entities/refresh-token.d.ts +1 -0
  8. package/dist/core/entities/refresh-token.d.ts.map +1 -1
  9. package/dist/core/entities/refresh-token.js.map +1 -1
  10. package/dist/core/jwt.d.ts +2 -0
  11. package/dist/core/jwt.d.ts.map +1 -1
  12. package/dist/core/jwt.js +3 -1
  13. package/dist/core/jwt.js.map +1 -1
  14. package/dist/db/db.d.ts +34 -0
  15. package/dist/db/db.d.ts.map +1 -1
  16. package/dist/db/refresh-tokens.d.ts +9 -0
  17. package/dist/db/refresh-tokens.d.ts.map +1 -1
  18. package/dist/db/refresh-tokens.js +14 -0
  19. package/dist/db/refresh-tokens.js.map +1 -1
  20. package/dist/db/schema/refresh-tokens.d.ts +17 -0
  21. package/dist/db/schema/refresh-tokens.d.ts.map +1 -1
  22. package/dist/db/schema/refresh-tokens.js +4 -0
  23. package/dist/db/schema/refresh-tokens.js.map +1 -1
  24. package/dist/index.d.ts +1 -1
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +3 -2
  27. package/dist/index.js.map +1 -1
  28. package/dist/presentation/auth/jwt-auth.d.ts +14 -1
  29. package/dist/presentation/auth/jwt-auth.d.ts.map +1 -1
  30. package/dist/presentation/auth/jwt-auth.js +34 -0
  31. package/dist/presentation/auth/jwt-auth.js.map +1 -1
  32. package/dist/tsconfig.test.tsbuildinfo +1 -1
  33. package/drizzle/0000_initial.sql +2 -0
  34. package/drizzle/meta/0000_snapshot.json +29 -0
  35. package/package.json +22 -23
  36. package/src/core/auth.test.ts +42 -0
  37. package/src/core/auth.ts +36 -19
  38. package/src/core/entities/refresh-token.ts +1 -0
  39. package/src/core/jwt.ts +3 -0
  40. package/src/db/refresh-tokens.test.ts +42 -0
  41. package/src/db/refresh-tokens.ts +42 -0
  42. package/src/db/schema/refresh-tokens.ts +6 -0
  43. package/src/index.test.ts +1 -0
  44. package/src/index.ts +8 -2
  45. package/src/presentation/auth/jwt-auth.test.ts +102 -1
  46. package/src/presentation/auth/jwt-auth.ts +55 -1
  47. package/src/presentation/e2eRoutes/index.test.ts +1 -0
  48. package/src/presentation/routes/index.test.ts +1 -0
  49. package/test/globalSetup.ts +3 -10
  50. package/test/routes.ts +1 -0
  51. package/tsconfig.build.tsbuildinfo +1 -1
@@ -1,10 +1,16 @@
1
1
  import {userAccessTokenKey} from '@shipfox/node-auth-root-key';
2
+ import {hashOpaqueToken} from '@shipfox/node-tokens';
2
3
  import type {FastifyInstance} from 'fastify';
3
4
  import Fastify from 'fastify';
4
5
  import {serializerCompiler, validatorCompiler} from 'fastify-type-provider-zod';
5
6
  import {signUserToken} from '#core/jwt.js';
7
+ import {
8
+ createRefreshToken,
9
+ revokeRefreshTokenByHash,
10
+ rotateRefreshToken,
11
+ } from '#db/refresh-tokens.js';
6
12
  import {createUser, findUserById} from '#db/users.js';
7
- import {createJwtAuthMethod, getClientContext} from './jwt-auth.js';
13
+ import {createJwtAuthMethod, getAuthenticatedSessionContext, getClientContext} from './jwt-auth.js';
8
14
 
9
15
  const SECRET = userAccessTokenKey();
10
16
 
@@ -28,6 +34,7 @@ describe('jwt-auth', () => {
28
34
  app.get('/protected', (request) => {
29
35
  return {client: getClientContext(request)};
30
36
  });
37
+ app.get('/session', async (request) => await getAuthenticatedSessionContext(request));
31
38
  await app.ready();
32
39
  });
33
40
 
@@ -131,6 +138,100 @@ describe('jwt-auth', () => {
131
138
  expect(res.json().client.email).toBe(email);
132
139
  });
133
140
 
141
+ test('resolves the active refresh-session identity from an authenticated request', async () => {
142
+ const user = await createUser({email: emailFor('jwt-session'), hashedPassword: 'h'});
143
+ const rawRefreshToken = `refresh-${crypto.randomUUID()}`;
144
+ const refreshToken = await createRefreshToken({
145
+ userId: user.id,
146
+ hashedToken: hashOpaqueToken(rawRefreshToken),
147
+ expiresAt: new Date(Date.now() + 60_000),
148
+ });
149
+ const initialToken = await signUserToken({
150
+ userId: user.id,
151
+ email: user.email,
152
+ memberships: [],
153
+ refreshSessionId: refreshToken.sessionId,
154
+ secret: SECRET,
155
+ expiresIn: '7d',
156
+ });
157
+ await rotateRefreshToken({
158
+ id: refreshToken.id,
159
+ currentHashedToken: hashOpaqueToken(rawRefreshToken),
160
+ nextHashedToken: hashOpaqueToken(`refresh-${crypto.randomUUID()}`),
161
+ expiresAt: new Date(Date.now() + 60_000),
162
+ });
163
+ const refreshedToken = await signUserToken({
164
+ userId: user.id,
165
+ email: user.email,
166
+ memberships: [],
167
+ refreshSessionId: refreshToken.sessionId,
168
+ secret: SECRET,
169
+ expiresIn: '7d',
170
+ });
171
+
172
+ const initial = await app.inject({
173
+ method: 'GET',
174
+ url: '/session',
175
+ headers: {authorization: `Bearer ${initialToken}`},
176
+ });
177
+ const refreshed = await app.inject({
178
+ method: 'GET',
179
+ url: '/session',
180
+ headers: {authorization: `Bearer ${refreshedToken}`},
181
+ });
182
+
183
+ expect(initial.statusCode).toBe(200);
184
+ expect(refreshed.statusCode).toBe(200);
185
+ expect(initial.json()).toEqual({userId: user.id, refreshSessionId: refreshToken.sessionId});
186
+ expect(refreshed.json()).toEqual(initial.json());
187
+ });
188
+
189
+ test('rejects an authenticated token after its refresh session is revoked', async () => {
190
+ const user = await createUser({email: emailFor('jwt-session-revoked'), hashedPassword: 'h'});
191
+ const rawRefreshToken = `refresh-${crypto.randomUUID()}`;
192
+ const refreshToken = await createRefreshToken({
193
+ userId: user.id,
194
+ hashedToken: hashOpaqueToken(rawRefreshToken),
195
+ expiresAt: new Date(Date.now() + 60_000),
196
+ });
197
+ const token = await signUserToken({
198
+ userId: user.id,
199
+ email: user.email,
200
+ memberships: [],
201
+ refreshSessionId: refreshToken.sessionId,
202
+ secret: SECRET,
203
+ expiresIn: '7d',
204
+ });
205
+ await revokeRefreshTokenByHash({hashedToken: hashOpaqueToken(rawRefreshToken)});
206
+
207
+ const res = await app.inject({
208
+ method: 'GET',
209
+ url: '/session',
210
+ headers: {authorization: `Bearer ${token}`},
211
+ });
212
+
213
+ expect(res.statusCode).toBe(401);
214
+ });
215
+
216
+ test('rejects an authenticated token without a refresh-session claim', async () => {
217
+ const user = await createUser({email: emailFor('jwt-old-format'), hashedPassword: 'h'});
218
+ const token = await signUserToken({
219
+ userId: user.id,
220
+ email: user.email,
221
+ memberships: [],
222
+ secret: SECRET,
223
+ expiresIn: '7d',
224
+ });
225
+
226
+ const res = await app.inject({
227
+ method: 'GET',
228
+ url: '/session',
229
+ headers: {authorization: `Bearer ${token}`},
230
+ });
231
+
232
+ expect(res.statusCode).toBe(401);
233
+ });
234
+
134
235
  test('helpers remain available', () => {
135
236
  expect(typeof findUserById).toBe('function');
136
237
  });
@@ -6,13 +6,27 @@ import {
6
6
  type UserContext,
7
7
  } from '@shipfox/api-auth-context';
8
8
  import {userAccessTokenKey} from '@shipfox/node-auth-root-key';
9
- import type {AuthMethod} from '@shipfox/node-fastify';
9
+ import {type AuthMethod, ClientError} from '@shipfox/node-fastify';
10
10
  import type {FastifyRequest} from 'fastify';
11
+ import type {RefreshToken} from '#core/entities/refresh-token.js';
12
+ import type {User} from '#core/entities/user.js';
13
+ import type {UserTokenClaims} from '#core/jwt.js';
11
14
  import {verifyUserToken} from '#core/jwt.js';
15
+ import {findActiveRefreshSession} from '#db/refresh-tokens.js';
12
16
  import {createBearerTokenAuthMethod} from './bearer-token-auth.js';
13
17
 
18
+ const AUTHENTICATED_SESSION_CONTEXT_KEY = Symbol.for('@shipfox/api-auth/session');
19
+
14
20
  export type ClientContext = UserContext;
15
21
 
22
+ export type UserId = User['id'];
23
+ export type RefreshSessionId = RefreshToken['sessionId'];
24
+
25
+ export type AuthenticatedSessionContext = {
26
+ userId: UserId;
27
+ refreshSessionId: RefreshSessionId;
28
+ };
29
+
16
30
  export interface CreateJwtAuthMethodOptions {
17
31
  secret: string;
18
32
  }
@@ -21,6 +35,45 @@ export function getClientContext(request: FastifyRequest): ClientContext | null
21
35
  return getUserContext(request);
22
36
  }
23
37
 
38
+ function setAuthenticatedSessionContext(request: FastifyRequest, claims: UserTokenClaims): void {
39
+ (request as unknown as Record<symbol, unknown>)[AUTHENTICATED_SESSION_CONTEXT_KEY] = {
40
+ userId: claims.sub,
41
+ refreshSessionId: claims.refreshSessionId,
42
+ };
43
+ }
44
+
45
+ function getAuthenticatedSessionTokenContext(request: FastifyRequest): {
46
+ userId: UserId;
47
+ refreshSessionId: RefreshSessionId | undefined;
48
+ } | null {
49
+ return (
50
+ ((request as unknown as Record<symbol, unknown>)[AUTHENTICATED_SESSION_CONTEXT_KEY] as
51
+ | {userId: UserId; refreshSessionId: RefreshSessionId | undefined}
52
+ | undefined) ?? null
53
+ );
54
+ }
55
+
56
+ /**
57
+ * Resolves the active refresh session backing an authenticated user request.
58
+ * The session check intentionally happens only for consumers that need browser-session binding.
59
+ */
60
+ export async function getAuthenticatedSessionContext(
61
+ request: FastifyRequest,
62
+ ): Promise<AuthenticatedSessionContext> {
63
+ const client = getClientContext(request);
64
+ const token = getAuthenticatedSessionTokenContext(request);
65
+ if (!client || !token || token.userId !== client.userId || !token.refreshSessionId)
66
+ throw new ClientError('Invalid or expired token', 'unauthorized', {status: 401});
67
+
68
+ const session = await findActiveRefreshSession({
69
+ sessionId: token.refreshSessionId,
70
+ userId: client.userId,
71
+ });
72
+ if (!session) throw new ClientError('Invalid or expired token', 'unauthorized', {status: 401});
73
+
74
+ return {userId: client.userId, refreshSessionId: session.sessionId};
75
+ }
76
+
24
77
  export function createJwtAuthMethod(): AuthMethod {
25
78
  return createBearerTokenAuthMethod({
26
79
  name: AUTH_USER,
@@ -34,6 +87,7 @@ export function createJwtAuthMethod(): AuthMethod {
34
87
  memberships: claims.memberships,
35
88
  });
36
89
  setUserContext(request, clientContext);
90
+ setAuthenticatedSessionContext(request, claims);
37
91
  },
38
92
  });
39
93
  }
@@ -10,6 +10,7 @@ describe('auth E2E routes', () => {
10
10
  routes: [
11
11
  createAuthE2eRoutes({
12
12
  listMembershipsForTokenClaims: vi.fn(() => Promise.resolve({memberships: []})),
13
+ getWorkspaceCreator: vi.fn(),
13
14
  preflightInvitationAcceptance: vi.fn(),
14
15
  acceptInvitation: vi.fn(),
15
16
  requireActiveMembership: vi.fn(),
@@ -4,6 +4,7 @@ import {buildAuthRoutes} from './index.js';
4
4
 
5
5
  const workspaces = {
6
6
  listMembershipsForTokenClaims: vi.fn(() => Promise.resolve({memberships: []})),
7
+ getWorkspaceCreator: vi.fn(),
7
8
  preflightInvitationAcceptance: vi.fn(),
8
9
  acceptInvitation: vi.fn(),
9
10
  requireActiveMembership: vi.fn(),
@@ -1,5 +1,5 @@
1
1
  import './env.js';
2
- import {emailChallengesModule} from '@shipfox/api-email-challenges';
2
+ import {initializeEmailChallengesForTests} from '@shipfox/api-email-challenges/test';
3
3
  import {runMigrations} from '@shipfox/node-drizzle';
4
4
  import {closePostgresClient, createPostgresClient} from '@shipfox/node-postgres';
5
5
  import {sql} from 'drizzle-orm';
@@ -10,16 +10,9 @@ export async function setup() {
10
10
  createPostgresClient();
11
11
 
12
12
  await runMigrations(db(), migrationsPath, '__drizzle_migrations_auth');
13
- const emailChallengesDatabase = emailChallengesModule.database;
14
- const emailChallengesMigrations =
15
- emailChallengesDatabase && !Array.isArray(emailChallengesDatabase)
16
- ? emailChallengesDatabase.migrationsPath
17
- : undefined;
18
- if (!emailChallengesMigrations)
19
- throw new Error('Email challenges module has no database migrations');
20
- await runMigrations(db(), emailChallengesMigrations, '__drizzle_migrations_email_challenges');
13
+ await initializeEmailChallengesForTests();
21
14
  await db().execute(
22
- sql`TRUNCATE email_challenges_challenges, email_challenges_send_limits, auth_outbox, auth_password_resets, auth_rate_limits, auth_refresh_tokens, auth_users CASCADE`,
15
+ sql`TRUNCATE auth_outbox, auth_password_resets, auth_rate_limits, auth_refresh_tokens, auth_users CASCADE`,
23
16
  );
24
17
 
25
18
  closeDb();
package/test/routes.ts CHANGED
@@ -36,6 +36,7 @@ const testConfig = vi.hoisted(
36
36
  const workspaceTestDoubles = vi.hoisted(() => {
37
37
  return {
38
38
  acceptInvitation: vi.fn(),
39
+ getWorkspaceCreator: vi.fn(),
39
40
  preflightInvitationAcceptance: vi.fn(),
40
41
  listMembershipsForTokenClaims: vi.fn(() => Promise.resolve({memberships: []})),
41
42
  requireActiveMembership: vi.fn(),