@cats-inc/cats-platform 0.1.10 → 0.1.21
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/.env.example +7 -10
- package/build/desktop/bootstrapPage.d.ts.map +1 -1
- package/build/desktop/bootstrapPage.js +162 -55
- package/build/desktop/bootstrapPage.js.map +1 -1
- package/build/desktop/cliInventoryProbe.d.ts +18 -0
- package/build/desktop/cliInventoryProbe.d.ts.map +1 -1
- package/build/desktop/cliInventoryProbe.js +42 -0
- package/build/desktop/cliInventoryProbe.js.map +1 -1
- package/build/desktop/contracts.d.ts +9 -0
- package/build/desktop/contracts.d.ts.map +1 -1
- package/build/desktop/main.js +101 -20
- package/build/desktop/main.js.map +1 -1
- package/build/desktop/readiness.d.ts.map +1 -1
- package/build/desktop/readiness.js +35 -2
- package/build/desktop/readiness.js.map +1 -1
- package/build/desktop/tray.d.ts +1 -1
- package/build/desktop/tray.d.ts.map +1 -1
- package/build/desktop/tray.js +2 -7
- package/build/desktop/tray.js.map +1 -1
- package/build/desktop/trayMenu.d.ts +23 -2
- package/build/desktop/trayMenu.d.ts.map +1 -1
- package/build/desktop/trayMenu.js +24 -30
- package/build/desktop/trayMenu.js.map +1 -1
- package/build/desktop/updateDialog.d.ts +40 -0
- package/build/desktop/updateDialog.d.ts.map +1 -0
- package/build/desktop/updateDialog.js +178 -0
- package/build/desktop/updateDialog.js.map +1 -0
- package/build/desktop/updateNotifications.js +2 -2
- package/build/desktop/updateNotifications.js.map +1 -1
- package/build/desktop/updaterAdapter.d.ts +1 -0
- package/build/desktop/updaterAdapter.d.ts.map +1 -1
- package/build/desktop/updaterAdapter.js +13 -1
- package/build/desktop/updaterAdapter.js.map +1 -1
- package/build/renderer/assets/{ActiveSessionPermissionChip-Dl2UOFgh.js → ActiveSessionPermissionChip-DImsuR9T.js} +1 -1
- package/build/renderer/assets/{App-CPqGDXVi.js → App-BfOvMP2m.js} +1 -1
- package/build/renderer/assets/{App-ChvP0G9X.js → App-DKOSn0o_.js} +1 -1
- package/build/renderer/assets/{App-C57f4p8k.js → App-DWd7I3Z2.js} +1 -1
- package/build/renderer/assets/{WorkspaceAppRoutes-DYg2vnjz.js → WorkspaceAppRoutes-CrbTA9En.js} +1 -1
- package/build/renderer/assets/app-BTww3Hl4.js +43 -0
- package/build/renderer/assets/{app-XnXwkcD2.css → app-CPxWQwLA.css} +1 -1
- package/build/renderer/assets/conversationSidebarRecentEntries-QuaOPxCb.js +6 -0
- package/build/renderer/assets/{useDraftSessionChips-By3XXabI.js → useDraftSessionChips-DCIId0eY.js} +1 -1
- package/build/renderer/index.html +2 -2
- package/build/server/app/renderer/auth/api.d.ts +28 -2
- package/build/server/app/renderer/auth/api.d.ts.map +1 -1
- package/build/server/app/renderer/auth/api.js +29 -4
- package/build/server/app/renderer/auth/api.js.map +1 -1
- package/build/server/app/renderer/settings/settingsDesktopUpdateLabels.d.ts +11 -4
- package/build/server/app/renderer/settings/settingsDesktopUpdateLabels.d.ts.map +1 -1
- package/build/server/app/renderer/settings/settingsDesktopUpdateLabels.js +25 -5
- package/build/server/app/renderer/settings/settingsDesktopUpdateLabels.js.map +1 -1
- package/build/server/app/server/authGatePolicy.js +1 -2
- package/build/server/app/server/authGatePolicy.js.map +1 -1
- package/build/server/app/server/authRoutes.d.ts +14 -1
- package/build/server/app/server/authRoutes.d.ts.map +1 -1
- package/build/server/app/server/authRoutes.js +395 -76
- package/build/server/app/server/authRoutes.js.map +1 -1
- package/build/server/app/server/contracts.d.ts +4 -1
- package/build/server/app/server/contracts.d.ts.map +1 -1
- package/build/server/app/server/dependencies.d.ts.map +1 -1
- package/build/server/app/server/dependencies.js +6 -1
- package/build/server/app/server/dependencies.js.map +1 -1
- package/build/server/app/server/platformSetupRoutes.d.ts +2 -1
- package/build/server/app/server/platformSetupRoutes.d.ts.map +1 -1
- package/build/server/app/server/platformSetupRoutes.js +129 -64
- package/build/server/app/server/platformSetupRoutes.js.map +1 -1
- package/build/server/app/server/requestRouter.d.ts.map +1 -1
- package/build/server/app/server/requestRouter.js +3 -0
- package/build/server/app/server/requestRouter.js.map +1 -1
- package/build/server/app/server/runtimeSurfaceProxy.d.ts.map +1 -1
- package/build/server/app/server/runtimeSurfaceProxy.js +12 -17
- package/build/server/app/server/runtimeSurfaceProxy.js.map +1 -1
- package/build/server/config.d.ts +1 -3
- package/build/server/config.d.ts.map +1 -1
- package/build/server/config.js +2 -13
- package/build/server/config.js.map +1 -1
- package/build/server/design/components/providerModelFieldsSupport.d.ts.map +1 -1
- package/build/server/design/components/providerModelFieldsSupport.js +4 -1
- package/build/server/design/components/providerModelFieldsSupport.js.map +1 -1
- package/build/server/design/components/useProviderTargetReconciliation.d.ts.map +1 -1
- package/build/server/design/components/useProviderTargetReconciliation.js +3 -1
- package/build/server/design/components/useProviderTargetReconciliation.js.map +1 -1
- package/build/server/platform/auth/actionGrant.d.ts +61 -0
- package/build/server/platform/auth/actionGrant.d.ts.map +1 -0
- package/build/server/platform/auth/actionGrant.js +108 -0
- package/build/server/platform/auth/actionGrant.js.map +1 -0
- package/build/server/platform/auth/adminCredentials.d.ts +37 -0
- package/build/server/platform/auth/adminCredentials.d.ts.map +1 -0
- package/build/server/platform/auth/adminCredentials.js +63 -0
- package/build/server/platform/auth/adminCredentials.js.map +1 -0
- package/build/server/platform/auth/bootstrap.d.ts +14 -0
- package/build/server/platform/auth/bootstrap.d.ts.map +1 -1
- package/build/server/platform/auth/bootstrap.js +29 -4
- package/build/server/platform/auth/bootstrap.js.map +1 -1
- package/build/server/platform/auth/config.d.ts.map +1 -1
- package/build/server/platform/auth/config.js +1 -1
- package/build/server/platform/auth/config.js.map +1 -1
- package/build/server/platform/auth/errorCodes.d.ts +2 -0
- package/build/server/platform/auth/errorCodes.d.ts.map +1 -1
- package/build/server/platform/auth/errorCodes.js +2 -0
- package/build/server/platform/auth/errorCodes.js.map +1 -1
- package/build/server/platform/auth/googleAccount.d.ts +40 -13
- package/build/server/platform/auth/googleAccount.d.ts.map +1 -1
- package/build/server/platform/auth/googleAccount.js +89 -70
- package/build/server/platform/auth/googleAccount.js.map +1 -1
- package/build/server/platform/auth/index.d.ts +4 -0
- package/build/server/platform/auth/index.d.ts.map +1 -1
- package/build/server/platform/auth/index.js +4 -0
- package/build/server/platform/auth/index.js.map +1 -1
- package/build/server/platform/auth/localLogin.d.ts +10 -0
- package/build/server/platform/auth/localLogin.d.ts.map +1 -1
- package/build/server/platform/auth/localLogin.js +17 -0
- package/build/server/platform/auth/localLogin.js.map +1 -1
- package/build/server/platform/auth/loginMethods.d.ts +18 -0
- package/build/server/platform/auth/loginMethods.d.ts.map +1 -0
- package/build/server/platform/auth/loginMethods.js +19 -0
- package/build/server/platform/auth/loginMethods.js.map +1 -0
- package/build/server/platform/auth/securityEvents.d.ts +31 -0
- package/build/server/platform/auth/securityEvents.d.ts.map +1 -0
- package/build/server/platform/auth/securityEvents.js +45 -0
- package/build/server/platform/auth/securityEvents.js.map +1 -0
- package/build/server/products/chat/api/contracts.d.ts +0 -8
- package/build/server/products/chat/api/contracts.d.ts.map +1 -1
- package/build/server/products/chat/api/setupRoutes.d.ts.map +1 -1
- package/build/server/products/chat/api/setupRoutes.js +2 -84
- package/build/server/products/chat/api/setupRoutes.js.map +1 -1
- package/build/server/products/shared/api/workspaceContracts.d.ts +0 -8
- package/build/server/products/shared/api/workspaceContracts.d.ts.map +1 -1
- package/build/server/runtime/client.d.ts +11 -0
- package/build/server/runtime/client.d.ts.map +1 -1
- package/build/server/runtime/client.js +23 -1
- package/build/server/runtime/client.js.map +1 -1
- package/build/server/shared/i18n/catalogs/en.d.ts.map +1 -1
- package/build/server/shared/i18n/catalogs/en.js +40 -0
- package/build/server/shared/i18n/catalogs/en.js.map +1 -1
- package/build/server/shared/i18n/catalogs/zh-TW.d.ts.map +1 -1
- package/build/server/shared/i18n/catalogs/zh-TW.js +39 -0
- package/build/server/shared/i18n/catalogs/zh-TW.js.map +1 -1
- package/build/server/shared/i18n/index.d.ts +39 -0
- package/build/server/shared/i18n/index.d.ts.map +1 -1
- package/build/server/shared/i18n/messageKeys.d.ts +39 -0
- package/build/server/shared/i18n/messageKeys.d.ts.map +1 -1
- package/build/server/shared/i18n/messageKeys.js +39 -0
- package/build/server/shared/i18n/messageKeys.js.map +1 -1
- package/build/server/shared/providerCatalog.d.ts +3 -2
- package/build/server/shared/providerCatalog.d.ts.map +1 -1
- package/build/server/shared/providerCatalog.js +9 -1
- package/build/server/shared/providerCatalog.js.map +1 -1
- package/build/server/shared/providerCatalogData.d.ts +11 -1
- package/build/server/shared/providerCatalogData.d.ts.map +1 -1
- package/build/server/shared/providerCatalogData.js +9 -0
- package/build/server/shared/providerCatalogData.js.map +1 -1
- package/build/server/shared/providerCatalogInstances.d.ts.map +1 -1
- package/build/server/shared/providerCatalogInstances.js +3 -0
- package/build/server/shared/providerCatalogInstances.js.map +1 -1
- package/package.json +1 -1
- package/scripts/README.md +30 -0
- package/scripts/linux/node-cli-common.sh +2 -75
- package/scripts/macos/node-cli-common.sh +2 -75
- package/scripts/windows/Check-WindowsSetupReadiness.ps1 +14 -443
- package/scripts/windows/Remove-MergedBranches.ps1 +229 -0
- package/build/renderer/assets/app-DVN9Ghrk.js +0 -43
- package/build/renderer/assets/conversationSidebarRecentEntries-XZVCnMLw.js +0 -6
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomBytes } from 'node:crypto';
|
|
2
|
-
import { generateSessionTokenMaterial, clearAllLoginThrottleState, collectLoginThrottleAlerts, createEmptyPlatformAuthState, createFirstAdminLocalAuthState, issueBrowserSession, isSessionChainActive, PlatformBrowserHandoffCapacityError, PLATFORM_BROWSER_HANDOFF_EXCHANGE_PATH, PLATFORM_BROWSER_HANDOFF_TTL_MS, normalizePlatformBrowserHandoffReturnTo, normalizeAccountIdentifier, authorizePlatformAuthRepairBootstrap,
|
|
2
|
+
import { generateSessionTokenMaterial, clearAllLoginThrottleState, collectLoginThrottleAlerts, createEmptyPlatformAuthState, createFirstAdminLocalAuthState, issueBrowserSession, isSessionChainActive, PlatformBrowserHandoffCapacityError, PLATFORM_BROWSER_HANDOFF_EXCHANGE_PATH, PLATFORM_BROWSER_HANDOFF_TTL_MS, normalizePlatformBrowserHandoffReturnTo, normalizeAccountIdentifier, authorizePlatformAuthRepairBootstrap, createLoginThrottleSubject, createPlatformAuthSecurityEvent, isPlatformAuthActionGrantPurpose, summarizePlatformLoginMethods, unlinkGoogleIdentityFromAccount, validatePlatformAdminCredentials, describePlatformAdminCredentialRejection, PLATFORM_AUTH_ACTION_HEADER, PLATFORM_AUTH_ERROR_CODES, PlatformAdminCredentialError, PlatformFirstAdminExistsError, evaluateLoginThrottle, recordFailedLogin, recordSuccessfulLogin, resolveBrowserPrincipalFromToken, revokeSession, revokeSessionFamily, AUTH_SESSION_COOKIE_NAME, clearAuthSessionCookie, serializeAuthSessionCookie, summarizePlatformPrincipal, evaluatePreAuthOriginGate, validateCatsCsrfToken as validateCatsSessionCsrfToken, touchSession, createGoogleBrowserSessionForLinkedIdentity, linkGoogleIdentityToAccount, validateGoogleGisCsrfToken, verifyPlatformGoogleIdentityToken, verifyPlatformLocalPasswordCredential, verifyPlatformLocalPasswordForAccount, } from '../../platform/auth/index.js';
|
|
3
3
|
import { readJsonBody, sendJson, sendMethodNotAllowed, } from '../../shared/http.js';
|
|
4
4
|
import { readGoogleCredentialRequestPayload } from './googleAuthRequest.js';
|
|
5
5
|
import { sendPlatformAuthError } from './authErrorResponses.js';
|
|
@@ -57,7 +57,7 @@ export async function routePlatformAuthApi(context) {
|
|
|
57
57
|
await handleGoogleLogin(context);
|
|
58
58
|
return true;
|
|
59
59
|
}
|
|
60
|
-
if (context.url.pathname === '/api/auth/
|
|
60
|
+
if (context.url.pathname === '/api/auth/reauth') {
|
|
61
61
|
if (context.method !== 'POST') {
|
|
62
62
|
sendMethodNotAllowed(context.response, ['POST']);
|
|
63
63
|
return true;
|
|
@@ -65,7 +65,7 @@ export async function routePlatformAuthApi(context) {
|
|
|
65
65
|
if (!enforcePreAuthOriginGate(context)) {
|
|
66
66
|
return true;
|
|
67
67
|
}
|
|
68
|
-
await
|
|
68
|
+
await handleLocalReauthentication(context);
|
|
69
69
|
return true;
|
|
70
70
|
}
|
|
71
71
|
if (context.url.pathname === '/api/auth/google/link') {
|
|
@@ -73,9 +73,23 @@ export async function routePlatformAuthApi(context) {
|
|
|
73
73
|
sendMethodNotAllowed(context.response, ['POST']);
|
|
74
74
|
return true;
|
|
75
75
|
}
|
|
76
|
+
if (!enforcePreAuthOriginGate(context)) {
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
76
79
|
await handleGoogleLink(context);
|
|
77
80
|
return true;
|
|
78
81
|
}
|
|
82
|
+
if (context.url.pathname === '/api/auth/google/unlink') {
|
|
83
|
+
if (context.method !== 'POST') {
|
|
84
|
+
sendMethodNotAllowed(context.response, ['POST']);
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
if (!enforcePreAuthOriginGate(context)) {
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
await handleGoogleUnlink(context);
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
79
93
|
if (context.url.pathname === '/api/auth/repair/first-admin') {
|
|
80
94
|
if (context.method !== 'POST') {
|
|
81
95
|
sendMethodNotAllowed(context.response, ['POST']);
|
|
@@ -369,10 +383,17 @@ async function handleRepairFirstAdmin(context) {
|
|
|
369
383
|
sendAuthError(context.response, 400, 'E_FORBIDDEN', 'Invalid auth repair request body.');
|
|
370
384
|
return;
|
|
371
385
|
}
|
|
372
|
-
|
|
373
|
-
|
|
386
|
+
// PLAN-104 Task 1.6: repair uses the same Admin credential validator as
|
|
387
|
+
// setup, without weakening the recovery-token/origin boundary below.
|
|
388
|
+
const credentialValidation = validatePlatformAdminCredentials({
|
|
389
|
+
identifier: body.identifier,
|
|
390
|
+
password: body.password,
|
|
391
|
+
});
|
|
392
|
+
if (!credentialValidation.ok) {
|
|
393
|
+
sendAuthError(context.response, 400, 'E_FORBIDDEN', describePlatformAdminCredentialRejection(credentialValidation.reason));
|
|
374
394
|
return;
|
|
375
395
|
}
|
|
396
|
+
const credentials = credentialValidation.credentials;
|
|
376
397
|
const now = context.dependencies.now?.() ?? new Date();
|
|
377
398
|
const authorization = authorizePlatformAuthRepairBootstrap({
|
|
378
399
|
remoteAddress: readRemoteAddress(context.request),
|
|
@@ -387,26 +408,47 @@ async function handleRepairFirstAdmin(context) {
|
|
|
387
408
|
sendAuthError(context.response, 403, 'E_FORBIDDEN', repairAuthorizationMessage(authorization.reason));
|
|
388
409
|
return;
|
|
389
410
|
}
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
: '
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
411
|
+
let created;
|
|
412
|
+
try {
|
|
413
|
+
created = await createFirstAdminLocalAuthState({
|
|
414
|
+
state: createEmptyPlatformAuthState(now),
|
|
415
|
+
displayName: typeof body.displayName === 'string' && body.displayName.trim()
|
|
416
|
+
? body.displayName
|
|
417
|
+
: 'Owner',
|
|
418
|
+
identifier: credentials.identifier,
|
|
419
|
+
password: credentials.password,
|
|
420
|
+
sessionSecret,
|
|
421
|
+
sessionTtlMs: context.dependencies.auth.sessionTtlMs,
|
|
422
|
+
now,
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
catch (error) {
|
|
426
|
+
if (error instanceof PlatformAdminCredentialError) {
|
|
427
|
+
sendAuthError(context.response, 400, 'E_FORBIDDEN', error.message);
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
if (error instanceof PlatformFirstAdminExistsError) {
|
|
431
|
+
sendAuthError(context.response, 409, 'E_FORBIDDEN', 'Auth repair is not required.');
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
throw error;
|
|
435
|
+
}
|
|
436
|
+
const repairedState = await context.dependencies.authStore.writeState(created.state);
|
|
402
437
|
if (authorization.mode === 'recovery_token') {
|
|
403
438
|
await context.dependencies.setAuthRecoveryTokenState?.(authorization.consumedTokenState);
|
|
404
439
|
}
|
|
440
|
+
await reportAuthSecurityEvent(context, {
|
|
441
|
+
kind: 'first_admin_created',
|
|
442
|
+
outcome: 'success',
|
|
443
|
+
accountId: created.account.id,
|
|
444
|
+
sessionId: created.session.session.id,
|
|
445
|
+
reason: 'repair',
|
|
446
|
+
});
|
|
405
447
|
sendJson(context.response, 200, buildAuthStatusPayload(context.dependencies.auth, {
|
|
406
448
|
account: created.account,
|
|
407
449
|
membership: created.membership,
|
|
408
450
|
session: created.session.session,
|
|
409
|
-
}, created.session.csrfToken), {
|
|
451
|
+
}, created.session.csrfToken, summarizePlatformLoginMethods(repairedState, created.account.id)), {
|
|
410
452
|
'Set-Cookie': serializeAuthSessionCookie(created.session.token, context.dependencies.auth.sessionTtlMs),
|
|
411
453
|
});
|
|
412
454
|
}
|
|
@@ -476,7 +518,7 @@ async function handleAuthStatus(context) {
|
|
|
476
518
|
now: context.dependencies.now?.() ?? new Date(),
|
|
477
519
|
remoteAddress: readRemoteAddress(context.request),
|
|
478
520
|
});
|
|
479
|
-
await context.dependencies.authStore.updateState((state) => ({
|
|
521
|
+
const nextState = await context.dependencies.authStore.updateState((state) => ({
|
|
480
522
|
...state,
|
|
481
523
|
sessions: state.sessions.map((session) => session.id === resolved.session.id
|
|
482
524
|
? {
|
|
@@ -491,7 +533,7 @@ async function handleAuthStatus(context) {
|
|
|
491
533
|
...touched,
|
|
492
534
|
csrfTokenHash: csrf.tokenHash,
|
|
493
535
|
},
|
|
494
|
-
}, csrf.token));
|
|
536
|
+
}, csrf.token, summarizePlatformLoginMethods(nextState, resolved.account.id)));
|
|
495
537
|
}
|
|
496
538
|
async function handleLocalLogin(context) {
|
|
497
539
|
const sessionSecret = context.dependencies.auth.sessionSecret;
|
|
@@ -546,7 +588,7 @@ async function handleLocalLogin(context) {
|
|
|
546
588
|
ttlMs: context.dependencies.auth.sessionTtlMs,
|
|
547
589
|
now,
|
|
548
590
|
});
|
|
549
|
-
await context.dependencies.authStore.updateState((current) => {
|
|
591
|
+
const nextState = await context.dependencies.authStore.updateState((current) => {
|
|
550
592
|
const cleared = recordSuccessfulLogin(current, {
|
|
551
593
|
subject: throttleSubject,
|
|
552
594
|
now,
|
|
@@ -560,7 +602,7 @@ async function handleLocalLogin(context) {
|
|
|
560
602
|
account: credential.account,
|
|
561
603
|
membership: credential.membership,
|
|
562
604
|
session: issued.session,
|
|
563
|
-
}, issued.csrfToken), {
|
|
605
|
+
}, issued.csrfToken, summarizePlatformLoginMethods(nextState, credential.account.id)), {
|
|
564
606
|
'Set-Cookie': serializeAuthSessionCookie(issued.token, context.dependencies.auth.sessionTtlMs),
|
|
565
607
|
});
|
|
566
608
|
}
|
|
@@ -605,7 +647,7 @@ async function handleGoogleLogin(context) {
|
|
|
605
647
|
sendAuthError(context.response, 401, 'E_UNAUTHENTICATED', 'Google account is not linked.');
|
|
606
648
|
return;
|
|
607
649
|
}
|
|
608
|
-
await context.dependencies.authStore.writeState(recordSuccessfulLogin(issued.state, {
|
|
650
|
+
const nextState = await context.dependencies.authStore.writeState(recordSuccessfulLogin(issued.state, {
|
|
609
651
|
subject: throttleSubject,
|
|
610
652
|
now,
|
|
611
653
|
}));
|
|
@@ -613,25 +655,74 @@ async function handleGoogleLogin(context) {
|
|
|
613
655
|
account: issued.account,
|
|
614
656
|
membership: issued.membership,
|
|
615
657
|
session: issued.session.session,
|
|
616
|
-
}, issued.session.csrfToken), {
|
|
658
|
+
}, issued.session.csrfToken, summarizePlatformLoginMethods(nextState, issued.account.id)), {
|
|
617
659
|
'Set-Cookie': serializeAuthSessionCookie(issued.session.token, context.dependencies.auth.sessionTtlMs),
|
|
618
660
|
});
|
|
619
661
|
}
|
|
620
|
-
|
|
662
|
+
/**
|
|
663
|
+
* `POST /api/auth/reauth` — local-password step-up.
|
|
664
|
+
*
|
|
665
|
+
* ADR-111 section 3 requires the sensitive-action proof to be server-owned.
|
|
666
|
+
* The renderer's password modal is presentation; this route is the boundary.
|
|
667
|
+
*/
|
|
668
|
+
async function handleLocalReauthentication(context) {
|
|
621
669
|
const sessionSecret = context.dependencies.auth.sessionSecret;
|
|
622
|
-
|
|
623
|
-
|
|
670
|
+
const actionGrantStore = context.dependencies.actionGrantStore;
|
|
671
|
+
if (!sessionSecret || !actionGrantStore) {
|
|
672
|
+
sendAuthError(context.response, 503, 'E_FORBIDDEN', 'Reauthentication is not configured.');
|
|
624
673
|
return;
|
|
625
674
|
}
|
|
626
|
-
const
|
|
627
|
-
if (!
|
|
675
|
+
const resolved = await resolveBrowserPrincipal(context);
|
|
676
|
+
if (!resolved) {
|
|
677
|
+
sendAuthError(context.response, 401, 'E_UNAUTHENTICATED', 'Authentication is required.');
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
if (!enforceCatsCsrfToken(context, resolved.session)) {
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
let body;
|
|
684
|
+
try {
|
|
685
|
+
body = await readJsonBody(context.request);
|
|
686
|
+
}
|
|
687
|
+
catch {
|
|
688
|
+
sendAuthError(context.response, 400, 'E_FORBIDDEN', 'Invalid reauthentication request body.');
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
if (!isPlatformAuthActionGrantPurpose(body.purpose)) {
|
|
692
|
+
sendAuthError(context.response, 400, 'E_FORBIDDEN', 'A supported action purpose is required.');
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
const purpose = body.purpose;
|
|
696
|
+
if (typeof body.password !== 'string' || !body.password) {
|
|
697
|
+
await reportAuthSecurityEvent(context, {
|
|
698
|
+
kind: 'step_up_failed',
|
|
699
|
+
outcome: 'failure',
|
|
700
|
+
accountId: resolved.account.id,
|
|
701
|
+
sessionId: resolved.session.id,
|
|
702
|
+
reason: 'password_required',
|
|
703
|
+
});
|
|
704
|
+
sendAuthError(context.response, 400, 'E_FORBIDDEN', 'Password is required.');
|
|
628
705
|
return;
|
|
629
706
|
}
|
|
630
|
-
const state = await context.dependencies.authStore.readState();
|
|
631
707
|
const now = context.dependencies.now?.() ?? new Date();
|
|
708
|
+
const state = await context.dependencies.authStore.readState();
|
|
709
|
+
const localIdentity = state.identities.find((identity) => identity.provider === 'local_password' && identity.accountId === resolved.account.id) ?? null;
|
|
710
|
+
if (!localIdentity) {
|
|
711
|
+
await reportAuthSecurityEvent(context, {
|
|
712
|
+
kind: 'step_up_failed',
|
|
713
|
+
outcome: 'failure',
|
|
714
|
+
accountId: resolved.account.id,
|
|
715
|
+
sessionId: resolved.session.id,
|
|
716
|
+
reason: 'local_password_missing',
|
|
717
|
+
});
|
|
718
|
+
sendAuthError(context.response, 403, 'E_REAUTH_REQUIRED', 'Local password is not available.');
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
// SPEC-113 requirement 17: failed step-up feeds the same composite and
|
|
722
|
+
// aggregate throttle policy that guards ordinary local login.
|
|
632
723
|
const throttleSubject = createLoginThrottleSubject({
|
|
633
|
-
provider: '
|
|
634
|
-
accountKey:
|
|
724
|
+
provider: 'local_password',
|
|
725
|
+
accountKey: localIdentity.providerSubject,
|
|
635
726
|
remoteAddress: readRemoteAddress(context.request),
|
|
636
727
|
});
|
|
637
728
|
const throttle = evaluateLoginThrottle(state, {
|
|
@@ -646,31 +737,43 @@ async function handleGoogleSetup(context) {
|
|
|
646
737
|
if (throttle.delayMs > 0) {
|
|
647
738
|
await sleep(throttle.delayMs, context.dependencies.sleep);
|
|
648
739
|
}
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
740
|
+
const credential = await verifyPlatformLocalPasswordForAccount(state, {
|
|
741
|
+
accountId: resolved.account.id,
|
|
742
|
+
password: body.password,
|
|
743
|
+
});
|
|
744
|
+
if (!credential) {
|
|
745
|
+
await recordFailedLoginWithAlerts(context, throttleSubject, now);
|
|
746
|
+
await reportAuthSecurityEvent(context, {
|
|
747
|
+
kind: 'step_up_failed',
|
|
748
|
+
outcome: 'failure',
|
|
749
|
+
accountId: resolved.account.id,
|
|
750
|
+
sessionId: resolved.session.id,
|
|
751
|
+
reason: 'invalid_password',
|
|
657
752
|
});
|
|
658
|
-
|
|
659
|
-
catch {
|
|
660
|
-
sendAuthError(context.response, 409, 'E_FORBIDDEN', 'First admin already exists.');
|
|
753
|
+
sendAuthError(context.response, 401, 'E_UNAUTHENTICATED', 'Invalid credentials.');
|
|
661
754
|
return;
|
|
662
755
|
}
|
|
663
|
-
await context.dependencies.authStore.
|
|
664
|
-
|
|
756
|
+
await context.dependencies.authStore.updateState((current) => recordSuccessfulLogin(current, { subject: throttleSubject, now }));
|
|
757
|
+
const grant = actionGrantStore.issue({
|
|
758
|
+
accountId: resolved.account.id,
|
|
759
|
+
sessionId: resolved.session.id,
|
|
760
|
+
purpose,
|
|
761
|
+
sessionSecret,
|
|
665
762
|
now,
|
|
666
|
-
}));
|
|
667
|
-
sendJson(context.response, 200, buildAuthStatusPayload(context.dependencies.auth, {
|
|
668
|
-
account: created.account,
|
|
669
|
-
membership: created.membership,
|
|
670
|
-
session: created.session.session,
|
|
671
|
-
}, created.session.csrfToken), {
|
|
672
|
-
'Set-Cookie': serializeAuthSessionCookie(created.session.token, context.dependencies.auth.sessionTtlMs),
|
|
673
763
|
});
|
|
764
|
+
await reportAuthSecurityEvent(context, {
|
|
765
|
+
kind: 'step_up_succeeded',
|
|
766
|
+
outcome: 'success',
|
|
767
|
+
accountId: resolved.account.id,
|
|
768
|
+
sessionId: resolved.session.id,
|
|
769
|
+
reason: purpose,
|
|
770
|
+
});
|
|
771
|
+
const payload = {
|
|
772
|
+
purpose,
|
|
773
|
+
actionToken: grant.token,
|
|
774
|
+
expiresAt: grant.expiresAt,
|
|
775
|
+
};
|
|
776
|
+
sendJson(context.response, 200, payload);
|
|
674
777
|
}
|
|
675
778
|
async function handleGoogleLink(context) {
|
|
676
779
|
const sessionSecret = context.dependencies.auth.sessionSecret;
|
|
@@ -686,50 +789,263 @@ async function handleGoogleLink(context) {
|
|
|
686
789
|
if (!enforceCatsCsrfToken(context, resolved.session)) {
|
|
687
790
|
return;
|
|
688
791
|
}
|
|
792
|
+
if (!await enforceActionGrant(context, resolved, 'link_google')) {
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
// The grant is spent from here on, whatever the outcome below
|
|
796
|
+
// (SPEC-113 requirement 33).
|
|
689
797
|
const identity = await readVerifiedBrowserGoogleIdentity(context);
|
|
690
798
|
if (!identity) {
|
|
799
|
+
await reportAuthSecurityEvent(context, {
|
|
800
|
+
kind: 'google_link_failed',
|
|
801
|
+
outcome: 'failure',
|
|
802
|
+
accountId: resolved.account.id,
|
|
803
|
+
sessionId: resolved.session.id,
|
|
804
|
+
reason: 'google_credential_rejected',
|
|
805
|
+
});
|
|
691
806
|
return;
|
|
692
807
|
}
|
|
693
808
|
const now = context.dependencies.now?.() ?? new Date();
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
809
|
+
const csrf = generateSessionTokenMaterial(sessionSecret);
|
|
810
|
+
const touched = touchSession(resolved.session, {
|
|
811
|
+
now,
|
|
812
|
+
remoteAddress: readRemoteAddress(context.request),
|
|
813
|
+
});
|
|
814
|
+
let rejection = null;
|
|
815
|
+
const nextState = await context.dependencies.authStore.updateState((state) => {
|
|
816
|
+
const outcome = linkGoogleIdentityToAccount({
|
|
817
|
+
state,
|
|
698
818
|
accountId: resolved.account.id,
|
|
699
819
|
identity,
|
|
700
820
|
now,
|
|
701
821
|
});
|
|
822
|
+
if (!outcome.ok) {
|
|
823
|
+
rejection = outcome.reason;
|
|
824
|
+
return state;
|
|
825
|
+
}
|
|
826
|
+
return {
|
|
827
|
+
...outcome.result.state,
|
|
828
|
+
sessions: outcome.result.state.sessions.map((session) => session.id === resolved.session.id
|
|
829
|
+
? { ...touched, csrfTokenHash: csrf.tokenHash }
|
|
830
|
+
: session),
|
|
831
|
+
};
|
|
832
|
+
});
|
|
833
|
+
if (rejection !== null) {
|
|
834
|
+
await reportAuthSecurityEvent(context, {
|
|
835
|
+
kind: 'google_link_failed',
|
|
836
|
+
outcome: 'failure',
|
|
837
|
+
accountId: resolved.account.id,
|
|
838
|
+
sessionId: resolved.session.id,
|
|
839
|
+
reason: rejection,
|
|
840
|
+
});
|
|
841
|
+
const mapped = mapGoogleLinkRejection(rejection);
|
|
842
|
+
sendAuthError(context.response, mapped.status, mapped.code, mapped.message);
|
|
843
|
+
return;
|
|
702
844
|
}
|
|
703
|
-
|
|
704
|
-
|
|
845
|
+
await reportAuthSecurityEvent(context, {
|
|
846
|
+
kind: 'google_link_succeeded',
|
|
847
|
+
outcome: 'success',
|
|
848
|
+
accountId: resolved.account.id,
|
|
849
|
+
sessionId: resolved.session.id,
|
|
850
|
+
reason: null,
|
|
851
|
+
});
|
|
852
|
+
const account = nextState.accounts.find((candidate) => candidate.id === resolved.account.id) ?? resolved.account;
|
|
853
|
+
sendJson(context.response, 200, buildAuthStatusPayload(context.dependencies.auth, {
|
|
854
|
+
account,
|
|
855
|
+
membership: resolved.membership,
|
|
856
|
+
session: { ...touched, csrfTokenHash: csrf.tokenHash },
|
|
857
|
+
}, csrf.token, summarizePlatformLoginMethods(nextState, resolved.account.id)));
|
|
858
|
+
}
|
|
859
|
+
async function handleGoogleUnlink(context) {
|
|
860
|
+
const sessionSecret = context.dependencies.auth.sessionSecret;
|
|
861
|
+
if (!sessionSecret) {
|
|
862
|
+
sendAuthError(context.response, 503, 'E_FORBIDDEN', 'Google unlink is not configured.');
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
const resolved = await resolveBrowserPrincipal(context);
|
|
866
|
+
if (!resolved) {
|
|
867
|
+
sendAuthError(context.response, 401, 'E_UNAUTHENTICATED', 'Authentication is required.');
|
|
705
868
|
return;
|
|
706
869
|
}
|
|
707
|
-
if (!
|
|
708
|
-
sendAuthError(context.response, 403, 'E_FORBIDDEN', 'Google account cannot be linked.');
|
|
870
|
+
if (!enforceCatsCsrfToken(context, resolved.session)) {
|
|
709
871
|
return;
|
|
710
872
|
}
|
|
873
|
+
if (!await enforceActionGrant(context, resolved, 'unlink_google')) {
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
876
|
+
const now = context.dependencies.now?.() ?? new Date();
|
|
711
877
|
const csrf = generateSessionTokenMaterial(sessionSecret);
|
|
712
878
|
const touched = touchSession(resolved.session, {
|
|
713
879
|
now,
|
|
714
880
|
remoteAddress: readRemoteAddress(context.request),
|
|
715
881
|
});
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
882
|
+
let rejection = null;
|
|
883
|
+
let revokedSessionIds = [];
|
|
884
|
+
const nextState = await context.dependencies.authStore.updateState((state) => {
|
|
885
|
+
const outcome = unlinkGoogleIdentityFromAccount({
|
|
886
|
+
state,
|
|
887
|
+
accountId: resolved.account.id,
|
|
888
|
+
keepSessionId: resolved.session.id,
|
|
889
|
+
now,
|
|
890
|
+
});
|
|
891
|
+
if (!outcome.ok) {
|
|
892
|
+
rejection = outcome.reason;
|
|
893
|
+
return state;
|
|
894
|
+
}
|
|
895
|
+
revokedSessionIds = outcome.result.revokedSessionIds;
|
|
896
|
+
return {
|
|
897
|
+
...outcome.result.state,
|
|
898
|
+
sessions: outcome.result.state.sessions.map((session) => session.id === resolved.session.id
|
|
899
|
+
? { ...touched, csrfTokenHash: csrf.tokenHash }
|
|
900
|
+
: session),
|
|
901
|
+
};
|
|
902
|
+
});
|
|
903
|
+
if (rejection !== null) {
|
|
904
|
+
await reportAuthSecurityEvent(context, {
|
|
905
|
+
kind: 'google_unlink_failed',
|
|
906
|
+
outcome: 'failure',
|
|
907
|
+
accountId: resolved.account.id,
|
|
908
|
+
sessionId: resolved.session.id,
|
|
909
|
+
reason: rejection,
|
|
910
|
+
});
|
|
911
|
+
const mapped = mapGoogleUnlinkRejection(rejection);
|
|
912
|
+
sendAuthError(context.response, mapped.status, mapped.code, mapped.message);
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
// Sessions that just lost their cookie must not keep a usable step-up
|
|
916
|
+
// capability either.
|
|
917
|
+
for (const sessionId of revokedSessionIds) {
|
|
918
|
+
context.dependencies.actionGrantStore?.revokeForSession(sessionId);
|
|
919
|
+
}
|
|
920
|
+
await reportAuthSecurityEvent(context, {
|
|
921
|
+
kind: 'google_unlink_succeeded',
|
|
922
|
+
outcome: 'success',
|
|
923
|
+
accountId: resolved.account.id,
|
|
924
|
+
sessionId: resolved.session.id,
|
|
925
|
+
reason: null,
|
|
724
926
|
});
|
|
927
|
+
const account = nextState.accounts.find((candidate) => candidate.id === resolved.account.id) ?? resolved.account;
|
|
725
928
|
sendJson(context.response, 200, buildAuthStatusPayload(context.dependencies.auth, {
|
|
726
|
-
account
|
|
727
|
-
membership:
|
|
728
|
-
session: {
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
929
|
+
account,
|
|
930
|
+
membership: resolved.membership,
|
|
931
|
+
session: { ...touched, csrfTokenHash: csrf.tokenHash },
|
|
932
|
+
}, csrf.token, summarizePlatformLoginMethods(nextState, resolved.account.id)));
|
|
933
|
+
}
|
|
934
|
+
/**
|
|
935
|
+
* Consumes the one-time action grant carried by `X-Cats-Auth-Action`.
|
|
936
|
+
* SPEC-113 requirement 21 rejects a token supplied through the URL, so the
|
|
937
|
+
* header is the only accepted transport.
|
|
938
|
+
*/
|
|
939
|
+
async function enforceActionGrant(context, resolved, purpose) {
|
|
940
|
+
const sessionSecret = context.dependencies.auth.sessionSecret;
|
|
941
|
+
const actionGrantStore = context.dependencies.actionGrantStore;
|
|
942
|
+
if (!sessionSecret || !actionGrantStore) {
|
|
943
|
+
sendAuthError(context.response, 503, 'E_FORBIDDEN', 'Sensitive actions are not configured.');
|
|
944
|
+
return false;
|
|
945
|
+
}
|
|
946
|
+
if (context.url.searchParams.has('actionToken')) {
|
|
947
|
+
sendAuthError(context.response, 403, 'E_REAUTH_REQUIRED', 'Action token must be sent as a header.');
|
|
948
|
+
return false;
|
|
949
|
+
}
|
|
950
|
+
const header = context.request.headers[PLATFORM_AUTH_ACTION_HEADER];
|
|
951
|
+
const token = typeof header === 'string' ? header : '';
|
|
952
|
+
const outcome = actionGrantStore.consume({
|
|
953
|
+
token,
|
|
954
|
+
accountId: resolved.account.id,
|
|
955
|
+
sessionId: resolved.session.id,
|
|
956
|
+
purpose,
|
|
957
|
+
sessionSecret,
|
|
958
|
+
now: context.dependencies.now?.() ?? new Date(),
|
|
959
|
+
});
|
|
960
|
+
if (!outcome.ok) {
|
|
961
|
+
await reportAuthSecurityEvent(context, {
|
|
962
|
+
kind: 'step_up_failed',
|
|
963
|
+
outcome: 'failure',
|
|
964
|
+
accountId: resolved.account.id,
|
|
965
|
+
sessionId: resolved.session.id,
|
|
966
|
+
reason: outcome.reason,
|
|
967
|
+
});
|
|
968
|
+
sendAuthError(context.response, 403, 'E_REAUTH_REQUIRED', actionGrantRejectionMessage(outcome.reason));
|
|
969
|
+
return false;
|
|
970
|
+
}
|
|
971
|
+
return true;
|
|
972
|
+
}
|
|
973
|
+
function actionGrantRejectionMessage(reason) {
|
|
974
|
+
switch (reason) {
|
|
975
|
+
case 'missing_token':
|
|
976
|
+
return 'Password confirmation is required.';
|
|
977
|
+
case 'unknown_or_expired':
|
|
978
|
+
return 'Password confirmation has expired. Confirm your password again.';
|
|
979
|
+
case 'purpose_mismatch':
|
|
980
|
+
case 'account_mismatch':
|
|
981
|
+
case 'session_mismatch':
|
|
982
|
+
return 'Password confirmation is not valid for this action.';
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
function mapGoogleLinkRejection(reason) {
|
|
986
|
+
switch (reason) {
|
|
987
|
+
case 'account_not_found':
|
|
988
|
+
return {
|
|
989
|
+
status: 403,
|
|
990
|
+
code: PLATFORM_AUTH_ERROR_CODES.forbidden,
|
|
991
|
+
message: 'Google account cannot be linked.',
|
|
992
|
+
};
|
|
993
|
+
case 'email_mismatch':
|
|
994
|
+
return {
|
|
995
|
+
status: 409,
|
|
996
|
+
code: PLATFORM_AUTH_ERROR_CODES.identityConflict,
|
|
997
|
+
message: 'The Google account email does not match this Cats account.',
|
|
998
|
+
};
|
|
999
|
+
case 'subject_owned_by_other_account':
|
|
1000
|
+
return {
|
|
1001
|
+
status: 409,
|
|
1002
|
+
code: PLATFORM_AUTH_ERROR_CODES.identityConflict,
|
|
1003
|
+
message: 'That Google account is already linked to another Cats account.',
|
|
1004
|
+
};
|
|
1005
|
+
case 'account_has_other_google_identity':
|
|
1006
|
+
return {
|
|
1007
|
+
status: 409,
|
|
1008
|
+
code: PLATFORM_AUTH_ERROR_CODES.identityConflict,
|
|
1009
|
+
message: 'This Cats account already has a different Google account linked.',
|
|
1010
|
+
};
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
function mapGoogleUnlinkRejection(reason) {
|
|
1014
|
+
switch (reason) {
|
|
1015
|
+
case 'account_not_found':
|
|
1016
|
+
return {
|
|
1017
|
+
status: 403,
|
|
1018
|
+
code: PLATFORM_AUTH_ERROR_CODES.forbidden,
|
|
1019
|
+
message: 'Google account cannot be unlinked.',
|
|
1020
|
+
};
|
|
1021
|
+
case 'google_not_linked':
|
|
1022
|
+
return {
|
|
1023
|
+
status: 409,
|
|
1024
|
+
code: PLATFORM_AUTH_ERROR_CODES.identityConflict,
|
|
1025
|
+
message: 'No Google account is linked.',
|
|
1026
|
+
};
|
|
1027
|
+
case 'local_fallback_missing':
|
|
1028
|
+
return {
|
|
1029
|
+
status: 409,
|
|
1030
|
+
code: PLATFORM_AUTH_ERROR_CODES.identityConflict,
|
|
1031
|
+
message: 'Set a local password before unlinking Google.',
|
|
1032
|
+
};
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
async function reportAuthSecurityEvent(context, input) {
|
|
1036
|
+
const reporter = context.dependencies.reportAuthSecurityEvent;
|
|
1037
|
+
if (!reporter) {
|
|
1038
|
+
return;
|
|
1039
|
+
}
|
|
1040
|
+
try {
|
|
1041
|
+
await reporter(createPlatformAuthSecurityEvent({
|
|
1042
|
+
...input,
|
|
1043
|
+
now: context.dependencies.now?.() ?? new Date(),
|
|
1044
|
+
}));
|
|
1045
|
+
}
|
|
1046
|
+
catch (error) {
|
|
1047
|
+
console.warn('[cats-platform] auth security event reporter failed', error);
|
|
1048
|
+
}
|
|
733
1049
|
}
|
|
734
1050
|
async function readVerifiedBrowserGoogleIdentity(context) {
|
|
735
1051
|
const googleClientId = context.dependencies.auth.google.clientId;
|
|
@@ -829,7 +1145,7 @@ async function resolveBrowserPrincipal(context) {
|
|
|
829
1145
|
now: context.dependencies.now?.() ?? new Date(),
|
|
830
1146
|
});
|
|
831
1147
|
}
|
|
832
|
-
function buildAuthStatusPayload(auth, resolved, csrfToken) {
|
|
1148
|
+
function buildAuthStatusPayload(auth, resolved, csrfToken, loginMethods = null) {
|
|
833
1149
|
return {
|
|
834
1150
|
authenticated: resolved !== null,
|
|
835
1151
|
principal: resolved ? summarizePrincipal(resolved) : null,
|
|
@@ -840,6 +1156,9 @@ function buildAuthStatusPayload(auth, resolved, csrfToken) {
|
|
|
840
1156
|
clientId: auth.google.clientId,
|
|
841
1157
|
},
|
|
842
1158
|
},
|
|
1159
|
+
// Requirement 11: an unauthenticated response never discloses whether an
|
|
1160
|
+
// account or provider identity exists.
|
|
1161
|
+
loginMethods: resolved ? loginMethods : null,
|
|
843
1162
|
};
|
|
844
1163
|
}
|
|
845
1164
|
function summarizePrincipal(resolved) {
|