@robodev-ai/runtime 0.2.0 → 0.4.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/package.json +2 -2
- package/src/auth-core.ts +49 -0
- package/src/index.ts +73 -1
- package/src/local-auth.test.ts +262 -6
- package/src/local-auth.ts +180 -5
- package/src/local-google.test.ts +93 -0
- package/src/local-google.ts +232 -0
- package/src/local-jobs.test.ts +36 -17
- package/src/local-storage.test.ts +217 -0
- package/src/local-storage.ts +213 -0
- package/src/sockets.test.ts +112 -0
- package/src/sockets.ts +293 -0
- package/src/storage-rules.test.ts +110 -0
- package/src/storage-rules.ts +80 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@robodev-ai/runtime",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Robodev project runtime — deploy-file classification, esbuild compile, module loading, schema push, route matching, OpenAPI, handler invocation, and
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "Robodev project runtime — deploy-file classification, esbuild compile, module loading, schema push, route matching, OpenAPI, handler invocation, Robodev Auth, the socket engine, and local file storage. Shared by hosted Starbase and `robodev dev`.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"repository": {
|
package/src/auth-core.ts
CHANGED
|
@@ -267,3 +267,52 @@ export async function revokeRefresh(db: AuthQueryable, refreshToken: string): Pr
|
|
|
267
267
|
hashToken(refreshToken),
|
|
268
268
|
]);
|
|
269
269
|
}
|
|
270
|
+
|
|
271
|
+
export type GoogleUpsertResult =
|
|
272
|
+
| { user: AuthRow; created: boolean; updated: boolean; previousName?: string | null }
|
|
273
|
+
| "signup_disabled";
|
|
274
|
+
|
|
275
|
+
export async function upsertGoogleUser(
|
|
276
|
+
pool: AuthQueryable,
|
|
277
|
+
input: { email: string; name: string | null; subject: string },
|
|
278
|
+
options: { allowSignups: boolean } = { allowSignups: true },
|
|
279
|
+
): Promise<GoogleUpsertResult> {
|
|
280
|
+
await ensureAuthSchema(pool);
|
|
281
|
+
const email = input.email.toLowerCase();
|
|
282
|
+
const existing = await pool.query<AuthRow>(
|
|
283
|
+
`SELECT id, email, password_hash, name, google_subject FROM robodev_auth.users WHERE email = $1`,
|
|
284
|
+
[email],
|
|
285
|
+
);
|
|
286
|
+
const row = existing.rows[0];
|
|
287
|
+
if (row) {
|
|
288
|
+
const previousName = row.name;
|
|
289
|
+
const nextName = input.name && input.name !== row.name ? input.name : row.name;
|
|
290
|
+
if (nextName !== row.name || row.google_subject !== input.subject) {
|
|
291
|
+
await pool.query(
|
|
292
|
+
`UPDATE robodev_auth.users SET name = $1, google_subject = $2 WHERE id = $3`,
|
|
293
|
+
[nextName, input.subject, row.id],
|
|
294
|
+
);
|
|
295
|
+
return {
|
|
296
|
+
user: { ...row, name: nextName, google_subject: input.subject },
|
|
297
|
+
created: false,
|
|
298
|
+
updated: true,
|
|
299
|
+
previousName,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
return { user: row, created: false, updated: false };
|
|
303
|
+
}
|
|
304
|
+
if (!options.allowSignups) return "signup_disabled";
|
|
305
|
+
const created: AuthRow = {
|
|
306
|
+
id: id("usr"),
|
|
307
|
+
email,
|
|
308
|
+
password_hash: null,
|
|
309
|
+
name: input.name,
|
|
310
|
+
google_subject: input.subject,
|
|
311
|
+
};
|
|
312
|
+
await pool.query(
|
|
313
|
+
`INSERT INTO robodev_auth.users (id, email, password_hash, name, google_subject)
|
|
314
|
+
VALUES ($1, $2, $3, $4, $5)`,
|
|
315
|
+
[created.id, created.email, created.password_hash, created.name, created.google_subject],
|
|
316
|
+
);
|
|
317
|
+
return { user: created, created: true, updated: false };
|
|
318
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* `@robodev-ai/runtime` — the project runtime shared by hosted Starbase deploys and the
|
|
3
3
|
* offline `robodev dev` loop: deploy-file classification, the esbuild compile, module
|
|
4
4
|
* loading, schema push, route matching, OpenAPI, handler invocation, Robodev Auth,
|
|
5
|
-
* and the offline `robodev dev` jobs engine.
|
|
5
|
+
* the socket engine, local file storage, and the offline `robodev dev` jobs engine.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
export {
|
|
@@ -166,8 +166,10 @@ export {
|
|
|
166
166
|
toAuthUser,
|
|
167
167
|
updateUserPassword,
|
|
168
168
|
updateUserProfile,
|
|
169
|
+
upsertGoogleUser,
|
|
169
170
|
type AuthQueryable,
|
|
170
171
|
type AuthRow,
|
|
172
|
+
type GoogleUpsertResult,
|
|
171
173
|
type NonceType,
|
|
172
174
|
} from "./auth-core.js";
|
|
173
175
|
|
|
@@ -222,3 +224,73 @@ export {
|
|
|
222
224
|
type LocalJobsEngineOptions,
|
|
223
225
|
type LocalJobsGeneration,
|
|
224
226
|
} from "./local-jobs.js";
|
|
227
|
+
|
|
228
|
+
export {
|
|
229
|
+
MAX_PROJECT_SOCKETS,
|
|
230
|
+
MAX_SOCKET_PAYLOAD_BYTES,
|
|
231
|
+
SOCKET_CLOSE_INVALID,
|
|
232
|
+
SOCKET_CLOSE_POLICY,
|
|
233
|
+
SOCKET_CLOSE_RESTART,
|
|
234
|
+
SOCKET_CLOSE_TOO_BIG,
|
|
235
|
+
SOCKET_IDLE_PING_MS,
|
|
236
|
+
assertKnownSocket,
|
|
237
|
+
attachProjectSocket,
|
|
238
|
+
bindSocketSession,
|
|
239
|
+
closeProjectSockets,
|
|
240
|
+
createProjectSocketsClient,
|
|
241
|
+
detachProjectSocket,
|
|
242
|
+
dispatchSocketsSend,
|
|
243
|
+
joinSocketRoom,
|
|
244
|
+
leaveSocketRoom,
|
|
245
|
+
parseSocketMessage,
|
|
246
|
+
projectSocketCount,
|
|
247
|
+
sendToSocketName,
|
|
248
|
+
sendToSocketRoom,
|
|
249
|
+
setProjectSocketNames,
|
|
250
|
+
socketAuthDisplay,
|
|
251
|
+
stringifySocketPayload,
|
|
252
|
+
type BindSocketSessionInput,
|
|
253
|
+
type LiveConn,
|
|
254
|
+
type SocketSessionHelpers,
|
|
255
|
+
type SocketWire,
|
|
256
|
+
} from "./sockets.js";
|
|
257
|
+
|
|
258
|
+
export {
|
|
259
|
+
DEFAULT_LIST_LIMIT,
|
|
260
|
+
MAX_LIST_LIMIT,
|
|
261
|
+
MAX_OBJECT_BYTES,
|
|
262
|
+
PRESIGN_GET_DEFAULT,
|
|
263
|
+
PRESIGN_GET_MAX,
|
|
264
|
+
assertObjectSize,
|
|
265
|
+
clampStorageExpiresIn,
|
|
266
|
+
mintStorageServeToken,
|
|
267
|
+
parseLogicalKey,
|
|
268
|
+
storageListQuerySchema,
|
|
269
|
+
verifyStorageServeToken,
|
|
270
|
+
} from "./storage-rules.js";
|
|
271
|
+
|
|
272
|
+
export {
|
|
273
|
+
createLocalStorageClient,
|
|
274
|
+
ensureStorageSchema,
|
|
275
|
+
resolveLocalObject,
|
|
276
|
+
type LocalObjectRow,
|
|
277
|
+
type LocalStorageClientOptions,
|
|
278
|
+
type StorageQueryable,
|
|
279
|
+
} from "./local-storage.js";
|
|
280
|
+
|
|
281
|
+
export {
|
|
282
|
+
LOCAL_DEV_IDENTITY_AUD,
|
|
283
|
+
LOCAL_DEV_IDENTITY_TYP,
|
|
284
|
+
assertLocalAppRedirectUri,
|
|
285
|
+
assertLocalBrokerRedirectUri,
|
|
286
|
+
brokerDevStartUrl,
|
|
287
|
+
exchangeLocalGoogleCode,
|
|
288
|
+
localGoogleAuthorizeUrl,
|
|
289
|
+
localGoogleCallbackUri,
|
|
290
|
+
projectGoogleOverrideMode,
|
|
291
|
+
redirectWithQuery,
|
|
292
|
+
resetLocalDevAssertionReplayForTests,
|
|
293
|
+
signLocalGoogleState,
|
|
294
|
+
verifyLocalDevIdentityAssertion,
|
|
295
|
+
verifyLocalGoogleState,
|
|
296
|
+
} from "./local-google.js";
|
package/src/local-auth.test.ts
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import test from "node:test";
|
|
3
|
+
import { createLocalJWKSet, exportJWK, generateKeyPair, SignJWT } from "jose";
|
|
3
4
|
import { handleLocalAuth, isLocalAuthPath, resolveLocalUser } from "./local-auth.js";
|
|
4
|
-
import type
|
|
5
|
+
import { upsertGoogleUser, type AuthQueryable } from "./auth-core.js";
|
|
6
|
+
import {
|
|
7
|
+
LOCAL_DEV_IDENTITY_AUD,
|
|
8
|
+
LOCAL_DEV_IDENTITY_TYP,
|
|
9
|
+
resetLocalDevAssertionReplayForTests,
|
|
10
|
+
signLocalGoogleState,
|
|
11
|
+
} from "./local-google.js";
|
|
12
|
+
import { verifyProjectUserToken } from "./project-jwt.js";
|
|
5
13
|
|
|
6
14
|
type UserRow = {
|
|
7
15
|
id: string;
|
|
@@ -44,15 +52,24 @@ class MemoryAuthDb implements AuthQueryable {
|
|
|
44
52
|
return rows(row ? [row] : []);
|
|
45
53
|
}
|
|
46
54
|
if (s.startsWith("INSERT INTO robodev_auth.users")) {
|
|
55
|
+
const hasGoogle = s.includes("google_subject");
|
|
47
56
|
this.users.push({
|
|
48
57
|
id: String(params[0]),
|
|
49
58
|
email: String(params[1]),
|
|
50
59
|
password_hash: (params[2] as string | null) ?? null,
|
|
51
60
|
name: (params[3] as string | null) ?? null,
|
|
52
|
-
google_subject: null,
|
|
61
|
+
google_subject: hasGoogle ? ((params[4] as string | null) ?? null) : null,
|
|
53
62
|
});
|
|
54
63
|
return rows([]);
|
|
55
64
|
}
|
|
65
|
+
if (s.includes("UPDATE robodev_auth.users SET name = $1, google_subject")) {
|
|
66
|
+
const user = this.users.find((row) => row.id === params[2]);
|
|
67
|
+
if (user) {
|
|
68
|
+
user.name = params[0] as string | null;
|
|
69
|
+
user.google_subject = params[1] as string | null;
|
|
70
|
+
}
|
|
71
|
+
return rows([]);
|
|
72
|
+
}
|
|
56
73
|
if (s.includes("UPDATE robodev_auth.users SET name = $1, email = $2")) {
|
|
57
74
|
const user = this.users.find((row) => row.id === params[2]);
|
|
58
75
|
if (user) {
|
|
@@ -133,6 +150,8 @@ function setup() {
|
|
|
133
150
|
projectId: "prj-local",
|
|
134
151
|
jwtSecret: "test-secret",
|
|
135
152
|
deliverCode: (input: Delivered) => delivered.push(input),
|
|
153
|
+
listenOrigin: "http://localhost:4000",
|
|
154
|
+
brokerBaseUrl: "https://robodev.povio.dev",
|
|
136
155
|
};
|
|
137
156
|
return { db, delivered, options };
|
|
138
157
|
}
|
|
@@ -383,14 +402,251 @@ test("forgot password resets and the new password logs in", async () => {
|
|
|
383
402
|
assert.equal(login.status, 200);
|
|
384
403
|
});
|
|
385
404
|
|
|
386
|
-
test("google
|
|
405
|
+
test("google start 302s to the Starbase broker", async () => {
|
|
406
|
+
const { options } = setup();
|
|
407
|
+
const result = await handleLocalAuth(
|
|
408
|
+
{
|
|
409
|
+
method: "GET",
|
|
410
|
+
pathname: "/api/user/auth/google",
|
|
411
|
+
query: { redirect_uri: "http://localhost:5173/app" },
|
|
412
|
+
},
|
|
413
|
+
options,
|
|
414
|
+
);
|
|
415
|
+
assert.equal(result.status, 302);
|
|
416
|
+
const location = new URL(String(result.headers?.Location));
|
|
417
|
+
assert.equal(location.origin, "https://robodev.povio.dev");
|
|
418
|
+
assert.equal(location.pathname, "/internal/auth/google/dev");
|
|
419
|
+
assert.equal(
|
|
420
|
+
location.searchParams.get("callback_uri"),
|
|
421
|
+
"http://localhost:4000/api/user/auth/google/callback",
|
|
422
|
+
);
|
|
423
|
+
assert.equal(location.searchParams.get("redirect_uri"), "http://localhost:5173/app");
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
test("google start rejects a public redirect_uri", async () => {
|
|
427
|
+
const { options } = setup();
|
|
428
|
+
const result = await handleLocalAuth(
|
|
429
|
+
{
|
|
430
|
+
method: "GET",
|
|
431
|
+
pathname: "/api/user/auth/google",
|
|
432
|
+
query: { redirect_uri: "https://evil.example/app" },
|
|
433
|
+
},
|
|
434
|
+
options,
|
|
435
|
+
);
|
|
436
|
+
assert.equal(result.status, 400);
|
|
437
|
+
assert.equal(result.body.code, "invalid_redirect_uri");
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
test("google start allows https loopback app redirect", async () => {
|
|
441
|
+
const { options } = setup();
|
|
442
|
+
const result = await handleLocalAuth(
|
|
443
|
+
{
|
|
444
|
+
method: "GET",
|
|
445
|
+
pathname: "/api/user/auth/google",
|
|
446
|
+
query: { redirect_uri: "https://localhost:5173/app" },
|
|
447
|
+
},
|
|
448
|
+
options,
|
|
449
|
+
);
|
|
450
|
+
assert.equal(result.status, 302);
|
|
451
|
+
const location = new URL(String(result.headers?.Location));
|
|
452
|
+
assert.equal(location.searchParams.get("redirect_uri"), "https://localhost:5173/app");
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
test("google override requires both project .env keys", async () => {
|
|
456
|
+
const { options } = setup();
|
|
457
|
+
const one = await handleLocalAuth(
|
|
458
|
+
{
|
|
459
|
+
method: "GET",
|
|
460
|
+
pathname: "/api/user/auth/google",
|
|
461
|
+
query: { redirect_uri: "http://localhost:5173/app" },
|
|
462
|
+
},
|
|
463
|
+
{ ...options, googleClientId: "only-id" },
|
|
464
|
+
);
|
|
465
|
+
assert.equal(one.status, 503);
|
|
466
|
+
assert.equal(one.body.code, "google_oauth_unconfigured");
|
|
467
|
+
|
|
468
|
+
const both = await handleLocalAuth(
|
|
469
|
+
{
|
|
470
|
+
method: "GET",
|
|
471
|
+
pathname: "/api/user/auth/google",
|
|
472
|
+
query: { redirect_uri: "http://localhost:5173/app" },
|
|
473
|
+
},
|
|
474
|
+
{ ...options, googleClientId: "id", googleClientSecret: "secret" },
|
|
475
|
+
);
|
|
476
|
+
assert.equal(both.status, 302);
|
|
477
|
+
const location = new URL(String(both.headers?.Location));
|
|
478
|
+
assert.equal(location.hostname, "accounts.google.com");
|
|
479
|
+
assert.equal(
|
|
480
|
+
location.searchParams.get("redirect_uri"),
|
|
481
|
+
"http://localhost:4000/api/user/auth/google/callback",
|
|
482
|
+
);
|
|
483
|
+
assert.equal(location.searchParams.get("client_id"), "id");
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
test("google broker assertion upserts google_subject and issues prj-local tokens", async () => {
|
|
487
|
+
resetLocalDevAssertionReplayForTests();
|
|
488
|
+
const { db, options } = setup();
|
|
489
|
+
const { privateKey, publicKey } = await generateKeyPair("EdDSA", { crv: "Ed25519" });
|
|
490
|
+
const jwk = await exportJWK(publicKey);
|
|
491
|
+
const kid = "test-kid";
|
|
492
|
+
const now = Math.floor(Date.now() / 1000);
|
|
493
|
+
const assertion = await new SignJWT({
|
|
494
|
+
typ: LOCAL_DEV_IDENTITY_TYP,
|
|
495
|
+
email: "G@B.com",
|
|
496
|
+
name: "Gail",
|
|
497
|
+
google_subject: "sub-9",
|
|
498
|
+
callback_uri: "http://localhost:4000/api/user/auth/google/callback",
|
|
499
|
+
redirect_uri: "http://localhost:5173/app",
|
|
500
|
+
})
|
|
501
|
+
.setProtectedHeader({ alg: "EdDSA", typ: "JWT", kid })
|
|
502
|
+
.setIssuer("https://robodev.povio.dev")
|
|
503
|
+
.setAudience(LOCAL_DEV_IDENTITY_AUD)
|
|
504
|
+
.setJti("jti-1")
|
|
505
|
+
.setIssuedAt(now)
|
|
506
|
+
.setExpirationTime(now + 120)
|
|
507
|
+
.sign(privateKey);
|
|
508
|
+
|
|
509
|
+
const identityJwks = createLocalJWKSet({ keys: [{ ...jwk, kid, use: "sig", alg: "EdDSA" }] });
|
|
510
|
+
|
|
511
|
+
try {
|
|
512
|
+
const result = await handleLocalAuth(
|
|
513
|
+
{
|
|
514
|
+
method: "GET",
|
|
515
|
+
pathname: "/api/user/auth/google/callback",
|
|
516
|
+
query: { assertion },
|
|
517
|
+
},
|
|
518
|
+
{ ...options, identityJwks },
|
|
519
|
+
);
|
|
520
|
+
assert.equal(result.status, 302);
|
|
521
|
+
const location = new URL(String(result.headers?.Location));
|
|
522
|
+
assert.equal(location.origin + location.pathname, "http://localhost:5173/app");
|
|
523
|
+
const accessToken = location.searchParams.get("accessToken");
|
|
524
|
+
const refreshToken = location.searchParams.get("refreshToken");
|
|
525
|
+
assert.ok(accessToken);
|
|
526
|
+
assert.ok(refreshToken);
|
|
527
|
+
const user = await verifyProjectUserToken(accessToken, "prj-local", "test-secret");
|
|
528
|
+
assert.equal(user.email, "g@b.com");
|
|
529
|
+
assert.equal(db.users[0]?.google_subject, "sub-9");
|
|
530
|
+
assert.equal(db.users[0]?.email, "g@b.com");
|
|
531
|
+
|
|
532
|
+
const replay = await handleLocalAuth(
|
|
533
|
+
{
|
|
534
|
+
method: "GET",
|
|
535
|
+
pathname: "/api/user/auth/google/callback",
|
|
536
|
+
query: { assertion },
|
|
537
|
+
},
|
|
538
|
+
{ ...options, identityJwks },
|
|
539
|
+
);
|
|
540
|
+
assert.equal(replay.status, 400);
|
|
541
|
+
assert.equal(replay.body.code, "invalid_assertion");
|
|
542
|
+
} finally {
|
|
543
|
+
resetLocalDevAssertionReplayForTests();
|
|
544
|
+
}
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
test("google broker assertion rejects a mismatched callback_uri", async () => {
|
|
548
|
+
resetLocalDevAssertionReplayForTests();
|
|
549
|
+
const { options } = setup();
|
|
550
|
+
const { privateKey, publicKey } = await generateKeyPair("EdDSA", { crv: "Ed25519" });
|
|
551
|
+
const jwk = await exportJWK(publicKey);
|
|
552
|
+
const now = Math.floor(Date.now() / 1000);
|
|
553
|
+
const assertion = await new SignJWT({
|
|
554
|
+
typ: LOCAL_DEV_IDENTITY_TYP,
|
|
555
|
+
email: "a@b.com",
|
|
556
|
+
name: "A",
|
|
557
|
+
google_subject: "sub",
|
|
558
|
+
callback_uri: "http://localhost:4001/api/user/auth/google/callback",
|
|
559
|
+
redirect_uri: "http://localhost:5173/app",
|
|
560
|
+
})
|
|
561
|
+
.setProtectedHeader({ alg: "EdDSA", typ: "JWT", kid: "k" })
|
|
562
|
+
.setIssuer("https://robodev.povio.dev")
|
|
563
|
+
.setAudience(LOCAL_DEV_IDENTITY_AUD)
|
|
564
|
+
.setJti("jti-mismatch")
|
|
565
|
+
.setIssuedAt(now)
|
|
566
|
+
.setExpirationTime(now + 120)
|
|
567
|
+
.sign(privateKey);
|
|
568
|
+
|
|
569
|
+
try {
|
|
570
|
+
const result = await handleLocalAuth(
|
|
571
|
+
{
|
|
572
|
+
method: "GET",
|
|
573
|
+
pathname: "/api/user/auth/google/callback",
|
|
574
|
+
query: { assertion },
|
|
575
|
+
},
|
|
576
|
+
{
|
|
577
|
+
...options,
|
|
578
|
+
identityJwks: createLocalJWKSet({ keys: [{ ...jwk, kid: "k", use: "sig", alg: "EdDSA" }] }),
|
|
579
|
+
},
|
|
580
|
+
);
|
|
581
|
+
assert.equal(result.status, 400);
|
|
582
|
+
assert.equal(result.body.code, "invalid_assertion");
|
|
583
|
+
} finally {
|
|
584
|
+
resetLocalDevAssertionReplayForTests();
|
|
585
|
+
}
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
test("google override callback upserts and 302s tokens", async () => {
|
|
589
|
+
const { db, options } = setup();
|
|
590
|
+
const state = await signLocalGoogleState("test-secret", "http://localhost:5173/app");
|
|
591
|
+
const originalFetch = globalThis.fetch;
|
|
592
|
+
globalThis.fetch = (async (url: string | URL | Request) => {
|
|
593
|
+
const href = String(url);
|
|
594
|
+
if (href.includes("oauth2.googleapis.com/token")) {
|
|
595
|
+
return new Response(JSON.stringify({ access_token: "tok" }), { status: 200 });
|
|
596
|
+
}
|
|
597
|
+
if (href.includes("userinfo")) {
|
|
598
|
+
return new Response(JSON.stringify({ id: "g-2", email: "o@b.com", name: "O" }), {
|
|
599
|
+
status: 200,
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
throw new Error(`unexpected fetch ${href}`);
|
|
603
|
+
}) as typeof fetch;
|
|
604
|
+
|
|
605
|
+
try {
|
|
606
|
+
const result = await handleLocalAuth(
|
|
607
|
+
{
|
|
608
|
+
method: "GET",
|
|
609
|
+
pathname: "/api/user/auth/google/callback",
|
|
610
|
+
query: { code: "ok", state },
|
|
611
|
+
},
|
|
612
|
+
{ ...options, googleClientId: "id", googleClientSecret: "secret" },
|
|
613
|
+
);
|
|
614
|
+
assert.equal(result.status, 302);
|
|
615
|
+
const location = new URL(String(result.headers?.Location));
|
|
616
|
+
assert.ok(location.searchParams.get("accessToken"));
|
|
617
|
+
assert.equal(db.users[0]?.email, "o@b.com");
|
|
618
|
+
assert.equal(db.users[0]?.google_subject, "g-2");
|
|
619
|
+
} finally {
|
|
620
|
+
globalThis.fetch = originalFetch;
|
|
621
|
+
}
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
test("google callback error 302s to a loopback app redirect", async () => {
|
|
387
625
|
const { options } = setup();
|
|
388
626
|
const result = await handleLocalAuth(
|
|
389
|
-
{
|
|
627
|
+
{
|
|
628
|
+
method: "GET",
|
|
629
|
+
pathname: "/api/user/auth/google/callback",
|
|
630
|
+
query: { error: "google_oauth_failed", redirect_uri: "http://localhost:5173/app" },
|
|
631
|
+
},
|
|
390
632
|
options,
|
|
391
633
|
);
|
|
392
|
-
assert.equal(result.status,
|
|
393
|
-
|
|
634
|
+
assert.equal(result.status, 302);
|
|
635
|
+
const location = new URL(String(result.headers?.Location));
|
|
636
|
+
assert.equal(location.searchParams.get("error"), "google_oauth_failed");
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
test("two local Auth dbs upsert the same Google email independently", async () => {
|
|
640
|
+
const a = setup();
|
|
641
|
+
const b = setup();
|
|
642
|
+
const first = await upsertGoogleUser(a.db, { email: "same@b.com", name: "S", subject: "sub" });
|
|
643
|
+
const second = await upsertGoogleUser(b.db, { email: "same@b.com", name: "S", subject: "sub" });
|
|
644
|
+
assert.notEqual(first, "signup_disabled");
|
|
645
|
+
assert.notEqual(second, "signup_disabled");
|
|
646
|
+
if (first === "signup_disabled" || second === "signup_disabled") return;
|
|
647
|
+
assert.notEqual(first.user.id, second.user.id);
|
|
648
|
+
assert.equal(a.db.users[0]?.email, "same@b.com");
|
|
649
|
+
assert.equal(b.db.users[0]?.email, "same@b.com");
|
|
394
650
|
});
|
|
395
651
|
|
|
396
652
|
test("unknown Auth routes 404", async () => {
|
package/src/local-auth.ts
CHANGED
|
@@ -14,12 +14,14 @@ import {
|
|
|
14
14
|
toAuthUser,
|
|
15
15
|
updateUserPassword,
|
|
16
16
|
updateUserProfile,
|
|
17
|
+
upsertGoogleUser,
|
|
17
18
|
type AuthQueryable,
|
|
18
19
|
} from "./auth-core.js";
|
|
19
20
|
import {
|
|
20
21
|
forgotCallbackBody,
|
|
21
22
|
forgotRequestBody,
|
|
22
23
|
GENERIC_STATUS_MESSAGE,
|
|
24
|
+
googleStartQuery,
|
|
23
25
|
loginBody,
|
|
24
26
|
magicConsumeQuery,
|
|
25
27
|
magicGenerateQuery,
|
|
@@ -28,11 +30,25 @@ import {
|
|
|
28
30
|
registerBody,
|
|
29
31
|
statusOk,
|
|
30
32
|
} from "./auth-schemas.js";
|
|
33
|
+
import type { JWTVerifyGetKey } from "jose";
|
|
34
|
+
import {
|
|
35
|
+
assertLocalAppRedirectUri,
|
|
36
|
+
brokerDevStartUrl,
|
|
37
|
+
exchangeLocalGoogleCode,
|
|
38
|
+
localGoogleAuthorizeUrl,
|
|
39
|
+
localGoogleCallbackUri,
|
|
40
|
+
projectGoogleOverrideMode,
|
|
41
|
+
redirectWithQuery,
|
|
42
|
+
signLocalGoogleState,
|
|
43
|
+
verifyLocalDevIdentityAssertion,
|
|
44
|
+
verifyLocalGoogleState,
|
|
45
|
+
} from "./local-google.js";
|
|
31
46
|
import { bearerToken, verifyProjectUserToken } from "./project-jwt.js";
|
|
32
47
|
|
|
33
48
|
export type LocalAuthResult = {
|
|
34
49
|
status: number;
|
|
35
50
|
body: Record<string, unknown>;
|
|
51
|
+
headers?: { Location: string };
|
|
36
52
|
};
|
|
37
53
|
|
|
38
54
|
export type LocalAuthRequest = {
|
|
@@ -52,12 +68,34 @@ export type LocalAuthOptions = {
|
|
|
52
68
|
* terminal instead of sending mail.
|
|
53
69
|
*/
|
|
54
70
|
deliverCode: (input: { to: string; type: string; code: string }) => void;
|
|
71
|
+
/** `http://localhost:${port}` — never 0.0.0.0. */
|
|
72
|
+
listenOrigin: string;
|
|
73
|
+
/** Process-env `STARBASE_URL`, default production. Not the project `.env`. */
|
|
74
|
+
brokerBaseUrl: string;
|
|
75
|
+
/** Project-root `.env` only. Both must be set to skip the Starbase broker. */
|
|
76
|
+
googleClientId?: string;
|
|
77
|
+
googleClientSecret?: string;
|
|
78
|
+
/** Test hook: skip the remote JWKS fetch. */
|
|
79
|
+
identityJwks?: JWTVerifyGetKey;
|
|
55
80
|
};
|
|
56
81
|
|
|
57
82
|
function fail(status: number, code: string, message?: string): LocalAuthResult {
|
|
58
83
|
return { status, body: { code, ...(message ? { message } : {}) } };
|
|
59
84
|
}
|
|
60
85
|
|
|
86
|
+
function redirect(location: string): LocalAuthResult {
|
|
87
|
+
return { status: 302, body: {}, headers: { Location: location } };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function loopbackAppRedirect(raw: string | undefined): string | null {
|
|
91
|
+
if (!raw) return null;
|
|
92
|
+
try {
|
|
93
|
+
return assertLocalAppRedirectUri(raw);
|
|
94
|
+
} catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
61
99
|
/** True for the reserved Auth surface `robodev dev` serves itself. */
|
|
62
100
|
export function isLocalAuthPath(pathname: string): boolean {
|
|
63
101
|
return pathname === "/api/user" || pathname.startsWith("/api/user/");
|
|
@@ -80,7 +118,8 @@ export async function resolveLocalUser(
|
|
|
80
118
|
* Password auth for a single local project: register, login, refresh, magic link,
|
|
81
119
|
* forgot password, and `/api/user/me`. Every operation runs through `auth-core`, the
|
|
82
120
|
* same code hosted Starbase Auth uses, so a local login behaves like a deployed one.
|
|
83
|
-
* Google
|
|
121
|
+
* Google uses the Starbase identity broker unless the project `.env` has both
|
|
122
|
+
* `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET`. SMTP still prints to the terminal.
|
|
84
123
|
*/
|
|
85
124
|
export async function handleLocalAuth(
|
|
86
125
|
request: LocalAuthRequest,
|
|
@@ -192,11 +231,147 @@ export async function handleLocalAuth(
|
|
|
192
231
|
}
|
|
193
232
|
|
|
194
233
|
if (method === "GET" && pathname === "/api/user/auth/google") {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
234
|
+
const q = googleStartQuery.parse(query);
|
|
235
|
+
let redirectUri: string;
|
|
236
|
+
try {
|
|
237
|
+
redirectUri = assertLocalAppRedirectUri(q.redirect_uri);
|
|
238
|
+
} catch (error) {
|
|
239
|
+
return fail(
|
|
240
|
+
400,
|
|
241
|
+
"invalid_redirect_uri",
|
|
242
|
+
error instanceof Error ? error.message : "invalid redirect_uri",
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
const callbackUri = localGoogleCallbackUri(options.listenOrigin);
|
|
246
|
+
const override = projectGoogleOverrideMode(
|
|
247
|
+
options.googleClientId,
|
|
248
|
+
options.googleClientSecret,
|
|
199
249
|
);
|
|
250
|
+
if (override === "one") {
|
|
251
|
+
return fail(
|
|
252
|
+
503,
|
|
253
|
+
"google_oauth_unconfigured",
|
|
254
|
+
"Set both GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET in the project .env, or unset both.",
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
if (override === "both") {
|
|
258
|
+
const state = await signLocalGoogleState(jwtSecret, redirectUri);
|
|
259
|
+
return redirect(
|
|
260
|
+
localGoogleAuthorizeUrl(state, {
|
|
261
|
+
clientId: options.googleClientId!.trim(),
|
|
262
|
+
redirectUri: callbackUri,
|
|
263
|
+
}),
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
return redirect(brokerDevStartUrl(options.brokerBaseUrl, { callbackUri, redirectUri }));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (method === "GET" && pathname === "/api/user/auth/google/callback") {
|
|
270
|
+
const q = query as Record<string, string | undefined>;
|
|
271
|
+
const callbackUri = localGoogleCallbackUri(options.listenOrigin);
|
|
272
|
+
if (q.error) {
|
|
273
|
+
const appRedirect = loopbackAppRedirect(q.redirect_uri);
|
|
274
|
+
if (appRedirect) {
|
|
275
|
+
return redirect(redirectWithQuery(appRedirect, { error: q.error }));
|
|
276
|
+
}
|
|
277
|
+
return fail(400, "invalid_redirect_uri");
|
|
278
|
+
}
|
|
279
|
+
if (q.assertion) {
|
|
280
|
+
try {
|
|
281
|
+
const identity = await verifyLocalDevIdentityAssertion(q.assertion, {
|
|
282
|
+
brokerBaseUrl: options.brokerBaseUrl,
|
|
283
|
+
callbackUri,
|
|
284
|
+
jwks: options.identityJwks,
|
|
285
|
+
});
|
|
286
|
+
const result = await upsertGoogleUser(
|
|
287
|
+
db,
|
|
288
|
+
{
|
|
289
|
+
email: identity.email,
|
|
290
|
+
name: identity.name,
|
|
291
|
+
subject: identity.googleSubject,
|
|
292
|
+
},
|
|
293
|
+
{ allowSignups: true },
|
|
294
|
+
);
|
|
295
|
+
if (result === "signup_disabled") {
|
|
296
|
+
return fail(400, "signup_disabled");
|
|
297
|
+
}
|
|
298
|
+
const tokens = await issueProjectTokenPair(
|
|
299
|
+
db,
|
|
300
|
+
projectId,
|
|
301
|
+
toAuthUser(result.user),
|
|
302
|
+
jwtSecret,
|
|
303
|
+
);
|
|
304
|
+
return redirect(
|
|
305
|
+
redirectWithQuery(identity.redirectUri, {
|
|
306
|
+
accessToken: tokens.accessToken,
|
|
307
|
+
refreshToken: tokens.refreshToken,
|
|
308
|
+
}),
|
|
309
|
+
);
|
|
310
|
+
} catch (error) {
|
|
311
|
+
const code =
|
|
312
|
+
error && typeof error === "object" && "code" in error
|
|
313
|
+
? String((error as { code: unknown }).code)
|
|
314
|
+
: "invalid_assertion";
|
|
315
|
+
const appRedirect = loopbackAppRedirect(q.redirect_uri);
|
|
316
|
+
if (appRedirect) {
|
|
317
|
+
return redirect(redirectWithQuery(appRedirect, { error: code }));
|
|
318
|
+
}
|
|
319
|
+
return fail(400, code === "invalid_redirect_uri" ? code : "invalid_assertion");
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
if (q.code && q.state) {
|
|
323
|
+
const override = projectGoogleOverrideMode(
|
|
324
|
+
options.googleClientId,
|
|
325
|
+
options.googleClientSecret,
|
|
326
|
+
);
|
|
327
|
+
if (override !== "both") {
|
|
328
|
+
return fail(503, "google_oauth_unconfigured");
|
|
329
|
+
}
|
|
330
|
+
try {
|
|
331
|
+
const localState = await verifyLocalGoogleState(jwtSecret, q.state);
|
|
332
|
+
const redirectUri = assertLocalAppRedirectUri(localState.redirectUri);
|
|
333
|
+
const profile = await exchangeLocalGoogleCode(q.code, {
|
|
334
|
+
clientId: options.googleClientId!.trim(),
|
|
335
|
+
clientSecret: options.googleClientSecret!.trim(),
|
|
336
|
+
redirectUri: callbackUri,
|
|
337
|
+
});
|
|
338
|
+
const result = await upsertGoogleUser(
|
|
339
|
+
db,
|
|
340
|
+
{
|
|
341
|
+
email: profile.email,
|
|
342
|
+
name: profile.name,
|
|
343
|
+
subject: profile.subject,
|
|
344
|
+
},
|
|
345
|
+
{ allowSignups: true },
|
|
346
|
+
);
|
|
347
|
+
if (result === "signup_disabled") {
|
|
348
|
+
return fail(400, "signup_disabled");
|
|
349
|
+
}
|
|
350
|
+
const tokens = await issueProjectTokenPair(
|
|
351
|
+
db,
|
|
352
|
+
projectId,
|
|
353
|
+
toAuthUser(result.user),
|
|
354
|
+
jwtSecret,
|
|
355
|
+
);
|
|
356
|
+
return redirect(
|
|
357
|
+
redirectWithQuery(redirectUri, {
|
|
358
|
+
accessToken: tokens.accessToken,
|
|
359
|
+
refreshToken: tokens.refreshToken,
|
|
360
|
+
}),
|
|
361
|
+
);
|
|
362
|
+
} catch (error) {
|
|
363
|
+
const appRedirect = loopbackAppRedirect(q.redirect_uri);
|
|
364
|
+
if (appRedirect) {
|
|
365
|
+
return redirect(redirectWithQuery(appRedirect, { error: "google_oauth_failed" }));
|
|
366
|
+
}
|
|
367
|
+
if (error && typeof error === "object" && "code" in error) {
|
|
368
|
+
const code = String((error as { code: unknown }).code);
|
|
369
|
+
return fail(400, code, error instanceof Error ? error.message : undefined);
|
|
370
|
+
}
|
|
371
|
+
return fail(400, "invalid_assertion", "Google sign-in failed");
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
return fail(400, "invalid_assertion");
|
|
200
375
|
}
|
|
201
376
|
} catch (error) {
|
|
202
377
|
if (error instanceof ZodError) {
|