@shipfox/api-auth 10.0.0 → 10.2.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 +29 -0
- package/README.md +20 -1
- package/dist/core/administration.d.ts +18 -1
- package/dist/core/administration.d.ts.map +1 -1
- package/dist/core/administration.js +111 -15
- package/dist/core/administration.js.map +1 -1
- package/dist/core/auth.d.ts.map +1 -1
- package/dist/core/auth.js +11 -2
- package/dist/core/auth.js.map +1 -1
- package/dist/db/admin-command.d.ts +18 -0
- package/dist/db/admin-command.d.ts.map +1 -0
- package/dist/db/admin-command.js +37 -0
- package/dist/db/admin-command.js.map +1 -0
- package/dist/db/admin-grants.d.ts +9 -0
- package/dist/db/admin-grants.d.ts.map +1 -1
- package/dist/db/admin-grants.js +31 -40
- package/dist/db/admin-grants.js.map +1 -1
- package/dist/db/admin-user-moderation.d.ts +29 -0
- package/dist/db/admin-user-moderation.d.ts.map +1 -0
- package/dist/db/admin-user-moderation.js +156 -0
- package/dist/db/admin-user-moderation.js.map +1 -0
- package/dist/db/admin-user-summary.d.ts +14 -0
- package/dist/db/admin-user-summary.d.ts.map +1 -0
- package/dist/db/admin-user-summary.js +31 -0
- package/dist/db/admin-user-summary.js.map +1 -0
- package/dist/db/admin-users.d.ts +2 -11
- package/dist/db/admin-users.d.ts.map +1 -1
- package/dist/db/admin-users.js +2 -27
- package/dist/db/admin-users.js.map +1 -1
- package/dist/db/refresh-tokens.d.ts +6 -0
- package/dist/db/refresh-tokens.d.ts.map +1 -1
- package/dist/db/refresh-tokens.js +31 -0
- package/dist/db/refresh-tokens.js.map +1 -1
- package/dist/db/schema/admin-command-results.d.ts +19 -2
- package/dist/db/schema/admin-command-results.d.ts.map +1 -1
- package/dist/db/schema/admin-command-results.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/metrics/instance.d.ts +1 -1
- package/dist/metrics/instance.d.ts.map +1 -1
- package/dist/metrics/instance.js.map +1 -1
- package/dist/presentation/auth/bearer-token-auth.d.ts +1 -0
- package/dist/presentation/auth/bearer-token-auth.d.ts.map +1 -1
- package/dist/presentation/auth/bearer-token-auth.js +4 -3
- package/dist/presentation/auth/bearer-token-auth.js.map +1 -1
- package/dist/presentation/auth/jwt-auth.d.ts.map +1 -1
- package/dist/presentation/auth/jwt-auth.js +32 -4
- package/dist/presentation/auth/jwt-auth.js.map +1 -1
- package/dist/presentation/routes/administration.d.ts +1 -0
- package/dist/presentation/routes/administration.d.ts.map +1 -1
- package/dist/presentation/routes/administration.js +116 -3
- package/dist/presentation/routes/administration.js.map +1 -1
- package/dist/presentation/routes/rate-limit.d.ts.map +1 -1
- package/dist/presentation/routes/rate-limit.js +6 -0
- package/dist/presentation/routes/rate-limit.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +4 -4
- package/src/core/administration.ts +142 -15
- package/src/core/auth.ts +9 -2
- package/src/db/admin-command.ts +85 -0
- package/src/db/admin-grants.test.ts +17 -1
- package/src/db/admin-grants.ts +52 -92
- package/src/db/admin-user-moderation.ts +230 -0
- package/src/db/admin-user-summary.ts +51 -0
- package/src/db/admin-users.ts +4 -49
- package/src/db/refresh-tokens.ts +54 -0
- package/src/db/schema/admin-command-results.ts +23 -2
- package/src/index.test.ts +4 -1
- package/src/index.ts +5 -0
- package/src/metrics/instance.ts +6 -1
- package/src/presentation/auth/bearer-token-auth.test.ts +17 -0
- package/src/presentation/auth/bearer-token-auth.ts +9 -2
- package/src/presentation/auth/jwt-auth.test.ts +1 -1
- package/src/presentation/auth/jwt-auth.ts +30 -1
- package/src/presentation/routes/administration.test.ts +373 -2
- package/src/presentation/routes/administration.ts +106 -1
- package/src/presentation/routes/rate-limit.ts +3 -0
- package/test/routes.ts +7 -1
- package/tsconfig.build.tsbuildinfo +1 -1
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import type {AdministrationActionEvent} from '@shipfox/api-common-dto';
|
|
2
|
+
import {and, eq, gt, isNull, sql} from 'drizzle-orm';
|
|
3
|
+
import {hasMinimumAdminRole, highestAdminRole} from '#core/admin-role-model.js';
|
|
4
|
+
import type {AdministratorUserSummary} from '#core/entities/administrator-read-model.js';
|
|
5
|
+
import {AdminRoleRequiredError, LastAdminOwnerError, UserNotFoundError} from '#core/errors.js';
|
|
6
|
+
import {
|
|
7
|
+
findAdminCommandResult,
|
|
8
|
+
lockAdminCommand,
|
|
9
|
+
lockAdminOwnerGrants,
|
|
10
|
+
storeAdminCommandResult,
|
|
11
|
+
type Tx,
|
|
12
|
+
writeAdminAction,
|
|
13
|
+
} from './admin-command.js';
|
|
14
|
+
import {findAdministratorUserSummary} from './admin-user-summary.js';
|
|
15
|
+
import {db} from './db.js';
|
|
16
|
+
import {lockUserSessionMutations} from './refresh-tokens.js';
|
|
17
|
+
import type {
|
|
18
|
+
StoredAdministratorUserSummary,
|
|
19
|
+
StoredAdminUserModerationResult,
|
|
20
|
+
} from './schema/admin-command-results.js';
|
|
21
|
+
import {adminGrants} from './schema/admin-grants.js';
|
|
22
|
+
import {refreshTokens} from './schema/refresh-tokens.js';
|
|
23
|
+
import {users} from './schema/users.js';
|
|
24
|
+
|
|
25
|
+
interface UserModerationCommandParams {
|
|
26
|
+
actorId: string;
|
|
27
|
+
userId: string;
|
|
28
|
+
idempotencyKeyFingerprint: string;
|
|
29
|
+
requestFingerprint: string;
|
|
30
|
+
event: AdministrationActionEvent;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function toStoredUserSummary(user: AdministratorUserSummary): StoredAdministratorUserSummary {
|
|
34
|
+
return {
|
|
35
|
+
id: user.id,
|
|
36
|
+
email: user.email,
|
|
37
|
+
name: user.name,
|
|
38
|
+
emailVerifiedAt: user.emailVerifiedAt?.toISOString() ?? null,
|
|
39
|
+
status: user.status,
|
|
40
|
+
createdAt: user.createdAt.toISOString(),
|
|
41
|
+
adminRole: user.adminRole,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function findUserSummary(tx: Tx, userId: string): Promise<StoredAdministratorUserSummary> {
|
|
46
|
+
const user = await findAdministratorUserSummary(tx, {id: userId});
|
|
47
|
+
if (!user) throw new Error('User summary query returned no rows');
|
|
48
|
+
return toStoredUserSummary(user);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function fromStoredResult(result: StoredAdminUserModerationResult) {
|
|
52
|
+
return {
|
|
53
|
+
user: {
|
|
54
|
+
id: result.user.id,
|
|
55
|
+
email: result.user.email,
|
|
56
|
+
name: result.user.name,
|
|
57
|
+
emailVerifiedAt: result.user.emailVerifiedAt ? new Date(result.user.emailVerifiedAt) : null,
|
|
58
|
+
status: result.user.status,
|
|
59
|
+
createdAt: new Date(result.user.createdAt),
|
|
60
|
+
adminRole: result.user.adminRole,
|
|
61
|
+
},
|
|
62
|
+
correlationId: result.correlationId,
|
|
63
|
+
sessionsRevoked: result.sessionsRevoked,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function findCommandResult(
|
|
68
|
+
tx: Tx,
|
|
69
|
+
params: Pick<
|
|
70
|
+
UserModerationCommandParams,
|
|
71
|
+
'actorId' | 'idempotencyKeyFingerprint' | 'requestFingerprint'
|
|
72
|
+
> & {command: string},
|
|
73
|
+
) {
|
|
74
|
+
const result = await findAdminCommandResult(tx, params);
|
|
75
|
+
if (!result) return undefined;
|
|
76
|
+
if (!('userModeration' in result.result)) {
|
|
77
|
+
throw new Error('Administrator command result has an unexpected shape');
|
|
78
|
+
}
|
|
79
|
+
return fromStoredResult(result.result.userModeration);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function storeCommandResult(
|
|
83
|
+
tx: Tx,
|
|
84
|
+
params: UserModerationCommandParams,
|
|
85
|
+
result: StoredAdminUserModerationResult,
|
|
86
|
+
): Promise<void> {
|
|
87
|
+
await storeAdminCommandResult(tx, params, {userModeration: result});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function readTargetUserForUpdate(tx: Tx, userId: string) {
|
|
91
|
+
const rows = await tx
|
|
92
|
+
.select({id: users.id, status: users.status})
|
|
93
|
+
.from(users)
|
|
94
|
+
.where(eq(users.id, userId))
|
|
95
|
+
.limit(1)
|
|
96
|
+
.for('update');
|
|
97
|
+
const user = rows[0];
|
|
98
|
+
if (!user || user.status === 'deleted') throw new UserNotFoundError(userId);
|
|
99
|
+
return user;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function requireActiveAdminOperator(tx: Tx, actorId: string): Promise<void> {
|
|
103
|
+
const actorRows = await tx
|
|
104
|
+
.select({status: users.status})
|
|
105
|
+
.from(users)
|
|
106
|
+
.where(eq(users.id, actorId))
|
|
107
|
+
.limit(1);
|
|
108
|
+
const actor = actorRows[0];
|
|
109
|
+
const grants = await tx
|
|
110
|
+
.select({role: adminGrants.role})
|
|
111
|
+
.from(adminGrants)
|
|
112
|
+
.where(and(eq(adminGrants.userId, actorId), isNull(adminGrants.revokedAt)));
|
|
113
|
+
const role = highestAdminRole(grants.map(({role}) => role));
|
|
114
|
+
|
|
115
|
+
if (actor?.status !== 'active' || !role || !hasMinimumAdminRole(role, 'admin-operator')) {
|
|
116
|
+
throw new AdminRoleRequiredError('admin-operator');
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function revokeActiveSessions(tx: Tx, userId: string): Promise<number> {
|
|
121
|
+
const revoked = await tx
|
|
122
|
+
.update(refreshTokens)
|
|
123
|
+
.set({revokedAt: sql`now()`, updatedAt: sql`now()`})
|
|
124
|
+
.where(
|
|
125
|
+
and(
|
|
126
|
+
eq(refreshTokens.userId, userId),
|
|
127
|
+
isNull(refreshTokens.revokedAt),
|
|
128
|
+
gt(refreshTokens.expiresAt, sql`now()`),
|
|
129
|
+
),
|
|
130
|
+
)
|
|
131
|
+
.returning({sessionId: refreshTokens.sessionId});
|
|
132
|
+
|
|
133
|
+
return new Set(revoked.map(({sessionId}) => sessionId)).size;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function executeUserModerationCommand(
|
|
137
|
+
params: UserModerationCommandParams & {
|
|
138
|
+
operation: 'suspend' | 'reactivate' | 'revoke-sessions';
|
|
139
|
+
},
|
|
140
|
+
) {
|
|
141
|
+
return await db().transaction(async (tx) => {
|
|
142
|
+
await lockAdminCommand(tx, params);
|
|
143
|
+
if (params.operation === 'suspend') await lockAdminOwnerGrants(tx);
|
|
144
|
+
|
|
145
|
+
const existing = await findCommandResult(tx, {
|
|
146
|
+
...params,
|
|
147
|
+
command: params.event.command,
|
|
148
|
+
});
|
|
149
|
+
if (existing) return existing;
|
|
150
|
+
|
|
151
|
+
await lockUserSessionMutations(tx, params.userId);
|
|
152
|
+
const user = await readTargetUserForUpdate(tx, params.userId);
|
|
153
|
+
await requireActiveAdminOperator(tx, params.actorId);
|
|
154
|
+
let sessionsRevoked = 0;
|
|
155
|
+
|
|
156
|
+
if (params.operation === 'suspend') {
|
|
157
|
+
if (user.status === 'active') {
|
|
158
|
+
const activeOwners = await tx
|
|
159
|
+
.select({id: adminGrants.id})
|
|
160
|
+
.from(adminGrants)
|
|
161
|
+
.innerJoin(users, eq(adminGrants.userId, users.id))
|
|
162
|
+
.where(
|
|
163
|
+
and(
|
|
164
|
+
eq(adminGrants.role, 'admin-owner'),
|
|
165
|
+
isNull(adminGrants.revokedAt),
|
|
166
|
+
eq(users.status, 'active'),
|
|
167
|
+
),
|
|
168
|
+
)
|
|
169
|
+
.limit(2);
|
|
170
|
+
const targetIsActiveOwner = await tx
|
|
171
|
+
.select({id: adminGrants.id})
|
|
172
|
+
.from(adminGrants)
|
|
173
|
+
.where(
|
|
174
|
+
and(
|
|
175
|
+
eq(adminGrants.userId, user.id),
|
|
176
|
+
eq(adminGrants.role, 'admin-owner'),
|
|
177
|
+
isNull(adminGrants.revokedAt),
|
|
178
|
+
),
|
|
179
|
+
)
|
|
180
|
+
.limit(1);
|
|
181
|
+
if (targetIsActiveOwner.length > 0 && activeOwners.length <= 1) {
|
|
182
|
+
throw new LastAdminOwnerError();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
await tx
|
|
186
|
+
.update(users)
|
|
187
|
+
.set({status: 'suspended', updatedAt: sql`now()`})
|
|
188
|
+
.where(eq(users.id, user.id));
|
|
189
|
+
}
|
|
190
|
+
sessionsRevoked = await revokeActiveSessions(tx, user.id);
|
|
191
|
+
} else if (params.operation === 'reactivate' && user.status === 'suspended') {
|
|
192
|
+
await tx
|
|
193
|
+
.update(users)
|
|
194
|
+
.set({status: 'active', updatedAt: sql`now()`})
|
|
195
|
+
.where(eq(users.id, user.id));
|
|
196
|
+
} else if (params.operation === 'revoke-sessions') {
|
|
197
|
+
sessionsRevoked = await revokeActiveSessions(tx, user.id);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const summary = await findUserSummary(tx, user.id);
|
|
201
|
+
const result: StoredAdminUserModerationResult = {
|
|
202
|
+
user: summary,
|
|
203
|
+
correlationId: params.event.correlationId,
|
|
204
|
+
sessionsRevoked,
|
|
205
|
+
};
|
|
206
|
+
await writeAdminAction(tx, params.event);
|
|
207
|
+
await storeCommandResult(tx, params, result);
|
|
208
|
+
return fromStoredResult(result);
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export type UserModerationResult = Awaited<ReturnType<typeof executeUserModerationCommand>>;
|
|
213
|
+
|
|
214
|
+
export async function suspendUserWithAudit(
|
|
215
|
+
params: UserModerationCommandParams,
|
|
216
|
+
): Promise<UserModerationResult> {
|
|
217
|
+
return await executeUserModerationCommand({...params, operation: 'suspend'});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export async function reactivateUserWithAudit(
|
|
221
|
+
params: UserModerationCommandParams,
|
|
222
|
+
): Promise<UserModerationResult> {
|
|
223
|
+
return await executeUserModerationCommand({...params, operation: 'reactivate'});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export async function revokeUserSessionsWithAudit(
|
|
227
|
+
params: UserModerationCommandParams,
|
|
228
|
+
): Promise<UserModerationResult> {
|
|
229
|
+
return await executeUserModerationCommand({...params, operation: 'revoke-sessions'});
|
|
230
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import {and, eq, isNull} from 'drizzle-orm';
|
|
2
|
+
import {highestAdminRole} from '#core/admin-role-model.js';
|
|
3
|
+
import type {AdministratorUserSummary} from '#core/entities/administrator-read-model.js';
|
|
4
|
+
import type {db} from './db.js';
|
|
5
|
+
import {adminGrants} from './schema/admin-grants.js';
|
|
6
|
+
import {users} from './schema/users.js';
|
|
7
|
+
|
|
8
|
+
type Tx = Parameters<Parameters<ReturnType<typeof db>['transaction']>[0]>[0];
|
|
9
|
+
export type AdministratorUserSummaryExecutor = ReturnType<typeof db> | Tx;
|
|
10
|
+
|
|
11
|
+
type AdministratorUserLookup = {id: string; email?: never} | {email: string; id?: never};
|
|
12
|
+
|
|
13
|
+
export async function findAdministratorUserSummary(
|
|
14
|
+
executor: AdministratorUserSummaryExecutor,
|
|
15
|
+
params: AdministratorUserLookup,
|
|
16
|
+
): Promise<AdministratorUserSummary | undefined> {
|
|
17
|
+
const identifier = 'id' in params ? eq(users.id, params.id) : eq(users.email, params.email);
|
|
18
|
+
const rows = await executor
|
|
19
|
+
.select({
|
|
20
|
+
id: users.id,
|
|
21
|
+
email: users.email,
|
|
22
|
+
name: users.name,
|
|
23
|
+
emailVerifiedAt: users.emailVerifiedAt,
|
|
24
|
+
status: users.status,
|
|
25
|
+
createdAt: users.createdAt,
|
|
26
|
+
adminRole: adminGrants.role,
|
|
27
|
+
})
|
|
28
|
+
.from(users)
|
|
29
|
+
.leftJoin(
|
|
30
|
+
adminGrants,
|
|
31
|
+
and(
|
|
32
|
+
eq(adminGrants.userId, users.id),
|
|
33
|
+
isNull(adminGrants.revokedAt),
|
|
34
|
+
eq(users.status, 'active'),
|
|
35
|
+
),
|
|
36
|
+
)
|
|
37
|
+
.where(identifier);
|
|
38
|
+
|
|
39
|
+
const first = rows[0];
|
|
40
|
+
if (!first) return undefined;
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
id: first.id,
|
|
44
|
+
email: first.email,
|
|
45
|
+
name: first.name,
|
|
46
|
+
emailVerifiedAt: first.emailVerifiedAt,
|
|
47
|
+
status: first.status,
|
|
48
|
+
createdAt: first.createdAt,
|
|
49
|
+
adminRole: highestAdminRole(rows.flatMap(({adminRole}) => (adminRole ? [adminRole] : []))),
|
|
50
|
+
} satisfies AdministratorUserSummary;
|
|
51
|
+
}
|
package/src/db/admin-users.ts
CHANGED
|
@@ -1,58 +1,13 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import {
|
|
3
|
-
import {highestAdminRole} from '#core/admin-role-model.js';
|
|
4
|
-
import type {UserStatus} from '#core/entities/user.js';
|
|
1
|
+
import type {AdministratorUserSummary} from '#core/entities/administrator-read-model.js';
|
|
2
|
+
import {findAdministratorUserSummary} from './admin-user-summary.js';
|
|
5
3
|
import {db} from './db.js';
|
|
6
|
-
import {adminGrants} from './schema/admin-grants.js';
|
|
7
|
-
import {users} from './schema/users.js';
|
|
8
4
|
|
|
9
|
-
export
|
|
10
|
-
id: string;
|
|
11
|
-
email: string;
|
|
12
|
-
name: string | null;
|
|
13
|
-
emailVerifiedAt: Date | null;
|
|
14
|
-
status: UserStatus;
|
|
15
|
-
createdAt: Date;
|
|
16
|
-
adminRole: AdminRole | null;
|
|
17
|
-
}
|
|
5
|
+
export type AdministratorUserRecord = AdministratorUserSummary;
|
|
18
6
|
|
|
19
7
|
type AdministratorUserLookup = {id: string; email?: never} | {email: string; id?: never};
|
|
20
8
|
|
|
21
9
|
export async function findAdministratorUser(
|
|
22
10
|
params: AdministratorUserLookup,
|
|
23
11
|
): Promise<AdministratorUserRecord | undefined> {
|
|
24
|
-
|
|
25
|
-
const rows = await db()
|
|
26
|
-
.select({
|
|
27
|
-
id: users.id,
|
|
28
|
-
email: users.email,
|
|
29
|
-
name: users.name,
|
|
30
|
-
emailVerifiedAt: users.emailVerifiedAt,
|
|
31
|
-
status: users.status,
|
|
32
|
-
createdAt: users.createdAt,
|
|
33
|
-
adminRole: adminGrants.role,
|
|
34
|
-
})
|
|
35
|
-
.from(users)
|
|
36
|
-
.leftJoin(
|
|
37
|
-
adminGrants,
|
|
38
|
-
and(
|
|
39
|
-
eq(adminGrants.userId, users.id),
|
|
40
|
-
isNull(adminGrants.revokedAt),
|
|
41
|
-
eq(users.status, 'active'),
|
|
42
|
-
),
|
|
43
|
-
)
|
|
44
|
-
.where(identifier);
|
|
45
|
-
|
|
46
|
-
const first = rows[0];
|
|
47
|
-
if (!first) return undefined;
|
|
48
|
-
|
|
49
|
-
return {
|
|
50
|
-
id: first.id,
|
|
51
|
-
email: first.email,
|
|
52
|
-
name: first.name,
|
|
53
|
-
emailVerifiedAt: first.emailVerifiedAt,
|
|
54
|
-
status: first.status,
|
|
55
|
-
createdAt: first.createdAt,
|
|
56
|
-
adminRole: highestAdminRole(rows.flatMap(({adminRole}) => (adminRole ? [adminRole] : []))),
|
|
57
|
-
};
|
|
12
|
+
return await findAdministratorUserSummary(db(), params);
|
|
58
13
|
}
|
package/src/db/refresh-tokens.ts
CHANGED
|
@@ -2,6 +2,13 @@ import {and, eq, gt, isNull, ne, sql} from 'drizzle-orm';
|
|
|
2
2
|
import type {RefreshToken} from '#core/entities/refresh-token.js';
|
|
3
3
|
import {db} from './db.js';
|
|
4
4
|
import {refreshTokens, toRefreshToken} from './schema/refresh-tokens.js';
|
|
5
|
+
import {users} from './schema/users.js';
|
|
6
|
+
|
|
7
|
+
type Tx = Parameters<Parameters<ReturnType<typeof db>['transaction']>[0]>[0];
|
|
8
|
+
|
|
9
|
+
export async function lockUserSessionMutations(tx: Tx, userId: string): Promise<void> {
|
|
10
|
+
await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${`auth_user_sessions:${userId}`}))`);
|
|
11
|
+
}
|
|
5
12
|
|
|
6
13
|
export interface CreateRefreshTokenParams {
|
|
7
14
|
sessionId?: string | undefined;
|
|
@@ -26,6 +33,33 @@ export async function createRefreshToken(params: CreateRefreshTokenParams): Prom
|
|
|
26
33
|
return toRefreshToken(row);
|
|
27
34
|
}
|
|
28
35
|
|
|
36
|
+
export async function createRefreshTokenForActiveUser(
|
|
37
|
+
params: CreateRefreshTokenParams,
|
|
38
|
+
): Promise<RefreshToken | undefined> {
|
|
39
|
+
return await db().transaction(async (tx) => {
|
|
40
|
+
await lockUserSessionMutations(tx, params.userId);
|
|
41
|
+
const userRows = await tx
|
|
42
|
+
.select({status: users.status})
|
|
43
|
+
.from(users)
|
|
44
|
+
.where(eq(users.id, params.userId))
|
|
45
|
+
.limit(1);
|
|
46
|
+
if (userRows[0]?.status !== 'active') return undefined;
|
|
47
|
+
|
|
48
|
+
const rows = await tx
|
|
49
|
+
.insert(refreshTokens)
|
|
50
|
+
.values({
|
|
51
|
+
sessionId: params.sessionId,
|
|
52
|
+
userId: params.userId,
|
|
53
|
+
hashedToken: params.hashedToken,
|
|
54
|
+
expiresAt: params.expiresAt,
|
|
55
|
+
})
|
|
56
|
+
.returning();
|
|
57
|
+
const row = rows[0];
|
|
58
|
+
if (!row) throw new Error('Insert returned no rows');
|
|
59
|
+
return toRefreshToken(row);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
29
63
|
/**
|
|
30
64
|
* Looks up the live session token by hash — one that can still authenticate as
|
|
31
65
|
* the current session. Returns `undefined` for revoked, expired, or already
|
|
@@ -108,12 +142,32 @@ export async function findRefreshTokenByHash(params: {
|
|
|
108
142
|
* If the successor insert fails, the predecessor rotation rolls back.
|
|
109
143
|
*/
|
|
110
144
|
export async function rotateRefreshToken(params: {
|
|
145
|
+
userId?: string | undefined;
|
|
111
146
|
id: string;
|
|
112
147
|
currentHashedToken: string;
|
|
113
148
|
nextHashedToken: string;
|
|
114
149
|
expiresAt: Date;
|
|
115
150
|
}): Promise<RefreshToken | undefined> {
|
|
116
151
|
return await db().transaction(async (tx) => {
|
|
152
|
+
const userId =
|
|
153
|
+
params.userId ??
|
|
154
|
+
(
|
|
155
|
+
await tx
|
|
156
|
+
.select({userId: refreshTokens.userId})
|
|
157
|
+
.from(refreshTokens)
|
|
158
|
+
.where(eq(refreshTokens.id, params.id))
|
|
159
|
+
.limit(1)
|
|
160
|
+
)[0]?.userId;
|
|
161
|
+
if (!userId) return undefined;
|
|
162
|
+
|
|
163
|
+
await lockUserSessionMutations(tx, userId);
|
|
164
|
+
const userRows = await tx
|
|
165
|
+
.select({status: users.status})
|
|
166
|
+
.from(users)
|
|
167
|
+
.where(eq(users.id, userId))
|
|
168
|
+
.limit(1);
|
|
169
|
+
if (userRows[0]?.status !== 'active') return undefined;
|
|
170
|
+
|
|
117
171
|
const rotatedRows = await tx
|
|
118
172
|
.update(refreshTokens)
|
|
119
173
|
.set({
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type {AdminRole} from '@shipfox/api-auth-dto';
|
|
2
2
|
import {uuidv7PrimaryKey} from '@shipfox/node-drizzle';
|
|
3
3
|
import {jsonb, text, timestamp, uniqueIndex, uuid} from 'drizzle-orm/pg-core';
|
|
4
|
+
import type {UserStatus} from '#core/entities/user.js';
|
|
4
5
|
import {pgTable} from './common.js';
|
|
5
6
|
import {users} from './users.js';
|
|
6
7
|
|
|
@@ -13,10 +14,30 @@ export interface StoredAdminGrant {
|
|
|
13
14
|
updatedAt: string;
|
|
14
15
|
}
|
|
15
16
|
|
|
16
|
-
export interface
|
|
17
|
-
|
|
17
|
+
export interface StoredAdministratorUserSummary {
|
|
18
|
+
id: string;
|
|
19
|
+
email: string;
|
|
20
|
+
name: string | null;
|
|
21
|
+
emailVerifiedAt: string | null;
|
|
22
|
+
status: UserStatus;
|
|
23
|
+
createdAt: string;
|
|
24
|
+
adminRole: AdminRole | null;
|
|
18
25
|
}
|
|
19
26
|
|
|
27
|
+
export interface StoredAdminUserModerationResult {
|
|
28
|
+
user: StoredAdministratorUserSummary;
|
|
29
|
+
correlationId: string;
|
|
30
|
+
sessionsRevoked: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type StoredAdminCommandResult =
|
|
34
|
+
| {
|
|
35
|
+
grant: StoredAdminGrant;
|
|
36
|
+
}
|
|
37
|
+
| {
|
|
38
|
+
userModeration: StoredAdminUserModerationResult;
|
|
39
|
+
};
|
|
40
|
+
|
|
20
41
|
export const adminCommandResults = pgTable(
|
|
21
42
|
'admin_command_results',
|
|
22
43
|
{
|
package/src/index.test.ts
CHANGED
|
@@ -78,7 +78,10 @@ describe('authModule', () => {
|
|
|
78
78
|
getWorkspaceOperatingState: vi.fn(),
|
|
79
79
|
},
|
|
80
80
|
});
|
|
81
|
-
expect(module.routes).toHaveLength(
|
|
81
|
+
expect(module.routes).toHaveLength(4);
|
|
82
|
+
expect(module.routes).toEqual(
|
|
83
|
+
expect.arrayContaining([expect.objectContaining({prefix: '/admin/auth'})]),
|
|
84
|
+
);
|
|
82
85
|
const signupPolicy = buildAuthRoutes.mock.calls[0]?.[2];
|
|
83
86
|
|
|
84
87
|
expect(signupPolicy).toEqual(expect.objectContaining({isSignupAllowed: expect.any(Function)}));
|
package/src/index.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {createRunnerSessionAuthMethod} from '#presentation/auth/runner-session-a
|
|
|
18
18
|
import {createAuthE2eRoutes} from '#presentation/e2eRoutes/index.js';
|
|
19
19
|
import {createAuthInterModulePresentation} from '#presentation/inter-module.js';
|
|
20
20
|
import {
|
|
21
|
+
administrationBootstrapRoutes,
|
|
21
22
|
administrationRoutes,
|
|
22
23
|
administrationUserRoutes,
|
|
23
24
|
} from '#presentation/routes/administration.js';
|
|
@@ -39,7 +40,10 @@ export {
|
|
|
39
40
|
export {
|
|
40
41
|
bootstrapFirstAdminOwner,
|
|
41
42
|
grantAdministratorRole,
|
|
43
|
+
reactivateAdministratorUser,
|
|
42
44
|
revokeAdministratorGrant,
|
|
45
|
+
revokeAdministratorUserSessions,
|
|
46
|
+
suspendAdministratorUser,
|
|
43
47
|
} from '#core/administration.js';
|
|
44
48
|
export type {
|
|
45
49
|
CreateSessionForUserError,
|
|
@@ -114,6 +118,7 @@ export function createAuthModule({
|
|
|
114
118
|
loginMethods: passwordLoginMethods(config.AUTH_PASSWORD_ENABLED),
|
|
115
119
|
routes: [
|
|
116
120
|
buildAuthRoutes(config.AUTH_PASSWORD_ENABLED, workspaces, signupPolicy),
|
|
121
|
+
administrationBootstrapRoutes,
|
|
117
122
|
administrationRoutes,
|
|
118
123
|
administrationUserRoutes,
|
|
119
124
|
],
|
package/src/metrics/instance.ts
CHANGED
|
@@ -3,7 +3,12 @@ import {instanceMetrics} from '@shipfox/node-opentelemetry';
|
|
|
3
3
|
export type AuthTokenType = 'session' | 'job_lease' | 'runner_session';
|
|
4
4
|
export type AuthTokenVerificationOutcome = 'ok' | 'rejected';
|
|
5
5
|
export type AuthTokenRefreshOutcome = 'rotated' | 'grace' | 'rejected';
|
|
6
|
-
export type AuthRateLimitAction =
|
|
6
|
+
export type AuthRateLimitAction =
|
|
7
|
+
| 'login'
|
|
8
|
+
| 'email-send'
|
|
9
|
+
| 'bootstrap'
|
|
10
|
+
| 'bootstrap-state'
|
|
11
|
+
| 'lookup';
|
|
7
12
|
export type AuthRateLimitScope = 'ip' | 'email';
|
|
8
13
|
export type AuthRateLimitOutcome = 'allowed' | 'blocked' | 'unavailable';
|
|
9
14
|
|
|
@@ -13,9 +13,11 @@ interface TestClaims {
|
|
|
13
13
|
describe('bearer-token-auth', () => {
|
|
14
14
|
let app: FastifyInstance;
|
|
15
15
|
let verifyToken: ReturnType<typeof vi.fn<(token: string) => Promise<TestClaims | null>>>;
|
|
16
|
+
let verifierErrorIsInvalid = true;
|
|
16
17
|
|
|
17
18
|
beforeEach(async () => {
|
|
18
19
|
verifyToken = vi.fn<(token: string) => Promise<TestClaims | null>>();
|
|
20
|
+
verifierErrorIsInvalid = true;
|
|
19
21
|
app = Fastify();
|
|
20
22
|
app.setValidatorCompiler(validatorCompiler);
|
|
21
23
|
app.setSerializerCompiler(serializerCompiler);
|
|
@@ -24,6 +26,7 @@ describe('bearer-token-auth', () => {
|
|
|
24
26
|
const authMethod = createBearerTokenAuthMethod({
|
|
25
27
|
name: 'test-bearer',
|
|
26
28
|
verifyToken,
|
|
29
|
+
isInvalidTokenError: () => verifierErrorIsInvalid,
|
|
27
30
|
invalidTokenError: {message: 'Invalid test bearer token', code: 'invalid-test-token'},
|
|
28
31
|
setContext: (request, claims) => {
|
|
29
32
|
(request as FastifyRequest & Record<typeof CONTEXT_KEY, typeof claims>)[CONTEXT_KEY] =
|
|
@@ -96,4 +99,18 @@ describe('bearer-token-auth', () => {
|
|
|
96
99
|
expect(res.json()).toEqual({code: 'invalid-test-token'});
|
|
97
100
|
expect(verifyToken).toHaveBeenCalledWith('invalid-token');
|
|
98
101
|
});
|
|
102
|
+
|
|
103
|
+
test('propagates verifier errors classified as service failures', async () => {
|
|
104
|
+
verifierErrorIsInvalid = false;
|
|
105
|
+
verifyToken.mockRejectedValue(new Error('database unavailable'));
|
|
106
|
+
|
|
107
|
+
const res = await app.inject({
|
|
108
|
+
method: 'GET',
|
|
109
|
+
url: '/protected',
|
|
110
|
+
headers: {authorization: 'Bearer valid-token'},
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
expect(res.statusCode).toBe(500);
|
|
114
|
+
expect(res.json().code).not.toBe('invalid-test-token');
|
|
115
|
+
});
|
|
99
116
|
});
|
|
@@ -9,6 +9,7 @@ interface UnauthorizedErrorParams {
|
|
|
9
9
|
interface BearerTokenAuthMethodOptions<TClaims> {
|
|
10
10
|
name: string;
|
|
11
11
|
verifyToken: (token: string) => Promise<TClaims | null>;
|
|
12
|
+
isInvalidTokenError?: (error: unknown) => boolean;
|
|
12
13
|
invalidTokenError: UnauthorizedErrorParams;
|
|
13
14
|
setContext: (request: FastifyRequest, claims: TClaims) => void;
|
|
14
15
|
}
|
|
@@ -27,7 +28,11 @@ export function createBearerTokenAuthMethod<TClaims>(
|
|
|
27
28
|
const token = extractBearerToken(request.headers.authorization);
|
|
28
29
|
if (!token) throwUnauthorized(missingBearerError);
|
|
29
30
|
|
|
30
|
-
const claims = await verifyBearerToken(
|
|
31
|
+
const claims = await verifyBearerToken(
|
|
32
|
+
token,
|
|
33
|
+
options.verifyToken,
|
|
34
|
+
options.isInvalidTokenError,
|
|
35
|
+
);
|
|
31
36
|
if (!claims) throwUnauthorized(options.invalidTokenError);
|
|
32
37
|
|
|
33
38
|
options.setContext(request, claims);
|
|
@@ -38,10 +43,12 @@ export function createBearerTokenAuthMethod<TClaims>(
|
|
|
38
43
|
async function verifyBearerToken<TClaims>(
|
|
39
44
|
token: string,
|
|
40
45
|
verifyToken: (token: string) => Promise<TClaims | null>,
|
|
46
|
+
isInvalidTokenError: ((error: unknown) => boolean) | undefined,
|
|
41
47
|
): Promise<TClaims | null> {
|
|
42
48
|
try {
|
|
43
49
|
return await verifyToken(token);
|
|
44
|
-
} catch {
|
|
50
|
+
} catch (error) {
|
|
51
|
+
if (isInvalidTokenError && !isInvalidTokenError(error)) throw error;
|
|
45
52
|
return null;
|
|
46
53
|
}
|
|
47
54
|
}
|
|
@@ -116,7 +116,7 @@ describe('jwt-auth', () => {
|
|
|
116
116
|
expect(res.statusCode).toBe(401);
|
|
117
117
|
});
|
|
118
118
|
|
|
119
|
-
test('
|
|
119
|
+
test('keeps a legacy token verifiable during the migration window', async () => {
|
|
120
120
|
const userId = crypto.randomUUID();
|
|
121
121
|
const email = emailFor('jwt-stateless');
|
|
122
122
|
const token = await signUserToken({
|
|
@@ -13,6 +13,7 @@ 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
15
|
import {findActiveRefreshSession} from '#db/refresh-tokens.js';
|
|
16
|
+
import {findUserById} from '#db/users.js';
|
|
16
17
|
import {createBearerTokenAuthMethod} from './bearer-token-auth.js';
|
|
17
18
|
|
|
18
19
|
const AUTHENTICATED_SESSION_CONTEXT_KEY = Symbol.for('@shipfox/api-auth/session');
|
|
@@ -31,6 +32,13 @@ export interface CreateJwtAuthMethodOptions {
|
|
|
31
32
|
secret: string;
|
|
32
33
|
}
|
|
33
34
|
|
|
35
|
+
class InvalidJwtTokenError extends Error {
|
|
36
|
+
constructor() {
|
|
37
|
+
super('Invalid JWT');
|
|
38
|
+
this.name = 'InvalidJwtTokenError';
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
34
42
|
export function getClientContext(request: FastifyRequest): ClientContext | null {
|
|
35
43
|
return getUserContext(request);
|
|
36
44
|
}
|
|
@@ -77,7 +85,28 @@ export async function getAuthenticatedSessionContext(
|
|
|
77
85
|
export function createJwtAuthMethod(): AuthMethod {
|
|
78
86
|
return createBearerTokenAuthMethod({
|
|
79
87
|
name: AUTH_USER,
|
|
80
|
-
verifyToken: (token) =>
|
|
88
|
+
verifyToken: async (token) => {
|
|
89
|
+
let claims: UserTokenClaims;
|
|
90
|
+
try {
|
|
91
|
+
claims = await verifyUserToken({token, secret: userAccessTokenKey()});
|
|
92
|
+
} catch {
|
|
93
|
+
throw new InvalidJwtTokenError();
|
|
94
|
+
}
|
|
95
|
+
const user = await findUserById({id: claims.sub});
|
|
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;
|
|
108
|
+
},
|
|
109
|
+
isInvalidTokenError: (error) => error instanceof InvalidJwtTokenError,
|
|
81
110
|
invalidTokenError: {message: 'Invalid or expired token', code: 'unauthorized'},
|
|
82
111
|
setContext: (request, claims) => {
|
|
83
112
|
const clientContext: ClientContext = buildUserContext({
|