@parlor/auth 0.0.0-stage → 0.1.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.
Files changed (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +25 -2
  3. package/dist/account.d.ts +52 -0
  4. package/dist/account.d.ts.map +1 -0
  5. package/dist/account.js +114 -0
  6. package/dist/account.js.map +1 -0
  7. package/dist/audit.d.ts +10 -0
  8. package/dist/audit.d.ts.map +1 -0
  9. package/dist/audit.js +130 -0
  10. package/dist/audit.js.map +1 -0
  11. package/dist/better-auth.d.ts +41 -0
  12. package/dist/better-auth.d.ts.map +1 -0
  13. package/dist/better-auth.js +118 -0
  14. package/dist/better-auth.js.map +1 -0
  15. package/dist/client/index.d.ts +43 -0
  16. package/dist/client/index.d.ts.map +1 -0
  17. package/dist/client/index.js +465 -0
  18. package/dist/client/index.js.map +1 -0
  19. package/dist/database.d.ts +7 -0
  20. package/dist/database.d.ts.map +1 -0
  21. package/dist/database.js +11 -0
  22. package/dist/database.js.map +1 -0
  23. package/dist/devices.d.ts +157 -0
  24. package/dist/devices.d.ts.map +1 -0
  25. package/dist/devices.js +398 -0
  26. package/dist/devices.js.map +1 -0
  27. package/dist/es256.d.ts +4 -0
  28. package/dist/es256.d.ts.map +1 -0
  29. package/dist/es256.js +44 -0
  30. package/dist/es256.js.map +1 -0
  31. package/dist/grants.d.ts +4 -0
  32. package/dist/grants.d.ts.map +1 -0
  33. package/dist/grants.js +6 -0
  34. package/dist/grants.js.map +1 -0
  35. package/dist/hosts.d.ts +116 -0
  36. package/dist/hosts.d.ts.map +1 -0
  37. package/dist/hosts.js +530 -0
  38. package/dist/hosts.js.map +1 -0
  39. package/dist/index.d.ts +9 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +9 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/migrations.d.ts +3 -0
  44. package/dist/migrations.d.ts.map +1 -0
  45. package/dist/migrations.js +17 -0
  46. package/dist/migrations.js.map +1 -0
  47. package/dist/pages.d.ts +4 -0
  48. package/dist/pages.d.ts.map +1 -0
  49. package/dist/pages.js +53 -0
  50. package/dist/pages.js.map +1 -0
  51. package/dist/provider.d.ts +74 -0
  52. package/dist/provider.d.ts.map +1 -0
  53. package/dist/provider.js +286 -0
  54. package/dist/provider.js.map +1 -0
  55. package/dist/revocation.d.ts +51 -0
  56. package/dist/revocation.d.ts.map +1 -0
  57. package/dist/revocation.js +233 -0
  58. package/dist/revocation.js.map +1 -0
  59. package/dist/routes.d.ts +15 -0
  60. package/dist/routes.d.ts.map +1 -0
  61. package/dist/routes.js +116 -0
  62. package/dist/routes.js.map +1 -0
  63. package/dist/schema.d.ts +2970 -0
  64. package/dist/schema.d.ts.map +1 -0
  65. package/dist/schema.js +308 -0
  66. package/dist/schema.js.map +1 -0
  67. package/dist/statements.d.ts +92 -0
  68. package/dist/statements.d.ts.map +1 -0
  69. package/dist/statements.js +199 -0
  70. package/dist/statements.js.map +1 -0
  71. package/dist/tokens.d.ts +69 -0
  72. package/dist/tokens.d.ts.map +1 -0
  73. package/dist/tokens.js +134 -0
  74. package/dist/tokens.js.map +1 -0
  75. package/dist/turn-keys.d.ts +16 -0
  76. package/dist/turn-keys.d.ts.map +1 -0
  77. package/dist/turn-keys.js +108 -0
  78. package/dist/turn-keys.js.map +1 -0
  79. package/package.json +57 -3
package/dist/grants.js ADDED
@@ -0,0 +1,6 @@
1
+ import { asId } from "@parlor/core";
2
+ /** One grant per user and OAuth client; re-authorizing after a revocation mints newer tokens. */
3
+ export function grantIdOf(userId, clientId) {
4
+ return asId("grant", `oauth:${userId}:${clientId}`);
5
+ }
6
+ //# sourceMappingURL=grants.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"grants.js","sourceRoot":"","sources":["../src/grants.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAgB,MAAM,cAAc,CAAC;AAElD,iGAAiG;AACjG,MAAM,UAAU,SAAS,CAAC,MAAc,EAAE,QAAgB;IACxD,OAAO,IAAI,CAAC,OAAO,EAAE,SAAS,MAAM,IAAI,QAAQ,EAAE,CAAC,CAAC;AACtD,CAAC"}
@@ -0,0 +1,116 @@
1
+ import { type DataProvider, type GrantId, type HostId, type HostPrincipal, type UserId } from "@parlor/core";
2
+ import { type ActionsOf } from "@parlor/core/registry";
3
+ import { type AnyPluginRoute, type DeviceKeys, type Migration, type PluginHost } from "@parlor/server";
4
+ import { z } from "zod";
5
+ import { type ParlorAuthSettings } from "./better-auth.js";
6
+ import { HOST_AUDIENCE, type HostAssertion } from "./tokens.js";
7
+ export declare const HOST_CLIENT_ID = "parlor-host";
8
+ export declare const MATCHING_CODE: RegExp;
9
+ export type HostRedirect = `http://127.0.0.1:${number}/callback` | "urn:parlor:ipc";
10
+ export interface HostAuthorizeRequest {
11
+ readonly redirectUri: HostRedirect;
12
+ readonly codeChallenge: string;
13
+ readonly state: string;
14
+ readonly hostName: string;
15
+ readonly platform: string;
16
+ readonly matchingCode: string;
17
+ /** RFC 7638 thumbprint of the key the code exchange must be signed with. */
18
+ readonly keyThumbprint: string;
19
+ }
20
+ /** `POST /hosts/token` */
21
+ export interface HostKeyExchange {
22
+ readonly code: string;
23
+ readonly codeVerifier: string;
24
+ readonly publicKey: JsonWebKey;
25
+ /** ES256 over the code with the key the authorize request named. */
26
+ readonly proof: string;
27
+ }
28
+ export interface AccountSummary {
29
+ readonly userId: UserId;
30
+ readonly name: string;
31
+ readonly email?: string | undefined;
32
+ }
33
+ /** The device that approved the pairing; the host pins it as trusted once confirmed locally. */
34
+ export type ApprovingDevice = DeviceKeys;
35
+ export interface HostGrantResponse {
36
+ readonly grant: {
37
+ readonly id: GrantId;
38
+ readonly hostId: HostId;
39
+ };
40
+ /** The host shows it locally before it keeps the grant. */
41
+ readonly account: AccountSummary;
42
+ readonly approvedBy?: ApprovingDevice | undefined;
43
+ }
44
+ export declare const HOST_ASSERTION_TTL_MS = 60000;
45
+ /** `authorization: ParlorHost <jws>`, DPoP style: bound to one method and URL, dead in a minute. */
46
+ export interface HostAssertionClaims {
47
+ readonly aud: typeof HOST_AUDIENCE;
48
+ readonly gid: string;
49
+ readonly htm: "GET" | "POST";
50
+ /** without the query */
51
+ readonly htu: string;
52
+ readonly iat: number;
53
+ readonly exp: number;
54
+ /** replay-checked in a bounded per-instance LRU over the TTL window */
55
+ readonly jti: string;
56
+ }
57
+ /** A grant as the server stores it: only the computer's public key. */
58
+ export interface GrantKey {
59
+ readonly grantId: GrantId;
60
+ readonly userId: UserId;
61
+ readonly hostId: HostId;
62
+ readonly publicKey: JsonWebKey;
63
+ readonly kid: string;
64
+ }
65
+ /** In memory after one primary-key lookup; dropped when `RevocationSet` reports the grant. */
66
+ export interface GrantKeys {
67
+ get(grantId: GrantId): Promise<GrantKey | undefined>;
68
+ }
69
+ /** A missing grant is remembered briefly; grant ids are never reused, so a miss stays one. */
70
+ export declare const GRANT_MISS_TTL_MS = 5000;
71
+ export interface GrantKeysOptions {
72
+ /** How long a loaded key is trusted; clamped to the revocation horizon. */
73
+ readonly ttlMs?: number | undefined;
74
+ readonly now?: (() => number) | undefined;
75
+ }
76
+ /**
77
+ * A grant's key after one primary-key lookup, kept for a few minutes. A grant revoked
78
+ * meanwhile has no row, and `RevocationSet` (which outlasts the cache) refuses it until the
79
+ * cache lets go.
80
+ */
81
+ export declare function createGrantKeys(database: DataProvider, options?: GrantKeysOptions): GrantKeys;
82
+ export declare function verifyHostAssertion(assertion: HostAssertion, request: {
83
+ readonly method: string;
84
+ readonly url: string;
85
+ }, keys: GrantKeys, now: number): Promise<GrantKey>;
86
+ export declare function hostPrincipalOf(grant: GrantKey): HostPrincipal;
87
+ export declare const HOSTS_TAG = "parlor_auth_hosts";
88
+ export declare const HOSTS_MIGRATION: Migration;
89
+ export interface ConnectedHost {
90
+ readonly hostId: HostId;
91
+ readonly name: string;
92
+ readonly platform: string;
93
+ readonly connectedAt: string;
94
+ readonly lastUsedAt: string | null;
95
+ }
96
+ export interface HostGrantDeps {
97
+ readonly host: PluginHost;
98
+ readonly settings: ParlorAuthSettings;
99
+ }
100
+ /** `hosts` and `revokeHost`, merged into the account actions once host grants land. */
101
+ export declare function hostAccountActions(deps: HostGrantDeps): import("@parlor/core/registry").ActionRegistry<{
102
+ hosts: import("@parlor/core").ActionDefinition<z.ZodObject<{}, z.core.$strip>, ConnectedHost[]>;
103
+ revokeHost: import("@parlor/core").ActionDefinition<z.ZodObject<{
104
+ hostId: z.ZodString;
105
+ }, z.core.$strip>, {
106
+ readonly id: string;
107
+ }>;
108
+ }>;
109
+ export type HostAccountActions = ActionsOf<ReturnType<typeof hostAccountActions>>;
110
+ /**
111
+ * `GET|POST /hosts/authorize` is the framework-owned consent page and its answer, `POST
112
+ * /hosts/token` the PKCE exchange binding the host's key, `POST /hosts/revoke` the host
113
+ * disconnecting itself.
114
+ */
115
+ export declare function hostRoutes(deps: HostGrantDeps): readonly AnyPluginRoute[];
116
+ //# sourceMappingURL=hosts.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hosts.d.ts","sourceRoot":"","sources":["../src/hosts.ts"],"names":[],"mappings":"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"}
package/dist/hosts.js ADDED
@@ -0,0 +1,530 @@
1
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
2
+ import { ActionError, asId, defineAction, } from "@parlor/core";
3
+ import { createRegistry } from "@parlor/core/registry";
4
+ import { AuthFailure, HOST_GRANTS_TAG, pluginRoute, readBoundedText, SHELL_ORIGINS, REVOCATION_HORIZON_MS, } from "@parlor/server";
5
+ import { sql } from "drizzle-orm";
6
+ import { calculateJwkThumbprint, compactVerify, decodeProtectedHeader } from "jose";
7
+ import { z } from "zod";
8
+ import { signedIn } from "./account.js";
9
+ import { AUTH_PLUGIN, devOrigins } from "./better-auth.js";
10
+ import { jwkOf, rowsOf } from "./database.js";
11
+ import { findPendingDevice } from "./devices.js";
12
+ import { escape, failure, page } from "./pages.js";
13
+ import { RevocationSet } from "./revocation.js";
14
+ import { HOST_ASSERTION_TYP, HOST_AUDIENCE } from "./tokens.js";
15
+ export const HOST_CLIENT_ID = "parlor-host";
16
+ export const MATCHING_CODE = /^[A-Z2-9]{3}-[A-Z2-9]{3}$/;
17
+ export const HOST_ASSERTION_TTL_MS = 60_000;
18
+ const GRANT_KEY_TTL_MS = 5 * 60_000;
19
+ const GRANT_KEY_CACHE_SIZE = 10_000;
20
+ /** A missing grant is remembered briefly; grant ids are never reused, so a miss stays one. */
21
+ export const GRANT_MISS_TTL_MS = 5_000;
22
+ const GRANT_MISS_CACHE_SIZE = 1_000;
23
+ const REPLAY_CACHE_SIZE = 100_000;
24
+ const CLOCK_SKEW_MS = 5_000;
25
+ const verifyKeys = new WeakMap();
26
+ const replays = new WeakMap();
27
+ const EC_P256 = { name: "ECDSA", namedCurve: "P-256" };
28
+ function verifyKeyOf(grant) {
29
+ let key = verifyKeys.get(grant);
30
+ if (!key) {
31
+ key = crypto.subtle.importKey("jwk", grant.publicKey, EC_P256, false, ["verify"]);
32
+ verifyKeys.set(grant, key);
33
+ }
34
+ return key;
35
+ }
36
+ /**
37
+ * A grant's key after one primary-key lookup, kept for a few minutes. A grant revoked
38
+ * meanwhile has no row, and `RevocationSet` (which outlasts the cache) refuses it until the
39
+ * cache lets go.
40
+ */
41
+ export function createGrantKeys(database, options = {}) {
42
+ const ttlMs = Math.min(options.ttlMs ?? GRANT_KEY_TTL_MS, REVOCATION_HORIZON_MS);
43
+ const now = options.now ?? Date.now;
44
+ const cache = new Map();
45
+ const misses = new Map();
46
+ const loading = new Map();
47
+ const load = async (grantId) => {
48
+ const rows = rowsOf(await database.read().execute(sql `
49
+ select grant_id, user_id, host_id, public_key, kid from parlor_auth_hosts
50
+ where grant_id = ${grantId}`));
51
+ const row = rows[0];
52
+ if (!row) {
53
+ misses.set(grantId, now());
54
+ while (misses.size > GRANT_MISS_CACHE_SIZE) {
55
+ misses.delete(misses.keys().next().value);
56
+ }
57
+ return undefined;
58
+ }
59
+ const key = {
60
+ grantId: asId("grant", row.grant_id),
61
+ userId: asId("user", row.user_id),
62
+ hostId: asId("host", row.host_id),
63
+ publicKey: jwkOf(row.public_key),
64
+ kid: row.kid,
65
+ };
66
+ cache.set(grantId, { key, loadedAt: now() });
67
+ while (cache.size > GRANT_KEY_CACHE_SIZE) {
68
+ cache.delete(cache.keys().next().value);
69
+ }
70
+ return key;
71
+ };
72
+ return {
73
+ async get(grantId) {
74
+ const hit = cache.get(grantId);
75
+ if (hit && now() - hit.loadedAt < ttlMs)
76
+ return hit.key;
77
+ cache.delete(grantId);
78
+ const missedAt = misses.get(grantId);
79
+ if (missedAt !== undefined) {
80
+ if (now() - missedAt < GRANT_MISS_TTL_MS)
81
+ return undefined;
82
+ misses.delete(grantId);
83
+ }
84
+ let pending = loading.get(grantId);
85
+ if (!pending) {
86
+ pending = load(grantId).finally(() => loading.delete(grantId));
87
+ loading.set(grantId, pending);
88
+ }
89
+ return pending;
90
+ },
91
+ };
92
+ }
93
+ const assertionClaims = z.object({
94
+ aud: z.literal(HOST_AUDIENCE),
95
+ gid: z.string().min(1),
96
+ htm: z.enum(["GET", "POST"]),
97
+ htu: z.string().min(1),
98
+ iat: z.number().int(),
99
+ exp: z.number().int(),
100
+ jti: z.string().min(8).max(128),
101
+ });
102
+ const withoutQuery = (url) => {
103
+ const parsed = new URL(url);
104
+ parsed.search = "";
105
+ parsed.hash = "";
106
+ return parsed.href;
107
+ };
108
+ function claimJti(keys, grantId, jti, now, rememberUntil) {
109
+ let seen = replays.get(keys);
110
+ if (!seen)
111
+ replays.set(keys, (seen = new Map()));
112
+ for (const [id, expiresAt] of seen) {
113
+ if (expiresAt > now)
114
+ break;
115
+ seen.delete(id);
116
+ }
117
+ const id = `${grantId}:${jti}`;
118
+ if (seen.has(id))
119
+ return false;
120
+ while (seen.size >= REPLAY_CACHE_SIZE)
121
+ seen.delete(seen.keys().next().value);
122
+ seen.set(id, rememberUntil);
123
+ return true;
124
+ }
125
+ function claimsOf(assertion) {
126
+ try {
127
+ const header = decodeProtectedHeader(assertion);
128
+ const payload = JSON.parse(Buffer.from(assertion.split(".")[1] ?? "", "base64url").toString("utf8"));
129
+ if (header.typ !== HOST_ASSERTION_TYP || typeof payload.gid !== "string") {
130
+ throw new Error("not a host assertion");
131
+ }
132
+ return { gid: payload.gid, kid: header.kid };
133
+ }
134
+ catch {
135
+ throw new AuthFailure("invalid", "The host assertion is malformed.");
136
+ }
137
+ }
138
+ export async function verifyHostAssertion(assertion, request, keys, now) {
139
+ const { gid, kid } = claimsOf(assertion);
140
+ const grant = await keys.get(asId("grant", gid));
141
+ if (!grant || kid !== grant.kid) {
142
+ throw new AuthFailure("invalid", "This computer is not connected.");
143
+ }
144
+ let payload;
145
+ try {
146
+ const verified = await compactVerify(assertion, await verifyKeyOf(grant), {
147
+ algorithms: ["ES256"],
148
+ });
149
+ payload = JSON.parse(new TextDecoder().decode(verified.payload));
150
+ }
151
+ catch {
152
+ throw new AuthFailure("invalid", "The host assertion is not signed by this computer.");
153
+ }
154
+ const parsed = assertionClaims.safeParse(payload);
155
+ if (!parsed.success)
156
+ throw new AuthFailure("invalid", "The host assertion is malformed.");
157
+ const claims = parsed.data;
158
+ if (claims.htm !== request.method) {
159
+ throw new AuthFailure("invalid", "The host assertion is for another method.");
160
+ }
161
+ let sameUrl;
162
+ try {
163
+ sameUrl = withoutQuery(claims.htu) === withoutQuery(request.url);
164
+ }
165
+ catch {
166
+ sameUrl = false;
167
+ }
168
+ if (!sameUrl)
169
+ throw new AuthFailure("invalid", "The host assertion is for another URL.");
170
+ const issuedAt = claims.iat * 1000;
171
+ if (claims.exp - claims.iat > HOST_ASSERTION_TTL_MS / 1000 ||
172
+ issuedAt - now > CLOCK_SKEW_MS) {
173
+ throw new AuthFailure("invalid", "The host assertion has an impossible lifetime.");
174
+ }
175
+ if (now - issuedAt > HOST_ASSERTION_TTL_MS || claims.exp * 1000 <= now) {
176
+ throw new AuthFailure("expired", "The host assertion expired.");
177
+ }
178
+ const acceptableUntil = Math.min(claims.exp * 1000, issuedAt + HOST_ASSERTION_TTL_MS);
179
+ if (!claimJti(keys, grant.grantId, claims.jti, now, acceptableUntil)) {
180
+ throw new AuthFailure("invalid", "The host assertion was already used.");
181
+ }
182
+ return grant;
183
+ }
184
+ export function hostPrincipalOf(grant) {
185
+ return {
186
+ kind: "host",
187
+ userId: grant.userId,
188
+ grantId: grant.grantId,
189
+ hostId: grant.hostId,
190
+ };
191
+ }
192
+ export const HOSTS_TAG = HOST_GRANTS_TAG;
193
+ export const HOSTS_MIGRATION = {
194
+ id: "auth/0003_hosts",
195
+ sql: `
196
+ create table parlor_auth_hosts (
197
+ grant_id text primary key,
198
+ host_id text not null unique,
199
+ user_id text not null,
200
+ name text not null,
201
+ platform text not null,
202
+ public_key jsonb not null,
203
+ kid text not null,
204
+ created_at timestamptz not null default now(),
205
+ last_used_at timestamptz
206
+ );
207
+ create index parlor_auth_hosts_user on parlor_auth_hosts (user_id);
208
+
209
+ create table parlor_auth_host_codes (
210
+ code_hash text primary key,
211
+ user_id text not null,
212
+ code_challenge text not null,
213
+ key_thumbprint text not null,
214
+ redirect_uri text not null,
215
+ host_name text not null,
216
+ platform text not null,
217
+ device_id text,
218
+ expires_at timestamptz not null
219
+ )`,
220
+ };
221
+ function databaseOf(host) {
222
+ if (!host.database)
223
+ throw new Error("Host grants need a database.");
224
+ return host.database;
225
+ }
226
+ /**
227
+ * Deletes the grant's key row and revokes the grant in one transaction. The row must go:
228
+ * `RevocationSet` forgets after its horizon, and a host assertion's `iat` is always fresh.
229
+ */
230
+ async function removeGrant(deps, match, userId) {
231
+ return databaseOf(deps.host).transaction(async (handle) => {
232
+ const tx = handle;
233
+ const removed = rowsOf(await tx.execute("hostId" in match
234
+ ? sql `delete from parlor_auth_hosts where host_id = ${match.hostId} and user_id = ${userId} returning grant_id, host_id`
235
+ : sql `delete from parlor_auth_hosts where grant_id = ${match.grantId} and user_id = ${userId} returning grant_id, host_id`))[0];
236
+ if (!removed)
237
+ return undefined;
238
+ await RevocationSet.revoke(tx, { kind: "grant", id: removed.grant_id });
239
+ return removed;
240
+ });
241
+ }
242
+ const isoOf = (value) => new Date(value).toISOString();
243
+ /** `hosts` and `revokeHost`, merged into the account actions once host grants land. */
244
+ export function hostAccountActions(deps) {
245
+ return createRegistry({
246
+ hosts: defineAction({
247
+ description: "List the computers connected to this account.",
248
+ input: z.object({}),
249
+ reads: [HOSTS_TAG],
250
+ run: async (_input, ctx) => {
251
+ const user = signedIn(ctx);
252
+ const rows = rowsOf(await databaseOf(deps.host).read().execute(sql `
253
+ select host_id, name, platform, created_at, last_used_at from parlor_auth_hosts
254
+ where user_id = ${user.userId} order by created_at, host_id`));
255
+ return rows.map((row) => ({
256
+ hostId: asId("host", row.host_id),
257
+ name: row.name,
258
+ platform: row.platform,
259
+ connectedAt: isoOf(row.created_at),
260
+ lastUsedAt: row.last_used_at === null ? null : isoOf(row.last_used_at),
261
+ }));
262
+ },
263
+ }),
264
+ revokeHost: defineAction({
265
+ description: "Disconnect a computer; its grant, turn tokens and stream end at once.",
266
+ input: z.object({ hostId: z.string().min(1) }),
267
+ run: async ({ hostId }, ctx) => {
268
+ const user = signedIn(ctx);
269
+ const removed = await removeGrant(deps, { hostId }, user.userId);
270
+ if (!removed)
271
+ throw new ActionError("not_found", `No computer ${hostId}`);
272
+ await deps.host.announce(ctx.scope, [[HOSTS_TAG, hostId]]);
273
+ return { id: hostId };
274
+ },
275
+ }),
276
+ });
277
+ }
278
+ const THUMBPRINT = /^[A-Za-z0-9_-]{43}$/;
279
+ const isPrintable = (text) => [...text].every((char) => {
280
+ const code = char.codePointAt(0);
281
+ return code >= 0x20 && code !== 0x7f;
282
+ });
283
+ const LOOPBACK = /^http:\/\/127\.0\.0\.1:(\d{1,5})\/callback$/;
284
+ const IPC_REDIRECT = "urn:parlor:ipc";
285
+ const CODE_TTL_MS = 10 * 60_000;
286
+ function isHostRedirect(value) {
287
+ if (value === IPC_REDIRECT)
288
+ return true;
289
+ if (typeof value !== "string")
290
+ return false;
291
+ const port = LOOPBACK.exec(value)?.[1];
292
+ return port !== undefined && Number(port) >= 1 && Number(port) <= 65_535;
293
+ }
294
+ const authorizeRequest = z.object({
295
+ redirectUri: z.custom(isHostRedirect, "Use a loopback redirect."),
296
+ codeChallenge: z.string().regex(THUMBPRINT),
297
+ state: z.string().min(1).max(256),
298
+ hostName: z
299
+ .string()
300
+ .trim()
301
+ .min(1)
302
+ .max(64)
303
+ .refine(isPrintable, "Use printable characters."),
304
+ platform: z
305
+ .string()
306
+ .trim()
307
+ .min(1)
308
+ .max(32)
309
+ .refine(isPrintable, "Use printable characters."),
310
+ matchingCode: z.string().regex(MATCHING_CODE),
311
+ keyThumbprint: z.string().regex(THUMBPRINT),
312
+ });
313
+ const authorizeAnswer = authorizeRequest.extend({
314
+ decision: z.enum(["connect", "cancel"]),
315
+ });
316
+ const publicKeySchema = z.looseObject({
317
+ kty: z.literal("EC"),
318
+ crv: z.literal("P-256"),
319
+ x: z.string().regex(THUMBPRINT),
320
+ y: z.string().regex(THUMBPRINT),
321
+ });
322
+ const keyExchange = z.object({
323
+ code: z.string().min(16).max(256),
324
+ codeVerifier: z.string().min(43).max(128),
325
+ publicKey: publicKeySchema,
326
+ proof: z.string().min(1).max(256),
327
+ });
328
+ const MAX_BODY_BYTES = 16 * 1024;
329
+ const sha256 = (text, encoding) => createHash("sha256").update(text).digest(encoding);
330
+ function sameText(a, b) {
331
+ const left = createHash("sha256").update(a).digest();
332
+ const right = createHash("sha256").update(b).digest();
333
+ return timingSafeEqual(left, right);
334
+ }
335
+ async function bodyOf(request) {
336
+ const text = await readBoundedText(request, MAX_BODY_BYTES);
337
+ if (text === undefined)
338
+ return { json: false, fields: undefined };
339
+ if ((request.headers.get("content-type") ?? "").includes("application/json")) {
340
+ try {
341
+ return { json: true, fields: JSON.parse(text) };
342
+ }
343
+ catch {
344
+ return { json: true, fields: undefined };
345
+ }
346
+ }
347
+ return { json: false, fields: Object.fromEntries(new URLSearchParams(text)) };
348
+ }
349
+ function invalidGrant() {
350
+ return Response.json({
351
+ error: {
352
+ code: "invalid_grant",
353
+ message: "The pairing code is invalid, expired or already used.",
354
+ },
355
+ }, { status: 400 });
356
+ }
357
+ async function verifyProof(publicKey, code, proof) {
358
+ try {
359
+ const key = await crypto.subtle.importKey("jwk", publicKey, EC_P256, false, [
360
+ "verify",
361
+ ]);
362
+ return await crypto.subtle.verify({ name: "ECDSA", hash: "SHA-256" }, key, Buffer.from(proof, "base64url"), new TextEncoder().encode(code));
363
+ }
364
+ catch {
365
+ return false;
366
+ }
367
+ }
368
+ /**
369
+ * `GET|POST /hosts/authorize` is the framework-owned consent page and its answer, `POST
370
+ * /hosts/token` the PKCE exchange binding the host's key, `POST /hosts/revoke` the host
371
+ * disconnecting itself.
372
+ */
373
+ export function hostRoutes(deps) {
374
+ const { host, settings } = deps;
375
+ const appName = () => process.env.PARLOR_APP_NAME ?? settings.appName ?? new URL(settings.origin).host;
376
+ const authorizeUrl = `${host.basePath}/${AUTH_PLUGIN}/hosts/authorize`;
377
+ const accountOf = async (userId) => host.auth?.account?.(userId);
378
+ return [
379
+ pluginRoute({
380
+ method: "GET",
381
+ path: "/hosts/authorize",
382
+ access: ["user"],
383
+ handle: async (request, caller) => {
384
+ const parsed = authorizeRequest.safeParse(Object.fromEntries(new URL(request.url).searchParams));
385
+ if (!parsed.success) {
386
+ return failure("This connection request is incomplete.", 400);
387
+ }
388
+ const pairing = parsed.data;
389
+ const profile = await accountOf(caller.principal.userId);
390
+ const app = appName();
391
+ const hidden = Object.entries(pairing)
392
+ .map(([name, value]) => `<input type="hidden" name="${name}" value="${escape(String(value))}">`)
393
+ .join("\n");
394
+ const body = `
395
+ <h1>Connect ${escape(pairing.hostName)} to ${escape(app)}?</h1>
396
+ <p>Signed in as <strong>${escape(profile?.email ?? profile?.name ?? "your account")}</strong>.</p>
397
+ <p>The coding agents on <strong>${escape(pairing.hostName)}</strong> (${escape(pairing.platform)}) will take your prompts from ${escape(app)} and run on that computer. Continue only if you just ran <code>connect</code> there.</p>
398
+ <form method="post" action="${escape(authorizeUrl)}">
399
+ ${hidden}
400
+ <button type="submit" name="decision" value="cancel">Cancel</button>
401
+ <button type="submit" name="decision" value="connect">Connect</button>
402
+ </form>`;
403
+ const target = pairing.redirectUri === IPC_REDIRECT
404
+ ? []
405
+ : [new URL(pairing.redirectUri).origin];
406
+ return page("Connect a computer", body, ["'self'", ...target]);
407
+ },
408
+ }),
409
+ pluginRoute({
410
+ method: "POST",
411
+ path: "/hosts/authorize",
412
+ access: ["user"],
413
+ handle: async (request, caller) => {
414
+ const { json, fields } = await bodyOf(request);
415
+ const refuse = (status, code, message) => json
416
+ ? Response.json({ error: { code, message } }, { status })
417
+ : failure(message, status);
418
+ const origin = request.headers.get("origin");
419
+ if (origin !== settings.origin &&
420
+ !(origin && [...SHELL_ORIGINS, ...devOrigins()].includes(origin))) {
421
+ return refuse(403, "forbidden", "This answer did not come from the consent page.");
422
+ }
423
+ const parsed = authorizeAnswer.safeParse(fields);
424
+ if (!parsed.success) {
425
+ return refuse(400, "invalid_input", "This connection request is incomplete.");
426
+ }
427
+ const { decision, ...pairing } = parsed.data;
428
+ const target = pairing.redirectUri === IPC_REDIRECT ? undefined : new URL(pairing.redirectUri);
429
+ if (decision === "cancel") {
430
+ if (!target)
431
+ return Response.json({ error: "access_denied", state: pairing.state });
432
+ target.searchParams.set("error", "access_denied");
433
+ target.searchParams.set("state", pairing.state);
434
+ return new Response(null, { status: 303, headers: { location: target.href } });
435
+ }
436
+ const code = randomBytes(32).toString("base64url");
437
+ await databaseOf(host).transaction(async (handle) => {
438
+ const tx = handle;
439
+ await tx.execute(sql `delete from parlor_auth_host_codes where expires_at < ${new Date().toISOString()}`);
440
+ await tx.execute(sql `
441
+ insert into parlor_auth_host_codes
442
+ (code_hash, user_id, code_challenge, key_thumbprint, redirect_uri, host_name, platform, expires_at)
443
+ values (${sha256(code, "hex")}, ${caller.principal.userId}, ${pairing.codeChallenge},
444
+ ${pairing.keyThumbprint}, ${pairing.redirectUri}, ${pairing.hostName}, ${pairing.platform},
445
+ ${new Date(Date.now() + CODE_TTL_MS).toISOString()})`);
446
+ });
447
+ if (!target)
448
+ return Response.json({ code, state: pairing.state });
449
+ target.searchParams.set("code", code);
450
+ target.searchParams.set("state", pairing.state);
451
+ return new Response(null, { status: 303, headers: { location: target.href } });
452
+ },
453
+ }),
454
+ {
455
+ method: "POST",
456
+ path: "/hosts/token",
457
+ access: "public",
458
+ handle: async (request) => {
459
+ const { fields } = await bodyOf(request);
460
+ const exchange = keyExchange.safeParse(fields);
461
+ if (!exchange.success || "d" in exchange.data.publicKey)
462
+ return invalidGrant();
463
+ const { code, codeVerifier, proof } = exchange.data;
464
+ const { kty, crv, x, y } = exchange.data.publicKey;
465
+ const publicKey = { kty, crv, x, y };
466
+ const database = databaseOf(host);
467
+ const stored = await database.transaction(async (handle) => rowsOf(await handle.execute(sql `
468
+ delete from parlor_auth_host_codes where code_hash = ${sha256(code, "hex")}
469
+ returning user_id, code_challenge, key_thumbprint, host_name, platform, expires_at`))[0]);
470
+ if (!stored ||
471
+ new Date(stored.expires_at).getTime() <= Date.now() ||
472
+ !sameText(sha256(codeVerifier, "base64url"), stored.code_challenge) ||
473
+ !sameText(await calculateJwkThumbprint(publicKey, "sha256"), stored.key_thumbprint) ||
474
+ !(await verifyProof(publicKey, code, proof))) {
475
+ return invalidGrant();
476
+ }
477
+ const grantId = asId("grant", `host:${crypto.randomUUID()}`);
478
+ const hostId = asId("host", crypto.randomUUID());
479
+ const userId = asId("user", stored.user_id);
480
+ await database.transaction(async (handle) => {
481
+ await handle.execute(sql `
482
+ insert into parlor_auth_hosts (grant_id, host_id, user_id, name, platform, public_key, kid)
483
+ values (${grantId}, ${hostId}, ${userId}, ${stored.host_name}, ${stored.platform},
484
+ ${JSON.stringify(publicKey)}::jsonb, ${stored.key_thumbprint})`);
485
+ });
486
+ await host.announce(userId, [[HOSTS_TAG, hostId]]);
487
+ const profile = await accountOf(userId);
488
+ const response = {
489
+ grant: { id: grantId, hostId },
490
+ account: { userId, name: profile?.name ?? "", email: profile?.email },
491
+ };
492
+ return Response.json(response);
493
+ },
494
+ },
495
+ pluginRoute({
496
+ method: "POST",
497
+ path: "/hosts/devices/link",
498
+ access: ["host"],
499
+ handle: async (request, caller) => {
500
+ const { fields } = await bodyOf(request);
501
+ const code = z.object({ code: z.string().max(32) }).safeParse(fields).data?.code ?? "";
502
+ const found = await findPendingDevice(databaseOf(host), caller.principal.userId, {
503
+ code,
504
+ });
505
+ if (!found) {
506
+ return Response.json({
507
+ error: {
508
+ code: "not_found",
509
+ message: "No device of this account shows that code. Check it, or show a new code on the device.",
510
+ },
511
+ }, { status: 404 });
512
+ }
513
+ return Response.json(found);
514
+ },
515
+ }),
516
+ pluginRoute({
517
+ method: "POST",
518
+ path: "/hosts/revoke",
519
+ access: ["host"],
520
+ handle: async (_request, caller) => {
521
+ const { userId, grantId } = caller.principal;
522
+ const removed = await removeGrant(deps, { grantId }, userId);
523
+ if (removed)
524
+ await host.announce(caller.scope, [[HOSTS_TAG, removed.host_id]]);
525
+ return Response.json({ id: grantId });
526
+ },
527
+ }),
528
+ ];
529
+ }
530
+ //# sourceMappingURL=hosts.js.map