@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/README.md CHANGED
@@ -48,14 +48,22 @@ app.use('/mcp', oauth.protect('contacts.read'), mcpRouter);
48
48
  Register each client once — there is no RFC 7591 dynamic client registration:
49
49
 
50
50
  ```js
51
+ import { CLAUDE_CONNECTOR_REDIRECT_URI, CLAUDE_CODE_REDIRECT_URIS } from '@jeffjassky/oauth-host';
52
+
51
53
  const { clientId, clientSecret } = await oauth.clients.create({
52
54
  name: 'Claude',
53
- redirectUris: ['<Claude connector callback, from their docs>'],
55
+ redirectUris: [CLAUDE_CONNECTOR_REDIRECT_URI, ...CLAUDE_CODE_REDIRECT_URIS],
54
56
  allowedScopes: ['openid', 'contacts.read'],
55
57
  branding: { publisher: 'Anthropic' },
56
58
  }); // secret returned once, never again
57
59
  ```
58
60
 
61
+ The callbacks ship as constants because `create()` compares them byte-for-byte
62
+ and retyping one out of a vendor's docs is the most typo-prone step in the setup.
63
+ ChatGPT's current callback is per-connector and therefore not a constant — copy
64
+ it from its setup screen. See
65
+ [Vendor callback URLs](https://jeffjassky.github.io/oauth-host/guide/mcp#vendor-callback-urls).
66
+
59
67
  Or let Claude and ChatGPT register themselves with a [client ID metadata
60
68
  document](https://jeffjassky.github.io/oauth-host/guide/cimd) — their
61
69
  `client_id` is an `https://` URL serving a JSON description of themselves, and
package/dist/index.cjs CHANGED
@@ -29,9 +29,11 @@ function clientSchema() {
29
29
  {
30
30
  clientId: { type: String, required: true, unique: true },
31
31
  name: { type: String, required: true },
32
- // `public` exists for CIMD clients, which hold no secret and are bound by
33
- // PKCE instead. The enum was written with this widening in mind, so it
34
- // was one member rather than a migration.
32
+ // `public` is a client that holds no secret and is bound by PKCE instead.
33
+ // Two ways in and they are independent: a CIMD row is written public, and
34
+ // `clients.create({ type: 'public' })` registers one by hand. The enum was
35
+ // written with this widening in mind, so it was one member rather than a
36
+ // migration.
35
37
  type: { type: String, enum: ["confidential", "public"], default: "confidential" },
36
38
  // How the row got here. A `cimd` row is re-derived from the client's own
37
39
  // metadata document, so this is what tells the re-fetch path which fields
@@ -500,6 +502,7 @@ function resolveConfig(config) {
500
502
  }
501
503
  const store = rateLimits.store ?? memoryStore();
502
504
  const normalizedMount = `/${String(mountPath).replace(/^\/+|\/+$/g, "")}`;
505
+ const indexes = { ready: false };
503
506
  const ctx = {
504
507
  models,
505
508
  issuer,
@@ -531,6 +534,11 @@ function resolveConfig(config) {
531
534
  cors: { tokenEndpoint: cors.tokenEndpoint ?? false, origins: cors.origins ?? [] },
532
535
  clockSkewMs,
533
536
  cimd: resolveCimd(clientIdMetadata, scopeIndex),
537
+ indexes,
538
+ async syncIndexes() {
539
+ await syncModelIndexes(models);
540
+ indexes.ready = true;
541
+ },
534
542
  logger,
535
543
  track,
536
544
  async audit(entry) {
@@ -1358,6 +1366,15 @@ function assertResources(ctx, input) {
1358
1366
  }
1359
1367
  return [...input];
1360
1368
  }
1369
+ function assertClientType(value) {
1370
+ if (value === void 0) return "confidential";
1371
+ if (value !== "confidential" && value !== "public") {
1372
+ throw new TypeError(
1373
+ `oauth-host: client type must be 'confidential' or 'public' (got ${JSON.stringify(value)})`
1374
+ );
1375
+ }
1376
+ return value;
1377
+ }
1361
1378
  function assertName(value) {
1362
1379
  if (typeof value !== "string" || !value.trim()) {
1363
1380
  throw new TypeError("oauth-host: a client needs a non-empty `name` \u2014 it is what the consent screen shows");
@@ -1379,35 +1396,38 @@ async function revokeTokensMatching(ctx, filter) {
1379
1396
  return res.modifiedCount ?? 0;
1380
1397
  }
1381
1398
  function createClientsApi(ctx) {
1399
+ async function create(spec) {
1400
+ if (!spec || typeof spec !== "object") {
1401
+ throw new TypeError("oauth-host: clients.create(spec) requires a spec object");
1402
+ }
1403
+ const name = assertName(spec.name);
1404
+ const type = assertClientType(spec.type);
1405
+ const redirectUris = assertRedirectUris(spec.redirectUris);
1406
+ const allowedScopes = assertScopes(ctx, spec.allowedScopes);
1407
+ const allowedResources = assertResources(ctx, spec.allowedResources);
1408
+ const clientId = spec.clientId ?? generateClientId();
1409
+ const clientSecret = type === "confidential" ? generateClientSecret() : void 0;
1410
+ const doc = await ctx.models.Client.create({
1411
+ clientId,
1412
+ name,
1413
+ type,
1414
+ registration: "manual",
1415
+ trusted: Boolean(spec.trusted),
1416
+ // Only the digest is stored. `clientSecret` below is the only time the
1417
+ // raw value exists outside the caller's variable. Empty for a public
1418
+ // client, which is the same shape a CIMD row is written with.
1419
+ secrets: clientSecret ? [{ hash: sha256(clientSecret), label: "initial", createdAt: /* @__PURE__ */ new Date() }] : [],
1420
+ redirectUris,
1421
+ allowedScopes,
1422
+ allowedResources,
1423
+ branding: spec.branding ?? {},
1424
+ status: "active"
1425
+ });
1426
+ await ctx.audit({ type: "oauth.client_created", actor: "admin", clientId, meta: { type } });
1427
+ return clientSecret ? { client: toPublicClient(doc), clientId, type: "confidential", clientSecret } : { client: toPublicClient(doc), clientId, type: "public" };
1428
+ }
1382
1429
  return {
1383
- async create(spec) {
1384
- if (!spec || typeof spec !== "object") {
1385
- throw new TypeError("oauth-host: clients.create(spec) requires a spec object");
1386
- }
1387
- const name = assertName(spec.name);
1388
- const redirectUris = assertRedirectUris(spec.redirectUris);
1389
- const allowedScopes = assertScopes(ctx, spec.allowedScopes);
1390
- const allowedResources = assertResources(ctx, spec.allowedResources);
1391
- const clientId = spec.clientId ?? generateClientId();
1392
- const clientSecret = generateClientSecret();
1393
- const doc = await ctx.models.Client.create({
1394
- clientId,
1395
- name,
1396
- type: "confidential",
1397
- registration: "manual",
1398
- trusted: Boolean(spec.trusted),
1399
- // Only the digest is stored. `clientSecret` below is the only time the
1400
- // raw value exists outside the caller's variable.
1401
- secrets: [{ hash: sha256(clientSecret), label: "initial", createdAt: /* @__PURE__ */ new Date() }],
1402
- redirectUris,
1403
- allowedScopes,
1404
- allowedResources,
1405
- branding: spec.branding ?? {},
1406
- status: "active"
1407
- });
1408
- await ctx.audit({ type: "oauth.client_created", actor: "admin", clientId });
1409
- return { client: toPublicClient(doc), clientId, clientSecret };
1410
- },
1430
+ create,
1411
1431
  async rotateSecret(clientId, opts = {}) {
1412
1432
  const doc = await ctx.models.Client.findOne({ clientId });
1413
1433
  if (!doc) throw notFound(clientId);
@@ -1436,7 +1456,7 @@ function createClientsApi(ctx) {
1436
1456
  clientId,
1437
1457
  meta: { retireAfter, retiresAt }
1438
1458
  });
1439
- return { client: toPublicClient(doc), clientId, clientSecret };
1459
+ return { client: toPublicClient(doc), clientId, type: "confidential", clientSecret };
1440
1460
  },
1441
1461
  async update(clientId, patch) {
1442
1462
  const doc = await ctx.models.Client.findOne({ clientId });
@@ -2480,8 +2500,17 @@ function createOAuthRouter(ctx) {
2480
2500
  const ipKey = (req) => req.ip ?? "unknown";
2481
2501
  const clientKey = (req) => clientIdFromRequest(req) ?? ipKey(req);
2482
2502
  const cors = corsMiddleware(ctx);
2503
+ const requireIndexes = w((_req, _res, next) => {
2504
+ if (ctx.indexes.ready) return next();
2505
+ throw new OAuthError(
2506
+ 503,
2507
+ "server_error",
2508
+ "This authorization server is not ready: `syncIndexes()` has not resolved yet. Await `oauth.syncIndexes()` at boot, before mounting the routers."
2509
+ );
2510
+ });
2483
2511
  router.get(
2484
2512
  "/authorize",
2513
+ requireIndexes,
2485
2514
  rateLimit(ctx, "authorize", ipKey),
2486
2515
  w(async (req, res) => {
2487
2516
  let validated;
@@ -2540,6 +2569,7 @@ function createOAuthRouter(ctx) {
2540
2569
  router.post(
2541
2570
  "/token",
2542
2571
  cors,
2572
+ requireIndexes,
2543
2573
  form,
2544
2574
  rateLimit(ctx, "token", clientKey),
2545
2575
  w(async (req, res) => {
@@ -2644,6 +2674,7 @@ function createOAuthRouter(ctx) {
2644
2674
  );
2645
2675
  router.post(
2646
2676
  "/consent/:requestId",
2677
+ requireIndexes,
2647
2678
  rateLimit(ctx, "consent", ipKey),
2648
2679
  w(async (req, res) => {
2649
2680
  const user = await requireUser(ctx, req);
@@ -2745,6 +2776,16 @@ function corsMiddleware(ctx) {
2745
2776
  };
2746
2777
  }
2747
2778
 
2779
+ // src/server/vendors.ts
2780
+ var CLAUDE_CONNECTOR_REDIRECT_URI = "https://claude.ai/api/mcp/auth_callback";
2781
+ var CLAUDE_CODE_REDIRECT_URIS = [
2782
+ "http://localhost/callback",
2783
+ "http://127.0.0.1/callback"
2784
+ ];
2785
+ var CHATGPT_LEGACY_REDIRECT_URI = "https://chatgpt.com/connector_platform_oauth_redirect";
2786
+ var CHATGPT_CONNECTOR_REDIRECT_URI_PATTERN = "https://chatgpt.com/connector/oauth/{callback_id}";
2787
+ var CIMD_ALLOWED_HOSTS = ["claude.ai", "chatgpt.com"];
2788
+
2748
2789
  // src/server/index.ts
2749
2790
  function createOAuthHost(config) {
2750
2791
  const ctx = resolveConfig(config);
@@ -2768,13 +2809,35 @@ function createOAuthHost(config) {
2768
2809
  * stops one user holding two live grants for the same client, and mongoose
2769
2810
  * builds indexes in the background — a cold database will happily serve
2770
2811
  * the write that violates it first. See standards/traps.md #3.
2812
+ *
2813
+ * Resolving this is also what flips `ready` and un-gates `/authorize`,
2814
+ * `POST /consent/:requestId` and `POST /token`.
2815
+ */
2816
+ syncIndexes: () => ctx.syncIndexes(),
2817
+ /**
2818
+ * Has `syncIndexes()` resolved?
2819
+ *
2820
+ * A boolean, not a promise, and the difference is the failure mode. A
2821
+ * promise would have to exist from construction, so a host that never calls
2822
+ * `syncIndexes()` would await it forever — a boot-order mistake turning into
2823
+ * an unexplained hang with no message. A boolean is plainly `false`, cannot
2824
+ * be awaited by accident, and is backed by three routes that say what is
2825
+ * wrong by name. Gate a mount on it, or simply
2826
+ * `await oauth.syncIndexes()` first, which is the same thing said directly.
2771
2827
  */
2772
- syncIndexes: () => syncModelIndexes(ctx.models),
2828
+ get ready() {
2829
+ return ctx.indexes.ready;
2830
+ },
2773
2831
  /** Escape hatch. Prefer the APIs above; these carry no invariants. */
2774
2832
  models: ctx.models
2775
2833
  };
2776
2834
  }
2777
2835
 
2836
+ exports.CHATGPT_CONNECTOR_REDIRECT_URI_PATTERN = CHATGPT_CONNECTOR_REDIRECT_URI_PATTERN;
2837
+ exports.CHATGPT_LEGACY_REDIRECT_URI = CHATGPT_LEGACY_REDIRECT_URI;
2838
+ exports.CIMD_ALLOWED_HOSTS = CIMD_ALLOWED_HOSTS;
2839
+ exports.CLAUDE_CODE_REDIRECT_URIS = CLAUDE_CODE_REDIRECT_URIS;
2840
+ exports.CLAUDE_CONNECTOR_REDIRECT_URI = CLAUDE_CONNECTOR_REDIRECT_URI;
2778
2841
  exports.OAuthError = OAuthError;
2779
2842
  exports.RedirectableAuthError = RedirectableAuthError;
2780
2843
  exports.UnredirectableError = UnredirectableError;