@promptbook/cli 0.112.0-139 → 0.112.0-140
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/README.md +1 -1
- package/apps/agents-server/README.md +9 -0
- package/apps/agents-server/src/app/actions.ts +18 -7
- package/apps/agents-server/src/app/admin/login-methods/shibboleth/page.tsx +7 -17
- package/apps/agents-server/src/app/admin/metadata/MetadataClient.tsx +395 -30
- package/apps/agents-server/src/app/api/agents/export/route.ts +28 -2
- package/apps/agents-server/src/app/api/auth/change-password/route.ts +53 -6
- package/apps/agents-server/src/app/api/auth/login/route.ts +17 -8
- package/apps/agents-server/src/app/api/chat/export/pdf/route.ts +167 -2
- package/apps/agents-server/src/app/api/emails/incoming/sendgrid/route.ts +24 -2
- package/apps/agents-server/src/app/api/metadata/import/route.ts +2 -1
- package/apps/agents-server/src/app/api/users/[username]/route.ts +3 -2
- package/apps/agents-server/src/app/api/users/route.ts +5 -4
- package/apps/agents-server/src/components/Homepage/AgentsList.tsx +1 -13
- package/apps/agents-server/src/components/Homepage/AgentsListHeader.tsx +3 -19
- package/apps/agents-server/src/components/Homepage/AgentsListListView.tsx +6 -0
- package/apps/agents-server/src/components/Homepage/AgentsListViewContent.tsx +6 -0
- package/apps/agents-server/src/components/Homepage/SortableFolderCard.tsx +7 -1
- package/apps/agents-server/src/components/Homepage/useAgentsListImportExportState.ts +45 -43
- package/apps/agents-server/src/components/Homepage/useAgentsListState.ts +1 -3
- package/apps/agents-server/src/components/UsersList/useUsersAdmin.ts +2 -10
- package/apps/agents-server/src/database/seedCoreAgents.ts +8 -7
- package/apps/agents-server/src/message-providers/email/sendgrid/verifySendgridInboundParseWebhook.ts +345 -0
- package/apps/agents-server/src/utils/agentsTransfer/createAgentsExportZipStream.ts +15 -2
- package/apps/agents-server/src/utils/authenticateUser.ts +110 -3
- package/apps/agents-server/src/utils/authenticationAttemptRateLimit.ts +504 -0
- package/apps/agents-server/src/utils/backup/createBooksBackupZipStream.ts +76 -5
- package/apps/agents-server/src/utils/chatExport/renderHtmlToPdfOnServer.ts +32 -0
- package/apps/agents-server/src/utils/chatExport/sanitizeChatPdfExportHtml.ts +193 -0
- package/apps/agents-server/src/utils/currentUserIdentity.ts +22 -9
- package/apps/agents-server/src/utils/externalChatRunner/processExternalUserChatJob.ts +5 -0
- package/apps/agents-server/src/utils/getUserById.ts +19 -5
- package/apps/agents-server/src/utils/localChatRunner/processLocalUserChatJob.ts +5 -0
- package/apps/agents-server/src/utils/metadataConfigurationTransfer.ts +32 -16
- package/apps/agents-server/src/utils/publicUser.ts +59 -0
- package/apps/agents-server/src/utils/shibbolethAuthentication.ts +17 -9
- package/apps/agents-server/src/utils/userChat/createRunUserChatJobPersistenceController.ts +17 -11
- package/apps/agents-server/src/utils/userChat/createUserChatHarnessProgressCard.ts +99 -0
- package/apps/agents-server/src/utils/userChat/createUserChatRunnerProgressCard.ts +63 -0
- package/apps/agents-server/src/utils/userChat/userChatMessageLifecycle.ts +0 -3
- package/apps/agents-server/src/utils/vpsConfiguration.ts +3 -0
- package/esm/index.es.js +14 -5
- package/esm/index.es.js.map +1 -1
- package/esm/src/book-components/Chat/utils/isVisibleChatToolCall.d.ts +10 -0
- package/esm/src/version.d.ts +1 -1
- package/package.json +1 -1
- package/src/book-components/Chat/Chat/createChatMessageToolCallRenderModel.ts +2 -9
- package/src/book-components/Chat/utils/isVisibleChatToolCall.ts +23 -0
- package/src/other/templates/getTemplatesPipelineCollection.ts +868 -755
- package/src/version.ts +2 -2
- package/src/versions.txt +1 -0
- package/umd/index.umd.js +14 -5
- package/umd/index.umd.js.map +1 -1
- package/umd/src/book-components/Chat/utils/isVisibleChatToolCall.d.ts +10 -0
- package/umd/src/version.d.ts +1 -1
|
@@ -1,11 +1,35 @@
|
|
|
1
1
|
import { $getTableName } from '../database/$getTableName';
|
|
2
2
|
import { $provideSupabaseForServer } from '../database/$provideSupabaseForServer';
|
|
3
|
-
import { AgentsServerDatabase } from '../database/schema';
|
|
3
|
+
import type { AgentsServerDatabase } from '../database/schema';
|
|
4
4
|
import { verifyPassword } from './auth';
|
|
5
|
+
import {
|
|
6
|
+
AUTHENTICATION_ATTEMPT_PURPOSES,
|
|
7
|
+
checkAuthenticationAttemptRateLimit,
|
|
8
|
+
createAuthenticationAttemptRateLimitErrorMessage,
|
|
9
|
+
recordAuthenticationAttempt,
|
|
10
|
+
recordRateLimitedAuthenticationAttempt,
|
|
11
|
+
type AuthenticationAttemptPurpose,
|
|
12
|
+
type AuthenticationAttemptRateLimitRejection,
|
|
13
|
+
} from './authenticationAttemptRateLimit';
|
|
5
14
|
import { isAdminPasswordEqual } from './isAdminPasswordEqual';
|
|
6
15
|
|
|
16
|
+
/**
|
|
17
|
+
* User row shape needed for password authentication.
|
|
18
|
+
*/
|
|
19
|
+
type AuthenticationUserRow = Pick<
|
|
20
|
+
AgentsServerDatabase['public']['Tables']['User']['Row'],
|
|
21
|
+
'username' | 'isAdmin' | 'passwordHash'
|
|
22
|
+
>;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Supabase projection for user fields needed by password authentication.
|
|
26
|
+
*/
|
|
27
|
+
const AUTHENTICATION_USER_SELECT_COLUMNS = 'username, isAdmin, passwordHash';
|
|
28
|
+
|
|
7
29
|
/**
|
|
8
30
|
* Type describing authenticated user.
|
|
31
|
+
*
|
|
32
|
+
* @private internal Agents Server type
|
|
9
33
|
*/
|
|
10
34
|
export type AuthenticatedUser = {
|
|
11
35
|
username: string;
|
|
@@ -13,8 +37,91 @@ export type AuthenticatedUser = {
|
|
|
13
37
|
isGlobalAdmin?: boolean;
|
|
14
38
|
};
|
|
15
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Rate-limited authentication result for login surfaces.
|
|
42
|
+
*
|
|
43
|
+
* @private internal Agents Server type
|
|
44
|
+
*/
|
|
45
|
+
export type RateLimitedAuthenticationResult =
|
|
46
|
+
| {
|
|
47
|
+
readonly isRateLimited: true;
|
|
48
|
+
readonly rateLimitRejection: AuthenticationAttemptRateLimitRejection;
|
|
49
|
+
readonly message: string;
|
|
50
|
+
}
|
|
51
|
+
| {
|
|
52
|
+
readonly isRateLimited: false;
|
|
53
|
+
readonly user: AuthenticatedUser | null;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Options for password-checking login surfaces.
|
|
58
|
+
*
|
|
59
|
+
* @private internal Agents Server type
|
|
60
|
+
*/
|
|
61
|
+
export type RateLimitedAuthenticationOptions = {
|
|
62
|
+
readonly requestIp: string;
|
|
63
|
+
readonly purpose?: AuthenticationAttemptPurpose;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Authenticates a user and records failed/successful login attempts with route-level rate limiting.
|
|
68
|
+
*
|
|
69
|
+
* @param username - Submitted username.
|
|
70
|
+
* @param password - Submitted password.
|
|
71
|
+
* @param options - Request identity and purpose for rate limiting and forensic logs.
|
|
72
|
+
* @returns Authentication result, or a rate-limit rejection.
|
|
73
|
+
*
|
|
74
|
+
* @private internal Agents Server helper
|
|
75
|
+
*/
|
|
76
|
+
export async function authenticateUserWithRateLimit(
|
|
77
|
+
username: string,
|
|
78
|
+
password: string,
|
|
79
|
+
options: RateLimitedAuthenticationOptions,
|
|
80
|
+
): Promise<RateLimitedAuthenticationResult> {
|
|
81
|
+
const purpose = options.purpose ?? AUTHENTICATION_ATTEMPT_PURPOSES.LOGIN;
|
|
82
|
+
const rateLimitDecision = checkAuthenticationAttemptRateLimit({
|
|
83
|
+
requestIp: options.requestIp,
|
|
84
|
+
username,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
if (!rateLimitDecision.isAllowed) {
|
|
88
|
+
recordRateLimitedAuthenticationAttempt({
|
|
89
|
+
requestIp: options.requestIp,
|
|
90
|
+
username,
|
|
91
|
+
purpose,
|
|
92
|
+
rejection: rateLimitDecision,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
isRateLimited: true,
|
|
97
|
+
rateLimitRejection: rateLimitDecision,
|
|
98
|
+
message: createAuthenticationAttemptRateLimitErrorMessage(rateLimitDecision),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const user = await authenticateUser(username, password);
|
|
103
|
+
|
|
104
|
+
recordAuthenticationAttempt({
|
|
105
|
+
requestIp: options.requestIp,
|
|
106
|
+
username,
|
|
107
|
+
purpose,
|
|
108
|
+
isSuccessful: user !== null,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
isRateLimited: false,
|
|
113
|
+
user,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
16
117
|
/**
|
|
17
118
|
* Handles authenticate user.
|
|
119
|
+
*
|
|
120
|
+
* @param username - Submitted username.
|
|
121
|
+
* @param password - Submitted password.
|
|
122
|
+
* @returns Authenticated user or `null` when credentials do not match.
|
|
123
|
+
*
|
|
124
|
+
* @private internal Agents Server helper
|
|
18
125
|
*/
|
|
19
126
|
export async function authenticateUser(username: string, password: string): Promise<AuthenticatedUser | null> {
|
|
20
127
|
// 1. Check if it's the environment admin
|
|
@@ -27,7 +134,7 @@ export async function authenticateUser(username: string, password: string): Prom
|
|
|
27
134
|
const supabase = $provideSupabaseForServer();
|
|
28
135
|
const { data: user, error } = await supabase
|
|
29
136
|
.from(await $getTableName('User'))
|
|
30
|
-
.select(
|
|
137
|
+
.select(AUTHENTICATION_USER_SELECT_COLUMNS)
|
|
31
138
|
.eq('username', username)
|
|
32
139
|
.single();
|
|
33
140
|
|
|
@@ -35,7 +142,7 @@ export async function authenticateUser(username: string, password: string): Prom
|
|
|
35
142
|
return null;
|
|
36
143
|
}
|
|
37
144
|
|
|
38
|
-
const userRow = user as
|
|
145
|
+
const userRow = user as AuthenticationUserRow;
|
|
39
146
|
const isValid = await verifyPassword(password, userRow.passwordHash);
|
|
40
147
|
|
|
41
148
|
if (!isValid) {
|
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
import { NextResponse, type NextRequest } from 'next/server';
|
|
2
|
+
import { spaceTrim } from 'spacetrim';
|
|
3
|
+
import { getRequestIp } from '../middleware/createMiddlewareRequestContext/getRequestIp';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Supported password-checking surfaces protected by the shared authentication attempt limiter.
|
|
7
|
+
*
|
|
8
|
+
* @private internal Agents Server constant
|
|
9
|
+
*/
|
|
10
|
+
export const AUTHENTICATION_ATTEMPT_PURPOSES = {
|
|
11
|
+
LOGIN: 'login',
|
|
12
|
+
CHANGE_PASSWORD: 'change-password',
|
|
13
|
+
} as const;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Sliding-window length for failed authentication attempts.
|
|
17
|
+
*
|
|
18
|
+
* @private internal Agents Server constant
|
|
19
|
+
*/
|
|
20
|
+
const AUTHENTICATION_ATTEMPT_RATE_LIMIT_WINDOW_MS = 15 * 60 * 1000;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Maximum failed authentication attempts allowed from one IP address inside the sliding window.
|
|
24
|
+
*
|
|
25
|
+
* @private internal Agents Server constant
|
|
26
|
+
*/
|
|
27
|
+
const AUTHENTICATION_ATTEMPT_MAX_FAILED_ATTEMPTS_PER_IP = 30;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Maximum failed authentication attempts allowed against one username inside the sliding window.
|
|
31
|
+
*
|
|
32
|
+
* @private internal Agents Server constant
|
|
33
|
+
*/
|
|
34
|
+
const AUTHENTICATION_ATTEMPT_MAX_FAILED_ATTEMPTS_PER_USERNAME = 10;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Base delay applied after the first failed password check for the same `(IP, username)` pair.
|
|
38
|
+
*
|
|
39
|
+
* @private internal Agents Server constant
|
|
40
|
+
*/
|
|
41
|
+
const AUTHENTICATION_ATTEMPT_BASE_BACKOFF_MS = 1_000;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Maximum exponential backoff applied after repeated failures for the same `(IP, username)` pair.
|
|
45
|
+
*
|
|
46
|
+
* @private internal Agents Server constant
|
|
47
|
+
*/
|
|
48
|
+
const AUTHENTICATION_ATTEMPT_MAX_BACKOFF_MS = 5 * 60 * 1000;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Failed attempt timestamps keyed by normalized request IP address.
|
|
52
|
+
*
|
|
53
|
+
* @private internal Agents Server constant
|
|
54
|
+
*/
|
|
55
|
+
const AUTHENTICATION_IP_FAILURE_TIMESTAMPS: Map<string, Array<number>> = new Map();
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Failed attempt timestamps keyed by normalized username.
|
|
59
|
+
*
|
|
60
|
+
* @private internal Agents Server constant
|
|
61
|
+
*/
|
|
62
|
+
const AUTHENTICATION_USERNAME_FAILURE_TIMESTAMPS: Map<string, Array<number>> = new Map();
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Failed attempt state keyed by normalized `(IP, username)` pair.
|
|
66
|
+
*
|
|
67
|
+
* @private internal Agents Server constant
|
|
68
|
+
*/
|
|
69
|
+
const AUTHENTICATION_PAIR_FAILURES: Map<string, AuthenticationPairFailureState> = new Map();
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Authentication attempt purpose.
|
|
73
|
+
*
|
|
74
|
+
* @private internal Agents Server type
|
|
75
|
+
*/
|
|
76
|
+
export type AuthenticationAttemptPurpose =
|
|
77
|
+
(typeof AUTHENTICATION_ATTEMPT_PURPOSES)[keyof typeof AUTHENTICATION_ATTEMPT_PURPOSES];
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Reason why an authentication attempt was temporarily rejected.
|
|
81
|
+
*
|
|
82
|
+
* @private internal Agents Server type
|
|
83
|
+
*/
|
|
84
|
+
export type AuthenticationAttemptRateLimitReason = 'pair-backoff' | 'ip-window' | 'username-window';
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Stored failure state for one `(IP, username)` pair.
|
|
88
|
+
*
|
|
89
|
+
* @private internal Agents Server type
|
|
90
|
+
*/
|
|
91
|
+
type AuthenticationPairFailureState = {
|
|
92
|
+
readonly failureTimestampsMs: ReadonlyArray<number>;
|
|
93
|
+
readonly lockedUntilMs: number;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Successful authentication rate-limit decision.
|
|
98
|
+
*
|
|
99
|
+
* @private internal Agents Server type
|
|
100
|
+
*/
|
|
101
|
+
export type AuthenticationAttemptRateLimitApproval = {
|
|
102
|
+
readonly isAllowed: true;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Rejected authentication rate-limit decision.
|
|
107
|
+
*
|
|
108
|
+
* @private internal Agents Server type
|
|
109
|
+
*/
|
|
110
|
+
export type AuthenticationAttemptRateLimitRejection = {
|
|
111
|
+
readonly isAllowed: false;
|
|
112
|
+
readonly reason: AuthenticationAttemptRateLimitReason;
|
|
113
|
+
readonly retryAfterSeconds: number;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Authentication rate-limit decision.
|
|
118
|
+
*
|
|
119
|
+
* @private internal Agents Server type
|
|
120
|
+
*/
|
|
121
|
+
export type AuthenticationAttemptRateLimitDecision =
|
|
122
|
+
| AuthenticationAttemptRateLimitApproval
|
|
123
|
+
| AuthenticationAttemptRateLimitRejection;
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Input for an authentication attempt rate-limit check.
|
|
127
|
+
*
|
|
128
|
+
* @private internal Agents Server type
|
|
129
|
+
*/
|
|
130
|
+
export type AuthenticationAttemptRateLimitOptions = {
|
|
131
|
+
readonly requestIp: string;
|
|
132
|
+
readonly username: string;
|
|
133
|
+
readonly nowMs?: number;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Input for recording one authentication attempt outcome.
|
|
138
|
+
*
|
|
139
|
+
* @private internal Agents Server type
|
|
140
|
+
*/
|
|
141
|
+
export type AuthenticationAttemptRecordOptions = {
|
|
142
|
+
readonly requestIp: string;
|
|
143
|
+
readonly username: string;
|
|
144
|
+
readonly purpose: AuthenticationAttemptPurpose;
|
|
145
|
+
readonly isSuccessful: boolean;
|
|
146
|
+
readonly nowMs?: number;
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Input for logging one rate-limited authentication attempt.
|
|
151
|
+
*
|
|
152
|
+
* @private internal Agents Server type
|
|
153
|
+
*/
|
|
154
|
+
export type AuthenticationAttemptRateLimitedRecordOptions = {
|
|
155
|
+
readonly requestIp: string;
|
|
156
|
+
readonly username: string;
|
|
157
|
+
readonly purpose: AuthenticationAttemptPurpose;
|
|
158
|
+
readonly rejection: AuthenticationAttemptRateLimitRejection;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Checks whether a password verification attempt is currently allowed.
|
|
163
|
+
*
|
|
164
|
+
* The limiter intentionally keeps in-memory state per server process. It is a
|
|
165
|
+
* route-level defense against online guessing and complements any front-door
|
|
166
|
+
* load balancer or CDN rate limits used by multi-instance deployments.
|
|
167
|
+
*
|
|
168
|
+
* @param options - Authentication attempt identity and optional clock override.
|
|
169
|
+
* @returns Decision describing whether the attempt may proceed.
|
|
170
|
+
*
|
|
171
|
+
* @private internal Agents Server helper
|
|
172
|
+
*/
|
|
173
|
+
export function checkAuthenticationAttemptRateLimit(
|
|
174
|
+
options: AuthenticationAttemptRateLimitOptions,
|
|
175
|
+
): AuthenticationAttemptRateLimitDecision {
|
|
176
|
+
const nowMs = options.nowMs ?? Date.now();
|
|
177
|
+
const normalizedRequestIp = normalizeAuthenticationAttemptBucketPart(options.requestIp);
|
|
178
|
+
const normalizedUsername = normalizeAuthenticationAttemptBucketPart(options.username);
|
|
179
|
+
const pairKey = createAuthenticationPairKey(normalizedRequestIp, normalizedUsername);
|
|
180
|
+
|
|
181
|
+
pruneAuthenticationAttemptState(nowMs);
|
|
182
|
+
|
|
183
|
+
const pairFailureState = AUTHENTICATION_PAIR_FAILURES.get(pairKey);
|
|
184
|
+
if (pairFailureState && pairFailureState.lockedUntilMs > nowMs) {
|
|
185
|
+
return createAuthenticationAttemptRejection('pair-backoff', pairFailureState.lockedUntilMs - nowMs);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const ipFailureTimestampsMs = AUTHENTICATION_IP_FAILURE_TIMESTAMPS.get(normalizedRequestIp) || [];
|
|
189
|
+
if (ipFailureTimestampsMs.length >= AUTHENTICATION_ATTEMPT_MAX_FAILED_ATTEMPTS_PER_IP) {
|
|
190
|
+
const oldestRelevantFailureMs = ipFailureTimestampsMs[0]!;
|
|
191
|
+
return createAuthenticationAttemptRejection(
|
|
192
|
+
'ip-window',
|
|
193
|
+
oldestRelevantFailureMs + AUTHENTICATION_ATTEMPT_RATE_LIMIT_WINDOW_MS - nowMs,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const usernameFailureTimestampsMs = AUTHENTICATION_USERNAME_FAILURE_TIMESTAMPS.get(normalizedUsername) || [];
|
|
198
|
+
if (usernameFailureTimestampsMs.length >= AUTHENTICATION_ATTEMPT_MAX_FAILED_ATTEMPTS_PER_USERNAME) {
|
|
199
|
+
const oldestRelevantFailureMs = usernameFailureTimestampsMs[0]!;
|
|
200
|
+
return createAuthenticationAttemptRejection(
|
|
201
|
+
'username-window',
|
|
202
|
+
oldestRelevantFailureMs + AUTHENTICATION_ATTEMPT_RATE_LIMIT_WINDOW_MS - nowMs,
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return { isAllowed: true };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Records one completed password verification attempt and logs it for forensics.
|
|
211
|
+
*
|
|
212
|
+
* @param options - Attempt identity, purpose, outcome, and optional clock override.
|
|
213
|
+
*
|
|
214
|
+
* @private internal Agents Server helper
|
|
215
|
+
*/
|
|
216
|
+
export function recordAuthenticationAttempt(options: AuthenticationAttemptRecordOptions): void {
|
|
217
|
+
const nowMs = options.nowMs ?? Date.now();
|
|
218
|
+
const normalizedRequestIp = normalizeAuthenticationAttemptBucketPart(options.requestIp);
|
|
219
|
+
const normalizedUsername = normalizeAuthenticationAttemptBucketPart(options.username);
|
|
220
|
+
const pairKey = createAuthenticationPairKey(normalizedRequestIp, normalizedUsername);
|
|
221
|
+
|
|
222
|
+
pruneAuthenticationAttemptState(nowMs);
|
|
223
|
+
|
|
224
|
+
if (options.isSuccessful) {
|
|
225
|
+
AUTHENTICATION_PAIR_FAILURES.delete(pairKey);
|
|
226
|
+
console.info('Authentication attempt succeeded', {
|
|
227
|
+
purpose: options.purpose,
|
|
228
|
+
username: normalizedUsername,
|
|
229
|
+
requestIp: normalizedRequestIp,
|
|
230
|
+
});
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const pairFailureState = AUTHENTICATION_PAIR_FAILURES.get(pairKey);
|
|
235
|
+
const pairFailureTimestampsMs = pruneFailureTimestamps(
|
|
236
|
+
pairFailureState?.failureTimestampsMs || [],
|
|
237
|
+
nowMs,
|
|
238
|
+
).concat(nowMs);
|
|
239
|
+
const backoffMs = calculateAuthenticationAttemptBackoffMs(pairFailureTimestampsMs.length);
|
|
240
|
+
|
|
241
|
+
AUTHENTICATION_PAIR_FAILURES.set(pairKey, {
|
|
242
|
+
failureTimestampsMs: pairFailureTimestampsMs,
|
|
243
|
+
lockedUntilMs: nowMs + backoffMs,
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
appendFailureTimestamp(AUTHENTICATION_IP_FAILURE_TIMESTAMPS, normalizedRequestIp, nowMs);
|
|
247
|
+
appendFailureTimestamp(AUTHENTICATION_USERNAME_FAILURE_TIMESTAMPS, normalizedUsername, nowMs);
|
|
248
|
+
|
|
249
|
+
console.warn('Authentication attempt failed', {
|
|
250
|
+
purpose: options.purpose,
|
|
251
|
+
username: normalizedUsername,
|
|
252
|
+
requestIp: normalizedRequestIp,
|
|
253
|
+
consecutiveFailures: pairFailureTimestampsMs.length,
|
|
254
|
+
lockedUntilIso: new Date(nowMs + backoffMs).toISOString(),
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Logs one authentication attempt that was blocked before password verification.
|
|
260
|
+
*
|
|
261
|
+
* @param options - Attempt identity, purpose, and rate-limit rejection.
|
|
262
|
+
*
|
|
263
|
+
* @private internal Agents Server helper
|
|
264
|
+
*/
|
|
265
|
+
export function recordRateLimitedAuthenticationAttempt(options: AuthenticationAttemptRateLimitedRecordOptions): void {
|
|
266
|
+
const normalizedRequestIp = normalizeAuthenticationAttemptBucketPart(options.requestIp);
|
|
267
|
+
const normalizedUsername = normalizeAuthenticationAttemptBucketPart(options.username);
|
|
268
|
+
|
|
269
|
+
console.warn('Authentication attempt rate limited', {
|
|
270
|
+
purpose: options.purpose,
|
|
271
|
+
username: normalizedUsername,
|
|
272
|
+
requestIp: normalizedRequestIp,
|
|
273
|
+
reason: options.rejection.reason,
|
|
274
|
+
retryAfterSeconds: options.rejection.retryAfterSeconds,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Creates the markdown error message returned to rate-limited authentication callers.
|
|
280
|
+
*
|
|
281
|
+
* @param rejection - Rate-limit rejection details.
|
|
282
|
+
* @returns User-facing markdown error message.
|
|
283
|
+
*
|
|
284
|
+
* @private internal Agents Server helper
|
|
285
|
+
*/
|
|
286
|
+
export function createAuthenticationAttemptRateLimitErrorMessage(
|
|
287
|
+
rejection: AuthenticationAttemptRateLimitRejection,
|
|
288
|
+
): string {
|
|
289
|
+
return spaceTrim(`
|
|
290
|
+
Too many authentication attempts.
|
|
291
|
+
|
|
292
|
+
Try again in **${rejection.retryAfterSeconds}** seconds.
|
|
293
|
+
`);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Creates a standard HTTP 429 response for rate-limited authentication routes.
|
|
298
|
+
*
|
|
299
|
+
* @param rejection - Rate-limit rejection details.
|
|
300
|
+
* @returns JSON response with a `Retry-After` header.
|
|
301
|
+
*
|
|
302
|
+
* @private internal Agents Server helper
|
|
303
|
+
*/
|
|
304
|
+
export function createAuthenticationAttemptRateLimitResponse(
|
|
305
|
+
rejection: AuthenticationAttemptRateLimitRejection,
|
|
306
|
+
): NextResponse {
|
|
307
|
+
return NextResponse.json(
|
|
308
|
+
{
|
|
309
|
+
error: createAuthenticationAttemptRateLimitErrorMessage(rejection),
|
|
310
|
+
},
|
|
311
|
+
{
|
|
312
|
+
status: 429,
|
|
313
|
+
headers: {
|
|
314
|
+
'Retry-After': String(rejection.retryAfterSeconds),
|
|
315
|
+
},
|
|
316
|
+
},
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Resolves the client IP from a route handler request.
|
|
322
|
+
*
|
|
323
|
+
* @param request - Incoming route request.
|
|
324
|
+
* @returns Best-effort request IP.
|
|
325
|
+
*
|
|
326
|
+
* @private internal Agents Server helper
|
|
327
|
+
*/
|
|
328
|
+
export function resolveAuthenticationAttemptRequestIp(request: NextRequest): string {
|
|
329
|
+
return getRequestIp(request);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Resolves the client IP from a server action header store.
|
|
334
|
+
*
|
|
335
|
+
* @param headerStore - Request headers available to the server action.
|
|
336
|
+
* @returns Best-effort request IP.
|
|
337
|
+
*
|
|
338
|
+
* @private internal Agents Server helper
|
|
339
|
+
*/
|
|
340
|
+
export function resolveAuthenticationAttemptHeaderIp(headerStore: { get(name: string): string | null }): string {
|
|
341
|
+
return resolveAuthenticationAttemptForwardedIp(headerStore) || '127.0.0.1';
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Clears in-memory authentication attempt buckets for focused unit tests.
|
|
346
|
+
*
|
|
347
|
+
* @private internal Agents Server test helper
|
|
348
|
+
*/
|
|
349
|
+
export function resetAuthenticationAttemptRateLimitForTests(): void {
|
|
350
|
+
AUTHENTICATION_IP_FAILURE_TIMESTAMPS.clear();
|
|
351
|
+
AUTHENTICATION_USERNAME_FAILURE_TIMESTAMPS.clear();
|
|
352
|
+
AUTHENTICATION_PAIR_FAILURES.clear();
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Appends a failure timestamp to one sliding-window bucket.
|
|
357
|
+
*
|
|
358
|
+
* @param buckets - Sliding-window buckets.
|
|
359
|
+
* @param bucketKey - Bucket identity.
|
|
360
|
+
* @param nowMs - Current timestamp.
|
|
361
|
+
*
|
|
362
|
+
* @private internal helper of `recordAuthenticationAttempt`
|
|
363
|
+
*/
|
|
364
|
+
function appendFailureTimestamp(buckets: Map<string, Array<number>>, bucketKey: string, nowMs: number): void {
|
|
365
|
+
const recentTimestampsMs = pruneFailureTimestamps(buckets.get(bucketKey) || [], nowMs).concat(nowMs);
|
|
366
|
+
buckets.set(bucketKey, recentTimestampsMs);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Removes expired failed-attempt entries from all buckets.
|
|
371
|
+
*
|
|
372
|
+
* @param nowMs - Current timestamp.
|
|
373
|
+
*
|
|
374
|
+
* @private internal helper of `checkAuthenticationAttemptRateLimit`
|
|
375
|
+
*/
|
|
376
|
+
function pruneAuthenticationAttemptState(nowMs: number): void {
|
|
377
|
+
pruneTimestampBuckets(AUTHENTICATION_IP_FAILURE_TIMESTAMPS, nowMs);
|
|
378
|
+
pruneTimestampBuckets(AUTHENTICATION_USERNAME_FAILURE_TIMESTAMPS, nowMs);
|
|
379
|
+
|
|
380
|
+
for (const [pairKey, pairFailureState] of AUTHENTICATION_PAIR_FAILURES.entries()) {
|
|
381
|
+
const failureTimestampsMs = pruneFailureTimestamps(pairFailureState.failureTimestampsMs, nowMs);
|
|
382
|
+
if (failureTimestampsMs.length === 0 && pairFailureState.lockedUntilMs <= nowMs) {
|
|
383
|
+
AUTHENTICATION_PAIR_FAILURES.delete(pairKey);
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
AUTHENTICATION_PAIR_FAILURES.set(pairKey, {
|
|
388
|
+
failureTimestampsMs,
|
|
389
|
+
lockedUntilMs: pairFailureState.lockedUntilMs,
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Removes expired timestamps from every bucket in a timestamp map.
|
|
396
|
+
*
|
|
397
|
+
* @param buckets - Sliding-window buckets.
|
|
398
|
+
* @param nowMs - Current timestamp.
|
|
399
|
+
*
|
|
400
|
+
* @private internal helper of `pruneAuthenticationAttemptState`
|
|
401
|
+
*/
|
|
402
|
+
function pruneTimestampBuckets(buckets: Map<string, Array<number>>, nowMs: number): void {
|
|
403
|
+
for (const [bucketKey, timestampsMs] of buckets.entries()) {
|
|
404
|
+
const recentTimestampsMs = pruneFailureTimestamps(timestampsMs, nowMs);
|
|
405
|
+
if (recentTimestampsMs.length === 0) {
|
|
406
|
+
buckets.delete(bucketKey);
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
buckets.set(bucketKey, recentTimestampsMs);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Keeps only failed-attempt timestamps inside the configured sliding window.
|
|
416
|
+
*
|
|
417
|
+
* @param timestampsMs - Candidate timestamps.
|
|
418
|
+
* @param nowMs - Current timestamp.
|
|
419
|
+
* @returns Timestamps that still belong to the active window.
|
|
420
|
+
*
|
|
421
|
+
* @private internal helper of `pruneTimestampBuckets`
|
|
422
|
+
*/
|
|
423
|
+
function pruneFailureTimestamps(timestampsMs: ReadonlyArray<number>, nowMs: number): Array<number> {
|
|
424
|
+
const windowStartMs = nowMs - AUTHENTICATION_ATTEMPT_RATE_LIMIT_WINDOW_MS;
|
|
425
|
+
return timestampsMs.filter((timestampMs) => timestampMs > windowStartMs);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Calculates exponential backoff duration for one `(IP, username)` pair.
|
|
430
|
+
*
|
|
431
|
+
* @param consecutiveFailureCount - Recent consecutive failure count.
|
|
432
|
+
* @returns Backoff duration in milliseconds.
|
|
433
|
+
*
|
|
434
|
+
* @private internal helper of `recordAuthenticationAttempt`
|
|
435
|
+
*/
|
|
436
|
+
function calculateAuthenticationAttemptBackoffMs(consecutiveFailureCount: number): number {
|
|
437
|
+
const exponentialBackoffMs =
|
|
438
|
+
AUTHENTICATION_ATTEMPT_BASE_BACKOFF_MS * 2 ** Math.max(0, consecutiveFailureCount - 1);
|
|
439
|
+
|
|
440
|
+
return Math.min(AUTHENTICATION_ATTEMPT_MAX_BACKOFF_MS, exponentialBackoffMs);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Builds a normalized `(IP, username)` pair key.
|
|
445
|
+
*
|
|
446
|
+
* @param normalizedRequestIp - Normalized request IP.
|
|
447
|
+
* @param normalizedUsername - Normalized username.
|
|
448
|
+
* @returns Pair bucket key.
|
|
449
|
+
*
|
|
450
|
+
* @private internal helper of authentication attempt limiter
|
|
451
|
+
*/
|
|
452
|
+
function createAuthenticationPairKey(normalizedRequestIp: string, normalizedUsername: string): string {
|
|
453
|
+
return `ip:${normalizedRequestIp}|username:${normalizedUsername}`;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Normalizes one string used in authentication attempt buckets.
|
|
458
|
+
*
|
|
459
|
+
* @param value - Raw bucket value.
|
|
460
|
+
* @returns Normalized non-empty bucket value.
|
|
461
|
+
*
|
|
462
|
+
* @private internal helper of authentication attempt limiter
|
|
463
|
+
*/
|
|
464
|
+
function normalizeAuthenticationAttemptBucketPart(value: string): string {
|
|
465
|
+
return value.trim().toLowerCase() || '<empty>';
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Converts a retry delay in milliseconds into a rejection result.
|
|
470
|
+
*
|
|
471
|
+
* @param reason - Rejection reason.
|
|
472
|
+
* @param retryAfterMs - Retry delay in milliseconds.
|
|
473
|
+
* @returns Rejection result.
|
|
474
|
+
*
|
|
475
|
+
* @private internal helper of `checkAuthenticationAttemptRateLimit`
|
|
476
|
+
*/
|
|
477
|
+
function createAuthenticationAttemptRejection(
|
|
478
|
+
reason: AuthenticationAttemptRateLimitReason,
|
|
479
|
+
retryAfterMs: number,
|
|
480
|
+
): AuthenticationAttemptRateLimitRejection {
|
|
481
|
+
return {
|
|
482
|
+
isAllowed: false,
|
|
483
|
+
reason,
|
|
484
|
+
retryAfterSeconds: Math.ceil(Math.max(1_000, retryAfterMs) / 1_000),
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Resolves the first `x-forwarded-for` value from a header-like object.
|
|
490
|
+
*
|
|
491
|
+
* @param headerStore - Header-like object.
|
|
492
|
+
* @returns Forwarded IP or `null` when unavailable.
|
|
493
|
+
*
|
|
494
|
+
* @private internal helper of authentication attempt limiter
|
|
495
|
+
*/
|
|
496
|
+
function resolveAuthenticationAttemptForwardedIp(headerStore: { get(name: string): string | null }): string | null {
|
|
497
|
+
const xForwardedFor = headerStore.get('x-forwarded-for');
|
|
498
|
+
if (!xForwardedFor) {
|
|
499
|
+
return null;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const forwardedIp = xForwardedFor.split(',')[0];
|
|
503
|
+
return forwardedIp?.trim() || null;
|
|
504
|
+
}
|