@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 +9 -1
- package/dist/index.cjs +96 -33
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +92 -34
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/types/index.d.ts +60 -6
- package/types/test-d.ts +0 -320
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: [
|
|
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`
|
|
33
|
-
//
|
|
34
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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;
|