@frockbot/cloudflare 0.0.0 → 0.7.292
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/build-artifact.ts +130 -0
- package/build-flutter-web.ts +508 -0
- package/deployment-config/README.md +414 -0
- package/deployment-config/cli.ts +228 -0
- package/deployment-config/generate.ts +689 -0
- package/deployment-config/jsonc.ts +18 -0
- package/deployment-config/profile-schema.generated.ts +391 -0
- package/deployment-config/profile.schema.json +358 -0
- package/deployment-config/profile.ts +124 -0
- package/migrations/0001_better_auth.sql +15 -0
- package/migrations/0002_drop_account_issuer.sql +8 -0
- package/package.json +61 -5
- package/release-version.ts +20 -0
- package/src/account-admission.ts +179 -0
- package/src/account-deletion.ts +274 -0
- package/src/admin-entrypoint.ts +194 -0
- package/src/admin-identities.ts +26 -0
- package/src/audit.ts +103 -0
- package/src/auth-package.access.ts +24 -0
- package/src/auth-package.ts +36 -0
- package/src/avatar-state-cleanup.ts +107 -0
- package/src/billing-computer.ts +267 -0
- package/src/billing-readiness.ts +21 -0
- package/src/billing.ts +461 -0
- package/src/bot-capabilities.ts +480 -0
- package/src/bot-recovery.ts +1 -0
- package/src/bot-state-channel.ts +702 -0
- package/src/bot-state.ts +3995 -0
- package/src/bot-template-cleanup.ts +98 -0
- package/src/bot-title-cleanup.ts +104 -0
- package/src/brand-icon.png +0 -0
- package/src/brand-logo.ts +2 -0
- package/src/brand.ts +36 -0
- package/src/client-compatibility.ts +51 -0
- package/src/compaction-announcement-cleanup.ts +113 -0
- package/src/computer-egress.ts +110 -0
- package/src/computer-host.ts +92 -0
- package/src/computer-screenshot-cleanup.ts +78 -0
- package/src/contracts.ts +832 -0
- package/src/debug.ts +272 -0
- package/src/default-packages-marker-cleanup.ts +39 -0
- package/src/deployment-policy-admin-host.ts +121 -0
- package/src/deployment-policy.ts +426 -0
- package/src/directory-profile-cleanup.ts +101 -0
- package/src/durable-rpc.ts +753 -0
- package/src/durable-session.ts +21 -0
- package/src/entry-boundary.ts +103 -0
- package/src/frock-ai.ts +338 -0
- package/src/gateway.ts +1392 -0
- package/src/group-chat.ts +736 -0
- package/src/hidden-bot-notifications-cleanup.ts +32 -0
- package/src/index.ts +2798 -0
- package/src/insights.ts +15 -0
- package/src/machine-messages-cleanup.ts +121 -0
- package/src/machine-socket.ts +164 -0
- package/src/memory-records.ts +181 -0
- package/src/memory.ts +145 -0
- package/src/model-rates.ts +252 -0
- package/src/native-auth.ts +1006 -0
- package/src/native-sessions.ts +121 -0
- package/src/notification-state-cleanup.ts +18 -0
- package/src/ollama-web-search-cleanup.ts +69 -0
- package/src/package-page-shapes-cleanup.ts +190 -0
- package/src/plugin-egress.ts +31 -0
- package/src/plugin-page-route.ts +72 -0
- package/src/plugin-panels-cleanup.ts +101 -0
- package/src/prepared-input-cleanup.ts +105 -0
- package/src/production-secrets.ts +484 -0
- package/src/project-cleanup.ts +123 -0
- package/src/project-events-cleanup.ts +152 -0
- package/src/publication-state-cleanup.ts +92 -0
- package/src/push.ts +498 -0
- package/src/request-body.ts +165 -0
- package/src/routine-state-cleanup.ts +57 -0
- package/src/search.ts +88 -0
- package/src/sidebar-label-cleanup.ts +165 -0
- package/src/skill-index-cleanup.ts +53 -0
- package/src/supersede-cleanup.ts +162 -0
- package/src/test-chat-cleanup.ts +93 -0
- package/src/uploads.ts +486 -0
- package/src/user-application.ts +1068 -0
- package/src/user-configuration.ts +4491 -0
- package/src/voice-assistant.ts +4999 -0
- package/src/voice-dictation.ts +743 -0
- package/src/working-context-cleanup.ts +96 -0
- package/src/workspace.ts +201 -0
- package/wrangler.jsonc +385 -0
- package/README.md +0 -3
|
@@ -0,0 +1,1006 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isProtocolValue,
|
|
3
|
+
type AuthStartCommand,
|
|
4
|
+
type ClientHello,
|
|
5
|
+
} from "@frockbot/core/protocol-schemas";
|
|
6
|
+
import {
|
|
7
|
+
clientCompatibilityResponse,
|
|
8
|
+
CLIENT_HELLO_HEADER,
|
|
9
|
+
UPDATE_APP_MESSAGE,
|
|
10
|
+
} from "./client-compatibility.js";
|
|
11
|
+
import {
|
|
12
|
+
returnPageV1,
|
|
13
|
+
type ReturnPageBrandV1,
|
|
14
|
+
} from "@frockbot/app/return-page";
|
|
15
|
+
import {
|
|
16
|
+
AUTH_NO_STORE_HEADERS_V1,
|
|
17
|
+
signInFailedV1 as error,
|
|
18
|
+
signInRedirectV1 as redirect,
|
|
19
|
+
} from "@frockbot/app/auth/shared";
|
|
20
|
+
import type { AccountAdmissionDecisionV1 } from "@frockbot/app/admin/shared";
|
|
21
|
+
import {
|
|
22
|
+
admissionRefusedResponse,
|
|
23
|
+
admissionUnavailableResponse,
|
|
24
|
+
} from "./account-admission.js";
|
|
25
|
+
import {
|
|
26
|
+
nativeReturnSchemeV1,
|
|
27
|
+
type AuthIdentityV1,
|
|
28
|
+
type AuthPackageIdentityV1,
|
|
29
|
+
type BrandV1,
|
|
30
|
+
} from "@frockbot/core/contracts";
|
|
31
|
+
import type {
|
|
32
|
+
NativeSessionOperation,
|
|
33
|
+
NativeSessionRecord,
|
|
34
|
+
} from "./native-sessions.js";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Each signed app's return page under `/native/return/`: Android's verified
|
|
38
|
+
* App Link, and the page each Apple build's browser return hands over on its
|
|
39
|
+
* own scheme. `macos-dev` and `ios-dev` are the FrockBot Dev builds, separate
|
|
40
|
+
* apps installed beside the released ones, which a deployment serves only
|
|
41
|
+
* when its profile names them.
|
|
42
|
+
*/
|
|
43
|
+
export type NativeReturnPlatformV1 =
|
|
44
|
+
"android" | "macos" | "macos-dev" | "ios" | "ios-dev";
|
|
45
|
+
const NATIVE_RETURN_PLATFORMS: readonly NativeReturnPlatformV1[] = [
|
|
46
|
+
"android",
|
|
47
|
+
"macos",
|
|
48
|
+
"macos-dev",
|
|
49
|
+
"ios",
|
|
50
|
+
"ios-dev",
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Where a signed app receives its sign-in: a path on the deployment's own
|
|
55
|
+
* origin. No deployment is named here — the origin is the one the Worker was
|
|
56
|
+
* given, so a second deployment's App Links are its own
|
|
57
|
+
* ([ADR 0028](../../../docs/adr/0028-open-deployment.md)).
|
|
58
|
+
*/
|
|
59
|
+
export function nativeReturnUriV1(
|
|
60
|
+
origin: string,
|
|
61
|
+
platform: NativeReturnPlatformV1,
|
|
62
|
+
): string {
|
|
63
|
+
return `${new URL(origin).origin}/native/return/${platform}`;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Where a development build of the app receives its sign-in. A custom scheme,
|
|
67
|
+
* the brand's development one, because a plain-HTTP loopback origin can never
|
|
68
|
+
* be an App Link; only a Worker running with `ALLOW_DEVELOPMENT_AUTH` ever
|
|
69
|
+
* lists it.
|
|
70
|
+
*/
|
|
71
|
+
export function nativeDevelopmentReturnUriV1(
|
|
72
|
+
brand: Pick<BrandV1, "nativeScheme">,
|
|
73
|
+
): string {
|
|
74
|
+
return `${nativeReturnSchemeV1(brand, "development")}://native/return/android`;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Which build each Apple return page hands over to, on the brand's scheme
|
|
78
|
+
* (`nativeReturnSchemeV1`). A Universal Link only reaches the app from
|
|
79
|
+
* Safari, and only on a user's own click; Chrome and Firefox never dispatch
|
|
80
|
+
* one, and Google's completion redirect is not a click — on an iPhone as on a
|
|
81
|
+
* Mac. The return page hands the same code and state to the app's scheme,
|
|
82
|
+
* which every browser can open. The code is useless without the PKCE verifier
|
|
83
|
+
* the app never shares.
|
|
84
|
+
*
|
|
85
|
+
* The development builds (`bun run update:desktop` on a Mac, a
|
|
86
|
+
* `FROCKBOT_IOS_DEV` build on an iPhone) are each a separate app beside the
|
|
87
|
+
* released one, with its own bundle identifier, so their sign-in comes back
|
|
88
|
+
* through their own `-dev` page and scheme: handed to the released scheme,
|
|
89
|
+
* the released app would take the code, or the system would open the wrong
|
|
90
|
+
* copy. Not in the Apple association, so Safari never offers it to the
|
|
91
|
+
* released app as a Universal Link either. Android needs no scheme.
|
|
92
|
+
*/
|
|
93
|
+
const NATIVE_RETURN_BUILDS: Record<
|
|
94
|
+
Exclude<NativeReturnPlatformV1, "android">,
|
|
95
|
+
"released" | "development"
|
|
96
|
+
> = {
|
|
97
|
+
macos: "released",
|
|
98
|
+
"macos-dev": "development",
|
|
99
|
+
ios: "released",
|
|
100
|
+
"ios-dev": "development",
|
|
101
|
+
};
|
|
102
|
+
/** The returns the Apple association claims: the released apps' own. */
|
|
103
|
+
const APPLE_ASSOCIATED_RETURNS = ["macos", "ios"] as const;
|
|
104
|
+
const PREFIX = "frockbot-native.";
|
|
105
|
+
const encoder = new TextEncoder();
|
|
106
|
+
const NO_STORE = AUTH_NO_STORE_HEADERS_V1;
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Deployment policy, never a client-selected target or a per-Bot grant: the
|
|
110
|
+
* returns the profile's `nativeAuth` names, comma-separated. Nothing is
|
|
111
|
+
* implied — a FrockBot Dev return is served only where it is named — and a
|
|
112
|
+
* flag with an unknown name, a space or a repeat serves none.
|
|
113
|
+
*/
|
|
114
|
+
export function nativeReturnUris(
|
|
115
|
+
flag: string | undefined,
|
|
116
|
+
origin: string,
|
|
117
|
+
): readonly string[] {
|
|
118
|
+
const named = flag?.split(",") ?? [];
|
|
119
|
+
const valid =
|
|
120
|
+
new Set(named).size === named.length &&
|
|
121
|
+
named.every((name) =>
|
|
122
|
+
(NATIVE_RETURN_PLATFORMS as readonly string[]).includes(name),
|
|
123
|
+
);
|
|
124
|
+
return valid
|
|
125
|
+
? NATIVE_RETURN_PLATFORMS.filter((platform) =>
|
|
126
|
+
named.includes(platform),
|
|
127
|
+
).map((platform) => nativeReturnUriV1(origin, platform))
|
|
128
|
+
: [];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** What the consent page calls the app a return belongs to. */
|
|
132
|
+
function nativeAppName(
|
|
133
|
+
platform: NativeReturnPlatformV1,
|
|
134
|
+
product: string,
|
|
135
|
+
): string {
|
|
136
|
+
switch (platform) {
|
|
137
|
+
case "android":
|
|
138
|
+
return `the ${product} app on this Android device`;
|
|
139
|
+
case "macos":
|
|
140
|
+
return `the ${product} app on this Mac`;
|
|
141
|
+
case "macos-dev":
|
|
142
|
+
return `${product} Dev on this Mac`;
|
|
143
|
+
case "ios":
|
|
144
|
+
return `the ${product} app on this iPhone`;
|
|
145
|
+
case "ios-dev":
|
|
146
|
+
return `${product} Dev on this iPhone`;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The signed apps a deployment's association files name: the profile's
|
|
152
|
+
* `nativeApps`, carried as the `NATIVE_APPS` var.
|
|
153
|
+
*/
|
|
154
|
+
export interface NativeAppsV1 {
|
|
155
|
+
android: readonly {
|
|
156
|
+
packageName: string;
|
|
157
|
+
sha256CertFingerprints: readonly string[];
|
|
158
|
+
}[];
|
|
159
|
+
apple: readonly string[];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const ANDROID_PACKAGE_V1 = /^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)+$/;
|
|
163
|
+
const CERT_FINGERPRINT_V1 = /^[0-9A-F]{2}(:[0-9A-F]{2}){31}$/;
|
|
164
|
+
const APPLE_APP_ID_V1 = /^[A-Z0-9]{10}\.[A-Za-z0-9.-]+$/;
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The `NATIVE_APPS` var, decoded. Deployment policy like `nativeAuth`: a value
|
|
168
|
+
* that is absent or malformed names no app, so the deployment claims no link
|
|
169
|
+
* rather than a guessed one.
|
|
170
|
+
*/
|
|
171
|
+
export function nativeAppsV1(flag: string | undefined): NativeAppsV1 {
|
|
172
|
+
const none: NativeAppsV1 = { android: [], apple: [] };
|
|
173
|
+
try {
|
|
174
|
+
const value: unknown = JSON.parse(flag ?? "null");
|
|
175
|
+
if (!isRecord(value) || !hasOnly(value, ["android", "apple"])) return none;
|
|
176
|
+
const android = value.android ?? [];
|
|
177
|
+
const apple = value.apple ?? [];
|
|
178
|
+
const valid =
|
|
179
|
+
Array.isArray(android) &&
|
|
180
|
+
android.every(
|
|
181
|
+
(app) =>
|
|
182
|
+
isRecord(app) &&
|
|
183
|
+
hasOnly(app, ["packageName", "sha256CertFingerprints"]) &&
|
|
184
|
+
typeof app.packageName === "string" &&
|
|
185
|
+
ANDROID_PACKAGE_V1.test(app.packageName) &&
|
|
186
|
+
Array.isArray(app.sha256CertFingerprints) &&
|
|
187
|
+
app.sha256CertFingerprints.length > 0 &&
|
|
188
|
+
app.sha256CertFingerprints.every(
|
|
189
|
+
(print) =>
|
|
190
|
+
typeof print === "string" && CERT_FINGERPRINT_V1.test(print),
|
|
191
|
+
),
|
|
192
|
+
) &&
|
|
193
|
+
Array.isArray(apple) &&
|
|
194
|
+
apple.every((id) => typeof id === "string" && APPLE_APP_ID_V1.test(id));
|
|
195
|
+
return valid ? ({ android, apple } as NativeAppsV1) : none;
|
|
196
|
+
} catch {
|
|
197
|
+
return none;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
202
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function hasOnly(
|
|
206
|
+
value: Record<string, unknown>,
|
|
207
|
+
keys: readonly string[],
|
|
208
|
+
): boolean {
|
|
209
|
+
return Object.keys(value).every((key) => keys.includes(key));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** The request origin with a fully qualified (trailing-dot) host normalised. */
|
|
213
|
+
export function requestOrigin(url: URL): string {
|
|
214
|
+
const host = url.hostname.endsWith(".")
|
|
215
|
+
? url.hostname.slice(0, -1)
|
|
216
|
+
: url.hostname;
|
|
217
|
+
return `${url.protocol}//${host}${url.port ? `:${url.port}` : ""}`;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// A supported app upgrade reuses a session, whatever version and protocol it
|
|
221
|
+
// moved to: the protocol is compatibility, and the gate refuses one it no longer
|
|
222
|
+
// serves. Catalogs stay bound.
|
|
223
|
+
function sameClient(a: ClientHello, b: ClientHello): boolean {
|
|
224
|
+
const shape = (h: ClientHello) =>
|
|
225
|
+
JSON.stringify({
|
|
226
|
+
schemaVersion: h.schemaVersion,
|
|
227
|
+
catalogs: h.catalogs.map((c) => `${c.id}:${c.digest}`).sort(),
|
|
228
|
+
});
|
|
229
|
+
return shape(a) === shape(b);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function isNativeAuthPath(path: string): boolean {
|
|
233
|
+
return (
|
|
234
|
+
path.startsWith("/api/auth/native/") ||
|
|
235
|
+
path.startsWith("/native/") ||
|
|
236
|
+
path === "/.well-known/assetlinks.json" ||
|
|
237
|
+
path === "/.well-known/apple-app-site-association"
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
interface StartClaims {
|
|
242
|
+
kind: "start";
|
|
243
|
+
start: AuthStartCommand;
|
|
244
|
+
hello: ClientHello;
|
|
245
|
+
expires: number;
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* A pending authorization bound to the User it is for. `consent` is what the
|
|
249
|
+
* consent page's button posts, and only that post turns it into the
|
|
250
|
+
* `exchange` code the app redeems. The two are signed alike and are never
|
|
251
|
+
* each other: `verify` reads only the kind it was asked for.
|
|
252
|
+
*/
|
|
253
|
+
interface ExchangeClaims {
|
|
254
|
+
kind: "consent" | "exchange";
|
|
255
|
+
start: AuthStartCommand;
|
|
256
|
+
hello: ClientHello;
|
|
257
|
+
expires: number;
|
|
258
|
+
userId: string;
|
|
259
|
+
}
|
|
260
|
+
interface SessionClaims {
|
|
261
|
+
kind: "session";
|
|
262
|
+
userId: string;
|
|
263
|
+
sessionId: string;
|
|
264
|
+
hello: ClientHello;
|
|
265
|
+
expires: number;
|
|
266
|
+
}
|
|
267
|
+
interface SettingsClaims {
|
|
268
|
+
kind: "settings";
|
|
269
|
+
userId: string;
|
|
270
|
+
hello: ClientHello;
|
|
271
|
+
home: "models" | "connections";
|
|
272
|
+
expires: number;
|
|
273
|
+
}
|
|
274
|
+
type Claims = StartClaims | ExchangeClaims | SessionClaims | SettingsClaims;
|
|
275
|
+
|
|
276
|
+
export interface NativeAuthOptions {
|
|
277
|
+
secret: string;
|
|
278
|
+
auth: AuthPackageIdentityV1;
|
|
279
|
+
// Only associated, signed targets belong here. No request can add an entry.
|
|
280
|
+
returnUris: readonly string[];
|
|
281
|
+
/** The signed apps the association files name. */
|
|
282
|
+
nativeApps: NativeAppsV1;
|
|
283
|
+
/** What the pages show of the product. */
|
|
284
|
+
brand: ReturnPageBrandV1;
|
|
285
|
+
/**
|
|
286
|
+
* The beta-access authority, asked after a bearer passes its read-only
|
|
287
|
+
* session check, or before a session is issued. Reads never provision a User.
|
|
288
|
+
* `null` means the identity no longer exists; a throw means the authority
|
|
289
|
+
* could not answer.
|
|
290
|
+
* Never asked for `developmentUserId`, which only a development stack sets.
|
|
291
|
+
*/
|
|
292
|
+
admit(userId: string): Promise<AccountAdmissionDecisionV1 | null>;
|
|
293
|
+
session(
|
|
294
|
+
userId: string,
|
|
295
|
+
operation: NativeSessionOperation,
|
|
296
|
+
): Promise<NativeSessionRecord | null>;
|
|
297
|
+
now?: () => number;
|
|
298
|
+
/**
|
|
299
|
+
* The deployment's own origin — every sign-in redirect, return URI and the
|
|
300
|
+
* origin check on `/native/*` is built from it. The Worker reads it from
|
|
301
|
+
* `BETTER_AUTH_URL`, which is what a development stack already points at its
|
|
302
|
+
* own host.
|
|
303
|
+
*/
|
|
304
|
+
origin: string;
|
|
305
|
+
/**
|
|
306
|
+
* The development sign-in door: with this set, `/native/authorize` issues
|
|
307
|
+
* the code for this User when the browser holds no session, in place of
|
|
308
|
+
* Google. Set only from `ALLOW_DEVELOPMENT_AUTH`, which production refuses.
|
|
309
|
+
*/
|
|
310
|
+
developmentUserId?: string;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export interface NativeAuth {
|
|
314
|
+
route(request: Request): Promise<Response | undefined>;
|
|
315
|
+
/**
|
|
316
|
+
* `admission` is the authority's answer for this bearer, so the gateway
|
|
317
|
+
* does not ask twice; it is absent for the development identity.
|
|
318
|
+
*/
|
|
319
|
+
authenticate(request: Request): Promise<
|
|
320
|
+
| {
|
|
321
|
+
session: AuthIdentityV1 | null;
|
|
322
|
+
refusal?: Response;
|
|
323
|
+
admission?: AccountAdmissionDecisionV1;
|
|
324
|
+
}
|
|
325
|
+
| undefined
|
|
326
|
+
>;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function base64(bytes: Uint8Array): string {
|
|
330
|
+
return btoa(String.fromCharCode(...bytes))
|
|
331
|
+
.replaceAll("+", "-")
|
|
332
|
+
.replaceAll("/", "_")
|
|
333
|
+
.replace(/=+$/, "");
|
|
334
|
+
}
|
|
335
|
+
function unbase64(text: string): Uint8Array<ArrayBuffer> {
|
|
336
|
+
return Uint8Array.from(
|
|
337
|
+
atob(text.replaceAll("-", "+").replaceAll("_", "/")),
|
|
338
|
+
(c) => c.charCodeAt(0),
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
export async function readNativeJsonBody(
|
|
342
|
+
request: Request,
|
|
343
|
+
maximumBytes = 8192,
|
|
344
|
+
): Promise<unknown> {
|
|
345
|
+
const text = await readLimitedText(request, maximumBytes);
|
|
346
|
+
let depth = 0,
|
|
347
|
+
quoted = false,
|
|
348
|
+
escaped = false;
|
|
349
|
+
for (const character of text) {
|
|
350
|
+
if (quoted) {
|
|
351
|
+
if (escaped) escaped = false;
|
|
352
|
+
else if (character === "\\") escaped = true;
|
|
353
|
+
else if (character === '"') quoted = false;
|
|
354
|
+
} else if (character === '"') quoted = true;
|
|
355
|
+
else if (character === "{" || character === "[") {
|
|
356
|
+
if (++depth > 16) throw new Error("Input nesting limit");
|
|
357
|
+
} else if (character === "}" || character === "]") depth--;
|
|
358
|
+
}
|
|
359
|
+
return JSON.parse(text);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async function readLimitedText(
|
|
363
|
+
request: Request,
|
|
364
|
+
maximumBytes: number,
|
|
365
|
+
): Promise<string> {
|
|
366
|
+
const reader = request.body?.getReader();
|
|
367
|
+
if (!reader) throw new Error("Missing input");
|
|
368
|
+
let size = 0;
|
|
369
|
+
const chunks: Uint8Array[] = [];
|
|
370
|
+
while (true) {
|
|
371
|
+
const next = await reader.read();
|
|
372
|
+
if (next.done) break;
|
|
373
|
+
size += next.value.length;
|
|
374
|
+
if (size > maximumBytes) {
|
|
375
|
+
await reader.cancel();
|
|
376
|
+
throw new Error("Too much input");
|
|
377
|
+
}
|
|
378
|
+
chunks.push(next.value);
|
|
379
|
+
}
|
|
380
|
+
const data = new Uint8Array(size);
|
|
381
|
+
let offset = 0;
|
|
382
|
+
for (const chunk of chunks) {
|
|
383
|
+
data.set(chunk, offset);
|
|
384
|
+
offset += chunk.length;
|
|
385
|
+
}
|
|
386
|
+
return new TextDecoder().decode(data);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export function createNativeAuth(options: NativeAuthOptions): NativeAuth {
|
|
390
|
+
// Normalised, so a configured origin with a trailing slash or a default port
|
|
391
|
+
// still matches the request origin the `/native/*` check compares it against.
|
|
392
|
+
const origin = new URL(options.origin).origin;
|
|
393
|
+
const product = options.brand.productName;
|
|
394
|
+
const now = options.now ?? Date.now;
|
|
395
|
+
const key = () =>
|
|
396
|
+
crypto.subtle.importKey(
|
|
397
|
+
"raw",
|
|
398
|
+
encoder.encode(`frockbot-native-v1:${options.secret}`),
|
|
399
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
400
|
+
false,
|
|
401
|
+
["sign", "verify"],
|
|
402
|
+
);
|
|
403
|
+
async function sign(claims: Claims): Promise<string> {
|
|
404
|
+
const payload = base64(encoder.encode(JSON.stringify(claims)));
|
|
405
|
+
const signature = await crypto.subtle.sign(
|
|
406
|
+
"HMAC",
|
|
407
|
+
await key(),
|
|
408
|
+
encoder.encode(payload),
|
|
409
|
+
);
|
|
410
|
+
return `${payload}.${base64(new Uint8Array(signature))}`;
|
|
411
|
+
}
|
|
412
|
+
async function verify(token: string, kind: Claims["kind"]): Promise<Claims> {
|
|
413
|
+
if (token.length > 4096) throw new Error("Invalid token");
|
|
414
|
+
const parts = token.split(".");
|
|
415
|
+
if (
|
|
416
|
+
parts.length !== 2 ||
|
|
417
|
+
!/^[A-Za-z0-9_-]+$/.test(parts[0]!) ||
|
|
418
|
+
!/^[A-Za-z0-9_-]+$/.test(parts[1]!)
|
|
419
|
+
)
|
|
420
|
+
throw new Error("Invalid token");
|
|
421
|
+
if (
|
|
422
|
+
!(await crypto.subtle.verify(
|
|
423
|
+
"HMAC",
|
|
424
|
+
await key(),
|
|
425
|
+
unbase64(parts[1]!),
|
|
426
|
+
encoder.encode(parts[0]!),
|
|
427
|
+
))
|
|
428
|
+
)
|
|
429
|
+
throw new Error("Invalid signature");
|
|
430
|
+
const value: unknown = JSON.parse(
|
|
431
|
+
new TextDecoder().decode(unbase64(parts[0]!)),
|
|
432
|
+
);
|
|
433
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
434
|
+
throw new Error("Invalid claims");
|
|
435
|
+
const v = value as Record<string, unknown>;
|
|
436
|
+
if (
|
|
437
|
+
v.kind !== kind ||
|
|
438
|
+
typeof v.expires !== "number" ||
|
|
439
|
+
!Number.isSafeInteger(v.expires) ||
|
|
440
|
+
v.expires <= now() ||
|
|
441
|
+
!isProtocolValue("ClientHello", v.hello)
|
|
442
|
+
)
|
|
443
|
+
throw new Error("Expired sign-in");
|
|
444
|
+
if (kind === "settings") {
|
|
445
|
+
if (
|
|
446
|
+
!isProtocolValue("Identifier", v.userId) ||
|
|
447
|
+
(v.home !== "models" && v.home !== "connections")
|
|
448
|
+
)
|
|
449
|
+
throw new Error("Invalid settings destination");
|
|
450
|
+
return {
|
|
451
|
+
kind,
|
|
452
|
+
userId: v.userId,
|
|
453
|
+
home: v.home,
|
|
454
|
+
hello: v.hello,
|
|
455
|
+
expires: v.expires,
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
if (kind !== "session") {
|
|
459
|
+
if (
|
|
460
|
+
!isProtocolValue("AuthStartCommand", v.start) ||
|
|
461
|
+
!options.returnUris.includes(v.start.returnUri)
|
|
462
|
+
)
|
|
463
|
+
throw new Error("Invalid return link");
|
|
464
|
+
if (kind === "start")
|
|
465
|
+
return { kind, start: v.start, hello: v.hello, expires: v.expires };
|
|
466
|
+
if (!isProtocolValue("Identifier", v.userId))
|
|
467
|
+
throw new Error("Invalid User");
|
|
468
|
+
return {
|
|
469
|
+
kind,
|
|
470
|
+
start: v.start,
|
|
471
|
+
hello: v.hello,
|
|
472
|
+
expires: v.expires,
|
|
473
|
+
userId: v.userId,
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
if (
|
|
477
|
+
!isProtocolValue("Identifier", v.userId) ||
|
|
478
|
+
!isProtocolValue("Identifier", v.sessionId)
|
|
479
|
+
)
|
|
480
|
+
throw new Error("Invalid session");
|
|
481
|
+
return {
|
|
482
|
+
kind,
|
|
483
|
+
userId: v.userId,
|
|
484
|
+
sessionId: v.sessionId,
|
|
485
|
+
hello: v.hello,
|
|
486
|
+
expires: v.expires,
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
function hello(request: Request): ClientHello {
|
|
490
|
+
const value: unknown = JSON.parse(
|
|
491
|
+
request.headers.get(CLIENT_HELLO_HEADER) ?? "null",
|
|
492
|
+
);
|
|
493
|
+
if (!isProtocolValue("ClientHello", value))
|
|
494
|
+
throw new Error(UPDATE_APP_MESSAGE);
|
|
495
|
+
return value;
|
|
496
|
+
}
|
|
497
|
+
async function browserIdentity(
|
|
498
|
+
request: Request,
|
|
499
|
+
): Promise<AuthIdentityV1 | null> {
|
|
500
|
+
// Browser cookies only: this endpoint cannot be used to launder another bearer.
|
|
501
|
+
const headers = new Headers();
|
|
502
|
+
const cookie = request.headers.get("cookie");
|
|
503
|
+
if (cookie) headers.set("cookie", cookie);
|
|
504
|
+
return options.auth.getSession(headers);
|
|
505
|
+
}
|
|
506
|
+
function operation(
|
|
507
|
+
claims: SessionClaims,
|
|
508
|
+
action: NativeSessionOperation["action"],
|
|
509
|
+
): NativeSessionOperation {
|
|
510
|
+
return {
|
|
511
|
+
schemaVersion: 1,
|
|
512
|
+
userId: claims.userId,
|
|
513
|
+
sessionId: claims.sessionId,
|
|
514
|
+
hello: claims.hello,
|
|
515
|
+
expiresAt: claims.expires,
|
|
516
|
+
action,
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
async function admitBeforeUser(
|
|
520
|
+
userId: string,
|
|
521
|
+
): Promise<AccountAdmissionDecisionV1 | "development" | Response | null> {
|
|
522
|
+
if (
|
|
523
|
+
options.developmentUserId !== undefined &&
|
|
524
|
+
userId === options.developmentUserId
|
|
525
|
+
) {
|
|
526
|
+
return "development";
|
|
527
|
+
}
|
|
528
|
+
let decision: AccountAdmissionDecisionV1 | null;
|
|
529
|
+
try {
|
|
530
|
+
decision = await options.admit(userId);
|
|
531
|
+
} catch {
|
|
532
|
+
return admissionUnavailableResponse();
|
|
533
|
+
}
|
|
534
|
+
if (decision && !decision.admitted) {
|
|
535
|
+
return admissionRefusedResponse(decision.reason, false);
|
|
536
|
+
}
|
|
537
|
+
return decision;
|
|
538
|
+
}
|
|
539
|
+
/** The code for `userId`, sent to the return the app started with. */
|
|
540
|
+
async function handBack(
|
|
541
|
+
claims: StartClaims | ExchangeClaims,
|
|
542
|
+
userId: string,
|
|
543
|
+
status: 302 | 303,
|
|
544
|
+
): Promise<Response> {
|
|
545
|
+
const code = await sign({
|
|
546
|
+
kind: "exchange",
|
|
547
|
+
start: claims.start,
|
|
548
|
+
hello: claims.hello,
|
|
549
|
+
expires: claims.expires,
|
|
550
|
+
userId,
|
|
551
|
+
});
|
|
552
|
+
const destination = new URL(claims.start.returnUri);
|
|
553
|
+
destination.searchParams.set("code", code);
|
|
554
|
+
destination.searchParams.set("state", claims.start.state);
|
|
555
|
+
return new Response(null, {
|
|
556
|
+
status,
|
|
557
|
+
headers: { ...NO_STORE, location: destination.toString() },
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* The press that lets this browser's User sign the app in. The consent it
|
|
562
|
+
* posts is bound to that User and to this authorization's state, challenge
|
|
563
|
+
* and return, and expires with it; the page cannot be framed.
|
|
564
|
+
*/
|
|
565
|
+
async function consentPage(
|
|
566
|
+
claims: StartClaims,
|
|
567
|
+
session: AuthIdentityV1,
|
|
568
|
+
): Promise<Response> {
|
|
569
|
+
const consent = await sign({
|
|
570
|
+
kind: "consent",
|
|
571
|
+
start: claims.start,
|
|
572
|
+
hello: claims.hello,
|
|
573
|
+
expires: claims.expires,
|
|
574
|
+
userId: session.user.id,
|
|
575
|
+
});
|
|
576
|
+
const platform = NATIVE_RETURN_PLATFORMS.find(
|
|
577
|
+
(candidate) =>
|
|
578
|
+
nativeReturnUriV1(origin, candidate) === claims.start.returnUri,
|
|
579
|
+
);
|
|
580
|
+
const destination = new URL(claims.start.returnUri);
|
|
581
|
+
return returnPageV1({
|
|
582
|
+
brand: options.brand,
|
|
583
|
+
title: `Sign in to ${product}`,
|
|
584
|
+
heading: `Sign in to ${platform ? nativeAppName(platform, product) : `the ${product} development build on this device`}?`,
|
|
585
|
+
lead: session.user.email
|
|
586
|
+
? `You’ll be signed in as ${session.user.email}.`
|
|
587
|
+
: "You’ll be signed in with the account this browser uses.",
|
|
588
|
+
form: {
|
|
589
|
+
label: "Sign in",
|
|
590
|
+
action: `${origin}/native/authorize`,
|
|
591
|
+
fields: { consent },
|
|
592
|
+
// The development scheme is the one return off this origin.
|
|
593
|
+
...(destination.origin === origin
|
|
594
|
+
? {}
|
|
595
|
+
: { redirects: [destination.protocol] }),
|
|
596
|
+
},
|
|
597
|
+
footnote:
|
|
598
|
+
"Only continue if you just started signing in from the app. If you didn’t, close this page: nothing is signed in until you press Sign in.",
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
return {
|
|
602
|
+
async authenticate(request) {
|
|
603
|
+
const bearer = request.headers.get("authorization");
|
|
604
|
+
if (!bearer?.startsWith(`Bearer ${PREFIX}`)) return undefined;
|
|
605
|
+
const refusal = clientCompatibilityResponse(
|
|
606
|
+
request,
|
|
607
|
+
new URL(`${origin}/api/native/session`),
|
|
608
|
+
);
|
|
609
|
+
if (refusal) return { session: null, refusal };
|
|
610
|
+
let claims: Claims;
|
|
611
|
+
try {
|
|
612
|
+
claims = await verify(bearer.slice(7 + PREFIX.length), "session");
|
|
613
|
+
if (claims.kind !== "session") return { session: null };
|
|
614
|
+
// The app recovers a rejected session through sign-in; 426 asks for an update.
|
|
615
|
+
if (!sameClient(hello(request), claims.hello)) return { session: null };
|
|
616
|
+
} catch {
|
|
617
|
+
return { session: null };
|
|
618
|
+
}
|
|
619
|
+
let record: NativeSessionRecord | null;
|
|
620
|
+
try {
|
|
621
|
+
record = await options.session(
|
|
622
|
+
claims.userId,
|
|
623
|
+
operation(claims, "read"),
|
|
624
|
+
);
|
|
625
|
+
} catch {
|
|
626
|
+
return { session: null };
|
|
627
|
+
}
|
|
628
|
+
if (!record) return { session: null };
|
|
629
|
+
const admitted = await admitBeforeUser(claims.userId);
|
|
630
|
+
if (admitted instanceof Response) {
|
|
631
|
+
return { session: null, refusal: admitted };
|
|
632
|
+
}
|
|
633
|
+
if (admitted === null) return { session: null };
|
|
634
|
+
try {
|
|
635
|
+
// The email, not just the id: admission and the admin check read it,
|
|
636
|
+
// so a native session that omitted it made a listed admin ordinary on
|
|
637
|
+
// the phone while the same account was an admin in a browser.
|
|
638
|
+
const profile = await options.auth.profile?.(record.userId);
|
|
639
|
+
return {
|
|
640
|
+
session: {
|
|
641
|
+
user: {
|
|
642
|
+
id: record.userId,
|
|
643
|
+
...(profile?.email ? { email: profile.email } : {}),
|
|
644
|
+
...(profile?.emailVerified ? { emailVerified: true } : {}),
|
|
645
|
+
},
|
|
646
|
+
},
|
|
647
|
+
...(admitted === "development" ? {} : { admission: admitted }),
|
|
648
|
+
};
|
|
649
|
+
} catch {
|
|
650
|
+
return { session: null };
|
|
651
|
+
}
|
|
652
|
+
},
|
|
653
|
+
async route(request) {
|
|
654
|
+
const url = new URL(request.url);
|
|
655
|
+
if (!isNativeAuthPath(url.pathname)) return undefined;
|
|
656
|
+
// The signed application's callback origin is not configurable by input.
|
|
657
|
+
// Google's asset-links fetcher asks for the fully qualified host
|
|
658
|
+
// ("bot.frockbot.com."); that trailing dot names the same origin.
|
|
659
|
+
if (requestOrigin(url) !== origin) return error(403);
|
|
660
|
+
const association =
|
|
661
|
+
url.pathname === "/.well-known/assetlinks.json" ||
|
|
662
|
+
url.pathname === "/.well-known/apple-app-site-association";
|
|
663
|
+
if (association && request.method === "HEAD") {
|
|
664
|
+
const full = await this.route(new Request(request.url));
|
|
665
|
+
return new Response(null, {
|
|
666
|
+
status: full?.status ?? 404,
|
|
667
|
+
headers: full?.headers,
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
try {
|
|
671
|
+
if (
|
|
672
|
+
url.pathname === "/.well-known/assetlinks.json" &&
|
|
673
|
+
request.method === "GET"
|
|
674
|
+
) {
|
|
675
|
+
if (options.nativeApps.android.length === 0) return error(404);
|
|
676
|
+
return Response.json(
|
|
677
|
+
options.nativeApps.android.map((app) => ({
|
|
678
|
+
relation: ["delegate_permission/common.handle_all_urls"],
|
|
679
|
+
target: {
|
|
680
|
+
namespace: "android_app",
|
|
681
|
+
package_name: app.packageName,
|
|
682
|
+
sha256_cert_fingerprints: app.sha256CertFingerprints,
|
|
683
|
+
},
|
|
684
|
+
})),
|
|
685
|
+
{ headers: { "cache-control": "public, max-age=300" } },
|
|
686
|
+
);
|
|
687
|
+
}
|
|
688
|
+
if (
|
|
689
|
+
url.pathname === "/.well-known/apple-app-site-association" &&
|
|
690
|
+
request.method === "GET"
|
|
691
|
+
) {
|
|
692
|
+
// Only the Apple returns this deployment serves: a path it answers
|
|
693
|
+
// 404 for is never offered to an app as a verified link.
|
|
694
|
+
const components = APPLE_ASSOCIATED_RETURNS.filter((platform) =>
|
|
695
|
+
options.returnUris.includes(nativeReturnUriV1(origin, platform)),
|
|
696
|
+
).map((platform) => ({ "/": `/native/return/${platform}` }));
|
|
697
|
+
if (components.length === 0 || options.nativeApps.apple.length === 0)
|
|
698
|
+
return error(404);
|
|
699
|
+
return Response.json(
|
|
700
|
+
{
|
|
701
|
+
applinks: {
|
|
702
|
+
details: [
|
|
703
|
+
{
|
|
704
|
+
appIDs: options.nativeApps.apple,
|
|
705
|
+
components,
|
|
706
|
+
},
|
|
707
|
+
],
|
|
708
|
+
},
|
|
709
|
+
},
|
|
710
|
+
{ headers: { "cache-control": "public, max-age=300" } },
|
|
711
|
+
);
|
|
712
|
+
}
|
|
713
|
+
// This signed link grants no browser session and no Connection. It only
|
|
714
|
+
// carries a short-lived navigation intent to the same authenticated User.
|
|
715
|
+
if (
|
|
716
|
+
url.pathname === "/api/auth/native/settings" &&
|
|
717
|
+
request.method === "POST"
|
|
718
|
+
) {
|
|
719
|
+
const principal = await this.authenticate(request);
|
|
720
|
+
if (principal?.refusal) return principal.refusal;
|
|
721
|
+
if (!principal?.session) return error(401);
|
|
722
|
+
const command = await readNativeJsonBody(request);
|
|
723
|
+
if (!isProtocolValue("SettingsHandoffCommand", command))
|
|
724
|
+
return error();
|
|
725
|
+
const expires = now() + 300_000;
|
|
726
|
+
const token = await sign({
|
|
727
|
+
kind: "settings",
|
|
728
|
+
userId: principal.session.user.id,
|
|
729
|
+
home: command.home,
|
|
730
|
+
hello: hello(request),
|
|
731
|
+
expires,
|
|
732
|
+
});
|
|
733
|
+
return Response.json(
|
|
734
|
+
{
|
|
735
|
+
schemaVersion: 1,
|
|
736
|
+
authorizationUrl: `${origin}/native/settings?request=${token}`,
|
|
737
|
+
expiresAt: new Date(expires).toISOString(),
|
|
738
|
+
},
|
|
739
|
+
{ headers: NO_STORE },
|
|
740
|
+
);
|
|
741
|
+
}
|
|
742
|
+
if (url.pathname === "/native/settings" && request.method === "GET") {
|
|
743
|
+
if ([...url.searchParams.keys()].join() !== "request") return error();
|
|
744
|
+
const token = url.searchParams.get("request") ?? "";
|
|
745
|
+
const claims = await verify(token, "settings");
|
|
746
|
+
if (claims.kind !== "settings") return error();
|
|
747
|
+
const session = await browserIdentity(request);
|
|
748
|
+
if (!session)
|
|
749
|
+
return options.auth.startSignIn(
|
|
750
|
+
request,
|
|
751
|
+
`${origin}/native/settings?request=${token}`,
|
|
752
|
+
);
|
|
753
|
+
if (session.user.id !== claims.userId)
|
|
754
|
+
return new Response(
|
|
755
|
+
`This browser is signed in to a different ${product} account. Switch accounts in the browser, then return to the app and try again.`,
|
|
756
|
+
{
|
|
757
|
+
status: 403,
|
|
758
|
+
headers: {
|
|
759
|
+
...NO_STORE,
|
|
760
|
+
"content-type": "text/plain; charset=utf-8",
|
|
761
|
+
},
|
|
762
|
+
},
|
|
763
|
+
);
|
|
764
|
+
return redirect(
|
|
765
|
+
`${origin}/?settings=${claims.home}${claims.home === "models" ? "#user-model-providers" : ""}`,
|
|
766
|
+
);
|
|
767
|
+
}
|
|
768
|
+
if (
|
|
769
|
+
url.pathname === "/api/auth/native/start" &&
|
|
770
|
+
request.method === "POST"
|
|
771
|
+
) {
|
|
772
|
+
const invalid = clientCompatibilityResponse(request, url);
|
|
773
|
+
if (invalid) return invalid;
|
|
774
|
+
const start = await readNativeJsonBody(request);
|
|
775
|
+
if (
|
|
776
|
+
!isProtocolValue("AuthStartCommand", start) ||
|
|
777
|
+
!options.returnUris.includes(start.returnUri)
|
|
778
|
+
)
|
|
779
|
+
return error();
|
|
780
|
+
const expires = now() + 300_000;
|
|
781
|
+
const token = await sign({
|
|
782
|
+
kind: "start",
|
|
783
|
+
start,
|
|
784
|
+
hello: hello(request),
|
|
785
|
+
expires,
|
|
786
|
+
});
|
|
787
|
+
return Response.json(
|
|
788
|
+
{
|
|
789
|
+
schemaVersion: 1,
|
|
790
|
+
authorizationUrl: `${origin}/native/authorize?request=${token}`,
|
|
791
|
+
expiresAt: new Date(expires).toISOString(),
|
|
792
|
+
},
|
|
793
|
+
{ headers: NO_STORE },
|
|
794
|
+
);
|
|
795
|
+
}
|
|
796
|
+
// A code is issued only from the consent page's press. Anyone can
|
|
797
|
+
// call the start with their own PKCE values and open this URL in the
|
|
798
|
+
// person's browser — an app that claims `frockbot://` can — so a
|
|
799
|
+
// browser that is already signed in must not hand that app a code by
|
|
800
|
+
// itself. `/native/complete` shows the same page after a fresh
|
|
801
|
+
// sign-in: it is a GET any page can open, so it cannot tell a sign-in
|
|
802
|
+
// that just happened from a browser that was signed in already.
|
|
803
|
+
if (
|
|
804
|
+
["/native/authorize", "/native/complete"].includes(url.pathname) &&
|
|
805
|
+
request.method === "GET"
|
|
806
|
+
) {
|
|
807
|
+
if ([...url.searchParams.keys()].join() !== "request") return error();
|
|
808
|
+
const token = url.searchParams.get("request") ?? "";
|
|
809
|
+
const claims = await verify(token, "start");
|
|
810
|
+
if (claims.kind !== "start") return error();
|
|
811
|
+
const session = await browserIdentity(request);
|
|
812
|
+
if (session) return consentPage(claims, session);
|
|
813
|
+
// The development door signs in a scripted app with no browser to
|
|
814
|
+
// press in, on a stack production refuses.
|
|
815
|
+
if (options.developmentUserId !== undefined)
|
|
816
|
+
return handBack(claims, options.developmentUserId, 302);
|
|
817
|
+
if (url.pathname === "/native/complete") return error(401);
|
|
818
|
+
return options.auth.startSignIn(
|
|
819
|
+
request,
|
|
820
|
+
`${origin}/native/complete?request=${token}`,
|
|
821
|
+
);
|
|
822
|
+
}
|
|
823
|
+
if (url.pathname === "/native/authorize" && request.method === "POST") {
|
|
824
|
+
// The press must come from the consent page itself. The signed
|
|
825
|
+
// consent below is what a cross-site form cannot have; this refuses
|
|
826
|
+
// one before any identity is asked.
|
|
827
|
+
const site = request.headers.get("sec-fetch-site");
|
|
828
|
+
const from = request.headers.get("origin");
|
|
829
|
+
if (
|
|
830
|
+
(site === null && from === null) ||
|
|
831
|
+
(site !== null && site !== "same-origin") ||
|
|
832
|
+
(from !== null && from !== origin)
|
|
833
|
+
)
|
|
834
|
+
return error(403);
|
|
835
|
+
const form = new URLSearchParams(
|
|
836
|
+
await readLimitedText(request, 8192),
|
|
837
|
+
);
|
|
838
|
+
if ([...form.keys()].join() !== "consent") return error();
|
|
839
|
+
const claims = await verify(form.get("consent") ?? "", "consent");
|
|
840
|
+
if (claims.kind !== "consent") return error();
|
|
841
|
+
const session = await browserIdentity(request);
|
|
842
|
+
if (!session) return error(401);
|
|
843
|
+
if (session.user.id !== claims.userId) return error(403);
|
|
844
|
+
return handBack(claims, claims.userId, 303);
|
|
845
|
+
}
|
|
846
|
+
if (
|
|
847
|
+
url.pathname === "/api/auth/native/exchange" &&
|
|
848
|
+
request.method === "POST"
|
|
849
|
+
) {
|
|
850
|
+
const invalid = clientCompatibilityResponse(request, url);
|
|
851
|
+
if (invalid) return invalid;
|
|
852
|
+
const command = await readNativeJsonBody(request);
|
|
853
|
+
if (!isProtocolValue("AuthExchangeCommand", command)) return error();
|
|
854
|
+
const claims = await verify(command.code, "exchange");
|
|
855
|
+
if (claims.kind !== "exchange") return error();
|
|
856
|
+
const digest = base64(
|
|
857
|
+
new Uint8Array(
|
|
858
|
+
await crypto.subtle.digest(
|
|
859
|
+
"SHA-256",
|
|
860
|
+
encoder.encode(command.codeVerifier),
|
|
861
|
+
),
|
|
862
|
+
),
|
|
863
|
+
);
|
|
864
|
+
if (
|
|
865
|
+
digest !== claims.start.codeChallenge ||
|
|
866
|
+
command.state !== claims.start.state ||
|
|
867
|
+
command.returnUri !== claims.start.returnUri ||
|
|
868
|
+
JSON.stringify(hello(request)) !== JSON.stringify(claims.hello)
|
|
869
|
+
)
|
|
870
|
+
return error();
|
|
871
|
+
const session: SessionClaims = {
|
|
872
|
+
kind: "session",
|
|
873
|
+
userId: claims.userId,
|
|
874
|
+
sessionId: claims.start.commandId,
|
|
875
|
+
hello: claims.hello,
|
|
876
|
+
expires: now() + 7 * 86400_000,
|
|
877
|
+
};
|
|
878
|
+
await options.session(
|
|
879
|
+
session.userId,
|
|
880
|
+
operation(session, "check-issue"),
|
|
881
|
+
);
|
|
882
|
+
const admitted = await admitBeforeUser(session.userId);
|
|
883
|
+
if (admitted instanceof Response) return admitted;
|
|
884
|
+
if (admitted === null) return error(401);
|
|
885
|
+
// Admission is committed before the bearer is returned. Replaying the
|
|
886
|
+
// same authorization, including a repeated callback, cannot issue twice.
|
|
887
|
+
const issued = await options.session(
|
|
888
|
+
session.userId,
|
|
889
|
+
operation(session, "issue"),
|
|
890
|
+
);
|
|
891
|
+
if (
|
|
892
|
+
!issued ||
|
|
893
|
+
issued.revoked ||
|
|
894
|
+
issued.userId !== session.userId ||
|
|
895
|
+
issued.sessionId !== session.sessionId ||
|
|
896
|
+
issued.expiresAt !== session.expires ||
|
|
897
|
+
JSON.stringify(issued.hello) !== JSON.stringify(session.hello)
|
|
898
|
+
)
|
|
899
|
+
return error();
|
|
900
|
+
return Response.json(
|
|
901
|
+
{
|
|
902
|
+
schemaVersion: 1,
|
|
903
|
+
userId: session.userId,
|
|
904
|
+
sessionId: session.sessionId,
|
|
905
|
+
expiresAt: new Date(session.expires).toISOString(),
|
|
906
|
+
sessionToken: PREFIX + (await sign(session)),
|
|
907
|
+
},
|
|
908
|
+
{ headers: NO_STORE },
|
|
909
|
+
);
|
|
910
|
+
}
|
|
911
|
+
if (
|
|
912
|
+
url.pathname === "/api/auth/native/revoke" &&
|
|
913
|
+
request.method === "POST"
|
|
914
|
+
) {
|
|
915
|
+
// This route is also called without the gateway's compatibility gate.
|
|
916
|
+
const invalid = clientCompatibilityResponse(request, url);
|
|
917
|
+
if (invalid) return invalid;
|
|
918
|
+
const bearer = request.headers.get("authorization") ?? "";
|
|
919
|
+
if (!bearer.startsWith(`Bearer ${PREFIX}`)) return error(401);
|
|
920
|
+
const claims = await verify(
|
|
921
|
+
bearer.slice(7 + PREFIX.length),
|
|
922
|
+
"session",
|
|
923
|
+
);
|
|
924
|
+
if (claims.kind !== "session") return error(401);
|
|
925
|
+
const command = await readNativeJsonBody(request);
|
|
926
|
+
if (
|
|
927
|
+
!isProtocolValue("SessionRevokeCommand", command) ||
|
|
928
|
+
command.sessionId !== claims.sessionId
|
|
929
|
+
)
|
|
930
|
+
return error();
|
|
931
|
+
// A 401 lets the app discard the unusable session and finish signing out.
|
|
932
|
+
if (!sameClient(hello(request), claims.hello))
|
|
933
|
+
return error(401, "Please sign in again.");
|
|
934
|
+
// Signing out narrows authority even while access is paused. The
|
|
935
|
+
// session owner revokes existing records without provisioning a User.
|
|
936
|
+
await options.session(claims.userId, operation(claims, "revoke"));
|
|
937
|
+
return Response.json(
|
|
938
|
+
{ schemaVersion: 1, status: "signed-out" },
|
|
939
|
+
{ headers: NO_STORE },
|
|
940
|
+
);
|
|
941
|
+
}
|
|
942
|
+
if (
|
|
943
|
+
options.returnUris.includes(url.origin + url.pathname) &&
|
|
944
|
+
request.method === "GET"
|
|
945
|
+
) {
|
|
946
|
+
const page = url.origin + url.pathname;
|
|
947
|
+
const platform = NATIVE_RETURN_PLATFORMS.find(
|
|
948
|
+
(candidate) => nativeReturnUriV1(origin, candidate) === page,
|
|
949
|
+
);
|
|
950
|
+
if (platform)
|
|
951
|
+
return nativeReturnPage(platform, origin, options.brand);
|
|
952
|
+
}
|
|
953
|
+
return error(404);
|
|
954
|
+
} catch {
|
|
955
|
+
return error();
|
|
956
|
+
}
|
|
957
|
+
},
|
|
958
|
+
};
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
/**
|
|
962
|
+
* The page Google's completion lands on in the user's browser. On a Mac or an
|
|
963
|
+
* iPhone it carries the code and state across to the app on its custom
|
|
964
|
+
* scheme, forwarding only those two query parameters and never reflecting
|
|
965
|
+
* them into markup. On Android the verified App Link has already opened the
|
|
966
|
+
* app; this page is what remains in the browser, and what a user sees if it
|
|
967
|
+
* did not.
|
|
968
|
+
*/
|
|
969
|
+
function nativeReturnPage(
|
|
970
|
+
platform: NativeReturnPlatformV1,
|
|
971
|
+
origin: string,
|
|
972
|
+
brand: ReturnPageBrandV1,
|
|
973
|
+
): Response {
|
|
974
|
+
const product = brand.productName;
|
|
975
|
+
const returnUri = new URL(nativeReturnUriV1(origin, platform));
|
|
976
|
+
const target =
|
|
977
|
+
platform === "android"
|
|
978
|
+
? undefined
|
|
979
|
+
: `${nativeReturnSchemeV1(brand, NATIVE_RETURN_BUILDS[platform])}://${returnUri.host}${returnUri.pathname}`;
|
|
980
|
+
return returnPageV1({
|
|
981
|
+
brand,
|
|
982
|
+
title: `Return to ${product}`,
|
|
983
|
+
heading: `Return to ${product} to finish signing in`,
|
|
984
|
+
lead: target
|
|
985
|
+
? `Your browser is handing you over to the ${product} app. Once it opens, you can close this tab.`
|
|
986
|
+
: `Head back to the ${product} app to finish signing in. You can close this page.`,
|
|
987
|
+
footnote: `If ${product} did not open, check that the latest app is installed and try signing in again.`,
|
|
988
|
+
...(target === undefined
|
|
989
|
+
? {}
|
|
990
|
+
: {
|
|
991
|
+
status: `Opening ${product}`,
|
|
992
|
+
action: { label: `Open ${product}`, href: target, id: "open" },
|
|
993
|
+
script: `(function () {
|
|
994
|
+
var incoming = new URLSearchParams(location.search);
|
|
995
|
+
var forwarded = new URLSearchParams();
|
|
996
|
+
["code", "state"].forEach(function (key) {
|
|
997
|
+
var value = incoming.get(key);
|
|
998
|
+
if (value !== null) forwarded.set(key, value);
|
|
999
|
+
});
|
|
1000
|
+
var href = ${JSON.stringify(target)} + (forwarded.toString() ? "?" + forwarded.toString() : "");
|
|
1001
|
+
document.getElementById("open").setAttribute("href", href);
|
|
1002
|
+
location.replace(href);
|
|
1003
|
+
})();`,
|
|
1004
|
+
}),
|
|
1005
|
+
});
|
|
1006
|
+
}
|