@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
|
@@ -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
|
|
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(),
|
package/test/globalSetup.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import './env.js';
|
|
2
|
-
import {
|
|
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
|
-
|
|
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
|
|
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(),
|