@shipfox/api-auth 18.0.0 → 20.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 +17 -2
- package/CHANGELOG.md +47 -0
- package/README.md +24 -9
- package/dist/core/administration.d.ts +15 -0
- package/dist/core/administration.d.ts.map +1 -1
- package/dist/core/administration.js +44 -2
- package/dist/core/administration.js.map +1 -1
- package/dist/core/auth.d.ts.map +1 -1
- package/dist/core/auth.js +5 -3
- package/dist/core/auth.js.map +1 -1
- package/dist/core/cimd.d.ts +39 -0
- package/dist/core/cimd.d.ts.map +1 -0
- package/dist/core/cimd.js +392 -0
- package/dist/core/cimd.js.map +1 -0
- package/dist/core/entities/agent-access.d.ts +75 -0
- package/dist/core/entities/agent-access.d.ts.map +1 -0
- package/dist/core/entities/agent-access.js +3 -0
- package/dist/core/entities/agent-access.js.map +1 -0
- package/dist/core/errors.d.ts +17 -0
- package/dist/core/errors.d.ts.map +1 -1
- package/dist/core/errors.js +32 -0
- package/dist/core/errors.js.map +1 -1
- package/dist/core/oauth-client-resolver.d.ts +54 -0
- package/dist/core/oauth-client-resolver.d.ts.map +1 -0
- package/dist/core/oauth-client-resolver.js +154 -0
- package/dist/core/oauth-client-resolver.js.map +1 -0
- package/dist/core/oauth-client.d.ts +39 -0
- package/dist/core/oauth-client.d.ts.map +1 -0
- package/dist/core/oauth-client.js +236 -0
- package/dist/core/oauth-client.js.map +1 -0
- package/dist/db/admin-user-moderation.d.ts.map +1 -1
- package/dist/db/admin-user-moderation.js +30 -26
- package/dist/db/admin-user-moderation.js.map +1 -1
- package/dist/db/admin-user-summary.d.ts +22 -0
- package/dist/db/admin-user-summary.d.ts.map +1 -1
- package/dist/db/admin-user-summary.js +75 -1
- package/dist/db/admin-user-summary.js.map +1 -1
- package/dist/db/admin-users.d.ts +11 -0
- package/dist/db/admin-users.d.ts.map +1 -1
- package/dist/db/admin-users.js +4 -1
- package/dist/db/admin-users.js.map +1 -1
- package/dist/db/agent-access-retention.d.ts +10 -0
- package/dist/db/agent-access-retention.d.ts.map +1 -0
- package/dist/db/agent-access-retention.js +11 -0
- package/dist/db/agent-access-retention.js.map +1 -0
- package/dist/db/agent-access.d.ts +255 -0
- package/dist/db/agent-access.d.ts.map +1 -0
- package/dist/db/agent-access.js +705 -0
- package/dist/db/agent-access.js.map +1 -0
- package/dist/db/db.d.ts +2604 -238
- package/dist/db/db.d.ts.map +1 -1
- package/dist/db/db.js +7 -0
- package/dist/db/db.js.map +1 -1
- package/dist/db/schema/agent-access.d.ts +1204 -0
- package/dist/db/schema/agent-access.d.ts.map +1 -0
- package/dist/db/schema/agent-access.js +267 -0
- package/dist/db/schema/agent-access.js.map +1 -0
- package/dist/db/schema/users.d.ts.map +1 -1
- package/dist/db/schema/users.js +8 -3
- package/dist/db/schema/users.js.map +1 -1
- package/dist/index.d.ts +11 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +26 -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/routes/administration.d.ts.map +1 -1
- package/dist/presentation/routes/administration.js +123 -20
- package/dist/presentation/routes/administration.js.map +1 -1
- package/dist/presentation/routes/email-verification/verify-email-resend.d.ts.map +1 -1
- package/dist/presentation/routes/email-verification/verify-email-resend.js +6 -1
- package/dist/presentation/routes/email-verification/verify-email-resend.js.map +1 -1
- package/dist/presentation/routes/oauth.d.ts +9 -0
- package/dist/presentation/routes/oauth.d.ts.map +1 -0
- package/dist/presentation/routes/oauth.js +150 -0
- package/dist/presentation/routes/oauth.js.map +1 -0
- package/dist/presentation/routes/rate-limit.d.ts.map +1 -1
- package/dist/presentation/routes/rate-limit.js +34 -0
- package/dist/presentation/routes/rate-limit.js.map +1 -1
- package/dist/presentation/routes/registration/signup.d.ts.map +1 -1
- package/dist/presentation/routes/registration/signup.js +59 -50
- package/dist/presentation/routes/registration/signup.js.map +1 -1
- package/dist/temporal/activities/agent-access-retention.d.ts +13 -0
- package/dist/temporal/activities/agent-access-retention.d.ts.map +1 -0
- package/dist/temporal/activities/agent-access-retention.js +58 -0
- package/dist/temporal/activities/agent-access-retention.js.map +1 -0
- package/dist/temporal/activities/index.d.ts +5 -0
- package/dist/temporal/activities/index.d.ts.map +1 -0
- package/dist/temporal/activities/index.js +8 -0
- package/dist/temporal/activities/index.js.map +1 -0
- package/dist/temporal/constants.d.ts +2 -0
- package/dist/temporal/constants.d.ts.map +1 -0
- package/dist/temporal/constants.js +3 -0
- package/dist/temporal/constants.js.map +1 -0
- package/dist/temporal/workflows/agent-access-retention-cron.d.ts +2 -0
- package/dist/temporal/workflows/agent-access-retention-cron.d.ts.map +1 -0
- package/dist/temporal/workflows/agent-access-retention-cron.js +22 -0
- package/dist/temporal/workflows/agent-access-retention-cron.js.map +1 -0
- package/dist/temporal/workflows/index.bundle.js +25227 -0
- package/dist/temporal/workflows/index.bundle.meta.json +1 -0
- package/dist/temporal/workflows/index.d.ts +2 -0
- package/dist/temporal/workflows/index.d.ts.map +1 -0
- package/dist/temporal/workflows/index.js +3 -0
- package/dist/temporal/workflows/index.js.map +1 -0
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/drizzle/0003_brainy_luckman.sql +6 -0
- package/drizzle/0004_yummy_runaways.sql +110 -0
- package/drizzle/meta/0003_snapshot.json +844 -0
- package/drizzle/meta/0004_snapshot.json +1751 -0
- package/drizzle/meta/_journal.json +14 -0
- package/package.json +11 -8
- package/src/core/admin-role.test.ts +70 -0
- package/src/core/administration.ts +78 -1
- package/src/core/auth.ts +3 -5
- package/src/core/cimd.test.ts +213 -0
- package/src/core/cimd.ts +532 -0
- package/src/core/entities/agent-access.ts +80 -0
- package/src/core/errors.ts +48 -0
- package/src/core/oauth-client-resolver.test.ts +198 -0
- package/src/core/oauth-client-resolver.ts +253 -0
- package/src/core/oauth-client.test.ts +157 -0
- package/src/core/oauth-client.ts +331 -0
- package/src/db/admin-user-moderation.ts +53 -45
- package/src/db/admin-user-summary.test.ts +228 -0
- package/src/db/admin-user-summary.ts +143 -1
- package/src/db/admin-users.ts +21 -1
- package/src/db/agent-access-retention.ts +10 -0
- package/src/db/agent-access.test.ts +1064 -0
- package/src/db/agent-access.ts +1491 -0
- package/src/db/db.ts +14 -0
- package/src/db/schema/agent-access.ts +273 -0
- package/src/db/schema/users.ts +9 -3
- package/src/index.test.ts +27 -0
- package/src/index.ts +82 -0
- package/src/metrics/instance.ts +6 -1
- package/src/presentation/routes/administration.test.ts +359 -2
- package/src/presentation/routes/administration.ts +142 -20
- package/src/presentation/routes/email-verification/verify-email-resend.ts +7 -1
- package/src/presentation/routes/oauth.test.ts +133 -0
- package/src/presentation/routes/oauth.ts +144 -0
- package/src/presentation/routes/rate-limit.ts +16 -0
- package/src/presentation/routes/registration/signup.ts +52 -41
- package/src/temporal/activities/agent-access-retention.ts +76 -0
- package/src/temporal/activities/index.ts +5 -0
- package/src/temporal/constants.ts +1 -0
- package/src/temporal/workflows/agent-access-retention-cron.ts +23 -0
- package/src/temporal/workflows/index.ts +1 -0
- package/test/globalSetup.ts +1 -1
- package/test/routes.ts +3 -1
- package/tsconfig.build.tsbuildinfo +1 -1
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import {
|
|
2
|
+
OAUTH_READ_SCOPE,
|
|
3
|
+
type OAuthClientMetadataDocumentDto,
|
|
4
|
+
type OAuthDynamicClientRegistrationRequestDto,
|
|
5
|
+
oauthClientMetadataDocumentSchema,
|
|
6
|
+
oauthDynamicClientRegistrationRequestSchema,
|
|
7
|
+
} from '@shipfox/api-auth-dto';
|
|
8
|
+
import type {AgentClient} from './entities/agent-access.js';
|
|
9
|
+
import {
|
|
10
|
+
InvalidOAuthClientMetadataError,
|
|
11
|
+
InvalidOAuthConfigurationError,
|
|
12
|
+
OAuthRedirectUriNotRegisteredError,
|
|
13
|
+
} from './errors.js';
|
|
14
|
+
|
|
15
|
+
const URI_SUFFIX_PATTERN = /[/?#]/u;
|
|
16
|
+
const PORT_PATTERN = /^\d+$/u;
|
|
17
|
+
const BRACKETED_PORT_PATTERN = /^:\d+$/u;
|
|
18
|
+
|
|
19
|
+
function hasControlCharacter(value: string): boolean {
|
|
20
|
+
return [...value].some((character) => {
|
|
21
|
+
const codePoint = character.codePointAt(0) ?? 0;
|
|
22
|
+
return codePoint < 0x20 || codePoint === 0x7f;
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const OAUTH_CLIENT_ID_MAX_BYTES = 2048;
|
|
27
|
+
export const OAUTH_CLIENT_NAME_MAX_BYTES = 256;
|
|
28
|
+
export const OAUTH_REDIRECT_URI_MAX_BYTES = 2048;
|
|
29
|
+
export const OAUTH_REDIRECT_URI_MAX_COUNT = 10;
|
|
30
|
+
export const OAUTH_CIMD_CACHE_MAX_AGE_SECONDS = 15 * 60;
|
|
31
|
+
|
|
32
|
+
export type OAuthGrantType = 'authorization_code' | 'refresh_token';
|
|
33
|
+
|
|
34
|
+
export interface ValidatedOAuthClientMetadata {
|
|
35
|
+
clientId: string;
|
|
36
|
+
clientName: string;
|
|
37
|
+
redirectUris: string[];
|
|
38
|
+
grantTypes: OAuthGrantType[];
|
|
39
|
+
responseTypes: ['code'];
|
|
40
|
+
tokenEndpointAuthMethod: 'none';
|
|
41
|
+
scope: typeof OAUTH_READ_SCOPE;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function hasByteLengthAtMost(value: string, maxBytes: number): boolean {
|
|
45
|
+
return Buffer.byteLength(value, 'utf8') <= maxBytes;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function rejectInvalidMetadata(): never {
|
|
49
|
+
throw new InvalidOAuthClientMetadataError();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function rejectInvalidConfiguration(): never {
|
|
53
|
+
throw new InvalidOAuthConfigurationError();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function parseUrl(value: string): URL {
|
|
57
|
+
try {
|
|
58
|
+
return new URL(value);
|
|
59
|
+
} catch {
|
|
60
|
+
return rejectInvalidMetadata();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function isLoopbackHostname(hostname: string): boolean {
|
|
65
|
+
const normalized = hostname.toLowerCase().replace(/^\[|\]$/gu, '');
|
|
66
|
+
return normalized === 'localhost' || normalized === '127.0.0.1' || normalized === '::1';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function validateUrlShape(url: URL): void {
|
|
70
|
+
if (
|
|
71
|
+
url.username ||
|
|
72
|
+
url.password ||
|
|
73
|
+
url.hash ||
|
|
74
|
+
url.hostname.length === 0 ||
|
|
75
|
+
hasControlCharacter(url.href)
|
|
76
|
+
) {
|
|
77
|
+
rejectInvalidMetadata();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Validates an OAuth redirect URI without making network assumptions. */
|
|
82
|
+
export function validateOAuthRedirectUri(value: string): URL {
|
|
83
|
+
if (
|
|
84
|
+
!hasByteLengthAtMost(value, OAUTH_REDIRECT_URI_MAX_BYTES) ||
|
|
85
|
+
value.length === 0 ||
|
|
86
|
+
value.trim() !== value ||
|
|
87
|
+
hasControlCharacter(value)
|
|
88
|
+
) {
|
|
89
|
+
return rejectInvalidMetadata();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const url = parseUrl(value);
|
|
93
|
+
validateUrlShape(url);
|
|
94
|
+
|
|
95
|
+
if (url.protocol === 'https:') return url;
|
|
96
|
+
if (url.protocol !== 'http:' || !isLoopbackHostname(url.hostname)) {
|
|
97
|
+
return rejectInvalidMetadata();
|
|
98
|
+
}
|
|
99
|
+
return url;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function isOAuthLoopbackRedirectUri(value: string): boolean {
|
|
103
|
+
try {
|
|
104
|
+
const url = validateOAuthRedirectUri(value);
|
|
105
|
+
return url.protocol === 'http:' && isLoopbackHostname(url.hostname);
|
|
106
|
+
} catch {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Redirect URI comparison is byte-for-byte for public HTTPS clients. Only a
|
|
113
|
+
* loopback HTTP client's port may vary, as required by native-app OAuth.
|
|
114
|
+
*/
|
|
115
|
+
export function oauthRedirectUriMatches(registered: string, requested: string): boolean {
|
|
116
|
+
try {
|
|
117
|
+
const registeredUrl = validateOAuthRedirectUri(registered);
|
|
118
|
+
const requestedUrl = validateOAuthRedirectUri(requested);
|
|
119
|
+
if (registered === requested) return true;
|
|
120
|
+
if (
|
|
121
|
+
registeredUrl.protocol !== 'http:' ||
|
|
122
|
+
requestedUrl.protocol !== 'http:' ||
|
|
123
|
+
!isLoopbackHostname(registeredUrl.hostname) ||
|
|
124
|
+
!isLoopbackHostname(requestedUrl.hostname)
|
|
125
|
+
) {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return withoutLoopbackPort(registered) === withoutLoopbackPort(requested);
|
|
130
|
+
} catch {
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function withoutLoopbackPort(value: string): string {
|
|
136
|
+
const authorityStart = value.indexOf('//') + 2;
|
|
137
|
+
const suffixOffset = value.slice(authorityStart).search(URI_SUFFIX_PATTERN);
|
|
138
|
+
const authorityEnd = suffixOffset === -1 ? value.length : authorityStart + suffixOffset;
|
|
139
|
+
const authority = value.slice(authorityStart, authorityEnd);
|
|
140
|
+
|
|
141
|
+
let authorityWithoutPort = authority;
|
|
142
|
+
if (authority.startsWith('[')) {
|
|
143
|
+
const closingBracket = authority.indexOf(']');
|
|
144
|
+
const port = closingBracket === -1 ? '' : authority.slice(closingBracket + 1);
|
|
145
|
+
if (closingBracket !== -1 && BRACKETED_PORT_PATTERN.test(port)) {
|
|
146
|
+
authorityWithoutPort = authority.slice(0, closingBracket + 1);
|
|
147
|
+
}
|
|
148
|
+
} else {
|
|
149
|
+
const colon = authority.lastIndexOf(':');
|
|
150
|
+
if (colon !== -1 && PORT_PATTERN.test(authority.slice(colon + 1))) {
|
|
151
|
+
authorityWithoutPort = authority.slice(0, colon);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return `${value.slice(0, authorityStart)}${authorityWithoutPort}${value.slice(authorityEnd)}`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function assertOAuthRedirectUriRegistered(
|
|
159
|
+
redirectUris: readonly string[],
|
|
160
|
+
requested: string,
|
|
161
|
+
): void {
|
|
162
|
+
validateOAuthRedirectUri(requested);
|
|
163
|
+
if (!redirectUris.some((registered) => oauthRedirectUriMatches(registered, requested))) {
|
|
164
|
+
throw new OAuthRedirectUriNotRegisteredError();
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Client ID Metadata Documents must be HTTPS URLs with a non-root path. */
|
|
169
|
+
export function validateOAuthClientId(value: string): URL {
|
|
170
|
+
if (
|
|
171
|
+
!hasByteLengthAtMost(value, OAUTH_CLIENT_ID_MAX_BYTES) ||
|
|
172
|
+
value.trim() !== value ||
|
|
173
|
+
hasControlCharacter(value)
|
|
174
|
+
) {
|
|
175
|
+
return rejectInvalidMetadata();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const url = parseUrl(value);
|
|
179
|
+
validateUrlShape(url);
|
|
180
|
+
if (url.protocol !== 'https:' || url.pathname === '/' || url.pathname.length === 0) {
|
|
181
|
+
return rejectInvalidMetadata();
|
|
182
|
+
}
|
|
183
|
+
return url;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Validates the public origin injected into the dormant route factory. */
|
|
187
|
+
export function validateOAuthPublicOrigin(value: string): string {
|
|
188
|
+
let url: URL;
|
|
189
|
+
try {
|
|
190
|
+
url = new URL(value);
|
|
191
|
+
} catch {
|
|
192
|
+
return rejectInvalidConfiguration();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (
|
|
196
|
+
(url.protocol !== 'https:' &&
|
|
197
|
+
!(url.protocol === 'http:' && isLoopbackHostname(url.hostname))) ||
|
|
198
|
+
value.trim() !== value ||
|
|
199
|
+
hasControlCharacter(value) ||
|
|
200
|
+
url.username ||
|
|
201
|
+
url.password ||
|
|
202
|
+
url.pathname !== '/' ||
|
|
203
|
+
url.search ||
|
|
204
|
+
url.hash ||
|
|
205
|
+
url.hostname.length === 0
|
|
206
|
+
) {
|
|
207
|
+
return rejectInvalidConfiguration();
|
|
208
|
+
}
|
|
209
|
+
return url.origin;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function validateByteBoundedString(value: string, maxBytes: number): void {
|
|
213
|
+
if (!hasByteLengthAtMost(value, maxBytes) || value.length === 0 || hasControlCharacter(value)) {
|
|
214
|
+
rejectInvalidMetadata();
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function validateGrantTypes(value: readonly OAuthGrantType[] | undefined): OAuthGrantType[] {
|
|
219
|
+
const grantTypes = [...(value ?? ['authorization_code'])];
|
|
220
|
+
if (
|
|
221
|
+
grantTypes.length === 0 ||
|
|
222
|
+
grantTypes.length > 2 ||
|
|
223
|
+
new Set(grantTypes).size !== grantTypes.length ||
|
|
224
|
+
!grantTypes.includes('authorization_code') ||
|
|
225
|
+
grantTypes.some(
|
|
226
|
+
(grantType) => grantType !== 'authorization_code' && grantType !== 'refresh_token',
|
|
227
|
+
)
|
|
228
|
+
) {
|
|
229
|
+
rejectInvalidMetadata();
|
|
230
|
+
}
|
|
231
|
+
return grantTypes;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function validateResponseTypes(value: readonly 'code'[] | undefined): ['code'] {
|
|
235
|
+
const responseTypes = [...(value ?? ['code'])];
|
|
236
|
+
if (responseTypes.length !== 1 || responseTypes[0] !== 'code') rejectInvalidMetadata();
|
|
237
|
+
return ['code'];
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function validateScope(value: string | undefined): typeof OAUTH_READ_SCOPE {
|
|
241
|
+
if (value !== undefined && value !== OAUTH_READ_SCOPE) rejectInvalidMetadata();
|
|
242
|
+
return OAUTH_READ_SCOPE;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function validateRedirectUris(redirectUris: readonly string[]): string[] {
|
|
246
|
+
if (
|
|
247
|
+
redirectUris.length === 0 ||
|
|
248
|
+
redirectUris.length > OAUTH_REDIRECT_URI_MAX_COUNT ||
|
|
249
|
+
new Set(redirectUris).size !== redirectUris.length
|
|
250
|
+
) {
|
|
251
|
+
rejectInvalidMetadata();
|
|
252
|
+
}
|
|
253
|
+
for (const redirectUri of redirectUris) validateOAuthRedirectUri(redirectUri);
|
|
254
|
+
return [...redirectUris];
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function validateTokenEndpointAuthMethod(value: string | undefined): 'none' {
|
|
258
|
+
if (value !== undefined && value !== 'none') rejectInvalidMetadata();
|
|
259
|
+
return 'none';
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function validateOAuthDynamicClientRegistration(
|
|
263
|
+
input: OAuthDynamicClientRegistrationRequestDto,
|
|
264
|
+
): Omit<ValidatedOAuthClientMetadata, 'clientId'> {
|
|
265
|
+
const parsed = oauthDynamicClientRegistrationRequestSchema.safeParse(input);
|
|
266
|
+
if (!parsed.success) rejectInvalidMetadata();
|
|
267
|
+
|
|
268
|
+
const value = parsed.data;
|
|
269
|
+
validateByteBoundedString(value.client_name, OAUTH_CLIENT_NAME_MAX_BYTES);
|
|
270
|
+
const grantTypes = validateGrantTypes(value.grant_types);
|
|
271
|
+
// Registered rows do not persist grant types yet. Keep the registration
|
|
272
|
+
// response and later resolution consistent until refresh-token support lands.
|
|
273
|
+
if (grantTypes.length !== 1 || grantTypes[0] !== 'authorization_code') {
|
|
274
|
+
rejectInvalidMetadata();
|
|
275
|
+
}
|
|
276
|
+
return {
|
|
277
|
+
clientName: value.client_name,
|
|
278
|
+
redirectUris: validateRedirectUris(value.redirect_uris),
|
|
279
|
+
grantTypes,
|
|
280
|
+
responseTypes: validateResponseTypes(value.response_types),
|
|
281
|
+
tokenEndpointAuthMethod: validateTokenEndpointAuthMethod(value.token_endpoint_auth_method),
|
|
282
|
+
scope: validateScope(value.scope),
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export function validateOAuthClientMetadataDocument(
|
|
287
|
+
input: OAuthClientMetadataDocumentDto,
|
|
288
|
+
fetchedUrl: string,
|
|
289
|
+
): ValidatedOAuthClientMetadata {
|
|
290
|
+
const parsed = oauthClientMetadataDocumentSchema.safeParse(input);
|
|
291
|
+
if (!parsed.success || parsed.data.client_id !== fetchedUrl) rejectInvalidMetadata();
|
|
292
|
+
|
|
293
|
+
validateOAuthClientId(fetchedUrl);
|
|
294
|
+
const value = parsed.data;
|
|
295
|
+
validateByteBoundedString(value.client_name, OAUTH_CLIENT_NAME_MAX_BYTES);
|
|
296
|
+
return {
|
|
297
|
+
clientId: fetchedUrl,
|
|
298
|
+
clientName: value.client_name,
|
|
299
|
+
redirectUris: validateRedirectUris(value.redirect_uris),
|
|
300
|
+
grantTypes: validateGrantTypes(value.grant_types),
|
|
301
|
+
responseTypes: validateResponseTypes(value.response_types),
|
|
302
|
+
tokenEndpointAuthMethod: validateTokenEndpointAuthMethod(value.token_endpoint_auth_method),
|
|
303
|
+
scope: validateScope(value.scope),
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export function metadataForAgentClient(client: AgentClient): ValidatedOAuthClientMetadata {
|
|
308
|
+
validateByteBoundedString(client.clientId, OAUTH_CLIENT_ID_MAX_BYTES);
|
|
309
|
+
validateByteBoundedString(client.name, OAUTH_CLIENT_NAME_MAX_BYTES);
|
|
310
|
+
validateRedirectUris(client.redirectUris);
|
|
311
|
+
return {
|
|
312
|
+
clientId: client.clientId,
|
|
313
|
+
clientName: client.name,
|
|
314
|
+
redirectUris: [...client.redirectUris],
|
|
315
|
+
grantTypes: ['authorization_code'],
|
|
316
|
+
responseTypes: ['code'],
|
|
317
|
+
tokenEndpointAuthMethod: 'none',
|
|
318
|
+
scope: OAUTH_READ_SCOPE,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export function assertOAuthClientMetadataMatchesRequest(params: {
|
|
323
|
+
metadata: ValidatedOAuthClientMetadata;
|
|
324
|
+
redirectUri?: string | undefined;
|
|
325
|
+
tokenEndpointAuthMethod?: string | undefined;
|
|
326
|
+
}): void {
|
|
327
|
+
if (params.redirectUri !== undefined) {
|
|
328
|
+
assertOAuthRedirectUriRegistered(params.metadata.redirectUris, params.redirectUri);
|
|
329
|
+
}
|
|
330
|
+
validateTokenEndpointAuthMethod(params.tokenEndpointAuthMethod);
|
|
331
|
+
}
|
|
@@ -139,6 +139,58 @@ async function revokeActiveSessions(tx: Tx, userId: string): Promise<number> {
|
|
|
139
139
|
return new Set(revoked.map(({sessionId}) => sessionId)).size;
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
async function suspendUser(tx: Tx, user: Awaited<ReturnType<typeof readTargetUserForUpdate>>) {
|
|
143
|
+
if (user.status !== 'active') return;
|
|
144
|
+
const activeOwners = await tx
|
|
145
|
+
.select({id: adminGrants.id})
|
|
146
|
+
.from(adminGrants)
|
|
147
|
+
.innerJoin(users, eq(adminGrants.userId, users.id))
|
|
148
|
+
.where(
|
|
149
|
+
and(
|
|
150
|
+
eq(adminGrants.role, 'admin-owner'),
|
|
151
|
+
isNull(adminGrants.revokedAt),
|
|
152
|
+
eq(users.status, 'active'),
|
|
153
|
+
),
|
|
154
|
+
)
|
|
155
|
+
.limit(2);
|
|
156
|
+
const targetIsActiveOwner = await tx
|
|
157
|
+
.select({id: adminGrants.id})
|
|
158
|
+
.from(adminGrants)
|
|
159
|
+
.where(
|
|
160
|
+
and(
|
|
161
|
+
eq(adminGrants.userId, user.id),
|
|
162
|
+
eq(adminGrants.role, 'admin-owner'),
|
|
163
|
+
isNull(adminGrants.revokedAt),
|
|
164
|
+
),
|
|
165
|
+
)
|
|
166
|
+
.limit(1);
|
|
167
|
+
if (targetIsActiveOwner.length > 0 && activeOwners.length <= 1) throw new LastAdminOwnerError();
|
|
168
|
+
await tx
|
|
169
|
+
.update(users)
|
|
170
|
+
.set({status: 'suspended', updatedAt: sql`now()`})
|
|
171
|
+
.where(eq(users.id, user.id));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function applyModerationOperation(
|
|
175
|
+
tx: Tx,
|
|
176
|
+
user: Awaited<ReturnType<typeof readTargetUserForUpdate>>,
|
|
177
|
+
operation: 'suspend' | 'reactivate' | 'revoke-sessions',
|
|
178
|
+
): Promise<number> {
|
|
179
|
+
if (operation === 'suspend') {
|
|
180
|
+
await suspendUser(tx, user);
|
|
181
|
+
return await revokeActiveSessions(tx, user.id);
|
|
182
|
+
}
|
|
183
|
+
if (operation === 'reactivate' && user.status === 'suspended') {
|
|
184
|
+
await tx
|
|
185
|
+
.update(users)
|
|
186
|
+
.set({status: 'active', updatedAt: sql`now()`})
|
|
187
|
+
.where(eq(users.id, user.id));
|
|
188
|
+
return 0;
|
|
189
|
+
}
|
|
190
|
+
if (operation === 'revoke-sessions') return await revokeActiveSessions(tx, user.id);
|
|
191
|
+
return 0;
|
|
192
|
+
}
|
|
193
|
+
|
|
142
194
|
async function executeUserModerationCommand(
|
|
143
195
|
params: UserModerationCommandParams & {
|
|
144
196
|
operation: 'suspend' | 'reactivate' | 'revoke-sessions';
|
|
@@ -157,51 +209,7 @@ async function executeUserModerationCommand(
|
|
|
157
209
|
await lockUserSessionMutations(tx, params.userId);
|
|
158
210
|
const user = await readTargetUserForUpdate(tx, params.userId);
|
|
159
211
|
await requireActiveAdminOperator(tx, params.actorId);
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
if (params.operation === 'suspend') {
|
|
163
|
-
if (user.status === 'active') {
|
|
164
|
-
const activeOwners = await tx
|
|
165
|
-
.select({id: adminGrants.id})
|
|
166
|
-
.from(adminGrants)
|
|
167
|
-
.innerJoin(users, eq(adminGrants.userId, users.id))
|
|
168
|
-
.where(
|
|
169
|
-
and(
|
|
170
|
-
eq(adminGrants.role, 'admin-owner'),
|
|
171
|
-
isNull(adminGrants.revokedAt),
|
|
172
|
-
eq(users.status, 'active'),
|
|
173
|
-
),
|
|
174
|
-
)
|
|
175
|
-
.limit(2);
|
|
176
|
-
const targetIsActiveOwner = await tx
|
|
177
|
-
.select({id: adminGrants.id})
|
|
178
|
-
.from(adminGrants)
|
|
179
|
-
.where(
|
|
180
|
-
and(
|
|
181
|
-
eq(adminGrants.userId, user.id),
|
|
182
|
-
eq(adminGrants.role, 'admin-owner'),
|
|
183
|
-
isNull(adminGrants.revokedAt),
|
|
184
|
-
),
|
|
185
|
-
)
|
|
186
|
-
.limit(1);
|
|
187
|
-
if (targetIsActiveOwner.length > 0 && activeOwners.length <= 1) {
|
|
188
|
-
throw new LastAdminOwnerError();
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
await tx
|
|
192
|
-
.update(users)
|
|
193
|
-
.set({status: 'suspended', updatedAt: sql`now()`})
|
|
194
|
-
.where(eq(users.id, user.id));
|
|
195
|
-
}
|
|
196
|
-
sessionsRevoked = await revokeActiveSessions(tx, user.id);
|
|
197
|
-
} else if (params.operation === 'reactivate' && user.status === 'suspended') {
|
|
198
|
-
await tx
|
|
199
|
-
.update(users)
|
|
200
|
-
.set({status: 'active', updatedAt: sql`now()`})
|
|
201
|
-
.where(eq(users.id, user.id));
|
|
202
|
-
} else if (params.operation === 'revoke-sessions') {
|
|
203
|
-
sessionsRevoked = await revokeActiveSessions(tx, user.id);
|
|
204
|
-
}
|
|
212
|
+
const sessionsRevoked = await applyModerationOperation(tx, user, params.operation);
|
|
205
213
|
|
|
206
214
|
const summary = await findUserSummary(tx, user.id);
|
|
207
215
|
const result: StoredAdminUserModerationResult = {
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import {eq} from 'drizzle-orm';
|
|
2
|
+
import {userFactory} from '#test/index.js';
|
|
3
|
+
import {createAdminGrant, revokeAdminGrant} from './admin-grants.js';
|
|
4
|
+
import {listAdministratorUserSummaries} from './admin-user-summary.js';
|
|
5
|
+
import {db} from './db.js';
|
|
6
|
+
import {users} from './schema/users.js';
|
|
7
|
+
|
|
8
|
+
async function setCreatedAt(userId: string, createdAt: Date): Promise<void> {
|
|
9
|
+
await db().update(users).set({createdAt}).where(eq(users.id, userId));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe('administrator user summaries db', () => {
|
|
13
|
+
test('lists users in descending created-at and id order with a limit-plus-one cursor', async () => {
|
|
14
|
+
const marker = `directory-order-${crypto.randomUUID()}`;
|
|
15
|
+
const actor = await userFactory.create({email: `${marker}-actor@example.com`});
|
|
16
|
+
const first = await userFactory.create({email: `${marker}-first@example.com`});
|
|
17
|
+
const second = await userFactory.create({email: `${marker}-second@example.com`});
|
|
18
|
+
const third = await userFactory.create({email: `${marker}-third@example.com`});
|
|
19
|
+
const base = new Date('2099-01-01T00:00:00.000Z');
|
|
20
|
+
await setCreatedAt(actor.id, new Date(base.getTime() - 1_000));
|
|
21
|
+
await setCreatedAt(first.id, new Date(base.getTime() + 2_000));
|
|
22
|
+
await setCreatedAt(second.id, new Date(base.getTime() + 2_000));
|
|
23
|
+
await setCreatedAt(third.id, new Date(base.getTime() + 1_000));
|
|
24
|
+
const sameTimestampOrder = [first, second].sort((left, right) => {
|
|
25
|
+
if (right.id < left.id) return -1;
|
|
26
|
+
if (right.id > left.id) return 1;
|
|
27
|
+
return 0;
|
|
28
|
+
});
|
|
29
|
+
const newerSameTimestamp = sameTimestampOrder[0];
|
|
30
|
+
const olderSameTimestamp = sameTimestampOrder[1];
|
|
31
|
+
if (!newerSameTimestamp || !olderSameTimestamp) {
|
|
32
|
+
throw new Error('Expected two users with the same timestamp');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const unfiltered = await listAdministratorUserSummaries(db(), {
|
|
36
|
+
actorId: actor.id,
|
|
37
|
+
limit: 4,
|
|
38
|
+
});
|
|
39
|
+
expect(unfiltered.rows.slice(0, 4).map(({id}) => id)).toEqual([
|
|
40
|
+
newerSameTimestamp.id,
|
|
41
|
+
olderSameTimestamp.id,
|
|
42
|
+
third.id,
|
|
43
|
+
actor.id,
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
const firstPage = await listAdministratorUserSummaries(db(), {
|
|
47
|
+
actorId: actor.id,
|
|
48
|
+
limit: 2,
|
|
49
|
+
search: marker,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
expect(firstPage.rows.map(({id}) => id)).toEqual([
|
|
53
|
+
newerSameTimestamp.id,
|
|
54
|
+
olderSameTimestamp.id,
|
|
55
|
+
]);
|
|
56
|
+
expect(firstPage.nextCursor).toEqual({
|
|
57
|
+
createdAt: new Date(base.getTime() + 2_000),
|
|
58
|
+
id: olderSameTimestamp.id,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const insertedAfterPage = await userFactory.create({
|
|
62
|
+
email: `${marker}-inserted@example.com`,
|
|
63
|
+
});
|
|
64
|
+
await setCreatedAt(insertedAfterPage.id, new Date(base.getTime() + 4_000));
|
|
65
|
+
|
|
66
|
+
const secondPage = await listAdministratorUserSummaries(db(), {
|
|
67
|
+
actorId: actor.id,
|
|
68
|
+
limit: 2,
|
|
69
|
+
search: marker,
|
|
70
|
+
...(firstPage.nextCursor ? {cursor: firstPage.nextCursor} : {}),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
expect(secondPage.rows.map(({id}) => id)).toEqual([third.id, actor.id]);
|
|
74
|
+
expect(secondPage.nextCursor).toBeNull();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('uses exact UUID search and case-insensitive multi-term literal matching', async () => {
|
|
78
|
+
const actor = await userFactory.create({email: `actor-${crypto.randomUUID()}@example.com`});
|
|
79
|
+
const user = await userFactory.create({
|
|
80
|
+
email: `literal%_\\${crypto.randomUUID()}@Example.com`,
|
|
81
|
+
name: 'Ada Lovelace',
|
|
82
|
+
});
|
|
83
|
+
await userFactory.create({
|
|
84
|
+
email: `control-${crypto.randomUUID()}@example.com`,
|
|
85
|
+
name: 'Ada Byron',
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const exact = await listAdministratorUserSummaries(db(), {
|
|
89
|
+
actorId: actor.id,
|
|
90
|
+
search: user.id,
|
|
91
|
+
limit: 10,
|
|
92
|
+
});
|
|
93
|
+
expect(exact.rows.map(({id}) => id)).toEqual([user.id]);
|
|
94
|
+
|
|
95
|
+
const matching = await listAdministratorUserSummaries(db(), {
|
|
96
|
+
actorId: actor.id,
|
|
97
|
+
search: ' ADA LOVELACE ',
|
|
98
|
+
limit: 10,
|
|
99
|
+
});
|
|
100
|
+
expect(matching.rows.map(({id}) => id)).toEqual([user.id]);
|
|
101
|
+
|
|
102
|
+
const literal = await listAdministratorUserSummaries(db(), {
|
|
103
|
+
actorId: actor.id,
|
|
104
|
+
search: '%_',
|
|
105
|
+
limit: 10,
|
|
106
|
+
});
|
|
107
|
+
expect(literal.rows.map(({id}) => id)).toEqual([user.id]);
|
|
108
|
+
|
|
109
|
+
const literalBackslash = await listAdministratorUserSummaries(db(), {
|
|
110
|
+
actorId: actor.id,
|
|
111
|
+
search: '\\',
|
|
112
|
+
limit: 10,
|
|
113
|
+
});
|
|
114
|
+
expect(literalBackslash.rows.map(({id}) => id)).toEqual([user.id]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('ignores revoked grants when filtering eligibility and projecting roles', async () => {
|
|
118
|
+
const marker = `directory-revoked-${crypto.randomUUID()}`;
|
|
119
|
+
const actor = await userFactory.create({email: `${marker}-actor@example.com`});
|
|
120
|
+
const revoked = await userFactory.create({
|
|
121
|
+
email: `${marker}-revoked@example.com`,
|
|
122
|
+
emailVerifiedAt: new Date(),
|
|
123
|
+
});
|
|
124
|
+
const grant = await createAdminGrant({userId: revoked.id, role: 'admin-observer'});
|
|
125
|
+
await revokeAdminGrant({grantId: grant.id});
|
|
126
|
+
|
|
127
|
+
const eligibleRows = await listAdministratorUserSummaries(db(), {
|
|
128
|
+
actorId: actor.id,
|
|
129
|
+
search: marker,
|
|
130
|
+
eligible: true,
|
|
131
|
+
limit: 10,
|
|
132
|
+
});
|
|
133
|
+
const allRows = await listAdministratorUserSummaries(db(), {
|
|
134
|
+
actorId: actor.id,
|
|
135
|
+
search: marker,
|
|
136
|
+
limit: 10,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
expect(eligibleRows.rows.map(({id}) => id)).toEqual([revoked.id]);
|
|
140
|
+
expect(allRows.rows).toHaveLength(2);
|
|
141
|
+
expect(allRows.rows).toEqual(
|
|
142
|
+
expect.arrayContaining([
|
|
143
|
+
expect.objectContaining({id: revoked.id, adminRole: null}),
|
|
144
|
+
expect.objectContaining({id: actor.id, adminRole: null}),
|
|
145
|
+
]),
|
|
146
|
+
);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test.each(['active', 'suspended', 'deleted'] as const)('filters by %s status', async (status) => {
|
|
150
|
+
const marker = `directory-status-${status}-${crypto.randomUUID()}`;
|
|
151
|
+
const actor = await userFactory.create({email: `actor-${crypto.randomUUID()}@example.com`});
|
|
152
|
+
const user = await userFactory.create({email: `${marker}-user@example.com`});
|
|
153
|
+
await db().update(users).set({status}).where(eq(users.id, user.id));
|
|
154
|
+
|
|
155
|
+
const result = await listAdministratorUserSummaries(db(), {
|
|
156
|
+
actorId: actor.id,
|
|
157
|
+
search: marker,
|
|
158
|
+
status,
|
|
159
|
+
limit: 10,
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
expect(result.rows).toEqual([expect.objectContaining({id: user.id, status})]);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('applies eligibility rules and projects the highest effective role once per user', async () => {
|
|
166
|
+
const marker = `directory-eligible-${crypto.randomUUID()}`;
|
|
167
|
+
const actor = await userFactory.create({
|
|
168
|
+
email: `${marker}-actor@example.com`,
|
|
169
|
+
emailVerifiedAt: new Date(),
|
|
170
|
+
});
|
|
171
|
+
const eligible = await userFactory.create({
|
|
172
|
+
email: `${marker}-eligible@example.com`,
|
|
173
|
+
emailVerifiedAt: new Date(),
|
|
174
|
+
});
|
|
175
|
+
const unverified = await userFactory.create({email: `${marker}-unverified@example.com`});
|
|
176
|
+
const granted = await userFactory.create({
|
|
177
|
+
email: `${marker}-granted@example.com`,
|
|
178
|
+
emailVerifiedAt: new Date(),
|
|
179
|
+
});
|
|
180
|
+
const suspended = await userFactory.create({
|
|
181
|
+
email: `${marker}-suspended@example.com`,
|
|
182
|
+
emailVerifiedAt: new Date(),
|
|
183
|
+
});
|
|
184
|
+
const deleted = await userFactory.create({
|
|
185
|
+
email: `${marker}-deleted@example.com`,
|
|
186
|
+
emailVerifiedAt: new Date(),
|
|
187
|
+
});
|
|
188
|
+
await db().update(users).set({status: 'suspended'}).where(eq(users.id, suspended.id));
|
|
189
|
+
await db().update(users).set({status: 'deleted'}).where(eq(users.id, deleted.id));
|
|
190
|
+
await createAdminGrant({userId: granted.id, role: 'admin-observer'});
|
|
191
|
+
await createAdminGrant({userId: granted.id, role: 'admin-operator'});
|
|
192
|
+
await createAdminGrant({userId: suspended.id, role: 'admin-observer'});
|
|
193
|
+
await createAdminGrant({userId: deleted.id, role: 'admin-owner'});
|
|
194
|
+
|
|
195
|
+
const all = await listAdministratorUserSummaries(db(), {
|
|
196
|
+
actorId: actor.id,
|
|
197
|
+
search: marker,
|
|
198
|
+
limit: 20,
|
|
199
|
+
});
|
|
200
|
+
expect(all.rows.filter(({id}) => id === granted.id)).toHaveLength(1);
|
|
201
|
+
expect(all.rows.find(({id}) => id === granted.id)?.adminRole).toBe('admin-operator');
|
|
202
|
+
expect(all.rows.find(({id}) => id === suspended.id)?.adminRole).toBeNull();
|
|
203
|
+
expect(all.rows.find(({id}) => id === deleted.id)?.adminRole).toBeNull();
|
|
204
|
+
|
|
205
|
+
const eligibleRows = await listAdministratorUserSummaries(db(), {
|
|
206
|
+
actorId: actor.id,
|
|
207
|
+
search: marker,
|
|
208
|
+
eligible: true,
|
|
209
|
+
limit: 20,
|
|
210
|
+
});
|
|
211
|
+
expect(eligibleRows.rows.map(({id}) => id)).toEqual([eligible.id]);
|
|
212
|
+
expect(eligibleRows.rows).not.toEqual(
|
|
213
|
+
expect.arrayContaining([expect.objectContaining({id: actor.id})]),
|
|
214
|
+
);
|
|
215
|
+
expect(unverified.id).not.toBe(eligibleRows.rows[0]?.id);
|
|
216
|
+
|
|
217
|
+
const ineligibleRows = await listAdministratorUserSummaries(db(), {
|
|
218
|
+
actorId: actor.id,
|
|
219
|
+
search: marker,
|
|
220
|
+
eligible: false,
|
|
221
|
+
limit: 20,
|
|
222
|
+
});
|
|
223
|
+
expect(ineligibleRows.rows.map(({id}) => id)).toEqual(
|
|
224
|
+
expect.arrayContaining([actor.id, unverified.id, granted.id, suspended.id, deleted.id]),
|
|
225
|
+
);
|
|
226
|
+
expect(ineligibleRows.rows.map(({id}) => id)).not.toContain(eligible.id);
|
|
227
|
+
});
|
|
228
|
+
});
|