@k2b/cloud 0.38.0 → 0.40.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/README.md +8 -0
- package/package.json +12 -5
- package/scripts/build-canvas-workers.ts +109 -29
- package/scripts/build.ts +3 -5
- package/src/_internal/app-favicon.ts +4 -2
- package/src/_internal/app-path.ts +25 -0
- package/src/_internal/app-presentation.ts +4 -0
- package/src/_internal/canvas-worker.ts +5 -3
- package/src/_internal/capabilities.ts +72 -48
- package/src/_internal/capability-reviews.ts +81 -0
- package/src/_internal/capability-schemas.ts +29 -0
- package/src/_internal/capability-sentences.ts +216 -1
- package/src/_internal/capability-streams.ts +7 -0
- package/src/_internal/define-app.ts +63 -16
- package/src/_internal/font-preloads.ts +2 -1
- package/src/_internal/panel-cookie.ts +39 -0
- package/src/_internal/registry-validation.ts +21 -26
- package/src/_internal/runtime-context.ts +1 -0
- package/src/_internal/runtime-watcher.ts +6 -0
- package/src/_internal/static-assets.ts +52 -3
- package/src/_internal/status-preserving-ssr.ts +2 -0
- package/src/access/PermissionEditor.browser-harness.tsx +57 -38
- package/src/access/PermissionEditor.tsx +32 -7
- package/src/access/ResourceApiKeys.tsx +5 -1
- package/src/account/EntitySearch.tsx +1 -1
- package/src/ai/action-review.ts +101 -0
- package/src/ai/approval-routes.ts +5 -2
- package/src/ai/approvals.ts +139 -28
- package/src/ai/background-review.ts +72 -0
- package/src/ai/browser.ts +10 -0
- package/src/ai/capabilities.ts +29 -11
- package/src/ai/capability-execution.ts +76 -43
- package/src/ai/capability-result.ts +95 -0
- package/src/ai/chat/action-card.ts +398 -0
- package/src/ai/chat/action-messages.ts +129 -0
- package/src/ai/chat/action-question.browser-harness.tsx +245 -0
- package/src/ai/chat/action-question.fixtures.ts +733 -0
- package/src/ai/chat/action-question.tsx +669 -0
- package/src/ai/chat/action-sentences.ts +1 -44
- package/src/ai/chat/blocks.tsx +19 -25
- package/src/ai/chat/message-actions.tsx +6 -0
- package/src/ai/chat/tool-action.tsx +481 -0
- package/src/ai/chat/turn-view.tsx +45 -163
- package/src/ai/chat/view-block.tsx +22 -7
- package/src/ai/chat-task-contracts.ts +31 -3
- package/src/ai/chat-task-reasons-migrate.ts +75 -0
- package/src/ai/chat-task-reasons.ts +92 -0
- package/src/ai/chat-tasks.ts +438 -78
- package/src/ai/client/controller.ts +59 -0
- package/src/ai/default-tools.ts +9 -1
- package/src/ai/executor.ts +190 -39
- package/src/ai/index.ts +14 -0
- package/src/ai/migrate.ts +39 -4
- package/src/ai/projects-routes.ts +3 -0
- package/src/ai/protocol.ts +14 -2
- package/src/ai/recent-resources.ts +240 -0
- package/src/ai/recent-sent-refs.ts +38 -0
- package/src/ai/request-context.ts +7 -1
- package/src/ai/routes.ts +172 -2
- package/src/ai/runtime.ts +13 -27
- package/src/ai/skills-routes.ts +3 -0
- package/src/ai/skills.ts +10 -1
- package/src/ai/store.ts +232 -27
- package/src/ai/timeline.ts +9 -1
- package/src/ai/tool-audit.ts +8 -0
- package/src/ai/tool-wording.ts +67 -0
- package/src/ai/turn-abort.ts +33 -0
- package/src/ai/types.ts +40 -4
- package/src/api/accounts-entities.ts +2 -1
- package/src/api/admin-ai-projects.ts +2 -0
- package/src/api/admin-ai-skills.ts +2 -0
- package/src/api/admin-ai-usage.ts +2 -0
- package/src/api/admin-core-settings.ts +190 -139
- package/src/api/admin-geoip.ts +80 -0
- package/src/api/admin-identity.ts +3 -0
- package/src/api/admin-lifecycle.ts +3 -0
- package/src/api/admin-outgoing-mail.ts +6 -2
- package/src/api/app-approval.ts +54 -6
- package/src/api/approvals-loader.ts +23 -1
- package/src/api/approvals.ts +3 -1
- package/src/api/auth.ts +25 -6
- package/src/api/capabilities.ts +44 -11
- package/src/api/capability-streams.ts +12 -0
- package/src/api/help.ts +4 -28
- package/src/api/index.ts +7 -1
- package/src/api/lasting-access-messages.ts +17 -0
- package/src/api/mcp.ts +3 -0
- package/src/api/me-panel-consent.ts +71 -0
- package/src/api/me.ts +80 -33
- package/src/api/search.ts +3 -1
- package/src/api/sync-ops.ts +2 -0
- package/src/api/widgets.ts +3 -0
- package/src/approvals/Approvals.tsx +180 -238
- package/src/approvals/messages.ts +12 -24
- package/src/browser/CloudResourceSearch.browser-harness.tsx +10 -2
- package/src/browser/CloudResourceSearch.tsx +10 -6
- package/src/browser/app-approval.ts +60 -6
- package/src/browser/commands.ts +22 -6
- package/src/browser/composer-catalog.ts +145 -0
- package/src/browser/composer-insert-messages.ts +31 -0
- package/src/browser/composer-insert.ts +160 -0
- package/src/browser/composer-open-messages.ts +17 -0
- package/src/browser/composer-open.ts +110 -0
- package/src/browser/discoverable-command.ts +6 -0
- package/src/browser/hints.ts +6 -0
- package/src/browser/live-connection.ts +26 -2
- package/src/browser/navigation-apps.ts +7 -0
- package/src/browser/panel-context-bridge.ts +30 -0
- package/src/browser/panel-context-receiver.ts +53 -0
- package/src/browser/panel-menu.tsx +56 -0
- package/src/browser/panel-messages.ts +35 -0
- package/src/browser/panel-shortcuts.ts +31 -0
- package/src/browser/panel-slot.ts +756 -0
- package/src/browser/panel.ts +65 -0
- package/src/browser/resource-picker.tsx +12 -1
- package/src/browser/search-commands.ts +6 -3
- package/src/capabilities/client.ts +8 -2
- package/src/capabilities/reviews.ts +205 -0
- package/src/capabilities/server.ts +10 -3
- package/src/capabilities/testing.ts +47 -0
- package/src/capabilities/types.ts +2 -2
- package/src/cli/account.ts +13 -3
- package/src/cli/admin/geoip.ts +115 -0
- package/src/cli/admin/index.ts +3 -0
- package/src/contracts/app-approval.ts +159 -12
- package/src/contracts/app.ts +14 -0
- package/src/contracts/approvals.ts +2 -2
- package/src/contracts/capabilities.ts +410 -18
- package/src/contracts/geoip.ts +42 -0
- package/src/contracts/index.ts +3 -0
- package/src/contracts/panel-context-plain.ts +99 -0
- package/src/contracts/panel-context.ts +93 -0
- package/src/contracts/registry.ts +3 -1
- package/src/contracts/seen-hints.ts +35 -0
- package/src/contracts/shared.ts +2 -0
- package/src/server/client-ip.ts +5 -0
- package/src/server/help-payload.ts +38 -0
- package/src/server/middleware/auth.ts +11 -1
- package/src/server/middleware/middleware.ts +9 -0
- package/src/server/middleware/no-transform.ts +19 -0
- package/src/server/middleware/rate-limit.ts +4 -6
- package/src/server/request-context/address.ts +45 -0
- package/src/server/request-context/geoip.ts +380 -0
- package/src/server/request-context/index.ts +33 -0
- package/src/server/request-context/user-agent.ts +95 -0
- package/src/server/services/access.ts +5 -1
- package/src/services/accounts/posix.ts +2 -1
- package/src/services/app-approval.ts +533 -90
- package/src/services/approvals/device.ts +9 -5
- package/src/services/approvals/index.ts +6 -5
- package/src/services/approvals/list.ts +26 -5
- package/src/services/auth-flows/notification-sender.ts +3 -0
- package/src/services/geoip/index.ts +164 -0
- package/src/services/geoip/store.ts +30 -0
- package/src/services/geoip/upload.ts +150 -0
- package/src/services/identity/routes.ts +5 -1
- package/src/services/index.ts +32 -3
- package/src/services/known-browsers.ts +312 -0
- package/src/services/login-address-store.ts +51 -0
- package/src/services/login-context.ts +118 -0
- package/src/services/mandates/index.ts +3 -0
- package/src/services/mandates/policy.ts +15 -3
- package/src/services/notifications/browser.ts +16 -5
- package/src/services/notifications/email-frame.ts +1 -1
- package/src/services/oauth-tokens.ts +16 -2
- package/src/services/panel-context-consent.ts +58 -0
- package/src/services/pdf/image-protocol.ts +135 -0
- package/src/services/pdf/image-variants.ts +124 -0
- package/src/services/pdf/image-worker.ts +31 -0
- package/src/services/pdf/image.ts +109 -0
- package/src/services/pdf/index.ts +9 -0
- package/src/services/service-account-credentials.ts +21 -0
- package/src/services/session/index.ts +97 -16
- package/src/services/session/recent.ts +13 -0
- package/src/services/settings/api.ts +11 -6
- package/src/services/settings/app.ts +5 -4
- package/src/services/settings/core-settings.ts +4 -3
- package/src/services/settings/index.ts +19 -17
- package/src/services/settings/namespace.ts +2 -1
- package/src/services/settings/store.ts +83 -11
- package/src/services/webauthn.ts +12 -0
- package/src/shared/ai-platform-prompt.ts +1 -1
- package/src/shared/app-presentation.ts +1 -0
- package/src/shared/email-html.ts +2 -0
- package/src/shared/help.ts +3 -0
- package/src/shared/redirect.ts +12 -0
- package/src/shared-text/cursor.ts +22 -0
- package/src/shared-text/documents.ts +142 -0
- package/src/shared-text/events.ts +43 -0
- package/src/shared-text/index.ts +12 -0
- package/src/shared-text/log.ts +48 -0
- package/src/shared-text/text-diff.ts +23 -0
- package/src/shared-text/types.ts +41 -0
- package/src/ssr/AdminLayout.tsx +2 -0
- package/src/ssr/AppLaunchpadPanel.tsx +2 -2
- package/src/ssr/CloudShell.island.tsx +95 -10
- package/src/ssr/Layout.tsx +66 -10
- package/src/ssr/LayoutHeader.tsx +15 -9
- package/src/ssr/LayoutHelp.browser-harness.tsx +2 -0
- package/src/ssr/LayoutHelp.tsx +435 -274
- package/src/ssr/LayoutHelpPanel.browser-harness.tsx +136 -0
- package/src/ssr/LayoutHelpTrigger.tsx +27 -2
- package/src/ssr/LayoutRail.tsx +2 -2
- package/src/ssr/LayoutTabBar.tsx +29 -0
- package/src/ssr/MobileNavigation.tsx +2 -2
- package/src/ssr/PanelLayout.tsx +35 -0
- package/src/ssr/PanelRuntime.island.tsx +94 -0
- package/src/ssr/PanelSlot.tsx +158 -0
- package/src/ssr/PanelTrigger.tsx +49 -0
- package/src/ssr/ShellRuntime.island.tsx +69 -5
- package/src/ssr/app-appearance.ts +3 -2
- package/src/ssr/app-color.ts +105 -0
- package/src/ssr/app-launchpad.tsx +18 -1
- package/src/ssr/app-navigation.ts +28 -0
- package/src/ssr/compact-shell.baseline.json +5270 -0
- package/src/ssr/compact-shell.browser-harness.ts +439 -0
- package/src/ssr/dialog-app-colors.browser-harness.tsx +43 -0
- package/src/ssr/help-messages.ts +4 -0
- package/src/ssr/help.ts +20 -0
- package/src/ssr/hints.ts +25 -0
- package/src/ssr/index.ts +5 -0
- package/src/ssr/islands/index.ts +4 -3
- package/src/ssr/layout-context.ts +2 -0
- package/src/ssr/layout-help-article.server.ts +50 -0
- package/src/ssr/layout-help-start.ts +33 -7
- package/src/ssr/panel-consent.ts +29 -0
- package/src/ssr/panel-context.ts +10 -0
- package/src/ssr/panel-slot.browser-harness.tsx +159 -0
- package/src/ssr/platform-messages.ts +48 -0
- package/src/ssr/rail-navigation.ts +44 -0
- package/src/ssr/shell-data.tsx +5 -0
- package/src/ssr/shell-shortcuts.ts +13 -2
- package/src/styles/effects.css +201 -11
- package/src/styles/file-chooser.css +2 -1
- package/src/styles/global.css +314 -63
- package/src/styles/panel.css +416 -0
- package/src/styles/resource-search.css +6 -2
- package/src/styles/tokens.css +36 -13
- package/src/styles/utilities-buttons.css +3 -2
- package/src/styles/utilities-data.css +2 -1
- package/src/styles/utilities-feedback.css +2 -2
- package/src/styles/utilities-layout.css +3 -13
- package/src/styles/utilities-markdown-editor.css +6 -6
- package/src/styles/utilities-navigation.css +22 -43
|
@@ -1,27 +1,41 @@
|
|
|
1
1
|
import { type SQL, sql } from "bun";
|
|
2
|
+
import type { z } from "zod";
|
|
2
3
|
import { lazySync } from "../_internal/process-sync";
|
|
3
4
|
import { env } from "../config/env";
|
|
4
5
|
import { type AccountCategory, accountCategory } from "../contracts/account-categories";
|
|
5
6
|
import {
|
|
7
|
+
APP_ACTION_LIMITS,
|
|
6
8
|
APP_APPROVAL_FEATURES,
|
|
7
9
|
APP_APPROVAL_PATH,
|
|
8
10
|
APP_APPROVAL_PROTOCOL,
|
|
9
11
|
type AppActionSummary,
|
|
10
12
|
type AppActionView,
|
|
13
|
+
type AppApprovalAccept,
|
|
14
|
+
type AppDeviceChange,
|
|
15
|
+
type AppDeviceChangeStatus,
|
|
11
16
|
type AppDevicePublicKey,
|
|
12
17
|
AppDevicePublicKeySchema,
|
|
13
18
|
type AppDeviceRequest,
|
|
19
|
+
type AppDeviceRevokeResultSchema,
|
|
14
20
|
type AppDeviceView,
|
|
21
|
+
AppLoginContextSchema,
|
|
22
|
+
type AppPairingConfirmResultSchema,
|
|
15
23
|
type AppPendingLogin,
|
|
16
24
|
appDeviceProofMessage,
|
|
17
25
|
appPairingProofMessage,
|
|
18
26
|
APP_APPROVAL_LIMITS as limits,
|
|
19
27
|
} from "../contracts/app-approval";
|
|
28
|
+
import { addressPrefix } from "../server/request-context/address";
|
|
20
29
|
import { publicCloudOrigin } from "../shared/app-url";
|
|
21
30
|
import { isAccountCategoryAllowed } from "./account-category-policy";
|
|
31
|
+
import { appApprovalText } from "./approvals/device";
|
|
22
32
|
import { audit } from "./audit";
|
|
33
|
+
import { countryHistory, findBrowser, trustState } from "./known-browsers";
|
|
23
34
|
import { logger } from "./logging";
|
|
35
|
+
import { type AppLoginAddressStore, appLoginAddressMatches, appLoginAddressProof, valkeyAppLoginAddressStore } from "./login-address-store";
|
|
36
|
+
import { type AppLoginRequestContext, buildLoginContext, trimAppLoginResponse } from "./login-context";
|
|
24
37
|
import { pairingSecret } from "./pairing-secret";
|
|
38
|
+
import { toPgTextArray } from "./postgres";
|
|
25
39
|
import { requireEligibleAccount, requireRecentWebSession } from "./session/recent";
|
|
26
40
|
import { CORE_SETTINGS } from "./settings/core-settings";
|
|
27
41
|
import { decryptValue } from "./settings/crypto";
|
|
@@ -109,6 +123,7 @@ type DeviceRow = {
|
|
|
109
123
|
created_at: Date;
|
|
110
124
|
last_used_at: Date | null;
|
|
111
125
|
assisted: boolean;
|
|
126
|
+
accepts: string[] | null;
|
|
112
127
|
};
|
|
113
128
|
type PairingRow = {
|
|
114
129
|
id: string;
|
|
@@ -126,7 +141,35 @@ type PairingRow = {
|
|
|
126
141
|
device_id: string | null;
|
|
127
142
|
comparison: string | null;
|
|
128
143
|
};
|
|
144
|
+
type ChangeRow = {
|
|
145
|
+
id: string;
|
|
146
|
+
issuer: string;
|
|
147
|
+
user_id: string;
|
|
148
|
+
kind: "pair" | "revoke";
|
|
149
|
+
pairing_id: string | null;
|
|
150
|
+
device_id: string | null;
|
|
151
|
+
end_sessions: boolean;
|
|
152
|
+
requested_by_sid: string;
|
|
153
|
+
auth_epoch: string;
|
|
154
|
+
state: "pending" | "approved" | "denied" | "cancelled";
|
|
155
|
+
created_at: Date;
|
|
156
|
+
expires_at: Date;
|
|
157
|
+
};
|
|
158
|
+
const changeStatus = (row: ChangeRow): AppDeviceChangeStatus => ({
|
|
159
|
+
state: row.state === "pending" && new Date(row.expires_at).getTime() <= Date.now() ? "expired" : row.state,
|
|
160
|
+
expiresAt: iso(row.expires_at),
|
|
161
|
+
});
|
|
162
|
+
const changeName = (name: string) =>
|
|
163
|
+
Array.from(name.replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, ""))
|
|
164
|
+
.slice(0, 80)
|
|
165
|
+
.join("");
|
|
129
166
|
type LoginRow = {
|
|
167
|
+
context: unknown;
|
|
168
|
+
kind: "browser" | "terminal";
|
|
169
|
+
device_authorization_id: string | null;
|
|
170
|
+
client_id: string | null;
|
|
171
|
+
scopes: string[] | null;
|
|
172
|
+
lifetime_seconds: number | null;
|
|
130
173
|
id: string;
|
|
131
174
|
user_id: string | null;
|
|
132
175
|
auth_epoch: string | null;
|
|
@@ -164,9 +207,30 @@ export type AppDeviceActions = {
|
|
|
164
207
|
approved: boolean;
|
|
165
208
|
}): Promise<boolean>;
|
|
166
209
|
};
|
|
167
|
-
|
|
210
|
+
export type AppTerminalLoginInput = {
|
|
211
|
+
deviceAuthorizationId: string;
|
|
212
|
+
identifier: string;
|
|
213
|
+
clientId: string;
|
|
214
|
+
scopes: string[];
|
|
215
|
+
lifetimeSeconds: number;
|
|
216
|
+
};
|
|
217
|
+
export type AppTerminalLogins = {
|
|
218
|
+
/** Live pending authorizations whose registry client still allows the device grant. */
|
|
219
|
+
describe(tx: SQL, ids: string[]): Promise<Map<string, { id: string; name: string }>>;
|
|
220
|
+
/** In the sign-in decision transaction; false aborts both decisions and their audits. */
|
|
221
|
+
decide(
|
|
222
|
+
tx: SQL,
|
|
223
|
+
input: {
|
|
224
|
+
deviceAuthorizationId: string;
|
|
225
|
+
clientId: string;
|
|
226
|
+
user: Pick<AccountRow, "id" | "uid" | "provider" | "profile" | "auth_epoch">;
|
|
227
|
+
decision: "approve" | "deny";
|
|
228
|
+
},
|
|
229
|
+
): Promise<boolean>;
|
|
230
|
+
};
|
|
231
|
+
// Bundle sign-in and Action wake-ups for 30 seconds while the previously woken request is still open.
|
|
168
232
|
// Cloud Login gives Actions a separate 30-per-token budget in five minutes, preserving the full sign-in budget.
|
|
169
|
-
const
|
|
233
|
+
const WAKE_SECONDS = 30;
|
|
170
234
|
/** Best-effort sign-in wake-ups through the trusted authenticator; returns the HTTP status. */
|
|
171
235
|
export type AppLoginPushSender = (
|
|
172
236
|
appOrigin: string,
|
|
@@ -232,7 +296,25 @@ export const createAppApprovalService = (
|
|
|
232
296
|
configuration = (requireEnabled = true) => readAppApprovalConfig(db, requireEnabled),
|
|
233
297
|
hints: AppLoginDecisionHints = syncLoginDecisionHints,
|
|
234
298
|
push: AppLoginPushSender = fetchPushSender,
|
|
299
|
+
addresses: AppLoginAddressStore = valkeyAppLoginAddressStore,
|
|
235
300
|
) => {
|
|
301
|
+
// Error messages may contain their inputs; log only the error class, never addresses or proofs.
|
|
302
|
+
const addressFailure = (error: unknown) =>
|
|
303
|
+
log.warn("App login address hint unavailable", { error: error instanceof Error ? error.name : "UnknownError" });
|
|
304
|
+
const addSameNetwork = async (logins: AppPendingLogin[], address?: string) => {
|
|
305
|
+
const prefix = addressPrefix(address ?? "unknown");
|
|
306
|
+
const browserLogins = logins.filter((login) => !login.terminal && login.context);
|
|
307
|
+
if (!prefix || !browserLogins.length) return;
|
|
308
|
+
try {
|
|
309
|
+
const proofs = await addresses.read(browserLogins.map((login) => login.requestId));
|
|
310
|
+
for (const login of browserLogins) {
|
|
311
|
+
const proof = proofs.get(login.requestId);
|
|
312
|
+
if (proof && login.context && appLoginAddressMatches(proof, prefix)) login.context.sameNetwork = true;
|
|
313
|
+
}
|
|
314
|
+
} catch (error) {
|
|
315
|
+
addressFailure(error);
|
|
316
|
+
}
|
|
317
|
+
};
|
|
236
318
|
const config = async (requireEnabled = true) => {
|
|
237
319
|
const value = await configuration(requireEnabled);
|
|
238
320
|
if (requireEnabled && (!value.enabled || !value.appOrigin)) return reject("UNAVAILABLE", 503);
|
|
@@ -246,12 +328,14 @@ export const createAppApprovalService = (
|
|
|
246
328
|
const cleanup = async () => {
|
|
247
329
|
// Bounded opportunistic maintenance: each accepted write retires up to 100
|
|
248
330
|
// expired rows per table, so bursts cannot trigger an unbounded sweep.
|
|
249
|
-
await db`DELETE FROM auth.
|
|
250
|
-
await db`DELETE FROM auth.
|
|
331
|
+
await db`DELETE FROM auth.app_device_changes WHERE id IN (SELECT id FROM auth.app_device_changes WHERE expires_at < now() - interval '1 minute' ORDER BY expires_at LIMIT 100 FOR UPDATE SKIP LOCKED)`;
|
|
332
|
+
await db`DELETE FROM auth.app_pairings WHERE id IN (SELECT p.id FROM auth.app_pairings p
|
|
333
|
+
WHERE p.expires_at < now() - interval '1 minute' AND NOT EXISTS (SELECT 1 FROM auth.app_device_changes c WHERE c.pairing_id=p.id)
|
|
334
|
+
ORDER BY p.expires_at LIMIT 100 FOR UPDATE SKIP LOCKED)`;
|
|
335
|
+
await db`DELETE FROM auth.app_logins WHERE id IN (SELECT id FROM auth.app_logins WHERE expires_at < now() AND NOT (kind='terminal' AND state='approved') ORDER BY expires_at LIMIT 100 FOR UPDATE SKIP LOCKED)`;
|
|
251
336
|
await db`DELETE FROM auth.app_device_proofs WHERE (device_id,jti) IN (SELECT device_id,jti FROM auth.app_device_proofs WHERE expires_at < now() ORDER BY expires_at LIMIT 100 FOR UPDATE SKIP LOCKED)`;
|
|
252
337
|
};
|
|
253
|
-
const
|
|
254
|
-
await fresh(tx, actor);
|
|
338
|
+
const pairingOwner = async (tx: SQL, actor: AppApprovalActor, pairing: PairingRow, cfg: AppApprovalConfig) => {
|
|
255
339
|
if (
|
|
256
340
|
pairing.initiated_by !== actor.userId ||
|
|
257
341
|
pairing.initiator_sid !== actor.sid ||
|
|
@@ -260,6 +344,10 @@ export const createAppApprovalService = (
|
|
|
260
344
|
return reject("FORBIDDEN", 403);
|
|
261
345
|
return eligible(tx, pairing.user_id);
|
|
262
346
|
};
|
|
347
|
+
const authorizePairing = async (tx: SQL, actor: AppApprovalActor, pairing: PairingRow, cfg: AppApprovalConfig) => {
|
|
348
|
+
await fresh(tx, actor);
|
|
349
|
+
return pairingOwner(tx, actor, pairing, cfg);
|
|
350
|
+
};
|
|
263
351
|
const activeDevice = async (tx: SQL, id: string, issuer: string): Promise<DeviceRow> => {
|
|
264
352
|
const [device] = await tx<DeviceRow[]>`SELECT * FROM auth.app_devices WHERE id = ${id}::uuid AND issuer = ${issuer} FOR UPDATE`;
|
|
265
353
|
if (!device || device.revoked_at) return reject("FORBIDDEN", 403);
|
|
@@ -267,14 +355,68 @@ export const createAppApprovalService = (
|
|
|
267
355
|
return device;
|
|
268
356
|
};
|
|
269
357
|
|
|
358
|
+
const enrollPairing = async (tx: SQL, p: PairingRow, issuer: string) => {
|
|
359
|
+
if (!p.device_id || !p.name) return reject("CONFLICT", 409);
|
|
360
|
+
const [count] = await tx<{ count: number }[]>`SELECT count(*)::int AS count FROM auth.app_devices
|
|
361
|
+
WHERE issuer=${issuer} AND user_id=${p.user_id}::uuid AND revoked_at IS NULL`;
|
|
362
|
+
if ((count?.count ?? 0) >= limits.devicesPerAccount) return reject("LIMIT_REACHED", 429);
|
|
363
|
+
const key = AppDevicePublicKeySchema.parse(p.public_key);
|
|
364
|
+
const existing =
|
|
365
|
+
await tx`SELECT id FROM auth.app_devices WHERE issuer=${issuer} AND user_id=${p.user_id}::uuid AND public_key=${JSON.stringify(key)}::text::jsonb`;
|
|
366
|
+
if (existing.length) return reject("CONFLICT", 409);
|
|
367
|
+
await tx`INSERT INTO auth.app_devices(id,issuer,user_id,name,public_key,assisted,enrolled_by)
|
|
368
|
+
VALUES (${p.device_id}::uuid,${issuer},${p.user_id}::uuid,${p.name},${JSON.stringify(key)}::text::jsonb,${p.assisted},${p.initiated_by}::uuid)`;
|
|
369
|
+
await tx`UPDATE auth.app_pairings SET state='confirmed' WHERE id=${p.id}::uuid`;
|
|
370
|
+
await record(tx, "device.enroll", p.initiated_by, p.device_id, { targetUserId: p.user_id, assisted: p.assisted });
|
|
371
|
+
};
|
|
372
|
+
const createChange = async (
|
|
373
|
+
tx: SQL,
|
|
374
|
+
cfg: AppApprovalConfig,
|
|
375
|
+
actor: AppApprovalActor,
|
|
376
|
+
epoch: string,
|
|
377
|
+
kind: ChangeRow["kind"],
|
|
378
|
+
targetId: string,
|
|
379
|
+
pairingId: string | null,
|
|
380
|
+
endSessions: boolean,
|
|
381
|
+
) => {
|
|
382
|
+
const [count] = await tx<{ count: number }[]>`SELECT count(*)::int AS count FROM auth.app_device_changes
|
|
383
|
+
WHERE issuer=${cfg.issuer} AND user_id=${actor.userId}::uuid AND state='pending' AND expires_at>now()`;
|
|
384
|
+
if ((count?.count ?? 0) >= limits.pendingPerAccount) return reject("LIMIT_REACHED", 429);
|
|
385
|
+
const id = crypto.randomUUID(),
|
|
386
|
+
expiresAt = future(limits.loginSeconds);
|
|
387
|
+
const [row] = await tx<ChangeRow[]>`INSERT INTO auth.app_device_changes
|
|
388
|
+
(id,issuer,user_id,kind,pairing_id,device_id,end_sessions,requested_by_sid,auth_epoch,expires_at)
|
|
389
|
+
VALUES (${id}::uuid,${cfg.issuer},${actor.userId}::uuid,${kind},${pairingId}::uuid,
|
|
390
|
+
${kind === "revoke" ? targetId : null}::uuid,${endSessions},${actor.sid}::uuid,${epoch},${expiresAt}) RETURNING *`;
|
|
391
|
+
// Audit metadata redacts keys that name sessions, so the choice is recorded as `endSignIns`.
|
|
392
|
+
await record(tx, "device_change.request", actor.userId, targetId, { changeId: id, kind, endSignIns: endSessions });
|
|
393
|
+
return row!;
|
|
394
|
+
};
|
|
395
|
+
const ownedChange = async (tx: SQL, cfg: AppApprovalConfig, actor: AppApprovalActor, id: string) => {
|
|
396
|
+
const [row] = await tx<ChangeRow[]>`SELECT * FROM auth.app_device_changes
|
|
397
|
+
WHERE id=${id}::uuid AND issuer=${cfg.issuer} AND user_id=${actor.userId}::uuid AND requested_by_sid=${actor.sid}::uuid`;
|
|
398
|
+
if (!row) return reject("UNAVAILABLE", 404);
|
|
399
|
+
return row;
|
|
400
|
+
};
|
|
401
|
+
|
|
270
402
|
/** Runs after the login commit and never delays or fails it. The body names only
|
|
271
403
|
* this Cloud and the request id; a 410 means the phone's subscription is gone. */
|
|
272
|
-
const wake = async (cfg: AppApprovalConfig, userId: string, requestId: string) => {
|
|
404
|
+
const wake = async (cfg: AppApprovalConfig, userId: string, requestId: string, options?: { accept: AppApprovalAccept }) => {
|
|
405
|
+
if (!cfg.enabled || !cfg.appOrigin) return;
|
|
273
406
|
try {
|
|
274
|
-
|
|
275
|
-
|
|
407
|
+
// A withdrawn device change keeps its window, so withdrawing and asking again cannot push without bound.
|
|
408
|
+
const rows = await db<{ push_token: string }[]>`
|
|
409
|
+
UPDATE auth.app_devices d SET login_wake_at=now(), login_wake_ref=${requestId}::uuid
|
|
410
|
+
WHERE issuer=${cfg.issuer} AND user_id=${userId}::uuid AND revoked_at IS NULL AND push_token IS NOT NULL
|
|
411
|
+
AND (${options?.accept ?? null}::text IS NULL OR ${options?.accept ?? null}::text=ANY(d.accepts))
|
|
412
|
+
AND (d.login_wake_at IS NULL OR d.login_wake_at <= now() - ${WAKE_SECONDS} * interval '1 second'
|
|
413
|
+
OR (NOT EXISTS (SELECT 1 FROM auth.app_logins w
|
|
414
|
+
WHERE w.id=d.login_wake_ref AND w.state='pending' AND w.expires_at > now())
|
|
415
|
+
AND NOT EXISTS (SELECT 1 FROM auth.app_device_changes w
|
|
416
|
+
WHERE w.id=d.login_wake_ref AND w.state IN ('pending','cancelled') AND w.expires_at > now())))
|
|
417
|
+
RETURNING push_token`;
|
|
276
418
|
await Promise.all(
|
|
277
|
-
rows.map(
|
|
419
|
+
Array.from(new Set(rows.map((row) => row.push_token)), async (token) => {
|
|
278
420
|
const status = await push(cfg.appOrigin, { token, cloudOrigin: cfg.issuer, requestRef: requestId });
|
|
279
421
|
if (status === 410)
|
|
280
422
|
await db`UPDATE auth.app_devices SET push_token=NULL WHERE issuer=${cfg.issuer} AND user_id=${userId}::uuid AND push_token=${token}`;
|
|
@@ -285,6 +427,31 @@ export const createAppApprovalService = (
|
|
|
285
427
|
}
|
|
286
428
|
};
|
|
287
429
|
|
|
430
|
+
const loginCandidate = async (tx: SQL, identifier: string, category?: AccountCategory) => {
|
|
431
|
+
const candidates = await tx<AccountRow[]>`
|
|
432
|
+
SELECT id,uid,provider,profile,auth_epoch,account_expires FROM auth.users
|
|
433
|
+
WHERE lower(uid)=lower(${identifier}) OR lower(btrim(mail))=lower(${identifier}) LIMIT 2`;
|
|
434
|
+
const candidate = candidates.length === 1 ? candidates[0] : undefined;
|
|
435
|
+
if (
|
|
436
|
+
candidate &&
|
|
437
|
+
((category !== undefined && accountCategory(candidate) !== category) ||
|
|
438
|
+
(candidate.account_expires && new Date(candidate.account_expires).getTime() <= Date.now()) ||
|
|
439
|
+
!(await isAccountCategoryAllowed(candidate, tx)))
|
|
440
|
+
)
|
|
441
|
+
return undefined;
|
|
442
|
+
if (!candidate) return undefined;
|
|
443
|
+
// Re-read under the account lock: starts share one budget, and epoch changes cannot race the snapshot.
|
|
444
|
+
const [locked] = await tx<AccountRow[]>`SELECT id,uid,provider,profile,auth_epoch,account_expires
|
|
445
|
+
FROM auth.users WHERE id=${candidate.id}::uuid FOR UPDATE`;
|
|
446
|
+
if (locked?.account_expires && new Date(locked.account_expires).getTime() <= Date.now()) return undefined;
|
|
447
|
+
return locked;
|
|
448
|
+
};
|
|
449
|
+
const loginBudget = async (tx: SQL, issuer: string, userId: string) => {
|
|
450
|
+
const [count] = await tx<{ count: number }[]>`SELECT count(*)::int AS count FROM auth.app_logins
|
|
451
|
+
WHERE issuer=${issuer} AND user_id=${userId}::uuid AND expires_at > now() AND state IN ('pending','approved')`;
|
|
452
|
+
return (count?.count ?? 0) < limits.pendingPerAccount;
|
|
453
|
+
};
|
|
454
|
+
|
|
288
455
|
return {
|
|
289
456
|
config,
|
|
290
457
|
cleanup,
|
|
@@ -303,7 +470,7 @@ export const createAppApprovalService = (
|
|
|
303
470
|
AND device.issuer=${cfg.issuer} AND device.revoked_at IS NULL AND device.push_token IS NOT NULL
|
|
304
471
|
AND device.actions_at IS NOT NULL
|
|
305
472
|
LIMIT ${limits.devicesPerAccount}
|
|
306
|
-
) AND (d.action_wake_at IS NULL OR d.action_wake_at <= now() - ${
|
|
473
|
+
) AND (d.action_wake_at IS NULL OR d.action_wake_at <= now() - ${WAKE_SECONDS} * interval '1 second'
|
|
307
474
|
OR NOT EXISTS (SELECT 1 FROM approvals.requests w
|
|
308
475
|
WHERE w.id=d.action_wake_ref AND w.status='pending' AND w.expires_at > now()))
|
|
309
476
|
RETURNING d.push_token,d.user_id`;
|
|
@@ -387,7 +554,9 @@ export const createAppApprovalService = (
|
|
|
387
554
|
pairingResult: async (id: string, token: string, key: AppDevicePublicKey) => {
|
|
388
555
|
const cfg = await config();
|
|
389
556
|
const [p] = await db<PairingRow[]>`SELECT * FROM auth.app_pairings WHERE id = ${id}::uuid AND issuer = ${cfg.issuer}`;
|
|
390
|
-
if (!p || !matches(token, p.secret_hash)
|
|
557
|
+
if (!p || !matches(token, p.secret_hash)) return reject("FORBIDDEN", 403);
|
|
558
|
+
const age = Date.now() - new Date(p.expires_at).getTime();
|
|
559
|
+
if (age >= 0 && !(age < 60_000 && (p.state === "confirmed" || p.state === "cancelled"))) return reject("FORBIDDEN", 403);
|
|
391
560
|
if (p.public_key) {
|
|
392
561
|
const claimed = AppDevicePublicKeySchema.parse(p.public_key);
|
|
393
562
|
if (claimed.x !== key.x || claimed.y !== key.y) return reject("FORBIDDEN", 403);
|
|
@@ -398,16 +567,33 @@ export const createAppApprovalService = (
|
|
|
398
567
|
const cfg = await config();
|
|
399
568
|
return db.begin(async (tx) => {
|
|
400
569
|
const [p] = await tx<PairingRow[]>`SELECT * FROM auth.app_pairings WHERE id = ${id}::uuid AND issuer = ${cfg.issuer}`;
|
|
401
|
-
if (!p
|
|
570
|
+
if (!p) return reject("UNAVAILABLE", 404);
|
|
571
|
+
const [change] = await tx<ChangeRow[]>`SELECT * FROM auth.app_device_changes WHERE pairing_id=${p.id}::uuid`;
|
|
572
|
+
const age = Date.now() - new Date(p.expires_at).getTime();
|
|
573
|
+
if (age >= 0 && !(age < 60_000 && (p.state === "confirmed" || change !== undefined))) return reject("UNAVAILABLE", 404);
|
|
402
574
|
// A pairing from before the account was signed out everywhere reads as expired.
|
|
403
|
-
|
|
404
|
-
|
|
575
|
+
const user = await (change ? pairingOwner(tx, actor, p, cfg) : authorizePairing(tx, actor, p, cfg));
|
|
576
|
+
if (user.auth_epoch !== p.auth_epoch) return reject("UNAVAILABLE", 404);
|
|
577
|
+
return {
|
|
578
|
+
state: p.state,
|
|
579
|
+
name: p.name,
|
|
580
|
+
comparison: p.comparison,
|
|
581
|
+
deviceId: p.device_id,
|
|
582
|
+
userId: p.user_id,
|
|
583
|
+
approval: change ? changeStatus(change) : null,
|
|
584
|
+
};
|
|
405
585
|
});
|
|
406
586
|
},
|
|
407
|
-
confirmPairing: async (actor: AppApprovalActor, id: string, code: string) => {
|
|
587
|
+
confirmPairing: async (actor: AppApprovalActor, id: string, code: string): Promise<z.infer<typeof AppPairingConfirmResultSchema>> => {
|
|
408
588
|
const cfg = await config();
|
|
409
|
-
|
|
410
|
-
|
|
589
|
+
let wakeId: string | undefined;
|
|
590
|
+
const result = await db.begin(async (tx) => {
|
|
591
|
+
const [snapshot] = await tx<PairingRow[]>`SELECT * FROM auth.app_pairings WHERE id=${id}::uuid AND issuer=${cfg.issuer}`;
|
|
592
|
+
if (!snapshot) return reject("CONFLICT", 409);
|
|
593
|
+
await tx`SELECT id FROM auth.users WHERE id=${snapshot.user_id}::uuid FOR NO KEY UPDATE`;
|
|
594
|
+
await tx`SELECT id FROM auth.app_devices WHERE issuer=${cfg.issuer} AND user_id=${snapshot.user_id}::uuid AND revoked_at IS NULL ORDER BY id FOR UPDATE`;
|
|
595
|
+
const [change] = await tx<ChangeRow[]>`SELECT * FROM auth.app_device_changes WHERE pairing_id=${id}::uuid FOR UPDATE`;
|
|
596
|
+
const [p] = await tx<PairingRow[]>`SELECT * FROM auth.app_pairings WHERE id=${id}::uuid AND issuer=${cfg.issuer} FOR UPDATE`;
|
|
411
597
|
if (
|
|
412
598
|
!p ||
|
|
413
599
|
p.state !== "claimed" ||
|
|
@@ -417,36 +603,66 @@ export const createAppApprovalService = (
|
|
|
417
603
|
!p.name
|
|
418
604
|
)
|
|
419
605
|
return reject("CONFLICT", 409);
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
if (
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
await tx
|
|
435
|
-
|
|
436
|
-
return { deviceId: p.device_id };
|
|
606
|
+
const user = await authorizePairing(tx, actor, p, cfg);
|
|
607
|
+
if (user.auth_epoch !== p.auth_epoch) return reject("CONFLICT", 409);
|
|
608
|
+
if (change) {
|
|
609
|
+
if (changeStatus(change).state !== "pending") return reject("CONFLICT", 409);
|
|
610
|
+
return { deviceId: p.device_id, approval: changeStatus(change) };
|
|
611
|
+
}
|
|
612
|
+
const [active] =
|
|
613
|
+
await tx`SELECT id FROM auth.app_devices WHERE issuer=${cfg.issuer} AND user_id=${p.user_id}::uuid AND revoked_at IS NULL LIMIT 1`;
|
|
614
|
+
if (!p.assisted && active) {
|
|
615
|
+
const requested = await createChange(tx, cfg, actor, user.auth_epoch, "pair", p.device_id, p.id, false);
|
|
616
|
+
await tx`UPDATE auth.app_pairings SET expires_at=GREATEST(expires_at,${requested.expires_at}) WHERE id=${p.id}::uuid`;
|
|
617
|
+
wakeId = requested.id;
|
|
618
|
+
return { deviceId: p.device_id, approval: changeStatus(requested) };
|
|
619
|
+
}
|
|
620
|
+
await enrollPairing(tx, p, cfg.issuer);
|
|
621
|
+
return { deviceId: p.device_id, approval: null };
|
|
437
622
|
});
|
|
623
|
+
if (wakeId) void wake(cfg, actor.userId, wakeId, { accept: "device-changes" });
|
|
624
|
+
return result;
|
|
438
625
|
},
|
|
439
626
|
cancelPairing: async (actor: AppApprovalActor, id: string) => {
|
|
440
627
|
const cfg = await config();
|
|
441
628
|
return db.begin(async (tx) => {
|
|
442
|
-
const [
|
|
629
|
+
const [snapshot] = await tx<PairingRow[]>`SELECT * FROM auth.app_pairings WHERE id=${id}::uuid AND issuer=${cfg.issuer}`;
|
|
630
|
+
if (!snapshot) return reject("UNAVAILABLE", 404);
|
|
631
|
+
await tx`SELECT id FROM auth.users WHERE id=${snapshot.user_id}::uuid FOR NO KEY UPDATE`;
|
|
632
|
+
const [change] = await tx`SELECT id FROM auth.app_device_changes WHERE pairing_id=${id}::uuid FOR UPDATE`;
|
|
633
|
+
const [p] = await tx<PairingRow[]>`SELECT * FROM auth.app_pairings WHERE id=${id}::uuid AND issuer=${cfg.issuer} FOR UPDATE`;
|
|
443
634
|
if (!p) return reject("UNAVAILABLE", 404);
|
|
444
|
-
await
|
|
635
|
+
if (change) await pairingOwner(tx, actor, p, cfg);
|
|
636
|
+
else await authorizePairing(tx, actor, p, cfg);
|
|
445
637
|
if (p.state === "confirmed") return reject("CONFLICT", 409);
|
|
638
|
+
await tx`UPDATE auth.app_device_changes SET state='cancelled' WHERE pairing_id=${id}::uuid AND state='pending'`;
|
|
446
639
|
await tx`UPDATE auth.app_pairings SET state='cancelled' WHERE id=${id}::uuid`;
|
|
447
640
|
await record(tx, "pairing.cancel", actor.userId, id);
|
|
448
641
|
});
|
|
449
642
|
},
|
|
643
|
+
/** This session's newest removal of a device, so a browser that lost the answer can follow or withdraw it. */
|
|
644
|
+
findRemoval: async (actor: AppApprovalActor, lookup: { changeId: string } | { deviceId: string }) => {
|
|
645
|
+
const cfg = await config(false);
|
|
646
|
+
const changeId = "changeId" in lookup ? lookup.changeId : null,
|
|
647
|
+
deviceId = "deviceId" in lookup ? lookup.deviceId : null;
|
|
648
|
+
const [row] = await db<ChangeRow[]>`SELECT * FROM auth.app_device_changes
|
|
649
|
+
WHERE issuer=${cfg.issuer} AND user_id=${actor.userId}::uuid AND requested_by_sid=${actor.sid}::uuid AND kind='revoke'
|
|
650
|
+
AND (${changeId}::uuid IS NULL OR id=${changeId}::uuid) AND (${deviceId}::uuid IS NULL OR device_id=${deviceId}::uuid)
|
|
651
|
+
ORDER BY created_at DESC, id DESC LIMIT 1`;
|
|
652
|
+
if (!row) return reject("UNAVAILABLE", 404);
|
|
653
|
+
return { changeId: row.id, endSessions: row.end_sessions, ...changeStatus(row) };
|
|
654
|
+
},
|
|
655
|
+
cancelChange: async (actor: AppApprovalActor, id: string) => {
|
|
656
|
+
const cfg = await config(false);
|
|
657
|
+
return db.begin(async (tx) => {
|
|
658
|
+
await tx`SELECT id FROM auth.users WHERE id=${actor.userId}::uuid FOR NO KEY UPDATE`;
|
|
659
|
+
const change = await ownedChange(tx, cfg, actor, id);
|
|
660
|
+
if (changeStatus(change).state !== "pending") return reject("CONFLICT", 409);
|
|
661
|
+
await tx`UPDATE auth.app_device_changes SET state='cancelled' WHERE id=${id}::uuid`;
|
|
662
|
+
if (change.pairing_id)
|
|
663
|
+
await tx`UPDATE auth.app_pairings SET state='cancelled' WHERE id=${change.pairing_id}::uuid AND state='claimed'`;
|
|
664
|
+
});
|
|
665
|
+
},
|
|
450
666
|
listDevices: async (actor: AppApprovalActor, after?: string) => {
|
|
451
667
|
const cfg = await config(false);
|
|
452
668
|
const rows = await db<
|
|
@@ -457,18 +673,40 @@ export const createAppApprovalService = (
|
|
|
457
673
|
nextCursor: rows.length > limits.pageSize ? rows[limits.pageSize - 1]!.id : null,
|
|
458
674
|
};
|
|
459
675
|
},
|
|
460
|
-
mutateDevice: async (
|
|
676
|
+
mutateDevice: async (
|
|
677
|
+
actor: AppApprovalActor,
|
|
678
|
+
id: string,
|
|
679
|
+
name?: string,
|
|
680
|
+
endSessions = true,
|
|
681
|
+
): Promise<z.infer<typeof AppDeviceRevokeResultSchema> | undefined> => {
|
|
461
682
|
const cfg = await config(false);
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
>`SELECT * FROM auth.app_devices
|
|
683
|
+
let wakeId: string | undefined;
|
|
684
|
+
const result = await db.begin(async (tx) => {
|
|
685
|
+
await tx`SELECT id FROM auth.users WHERE id=${actor.userId}::uuid FOR NO KEY UPDATE`;
|
|
686
|
+
const user = await fresh(tx, actor);
|
|
687
|
+
const [device] = await tx<DeviceRow[]>`SELECT * FROM auth.app_devices
|
|
688
|
+
WHERE id=${id}::uuid AND issuer=${cfg.issuer} AND user_id=${actor.userId}::uuid FOR UPDATE`;
|
|
467
689
|
if (!device) return reject("UNAVAILABLE", 404);
|
|
468
|
-
if (name !== undefined)
|
|
469
|
-
|
|
470
|
-
|
|
690
|
+
if (name !== undefined) {
|
|
691
|
+
await tx`UPDATE auth.app_devices SET name=${name} WHERE id=${id}::uuid`;
|
|
692
|
+
await record(tx, "device.rename", actor.userId, id);
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
if (device.revoked_at) return { state: "revoked" as const };
|
|
696
|
+
// Only a configured Cloud can ask its devices; without one, an administrator revokes.
|
|
697
|
+
if (!cfg.enabled || !cfg.appOrigin) return reject("UNAVAILABLE", 503);
|
|
698
|
+
// A retry after a lost answer gets the same request; a changed session choice replaces it.
|
|
699
|
+
const [open] = await tx<ChangeRow[]>`SELECT * FROM auth.app_device_changes
|
|
700
|
+
WHERE issuer=${cfg.issuer} AND user_id=${actor.userId}::uuid AND kind='revoke' AND device_id=${id}::uuid
|
|
701
|
+
AND requested_by_sid=${actor.sid}::uuid AND state='pending' AND expires_at>now() FOR UPDATE`;
|
|
702
|
+
if (open?.end_sessions === endSessions) return { state: "pending" as const, changeId: open.id, expiresAt: iso(open.expires_at) };
|
|
703
|
+
if (open) await tx`UPDATE auth.app_device_changes SET state='cancelled' WHERE id=${open.id}::uuid`;
|
|
704
|
+
const requested = await createChange(tx, cfg, actor, user.auth_epoch, "revoke", id, null, endSessions);
|
|
705
|
+
wakeId = requested.id;
|
|
706
|
+
return { state: "pending" as const, changeId: requested.id, expiresAt: iso(requested.expires_at) };
|
|
471
707
|
});
|
|
708
|
+
if (wakeId) void wake(cfg, actor.userId, wakeId, { accept: "device-changes" });
|
|
709
|
+
return result;
|
|
472
710
|
},
|
|
473
711
|
/** Active devices of any account, for administrators. Bounded by the active-device limit. */
|
|
474
712
|
listUserDevices: async (actor: AppDeviceAdministrator, userId: string): Promise<AppDeviceView[]> => {
|
|
@@ -495,7 +733,7 @@ export const createAppApprovalService = (
|
|
|
495
733
|
return { device: view(row!), revoked: true };
|
|
496
734
|
});
|
|
497
735
|
},
|
|
498
|
-
startLogin: async (identifier: string, category: AccountCategory) => {
|
|
736
|
+
startLogin: async (identifier: string, category: AccountCategory, request?: AppLoginRequestContext) => {
|
|
499
737
|
const cfg = await config();
|
|
500
738
|
await cleanup();
|
|
501
739
|
const id = crypto.randomUUID(),
|
|
@@ -505,35 +743,68 @@ export const createAppApprovalService = (
|
|
|
505
743
|
expiresAt = future(limits.loginSeconds);
|
|
506
744
|
let owner: string | undefined;
|
|
507
745
|
await db.begin(async (tx) => {
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
)
|
|
518
|
-
candidate
|
|
519
|
-
if (candidate) {
|
|
520
|
-
await tx`SELECT id FROM auth.users WHERE id=${candidate.id}::uuid FOR UPDATE`;
|
|
521
|
-
const [count] = await tx<
|
|
522
|
-
{ count: number }[]
|
|
523
|
-
>`SELECT count(*)::int AS count FROM auth.app_logins WHERE issuer=${cfg.issuer} AND user_id=${candidate.id}::uuid AND expires_at > now() AND state IN ('pending','approved')`;
|
|
524
|
-
if ((count?.count ?? 0) >= limits.pendingPerAccount) candidate = undefined;
|
|
525
|
-
}
|
|
526
|
-
// Unknown, ambiguous, disabled and throttled accounts have the identical
|
|
527
|
-
// public shape and a decoy pending transaction; no account enumeration.
|
|
528
|
-
await tx`INSERT INTO auth.app_logins(id,issuer,user_id,auth_epoch,category,browser_hash,challenge,comparison,expires_at)
|
|
529
|
-
VALUES (${id}::uuid,${cfg.issuer},${candidate?.id ?? null}::uuid,${candidate?.auth_epoch ?? null},${category},${hash(browserSecret)},${challenge},${code},${expiresAt})`;
|
|
746
|
+
let candidate = await loginCandidate(tx, identifier, category);
|
|
747
|
+
if (candidate && !(await loginBudget(tx, cfg.issuer, candidate.id))) candidate = undefined;
|
|
748
|
+
const now = new Date();
|
|
749
|
+
// These reads also run for decoys; the helpers use random account IDs when absent.
|
|
750
|
+
const browser = await findBrowser(tx, { userId: candidate?.id, cookie: request?.cookie ?? null, now });
|
|
751
|
+
const trust = await trustState(tx, now);
|
|
752
|
+
const countries = await countryHistory(tx, { userId: candidate?.id, country: request?.country ?? null, now });
|
|
753
|
+
const context = buildLoginContext({ request, browser, trust, countries, now });
|
|
754
|
+
// Unknown, ambiguous, disabled and throttled accounts keep the identical public shape.
|
|
755
|
+
await tx`INSERT INTO auth.app_logins(id,issuer,user_id,auth_epoch,category,browser_hash,challenge,comparison,expires_at,context)
|
|
756
|
+
VALUES (${id}::uuid,${cfg.issuer},${candidate?.id ?? null}::uuid,${candidate?.auth_epoch ?? null},${category},${hash(browserSecret)},${challenge},${code},${expiresAt},${candidate && context ? JSON.stringify(context) : null}::text::jsonb)`;
|
|
530
757
|
owner = candidate?.id;
|
|
531
758
|
});
|
|
759
|
+
// No cache I/O in SQL transactions; decoys do the same address work as real requests.
|
|
760
|
+
if (request?.prefix) {
|
|
761
|
+
try {
|
|
762
|
+
await addresses.remember(id, appLoginAddressProof(request.prefix), expiresAt);
|
|
763
|
+
} catch (error) {
|
|
764
|
+
addressFailure(error);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
532
767
|
// Not awaited: decoy and real requests answer alike, and push is only a wake-up.
|
|
533
768
|
if (owner) void wake(cfg, owner, id);
|
|
534
769
|
return { requestId: id, browserSecret, comparison: code, expiresAt: iso(expiresAt), pollAfterSeconds: limits.pollSeconds };
|
|
535
770
|
},
|
|
536
|
-
|
|
771
|
+
startTerminalLogin: async (
|
|
772
|
+
input: AppTerminalLoginInput,
|
|
773
|
+
access: (tx: SQL, account: Pick<AccountRow, "id" | "profile">) => Promise<boolean>,
|
|
774
|
+
) => {
|
|
775
|
+
const cfg = await config();
|
|
776
|
+
await cleanup();
|
|
777
|
+
const id = crypto.randomUUID(),
|
|
778
|
+
code = comparison(),
|
|
779
|
+
expiresAt = future(limits.loginSeconds);
|
|
780
|
+
let owner: string | undefined;
|
|
781
|
+
await db.begin(async (tx) => {
|
|
782
|
+
let candidate = await loginCandidate(tx, input.identifier);
|
|
783
|
+
const subject = candidate ?? { id: crypto.randomUUID(), profile: "user" as const };
|
|
784
|
+
// Even a decoy performs the client-access, device and shared-budget queries.
|
|
785
|
+
const allowed = await access(tx, subject);
|
|
786
|
+
const devices = await tx`SELECT id FROM auth.app_devices WHERE issuer=${cfg.issuer}
|
|
787
|
+
AND user_id=${subject.id}::uuid AND revoked_at IS NULL LIMIT 1`;
|
|
788
|
+
const budget = await loginBudget(tx, cfg.issuer, subject.id);
|
|
789
|
+
if (!allowed || !devices.length || !budget) candidate = undefined;
|
|
790
|
+
const inserted = await tx`INSERT INTO auth.app_logins
|
|
791
|
+
(id,issuer,user_id,auth_epoch,category,browser_hash,challenge,comparison,expires_at,kind,device_authorization_id,client_id,scopes,lifetime_seconds)
|
|
792
|
+
VALUES (${id}::uuid,${cfg.issuer},${candidate?.id ?? null}::uuid,${candidate?.auth_epoch ?? null},
|
|
793
|
+
${candidate ? accountCategory(candidate) : "login"},${hash(secret())},${secret()},${code},${expiresAt},'terminal',
|
|
794
|
+
${input.deviceAuthorizationId}::uuid,${input.clientId},${toPgTextArray(input.scopes)}::text[],${input.lifetimeSeconds})
|
|
795
|
+
ON CONFLICT (device_authorization_id) WHERE device_authorization_id IS NOT NULL DO NOTHING RETURNING id`;
|
|
796
|
+
if (!inserted.length) return reject("CONFLICT", 409);
|
|
797
|
+
owner = candidate?.id;
|
|
798
|
+
});
|
|
799
|
+
if (owner) void wake(cfg, owner, id, { accept: "terminal-logins" });
|
|
800
|
+
return { comparison: code, expiresAt: iso(expiresAt) };
|
|
801
|
+
},
|
|
802
|
+
deviceCommand: async (
|
|
803
|
+
request: AppDeviceRequest,
|
|
804
|
+
actions: AppDeviceActions,
|
|
805
|
+
terminal?: AppTerminalLogins,
|
|
806
|
+
options?: { address?: string },
|
|
807
|
+
) => {
|
|
537
808
|
const cfg = await config();
|
|
538
809
|
const { proof, signature } = request;
|
|
539
810
|
const now = Math.floor(Date.now() / 1000);
|
|
@@ -547,8 +818,19 @@ export const createAppApprovalService = (
|
|
|
547
818
|
)
|
|
548
819
|
return reject("FORBIDDEN", 403);
|
|
549
820
|
await cleanup();
|
|
821
|
+
let decidedBrowserId: string | undefined;
|
|
550
822
|
const outcome = await db.begin(async (tx) => {
|
|
823
|
+
let changeUserId: string | undefined;
|
|
824
|
+
if (proof.command.operation === "change") {
|
|
825
|
+
const [snapshot] = await tx<
|
|
826
|
+
Pick<DeviceRow, "user_id">[]
|
|
827
|
+
>`SELECT user_id FROM auth.app_devices WHERE id=${proof.deviceId}::uuid AND issuer=${cfg.issuer}`;
|
|
828
|
+
if (!snapshot) return reject("FORBIDDEN", 403);
|
|
829
|
+
changeUserId = snapshot.user_id;
|
|
830
|
+
await tx`SELECT id FROM auth.users WHERE id=${changeUserId}::uuid FOR NO KEY UPDATE`;
|
|
831
|
+
}
|
|
551
832
|
const device = await activeDevice(tx, proof.deviceId, cfg.issuer);
|
|
833
|
+
if (changeUserId && device.user_id !== changeUserId) return reject("CONFLICT", 409);
|
|
552
834
|
if (!(await verify(AppDevicePublicKeySchema.parse(device.public_key), appDeviceProofMessage(proof), signature)))
|
|
553
835
|
return reject("FORBIDDEN", 403);
|
|
554
836
|
const used =
|
|
@@ -556,6 +838,77 @@ export const createAppApprovalService = (
|
|
|
556
838
|
if (!used.length) return reject("CONFLICT", 409);
|
|
557
839
|
await tx`UPDATE auth.app_devices SET last_used_at=now() WHERE id=${device.id}::uuid`;
|
|
558
840
|
const command = proof.command;
|
|
841
|
+
if (command.operation === "change") {
|
|
842
|
+
// Like terminal sign-ins, only a device whose latest inbox opted in may decide device changes.
|
|
843
|
+
if (!device.accepts?.includes("device-changes")) return reject("CONFLICT", 409);
|
|
844
|
+
let target: DeviceRow | undefined;
|
|
845
|
+
if (command.kind === "revoke") {
|
|
846
|
+
[target] = await tx<DeviceRow[]>`SELECT * FROM auth.app_devices
|
|
847
|
+
WHERE id=${command.targetId}::uuid AND issuer=${cfg.issuer} AND user_id=${device.user_id}::uuid FOR UPDATE`;
|
|
848
|
+
}
|
|
849
|
+
const [change] = await tx<ChangeRow[]>`SELECT * FROM auth.app_device_changes
|
|
850
|
+
WHERE id=${command.changeId}::uuid AND issuer=${cfg.issuer} AND user_id=${device.user_id}::uuid FOR UPDATE`;
|
|
851
|
+
const user = await eligible(tx, device.user_id);
|
|
852
|
+
if (
|
|
853
|
+
!change ||
|
|
854
|
+
changeStatus(change).state !== "pending" ||
|
|
855
|
+
change.kind !== command.kind ||
|
|
856
|
+
change.end_sessions !== command.endSessions ||
|
|
857
|
+
change.auth_epoch !== user.auth_epoch
|
|
858
|
+
)
|
|
859
|
+
return reject("CONFLICT", 409);
|
|
860
|
+
let pairing: PairingRow | undefined;
|
|
861
|
+
if (change.kind === "pair") {
|
|
862
|
+
[pairing] = await tx<
|
|
863
|
+
PairingRow[]
|
|
864
|
+
>`SELECT * FROM auth.app_pairings WHERE id=${change.pairing_id}::uuid AND issuer=${cfg.issuer} FOR UPDATE`;
|
|
865
|
+
if (
|
|
866
|
+
!pairing ||
|
|
867
|
+
pairing.user_id !== user.id ||
|
|
868
|
+
pairing.state !== "claimed" ||
|
|
869
|
+
new Date(pairing.expires_at).getTime() <= Date.now() ||
|
|
870
|
+
pairing.auth_epoch !== user.auth_epoch ||
|
|
871
|
+
pairing.device_id !== command.targetId
|
|
872
|
+
)
|
|
873
|
+
return reject("CONFLICT", 409);
|
|
874
|
+
const [initiator] = await tx`SELECT sid FROM auth.session_families WHERE sid=${pairing.initiator_sid}::uuid
|
|
875
|
+
AND user_id=${pairing.initiated_by}::uuid AND auth_epoch=${user.auth_epoch} AND revoked_at IS NULL AND expires_at>now() FOR UPDATE`;
|
|
876
|
+
if (!initiator) return reject("CONFLICT", 409);
|
|
877
|
+
} else {
|
|
878
|
+
if (!target || change.device_id !== command.targetId) return reject("CONFLICT", 409);
|
|
879
|
+
if (command.decision === "deny" && device.id === change.device_id) return reject("CONFLICT", 409);
|
|
880
|
+
const [requester] = await tx`SELECT sid FROM auth.session_families WHERE sid=${change.requested_by_sid}::uuid
|
|
881
|
+
AND user_id=${change.user_id}::uuid AND auth_epoch=${user.auth_epoch} AND revoked_at IS NULL AND expires_at>now() FOR UPDATE`;
|
|
882
|
+
if (!requester) return reject("CONFLICT", 409);
|
|
883
|
+
}
|
|
884
|
+
const state = command.decision === "approve" ? ("approved" as const) : ("denied" as const);
|
|
885
|
+
// Locks above may outlive the request. Expiry wins before applying either decision.
|
|
886
|
+
const decided = await tx`UPDATE auth.app_device_changes SET state=${state},decided_by=${device.id}::uuid
|
|
887
|
+
WHERE id=${change.id}::uuid AND state='pending' AND expires_at > clock_timestamp() RETURNING id`;
|
|
888
|
+
if (!decided.length) return reject("CONFLICT", 409);
|
|
889
|
+
let ended = 0;
|
|
890
|
+
if (state === "denied") {
|
|
891
|
+
if (pairing) await tx`UPDATE auth.app_pairings SET state='cancelled' WHERE id=${pairing.id}::uuid`;
|
|
892
|
+
} else if (pairing) {
|
|
893
|
+
await enrollPairing(tx, pairing, cfg.issuer);
|
|
894
|
+
} else {
|
|
895
|
+
await tx`UPDATE auth.app_devices SET revoked_at=COALESCE(revoked_at,now()) WHERE id=${command.targetId}::uuid`;
|
|
896
|
+
if (change.end_sessions) {
|
|
897
|
+
const families = await tx`UPDATE auth.session_families SET revoked_at=now(), revocation_reason='app_device_removed'
|
|
898
|
+
WHERE user_id=${user.id}::uuid AND app_device_id=${command.targetId}::uuid AND sid<>${change.requested_by_sid}::uuid
|
|
899
|
+
AND revoked_at IS NULL AND expires_at>now() AND auth_epoch=${user.auth_epoch} RETURNING sid`;
|
|
900
|
+
ended = families.length;
|
|
901
|
+
}
|
|
902
|
+
await record(tx, "device.revoke", user.id, command.targetId, { changeId: change.id });
|
|
903
|
+
}
|
|
904
|
+
await record(tx, `device_change.${command.decision}`, user.id, command.targetId, {
|
|
905
|
+
changeId: change.id,
|
|
906
|
+
kind: change.kind,
|
|
907
|
+
endSignIns: change.end_sessions,
|
|
908
|
+
ended,
|
|
909
|
+
});
|
|
910
|
+
return { state };
|
|
911
|
+
}
|
|
559
912
|
if (command.operation === "push") {
|
|
560
913
|
await tx`UPDATE auth.app_devices SET push_token=${command.token} WHERE id=${device.id}::uuid`;
|
|
561
914
|
return { state: "updated" as const };
|
|
@@ -580,21 +933,72 @@ export const createAppApprovalService = (
|
|
|
580
933
|
return { operation: command.operation, userId: device.user_id, deviceId: device.id };
|
|
581
934
|
if (command.operation === "pending" || command.operation === "inbox") {
|
|
582
935
|
const user = await eligible(tx, device.user_id);
|
|
936
|
+
const acceptsTerminal =
|
|
937
|
+
command.operation === "inbox" && command.accept?.includes("terminal-logins") === true && terminal !== undefined;
|
|
583
938
|
const rows = await tx<
|
|
584
939
|
LoginRow[]
|
|
585
|
-
>`SELECT * FROM auth.app_logins WHERE issuer=${cfg.issuer} AND user_id=${device.user_id}::uuid AND state='pending' AND expires_at>now() AND auth_epoch=${user.auth_epoch} AND category=${accountCategory(user)} ORDER BY created_at LIMIT ${limits.pendingPerAccount}`;
|
|
586
|
-
const
|
|
587
|
-
|
|
940
|
+
>`SELECT * FROM auth.app_logins WHERE (kind='browser' OR ${acceptsTerminal}) AND issuer=${cfg.issuer} AND user_id=${device.user_id}::uuid AND state='pending' AND expires_at>now() AND auth_epoch=${user.auth_epoch} AND category=${accountCategory(user)} ORDER BY created_at LIMIT ${limits.pendingPerAccount}`;
|
|
941
|
+
const clients =
|
|
942
|
+
acceptsTerminal && terminal
|
|
943
|
+
? await terminal.describe(
|
|
944
|
+
tx,
|
|
945
|
+
rows.flatMap((row) => (row.device_authorization_id ? [row.device_authorization_id] : [])),
|
|
946
|
+
)
|
|
947
|
+
: new Map<string, { id: string; name: string }>();
|
|
948
|
+
const requests: AppPendingLogin[] = [];
|
|
949
|
+
for (const row of rows) {
|
|
950
|
+
const login: AppPendingLogin = {
|
|
588
951
|
requestId: row.id,
|
|
589
952
|
challenge: row.challenge,
|
|
590
953
|
comparison: row.comparison,
|
|
591
954
|
createdAt: iso(row.created_at),
|
|
592
955
|
expiresAt: iso(row.expires_at),
|
|
593
|
-
}
|
|
594
|
-
|
|
956
|
+
};
|
|
957
|
+
if (row.kind === "terminal") {
|
|
958
|
+
const client = row.device_authorization_id ? clients.get(row.device_authorization_id) : undefined;
|
|
959
|
+
if (!client || client.id !== row.client_id || !row.scopes || !row.lifetime_seconds) continue;
|
|
960
|
+
login.terminal = {
|
|
961
|
+
client: { id: client.id, name: appApprovalText(client.name || client.id, APP_ACTION_LIMITS.nameChars) || "OAuth" },
|
|
962
|
+
scopes: row.scopes,
|
|
963
|
+
lifetimeSeconds: row.lifetime_seconds,
|
|
964
|
+
};
|
|
965
|
+
}
|
|
966
|
+
if (row.kind === "browser" && command.accept?.includes("login-context")) {
|
|
967
|
+
const parsed = AppLoginContextSchema.safeParse(row.context);
|
|
968
|
+
if (parsed.success) {
|
|
969
|
+
// sameNetwork is transient, even if an unexpected stored value contains it.
|
|
970
|
+
const { sameNetwork: _transient, ...context } = parsed.data;
|
|
971
|
+
login.context = context;
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
requests.push(login);
|
|
975
|
+
}
|
|
595
976
|
if (command.operation === "inbox") {
|
|
596
|
-
await tx`UPDATE auth.app_devices SET actions_at=now() WHERE id=${device.id}::uuid`;
|
|
597
|
-
|
|
977
|
+
await tx`UPDATE auth.app_devices SET actions_at=now(), accepts=${toPgTextArray(command.accept ?? [])}::text[] WHERE id=${device.id}::uuid`;
|
|
978
|
+
let changes: AppDeviceChange[] | undefined;
|
|
979
|
+
if (command.accept?.includes("device-changes")) {
|
|
980
|
+
const rows = await tx<
|
|
981
|
+
(ChangeRow & { target_id: string; name: string })[]
|
|
982
|
+
>`SELECT c.*, COALESCE(p.device_id,d.id) AS target_id, COALESCE(p.name,d.name) AS name
|
|
983
|
+
FROM auth.app_device_changes c LEFT JOIN auth.app_pairings p ON p.id=c.pairing_id LEFT JOIN auth.app_devices d ON d.id=c.device_id
|
|
984
|
+
WHERE c.issuer=${cfg.issuer} AND c.user_id=${device.user_id}::uuid AND c.state='pending' AND c.expires_at>now() AND c.auth_epoch=${user.auth_epoch}
|
|
985
|
+
AND ((c.kind='revoke' AND EXISTS (SELECT 1 FROM auth.session_families f
|
|
986
|
+
WHERE f.sid=c.requested_by_sid AND f.user_id=c.user_id
|
|
987
|
+
AND f.revoked_at IS NULL AND f.expires_at>now() AND f.auth_epoch=${user.auth_epoch}))
|
|
988
|
+
OR (p.state='claimed' AND p.expires_at>now() AND p.auth_epoch=${user.auth_epoch}
|
|
989
|
+
AND EXISTS (SELECT 1 FROM auth.session_families f WHERE f.sid=p.initiator_sid AND f.user_id=p.initiated_by
|
|
990
|
+
AND f.revoked_at IS NULL AND f.expires_at>now() AND f.auth_epoch=${user.auth_epoch})))
|
|
991
|
+
ORDER BY c.created_at,c.id LIMIT ${limits.pendingPerAccount}`;
|
|
992
|
+
changes = rows.map((row) => ({
|
|
993
|
+
changeId: row.id,
|
|
994
|
+
kind: row.kind,
|
|
995
|
+
device: { id: row.target_id, name: changeName(row.name) },
|
|
996
|
+
endSessions: row.end_sessions,
|
|
997
|
+
createdAt: iso(row.created_at),
|
|
998
|
+
expiresAt: iso(row.expires_at),
|
|
999
|
+
}));
|
|
1000
|
+
}
|
|
1001
|
+
return { operation: "inbox" as const, userId: device.user_id, logins: requests, changes };
|
|
598
1002
|
}
|
|
599
1003
|
return { requests, pollAfterSeconds: limits.pollSeconds };
|
|
600
1004
|
}
|
|
@@ -612,22 +1016,46 @@ export const createAppApprovalService = (
|
|
|
612
1016
|
row.category !== accountCategory(user)
|
|
613
1017
|
)
|
|
614
1018
|
return reject("CONFLICT", 409);
|
|
1019
|
+
if (
|
|
1020
|
+
row.kind === "terminal" &&
|
|
1021
|
+
(!device.accepts?.includes("terminal-logins") ||
|
|
1022
|
+
!terminal ||
|
|
1023
|
+
!row.device_authorization_id ||
|
|
1024
|
+
!row.client_id ||
|
|
1025
|
+
!(await terminal.decide(tx, {
|
|
1026
|
+
deviceAuthorizationId: row.device_authorization_id,
|
|
1027
|
+
clientId: row.client_id,
|
|
1028
|
+
user,
|
|
1029
|
+
decision: command.decision,
|
|
1030
|
+
})))
|
|
1031
|
+
)
|
|
1032
|
+
return reject("CONFLICT", 409);
|
|
615
1033
|
const state = command.decision === "approve" ? "approved" : "denied";
|
|
616
|
-
|
|
617
|
-
await
|
|
1034
|
+
// A hook may wait on another transaction. Expiry still wins before either decision commits.
|
|
1035
|
+
const decided = await tx`UPDATE auth.app_logins SET state=${state},approved_by=${device.id}::uuid
|
|
1036
|
+
WHERE id=${row.id}::uuid AND expires_at > clock_timestamp() RETURNING id`;
|
|
1037
|
+
if (!decided.length) return reject("CONFLICT", 409);
|
|
1038
|
+
const storedContext = row.kind === "browser" ? AppLoginContextSchema.safeParse(row.context) : null;
|
|
1039
|
+
await record(tx, `login.${command.decision}`, device.user_id, device.id, {
|
|
1040
|
+
requestId: row.id,
|
|
1041
|
+
...(row.kind === "terminal" ? { kind: "terminal", clientId: row.client_id, deviceGrantId: row.device_authorization_id } : {}),
|
|
1042
|
+
...(storedContext?.success ? { level: storedContext.data.level } : {}),
|
|
1043
|
+
});
|
|
1044
|
+
if (row.kind === "browser") decidedBrowserId = row.id;
|
|
618
1045
|
return { state };
|
|
619
1046
|
});
|
|
620
1047
|
if (outcome.operation !== undefined) {
|
|
621
1048
|
const command = proof.command;
|
|
622
1049
|
const input = { userId: outcome.userId, issuer: cfg.issuer };
|
|
623
1050
|
if (outcome.operation === "inbox") {
|
|
624
|
-
const inbox = {
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
}
|
|
630
|
-
|
|
1051
|
+
const inbox = {
|
|
1052
|
+
logins: outcome.logins,
|
|
1053
|
+
...(outcome.changes === undefined ? {} : { changes: outcome.changes, moreChanges: 0 }),
|
|
1054
|
+
...(await actions.inbox(input)),
|
|
1055
|
+
pollAfterSeconds: limits.pollSeconds,
|
|
1056
|
+
};
|
|
1057
|
+
await addSameNetwork(inbox.logins, options?.address);
|
|
1058
|
+
return trimAppLoginResponse(inbox);
|
|
631
1059
|
}
|
|
632
1060
|
if (command.operation === "action") {
|
|
633
1061
|
const view = await actions.view({ ...input, actionId: command.actionId });
|
|
@@ -647,15 +1075,26 @@ export const createAppApprovalService = (
|
|
|
647
1075
|
}
|
|
648
1076
|
return reject("INVALID_REQUEST", 400);
|
|
649
1077
|
}
|
|
1078
|
+
if (outcome.requests !== undefined) {
|
|
1079
|
+
await addSameNetwork(outcome.requests, options?.address);
|
|
1080
|
+
return trimAppLoginResponse(outcome);
|
|
1081
|
+
}
|
|
650
1082
|
// Only after commit: the waiting browser re-reads Postgres, so a hint never grants anything.
|
|
651
1083
|
if (proof.command.operation === "decide") hints.publish(proof.command.requestId);
|
|
1084
|
+
if (decidedBrowserId) {
|
|
1085
|
+
try {
|
|
1086
|
+
await addresses.forget(decidedBrowserId);
|
|
1087
|
+
} catch (error) {
|
|
1088
|
+
addressFailure(error);
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
652
1091
|
return outcome;
|
|
653
1092
|
},
|
|
654
1093
|
/** With `hold`, a pending request is answered on its decision, its expiry, or after `hold.ms`, whichever comes first. */
|
|
655
1094
|
browserStatus: async (id: string, token: string, hold?: { ms: number; signal?: AbortSignal }) => {
|
|
656
1095
|
const cfg = await config();
|
|
657
1096
|
const read = async () => {
|
|
658
|
-
const [row] = await db<LoginRow[]>`SELECT * FROM auth.app_logins WHERE id=${id}::uuid AND issuer=${cfg.issuer}`;
|
|
1097
|
+
const [row] = await db<LoginRow[]>`SELECT * FROM auth.app_logins WHERE kind='browser' AND id=${id}::uuid AND issuer=${cfg.issuer}`;
|
|
659
1098
|
if (!row || !matches(token, row.browser_hash)) return reject("UNAVAILABLE", 404);
|
|
660
1099
|
const expiresIn = new Date(row.expires_at).getTime() - Date.now();
|
|
661
1100
|
return { state: expiresIn <= 0 ? "expired" : row.state, expiresIn };
|
|
@@ -686,10 +1125,14 @@ export const createAppApprovalService = (
|
|
|
686
1125
|
const cfg = await config();
|
|
687
1126
|
return db.begin(async (tx) => {
|
|
688
1127
|
// Lock order is device -> request for both approval and completion.
|
|
689
|
-
const [snapshot] = await tx<
|
|
1128
|
+
const [snapshot] = await tx<
|
|
1129
|
+
LoginRow[]
|
|
1130
|
+
>`SELECT * FROM auth.app_logins WHERE kind='browser' AND id=${id}::uuid AND issuer=${cfg.issuer}`;
|
|
690
1131
|
if (!snapshot || !snapshot.approved_by || !matches(token, snapshot.browser_hash)) return reject("CONFLICT", 409);
|
|
691
1132
|
const device = await activeDevice(tx, snapshot.approved_by, cfg.issuer);
|
|
692
|
-
const [row] = await tx<
|
|
1133
|
+
const [row] = await tx<
|
|
1134
|
+
LoginRow[]
|
|
1135
|
+
>`SELECT * FROM auth.app_logins WHERE kind='browser' AND id=${id}::uuid AND issuer=${cfg.issuer} FOR UPDATE`;
|
|
693
1136
|
const user = await eligible(tx, device.user_id);
|
|
694
1137
|
if (
|
|
695
1138
|
!row ||
|
|
@@ -705,7 +1148,7 @@ export const createAppApprovalService = (
|
|
|
705
1148
|
await record(tx, "login.consume", user.id, device.id, { requestId: id });
|
|
706
1149
|
// This commit is the authorization boundary. A later issuance/network
|
|
707
1150
|
// failure requires a new login, never replay of the consumed approval.
|
|
708
|
-
return user.id;
|
|
1151
|
+
return { userId: user.id, authEpoch: Number(row.auth_epoch), deviceId: device.id, deviceName: device.name };
|
|
709
1152
|
});
|
|
710
1153
|
},
|
|
711
1154
|
};
|