@jeffjassky/oauth-host 0.1.0 → 0.3.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/dist/index.js CHANGED
@@ -22,9 +22,11 @@ function clientSchema() {
22
22
  {
23
23
  clientId: { type: String, required: true, unique: true },
24
24
  name: { type: String, required: true },
25
- // `public` exists for CIMD clients, which hold no secret and are bound by
26
- // PKCE instead. The enum was written with this widening in mind, so it
27
- // was one member rather than a migration.
25
+ // `public` is a client that holds no secret and is bound by PKCE instead.
26
+ // Two ways in and they are independent: a CIMD row is written public, and
27
+ // `clients.create({ type: 'public' })` registers one by hand. The enum was
28
+ // written with this widening in mind, so it was one member rather than a
29
+ // migration.
28
30
  type: { type: String, enum: ["confidential", "public"], default: "confidential" },
29
31
  // How the row got here. A `cimd` row is re-derived from the client's own
30
32
  // metadata document, so this is what tells the re-fetch path which fields
@@ -493,6 +495,7 @@ function resolveConfig(config) {
493
495
  }
494
496
  const store = rateLimits.store ?? memoryStore();
495
497
  const normalizedMount = `/${String(mountPath).replace(/^\/+|\/+$/g, "")}`;
498
+ const indexes = { ready: false };
496
499
  const ctx = {
497
500
  models,
498
501
  issuer,
@@ -524,6 +527,11 @@ function resolveConfig(config) {
524
527
  cors: { tokenEndpoint: cors.tokenEndpoint ?? false, origins: cors.origins ?? [] },
525
528
  clockSkewMs,
526
529
  cimd: resolveCimd(clientIdMetadata, scopeIndex),
530
+ indexes,
531
+ async syncIndexes() {
532
+ await syncModelIndexes(models);
533
+ indexes.ready = true;
534
+ },
527
535
  logger,
528
536
  track,
529
537
  async audit(entry) {
@@ -1351,6 +1359,15 @@ function assertResources(ctx, input) {
1351
1359
  }
1352
1360
  return [...input];
1353
1361
  }
1362
+ function assertClientType(value) {
1363
+ if (value === void 0) return "confidential";
1364
+ if (value !== "confidential" && value !== "public") {
1365
+ throw new TypeError(
1366
+ `oauth-host: client type must be 'confidential' or 'public' (got ${JSON.stringify(value)})`
1367
+ );
1368
+ }
1369
+ return value;
1370
+ }
1354
1371
  function assertName(value) {
1355
1372
  if (typeof value !== "string" || !value.trim()) {
1356
1373
  throw new TypeError("oauth-host: a client needs a non-empty `name` \u2014 it is what the consent screen shows");
@@ -1372,35 +1389,38 @@ async function revokeTokensMatching(ctx, filter) {
1372
1389
  return res.modifiedCount ?? 0;
1373
1390
  }
1374
1391
  function createClientsApi(ctx) {
1392
+ async function create(spec) {
1393
+ if (!spec || typeof spec !== "object") {
1394
+ throw new TypeError("oauth-host: clients.create(spec) requires a spec object");
1395
+ }
1396
+ const name = assertName(spec.name);
1397
+ const type = assertClientType(spec.type);
1398
+ const redirectUris = assertRedirectUris(spec.redirectUris);
1399
+ const allowedScopes = assertScopes(ctx, spec.allowedScopes);
1400
+ const allowedResources = assertResources(ctx, spec.allowedResources);
1401
+ const clientId = spec.clientId ?? generateClientId();
1402
+ const clientSecret = type === "confidential" ? generateClientSecret() : void 0;
1403
+ const doc = await ctx.models.Client.create({
1404
+ clientId,
1405
+ name,
1406
+ type,
1407
+ registration: "manual",
1408
+ trusted: Boolean(spec.trusted),
1409
+ // Only the digest is stored. `clientSecret` below is the only time the
1410
+ // raw value exists outside the caller's variable. Empty for a public
1411
+ // client, which is the same shape a CIMD row is written with.
1412
+ secrets: clientSecret ? [{ hash: sha256(clientSecret), label: "initial", createdAt: /* @__PURE__ */ new Date() }] : [],
1413
+ redirectUris,
1414
+ allowedScopes,
1415
+ allowedResources,
1416
+ branding: spec.branding ?? {},
1417
+ status: "active"
1418
+ });
1419
+ await ctx.audit({ type: "oauth.client_created", actor: "admin", clientId, meta: { type } });
1420
+ return clientSecret ? { client: toPublicClient(doc), clientId, type: "confidential", clientSecret } : { client: toPublicClient(doc), clientId, type: "public" };
1421
+ }
1375
1422
  return {
1376
- async create(spec) {
1377
- if (!spec || typeof spec !== "object") {
1378
- throw new TypeError("oauth-host: clients.create(spec) requires a spec object");
1379
- }
1380
- const name = assertName(spec.name);
1381
- const redirectUris = assertRedirectUris(spec.redirectUris);
1382
- const allowedScopes = assertScopes(ctx, spec.allowedScopes);
1383
- const allowedResources = assertResources(ctx, spec.allowedResources);
1384
- const clientId = spec.clientId ?? generateClientId();
1385
- const clientSecret = generateClientSecret();
1386
- const doc = await ctx.models.Client.create({
1387
- clientId,
1388
- name,
1389
- type: "confidential",
1390
- registration: "manual",
1391
- trusted: Boolean(spec.trusted),
1392
- // Only the digest is stored. `clientSecret` below is the only time the
1393
- // raw value exists outside the caller's variable.
1394
- secrets: [{ hash: sha256(clientSecret), label: "initial", createdAt: /* @__PURE__ */ new Date() }],
1395
- redirectUris,
1396
- allowedScopes,
1397
- allowedResources,
1398
- branding: spec.branding ?? {},
1399
- status: "active"
1400
- });
1401
- await ctx.audit({ type: "oauth.client_created", actor: "admin", clientId });
1402
- return { client: toPublicClient(doc), clientId, clientSecret };
1403
- },
1423
+ create,
1404
1424
  async rotateSecret(clientId, opts = {}) {
1405
1425
  const doc = await ctx.models.Client.findOne({ clientId });
1406
1426
  if (!doc) throw notFound(clientId);
@@ -1429,7 +1449,7 @@ function createClientsApi(ctx) {
1429
1449
  clientId,
1430
1450
  meta: { retireAfter, retiresAt }
1431
1451
  });
1432
- return { client: toPublicClient(doc), clientId, clientSecret };
1452
+ return { client: toPublicClient(doc), clientId, type: "confidential", clientSecret };
1433
1453
  },
1434
1454
  async update(clientId, patch) {
1435
1455
  const doc = await ctx.models.Client.findOne({ clientId });
@@ -2473,8 +2493,17 @@ function createOAuthRouter(ctx) {
2473
2493
  const ipKey = (req) => req.ip ?? "unknown";
2474
2494
  const clientKey = (req) => clientIdFromRequest(req) ?? ipKey(req);
2475
2495
  const cors = corsMiddleware(ctx);
2496
+ const requireIndexes = w((_req, _res, next) => {
2497
+ if (ctx.indexes.ready) return next();
2498
+ throw new OAuthError(
2499
+ 503,
2500
+ "server_error",
2501
+ "This authorization server is not ready: `syncIndexes()` has not resolved yet. Await `oauth.syncIndexes()` at boot, before mounting the routers."
2502
+ );
2503
+ });
2476
2504
  router.get(
2477
2505
  "/authorize",
2506
+ requireIndexes,
2478
2507
  rateLimit(ctx, "authorize", ipKey),
2479
2508
  w(async (req, res) => {
2480
2509
  let validated;
@@ -2533,6 +2562,7 @@ function createOAuthRouter(ctx) {
2533
2562
  router.post(
2534
2563
  "/token",
2535
2564
  cors,
2565
+ requireIndexes,
2536
2566
  form,
2537
2567
  rateLimit(ctx, "token", clientKey),
2538
2568
  w(async (req, res) => {
@@ -2637,6 +2667,7 @@ function createOAuthRouter(ctx) {
2637
2667
  );
2638
2668
  router.post(
2639
2669
  "/consent/:requestId",
2670
+ requireIndexes,
2640
2671
  rateLimit(ctx, "consent", ipKey),
2641
2672
  w(async (req, res) => {
2642
2673
  const user = await requireUser(ctx, req);
@@ -2738,6 +2769,16 @@ function corsMiddleware(ctx) {
2738
2769
  };
2739
2770
  }
2740
2771
 
2772
+ // src/server/vendors.ts
2773
+ var CLAUDE_CONNECTOR_REDIRECT_URI = "https://claude.ai/api/mcp/auth_callback";
2774
+ var CLAUDE_CODE_REDIRECT_URIS = [
2775
+ "http://localhost/callback",
2776
+ "http://127.0.0.1/callback"
2777
+ ];
2778
+ var CHATGPT_LEGACY_REDIRECT_URI = "https://chatgpt.com/connector_platform_oauth_redirect";
2779
+ var CHATGPT_CONNECTOR_REDIRECT_URI_PATTERN = "https://chatgpt.com/connector/oauth/{callback_id}";
2780
+ var CIMD_ALLOWED_HOSTS = ["claude.ai", "chatgpt.com"];
2781
+
2741
2782
  // src/server/index.ts
2742
2783
  function createOAuthHost(config) {
2743
2784
  const ctx = resolveConfig(config);
@@ -2761,13 +2802,30 @@ function createOAuthHost(config) {
2761
2802
  * stops one user holding two live grants for the same client, and mongoose
2762
2803
  * builds indexes in the background — a cold database will happily serve
2763
2804
  * the write that violates it first. See standards/traps.md #3.
2805
+ *
2806
+ * Resolving this is also what flips `ready` and un-gates `/authorize`,
2807
+ * `POST /consent/:requestId` and `POST /token`.
2808
+ */
2809
+ syncIndexes: () => ctx.syncIndexes(),
2810
+ /**
2811
+ * Has `syncIndexes()` resolved?
2812
+ *
2813
+ * A boolean, not a promise, and the difference is the failure mode. A
2814
+ * promise would have to exist from construction, so a host that never calls
2815
+ * `syncIndexes()` would await it forever — a boot-order mistake turning into
2816
+ * an unexplained hang with no message. A boolean is plainly `false`, cannot
2817
+ * be awaited by accident, and is backed by three routes that say what is
2818
+ * wrong by name. Gate a mount on it, or simply
2819
+ * `await oauth.syncIndexes()` first, which is the same thing said directly.
2764
2820
  */
2765
- syncIndexes: () => syncModelIndexes(ctx.models),
2821
+ get ready() {
2822
+ return ctx.indexes.ready;
2823
+ },
2766
2824
  /** Escape hatch. Prefer the APIs above; these carry no invariants. */
2767
2825
  models: ctx.models
2768
2826
  };
2769
2827
  }
2770
2828
 
2771
- export { OAuthError, RedirectableAuthError, UnredirectableError, createModels, createOAuthHost, createUserAdapter, defaultResolveUser, syncModelIndexes };
2829
+ export { CHATGPT_CONNECTOR_REDIRECT_URI_PATTERN, CHATGPT_LEGACY_REDIRECT_URI, CIMD_ALLOWED_HOSTS, CLAUDE_CODE_REDIRECT_URIS, CLAUDE_CONNECTOR_REDIRECT_URI, OAuthError, RedirectableAuthError, UnredirectableError, createModels, createOAuthHost, createUserAdapter, defaultResolveUser, syncModelIndexes };
2772
2830
  //# sourceMappingURL=index.js.map
2773
2831
  //# sourceMappingURL=index.js.map