@12-apps/mcp 3.16.0 → 3.17.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/ADOPTING.md +41 -0
- package/README.md +1 -1
- package/dist/{chunk-VDD4YRNP.js → chunk-EANHJLDH.js} +13 -4
- package/dist/chunk-EANHJLDH.js.map +1 -0
- package/dist/{chunk-UIILEGAC.js → chunk-WCZC4TPX.js} +193 -48
- package/dist/chunk-WCZC4TPX.js.map +1 -0
- package/dist/{create-api-mcp-oauth-CsC0jlH7.d.ts → create-api-mcp-oauth-BEvYLRBV.d.ts} +96 -4
- package/dist/hono/index.d.ts +1 -1
- package/dist/hono/index.js +1 -1
- package/dist/index.d.ts +100 -2
- package/dist/index.js +71 -6
- package/dist/index.js.map +1 -1
- package/dist/manifest/server.d.ts +1 -1
- package/dist/manifest/server.js +2 -2
- package/dist/oauth/index.d.ts +18 -3
- package/dist/oauth/index.js +4 -2
- package/package.json +5 -5
- package/prisma/mcp.prisma +10 -0
- package/prisma/migrations/20260910120000_add_refresh_grace_seal/migration.sql +37 -0
- package/src/index.ts +11 -0
- package/src/oauth/access-token.ts +72 -10
- package/src/oauth/context.ts +20 -0
- package/src/oauth/index.ts +2 -0
- package/src/oauth/prisma-stores.ts +16 -5
- package/src/oauth/refresh-lineage.ts +77 -0
- package/src/oauth/refresh.ts +169 -82
- package/src/oauth/rotation-grace.ts +216 -0
- package/src/oauth/stores.ts +45 -1
- package/src/oauth/token-grants.ts +4 -1
- package/src/server/auth-failure.ts +145 -0
- package/src/server/jsonrpc.ts +44 -5
- package/dist/chunk-UIILEGAC.js.map +0 -1
- package/dist/chunk-VDD4YRNP.js.map +0 -1
package/ADOPTING.md
CHANGED
|
@@ -69,6 +69,38 @@ library updates, every host updates with **no app changes**. Same contract
|
|
|
69
69
|
adapter does it with `updateMany({ where: { tokenHash, revokedAt: null } })`
|
|
70
70
|
inside an interactive `$transaction`; the harness does the same in raw SQL, on
|
|
71
71
|
purpose, as the worked example of a non-Prisma host meeting the contract.
|
|
72
|
+
|
|
73
|
+
**The claim still decides; what changed is what the LOSER is told.** For
|
|
74
|
+
`refreshRotationGraceMs` after a rotation, re-presenting the consumed parent is
|
|
75
|
+
a RETRY and answers with the successor that rotation already minted, recovered
|
|
76
|
+
from the sealed `graceSeal` column. Exactly one successor is still ever
|
|
77
|
+
written, so no second family exists. Before this, a lost response or two of a
|
|
78
|
+
client's own sessions refreshing at once revoked the whole lineage — including
|
|
79
|
+
the token just handed to the winner — and cost a connected user their session.
|
|
80
|
+
|
|
81
|
+
**Two things this obliges you to do, and one it obliges you to accept.**
|
|
82
|
+
|
|
83
|
+
- **`graceSeal` is a REQUIRED column from this version on**, and the migration
|
|
84
|
+
has to land in the SAME change that raises the pin. `rotate` writes the
|
|
85
|
+
field on every successor and clears it on every parent it consumes, so a
|
|
86
|
+
PRISMA host missing the column does not quietly lose the window — the
|
|
87
|
+
delegate rejects the unknown key and every rotation throws, which is a dead
|
|
88
|
+
token endpoint. If your client is duck-typed into `McpOauthPrisma`,
|
|
89
|
+
type-checking will not catch that for you. A hand-written store gets no such
|
|
90
|
+
backstop at all: omit the field there and the window silently never applies,
|
|
91
|
+
so writing and clearing it is part of meeting the port.
|
|
92
|
+
- **`rotate` and every revoke must CLEAR the seal** (`revokedAt` alone is not
|
|
93
|
+
enough). A seal is openable only by the plaintext of the token it was
|
|
94
|
+
rotated from — so spent seals left in place CHAIN: one historical plaintext
|
|
95
|
+
plus a copy of the table walks forward hop by hop to the live token,
|
|
96
|
+
offline, with no server call and therefore no replay detection. Cleared on
|
|
97
|
+
consumption, at most one hop is ever open.
|
|
98
|
+
- **Accept that detection narrows.** It is not a one-rotation deferral: two
|
|
99
|
+
parties holding one successor take the retry path again at every rotation
|
|
100
|
+
and stay in lockstep while their uses keep landing inside the window. The
|
|
101
|
+
guarantee is that a collision is detected once two uses fall more than
|
|
102
|
+
`refreshRotationGraceMs` apart. `0` buys the strict rule back, at the price
|
|
103
|
+
the window was added to stop paying.
|
|
72
104
|
8. **`codeReplay` is REQUIRED, and that is the point.** Single-use codes are only as
|
|
73
105
|
strong as the replay store, and the in-process one remembers redeemed `jti`s IN
|
|
74
106
|
THIS PROCESS: exact on one instance, and on several a code can be replayed
|
|
@@ -124,6 +156,14 @@ library updates, every host updates with **no app changes**. Same contract
|
|
|
124
156
|
specification, and `Cache-Control: no-store` is on every credential-bearing
|
|
125
157
|
response. Wrapping any of it would break every client. That is why the adapters
|
|
126
158
|
are one line and hand the `Response` straight back.
|
|
159
|
+
13. **Tell `handleMcpJsonRpc` WHY the bearer failed.** Its `failure` parameter is
|
|
160
|
+
optional so an existing caller keeps compiling, and omitting it is a real
|
|
161
|
+
cost: every refusal then reads as "no token", and an agent cannot tell a
|
|
162
|
+
lapsed connection from a token minted for another deployment or a surface the
|
|
163
|
+
operator never switched on. Catch the `AccessTokenError` your verifier throws,
|
|
164
|
+
map its `reason` through, and the tool call comes back with a sentence a model
|
|
165
|
+
can relay and a `data` payload a client can act on — `refresh`, `reconnect` or
|
|
166
|
+
`contact_operator`. See `src/server/auth-failure.ts`.
|
|
127
167
|
|
|
128
168
|
## The config, field by field
|
|
129
169
|
|
|
@@ -140,6 +180,7 @@ library updates, every host updates with **no app changes**. Same contract
|
|
|
140
180
|
| `loginPath` / `loginCallbackParam` | no | `/login` / `callbackUrl` | Auth.js's names |
|
|
141
181
|
| `accessTokenTtlSeconds` | no | 900 | 15 minutes |
|
|
142
182
|
| `refreshTokenTtlMs` | no | 30 days | |
|
|
183
|
+
| `refreshRotationGraceMs` | no | 30 s | how long a just-rotated refresh token keeps answering with the successor it minted, instead of being treated as a replay. `0` restores the strict single-use rule — see rule 7 |
|
|
143
184
|
| `codeReplay` | **yes** | — (no default, on purpose) | a shared atomic store, or `'in-process'` to acknowledge one pod — rule 8 |
|
|
144
185
|
| `resolveApproval` | no | refuse unapproved clients | the consent seam — rule 9 |
|
|
145
186
|
| `preApprovedClientIds` | no | `[]` | first-party client ids exempt from the approval gate — rule 9 |
|
package/README.md
CHANGED
|
@@ -44,7 +44,7 @@ contract, so it lives here now:
|
|
|
44
44
|
|
|
45
45
|
| Entry | Export | Role |
|
|
46
46
|
|---|---|---|
|
|
47
|
-
| `./oauth` | `createApiMcpOauth({ stores, resolveSession })` | OAuth 2.1 authorization server: `register` (RFC 7591) / `authorize` (code + mandatory PKCE S256) / `token` (code + refresh), the JWKS, and BOTH `.well-known` documents. Also the primitives — stateless signed codes, ES256 access tokens, hashed rotating refresh tokens with lineage revocation, the `verifyBearer` resource-server half. |
|
|
47
|
+
| `./oauth` | `createApiMcpOauth({ stores, resolveSession })` | OAuth 2.1 authorization server: `register` (RFC 7591) / `authorize` (code + mandatory PKCE S256) / `token` (code + refresh), the JWKS, and BOTH `.well-known` documents. Also the primitives — stateless signed codes, ES256 access tokens, hashed rotating refresh tokens with lineage revocation and a retry grace window, the `verifyBearer` resource-server half. |
|
|
48
48
|
| `./hono` | `mcpOauthRouter(config)` | The same surface as a router, mounted at the **origin root** (a connector reads `.well-known` from the origin, never from a prefix). `hono` is an OPTIONAL peer. |
|
|
49
49
|
| `./generate` | `mcpGenerateCli(options)` | `mcp:generate` / `mcp:check` — the committed manifest and its drift gate. |
|
|
50
50
|
| `./coverage` | `mcpCoverageCli(options)` | `mcp:coverage` — every route method and server action either exposed as a tool or excluded with a reason. |
|
|
@@ -45,7 +45,10 @@ function refreshTokenStore(getPrisma) {
|
|
|
45
45
|
const prisma = await getPrisma();
|
|
46
46
|
await prisma.oAuthRefreshToken.updateMany({
|
|
47
47
|
where: { tokenHash: { in: [...tokenHashes] } },
|
|
48
|
-
|
|
48
|
+
// The seals go with the revocation. A dead lineage that still carries
|
|
49
|
+
// openable seals is a chain anyone holding one of its plaintexts can
|
|
50
|
+
// still walk offline, which would make the revocation cosmetic.
|
|
51
|
+
data: { revokedAt: at, graceSeal: null }
|
|
49
52
|
});
|
|
50
53
|
},
|
|
51
54
|
async rotate(successor, parentHash, at) {
|
|
@@ -53,7 +56,12 @@ function refreshTokenStore(getPrisma) {
|
|
|
53
56
|
return prisma.$transaction(async (tx) => {
|
|
54
57
|
const { count } = await tx.oAuthRefreshToken.updateMany({
|
|
55
58
|
where: { tokenHash: parentHash, revokedAt: null },
|
|
56
|
-
|
|
59
|
+
// `graceSeal: null` is part of the claim, not a cleanup. The parent's
|
|
60
|
+
// seal is openable by the plaintext it was rotated from, so leaving it
|
|
61
|
+
// behind would chain: one historical plaintext plus a copy of this
|
|
62
|
+
// table walks forward to the live token offline, hop by hop, with no
|
|
63
|
+
// server call to detect. Cleared here, at most one hop is ever open.
|
|
64
|
+
data: { revokedAt: at, graceSeal: null }
|
|
57
65
|
});
|
|
58
66
|
if (count !== 1) return false;
|
|
59
67
|
await tx.oAuthRefreshToken.create({ data: successor });
|
|
@@ -64,7 +72,8 @@ function refreshTokenStore(getPrisma) {
|
|
|
64
72
|
const prisma = await getPrisma();
|
|
65
73
|
const { count } = await prisma.oAuthRefreshToken.updateMany({
|
|
66
74
|
where: { userEmail, clientId, revokedAt: null },
|
|
67
|
-
|
|
75
|
+
// Disconnecting a host must leave nothing openable behind either.
|
|
76
|
+
data: { revokedAt: /* @__PURE__ */ new Date(), graceSeal: null }
|
|
68
77
|
});
|
|
69
78
|
return count;
|
|
70
79
|
}
|
|
@@ -194,4 +203,4 @@ export {
|
|
|
194
203
|
listAiConnections,
|
|
195
204
|
disconnectAiHost
|
|
196
205
|
};
|
|
197
|
-
//# sourceMappingURL=chunk-
|
|
206
|
+
//# sourceMappingURL=chunk-EANHJLDH.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/oauth/prisma-stores.ts","../src/oauth/connections.ts"],"sourcesContent":["import type {\n McpConnectionStore,\n McpOauthStores,\n NewOAuthClient,\n NewRefreshToken,\n OAuthClientStore,\n RefreshTokenStore,\n StoredMcpConnection,\n StoredOAuthClient,\n StoredRefreshToken,\n} from \"./stores\";\n\n/**\n * The ports of `./stores.ts`, filled by Prisma (12-23).\n *\n * The package owns the three models (`prisma/mcp.prisma`), so their delegate\n * shapes are known and this adapter can be exact. A host with Prisma therefore\n * writes ONE line —\n *\n * stores: createPrismaMcpStores(async () => getPrismaClient() as unknown as McpOauthPrisma)\n *\n * — and no host code at all beyond it. The client is duck-typed (only the\n * delegates used, only the arguments used) so this file never imports a project's\n * generated client, and a non-Prisma host fills the ports directly instead.\n */\n\n/** A `where` on the composite unique of `mcp_connections`. */\ninterface ConnectionKey {\n userId_oauthClientId: { userId: string; oauthClientId: string };\n}\n\n/** The minimal Prisma surface the AS needs. Every field is one the surface writes. */\nexport interface McpOauthPrisma {\n oAuthClient: {\n create(args: { data: NewOAuthClient }): Promise<StoredOAuthClient>;\n findUnique(args: { where: { clientId: string } }): Promise<StoredOAuthClient | null>;\n };\n oAuthRefreshToken: {\n create(args: { data: NewRefreshToken }): Promise<unknown>;\n findUnique(args: { where: { tokenHash: string } }): Promise<StoredRefreshToken | null>;\n findFirst(args: { where: { rotatedFrom: string } }): Promise<{ tokenHash: string } | null>;\n findMany(args: {\n where: { userEmail: string; clientId: string };\n }): Promise<StoredRefreshToken[]>;\n // No single-row `update`: the rotation used to revoke its parent with one and\n // that was the bug (unconditional, so two concurrent rotations both won). Every\n // revoke here is now an `updateMany` with a predicate that says WHICH rows may\n // move, which is also why this delegate list stays honest about what is written.\n updateMany(args: {\n where:\n | { tokenHash: { in: string[] } }\n | { userEmail: string; clientId: string; revokedAt: null };\n // `graceSeal` rides on every revoke: a revoked row must not keep an\n // openable seal behind it (see `RefreshTokenStore.revokeHashes`).\n data: { revokedAt: Date; graceSeal?: null };\n }): Promise<{ count: number }>;\n };\n mcpConnection: {\n findUnique(args: {\n where: ConnectionKey;\n select: { lastActiveAt: true };\n }): Promise<{ lastActiveAt: Date } | null>;\n findFirst(args: {\n where: { userId: string; revokedAt: null; host: null };\n orderBy: { lastActiveAt: \"desc\" };\n select: { id: true };\n }): Promise<{ id: string } | null>;\n findMany(args: {\n where: { userId: string; revokedAt: null; host?: string | null };\n orderBy?: { lastActiveAt: \"desc\" };\n select: Record<string, true>;\n }): Promise<Record<string, unknown>[]>;\n upsert(args: {\n where: ConnectionKey;\n create: Record<string, unknown>;\n update: Record<string, unknown>;\n }): Promise<unknown>;\n update(args: { where: { id: string }; data: Record<string, unknown> }): Promise<unknown>;\n updateMany(args: {\n where: { id: { in: string[] } } | { userId: string; revokedAt: null; host: string };\n data: Record<string, unknown>;\n }): Promise<{ count: number }>;\n };\n /**\n * Prisma's INTERACTIVE transaction, used for the rotation's claim + write. The\n * callback form (not the array form) is required: the successor may only be\n * created once the conditional revoke has reported that it, and not a concurrent\n * sibling, claimed the parent — see `RefreshTokenStore.rotate`.\n */\n $transaction<T>(fn: (tx: McpOauthTx) => Promise<T>): Promise<T>;\n}\n\n/**\n * The delegate subset used INSIDE the rotation transaction. Not exported: it is\n * reachable structurally through `McpOauthPrisma.$transaction`, so no host ever\n * needs to name it, and exporting a type nobody imports is what knip flags.\n */\ninterface McpOauthTx {\n oAuthRefreshToken: {\n create(args: { data: NewRefreshToken }): Promise<unknown>;\n updateMany(args: {\n where: { tokenHash: string; revokedAt: null };\n data: { revokedAt: Date; graceSeal?: null };\n }): Promise<{ count: number }>;\n };\n}\n\n/** A lazily-resolved client, so a host's singleton is awaited per call. */\nexport type McpOauthPrismaProvider = () => Promise<McpOauthPrisma>;\n\nfunction clientStore(getPrisma: McpOauthPrismaProvider): OAuthClientStore {\n return {\n async create(client: NewOAuthClient) {\n const prisma = await getPrisma();\n return prisma.oAuthClient.create({ data: client });\n },\n async findByClientId(clientId: string) {\n const prisma = await getPrisma();\n return prisma.oAuthClient.findUnique({ where: { clientId } });\n },\n };\n}\n\nfunction refreshTokenStore(getPrisma: McpOauthPrismaProvider): RefreshTokenStore {\n return {\n async create(token) {\n const prisma = await getPrisma();\n await prisma.oAuthRefreshToken.create({ data: token });\n },\n async findByHash(tokenHash) {\n const prisma = await getPrisma();\n return prisma.oAuthRefreshToken.findUnique({ where: { tokenHash } });\n },\n async hasSuccessor(tokenHash) {\n const prisma = await getPrisma();\n const successor = await prisma.oAuthRefreshToken.findFirst({\n where: { rotatedFrom: tokenHash },\n });\n return successor !== null;\n },\n async listFamily(userEmail, clientId) {\n const prisma = await getPrisma();\n return prisma.oAuthRefreshToken.findMany({ where: { userEmail, clientId } });\n },\n async revokeHashes(tokenHashes, at) {\n if (tokenHashes.length === 0) return;\n const prisma = await getPrisma();\n await prisma.oAuthRefreshToken.updateMany({\n where: { tokenHash: { in: [...tokenHashes] } },\n // The seals go with the revocation. A dead lineage that still carries\n // openable seals is a chain anyone holding one of its plaintexts can\n // still walk offline, which would make the revocation cosmetic.\n data: { revokedAt: at, graceSeal: null },\n });\n },\n async rotate(successor, parentHash, at) {\n const prisma = await getPrisma();\n return prisma.$transaction(async (tx) => {\n // CLAIM-ONCE. The `revokedAt: null` predicate is what makes this safe under\n // concurrency, and it is load-bearing rather than defensive: on Postgres's\n // default READ COMMITTED, a second transaction's `updateMany` blocks on the\n // row lock, then re-evaluates this WHERE against the COMMITTED row — which\n // now has a `revokedAt` — and reports 0 rows. So exactly one caller can ever\n // see count 1, and it is the only one that goes on to create a successor.\n // An unconditional `update` would let both through: two live successors of\n // one parent, and replay detection silently defeated (it waits for a third\n // use of the parent that now never comes).\n const { count } = await tx.oAuthRefreshToken.updateMany({\n where: { tokenHash: parentHash, revokedAt: null },\n // `graceSeal: null` is part of the claim, not a cleanup. The parent's\n // seal is openable by the plaintext it was rotated from, so leaving it\n // behind would chain: one historical plaintext plus a copy of this\n // table walks forward to the live token offline, hop by hop, with no\n // server call to detect. Cleared here, at most one hop is ever open.\n data: { revokedAt: at, graceSeal: null },\n });\n // Lost the claim: write NOTHING. The zero-row update commits as the no-op\n // it is, so there is nothing to roll back.\n if (count !== 1) return false;\n // Same transaction as the claim, so a crash cannot leave a live parent AND\n // a live child either.\n await tx.oAuthRefreshToken.create({ data: successor });\n return true;\n });\n },\n async revokeLiveForClient(userEmail, clientId) {\n const prisma = await getPrisma();\n const { count } = await prisma.oAuthRefreshToken.updateMany({\n where: { userEmail, clientId, revokedAt: null },\n // Disconnecting a host must leave nothing openable behind either.\n data: { revokedAt: new Date(), graceSeal: null },\n });\n return count;\n },\n };\n}\n\n/** The connection columns the account surface reads. */\nconst CONNECTION_SELECT = {\n oauthClientId: true,\n clientName: true,\n host: true,\n connectedAt: true,\n lastActiveAt: true,\n} as const;\n\nfunction connectionStore(getPrisma: McpOauthPrismaProvider): McpConnectionStore {\n return {\n async lastActiveAt(userId, oauthClientId) {\n const prisma = await getPrisma();\n const row = await prisma.mcpConnection.findUnique({\n where: { userId_oauthClientId: { userId, oauthClientId } },\n select: { lastActiveAt: true },\n });\n return row?.lastActiveAt ?? null;\n },\n async recordActivity({ userId, oauthClientId, clientName, host, at }) {\n const prisma = await getPrisma();\n await prisma.mcpConnection.upsert({\n where: { userId_oauthClientId: { userId, oauthClientId } },\n create: { userId, oauthClientId, clientName, host, connectedAt: at, lastActiveAt: at },\n // Never blank a known host on refresh — keep the existing attribution\n // when this grant cannot derive one.\n update: {\n clientName,\n lastActiveAt: at,\n revokedAt: null,\n ...(host ? { host } : {}),\n },\n });\n },\n async listActive(userId) {\n const prisma = await getPrisma();\n const rows = await prisma.mcpConnection.findMany({\n where: { userId, revokedAt: null },\n orderBy: { lastActiveAt: \"desc\" },\n select: { ...CONNECTION_SELECT },\n });\n return rows as unknown as StoredMcpConnection[];\n },\n revokeByHost: (userId, host) => revokeByHost(getPrisma, userId, host),\n announce: (userId, host) => announce(getPrisma, userId, host),\n };\n}\n\n/**\n * Disconnect one provider's connections — and ONLY that provider's.\n *\n * Every read and write is scoped by `userId`: a connection is per-user (an MCP\n * bearer is not tenant-scoped), so the user id IS the isolation here, and the\n * `id` list passed to the update comes from a query that already applied it.\n */\nasync function revokeByHost(\n getPrisma: McpOauthPrismaProvider,\n userId: string,\n host: string,\n): Promise<string[]> {\n const prisma = await getPrisma();\n const attributed = await prisma.mcpConnection.findMany({\n where: { userId, revokedAt: null, host },\n select: { id: true, oauthClientId: true },\n });\n // A legacy `host = null` row is claimed only when the provider has no row of\n // its own: pre-attribution connections must stay disconnectable, but a provider\n // that DID attribute can never revoke another assistant's row.\n const targets =\n attributed.length > 0\n ? attributed\n : await prisma.mcpConnection.findMany({\n where: { userId, revokedAt: null, host: null },\n select: { id: true, oauthClientId: true },\n });\n if (targets.length === 0) return [];\n await prisma.mcpConnection.updateMany({\n where: { id: { in: targets.map((row) => String(row.id)) } },\n data: { revokedAt: new Date() },\n });\n return targets.map((row) => String(row.oauthClientId));\n}\n\n/** A provider's self-report: refresh its own row, or claim the unattributed one. */\nasync function announce(\n getPrisma: McpOauthPrismaProvider,\n userId: string,\n host: string,\n): Promise<number> {\n const prisma = await getPrisma();\n const now = new Date();\n const refreshed = await prisma.mcpConnection.updateMany({\n where: { userId, revokedAt: null, host },\n data: { lastActiveAt: now, revokedAt: null },\n });\n if (refreshed.count > 0) return refreshed.count;\n\n // No row for this provider yet — attribute the just-connected one. Scoped by\n // user, so a self-report can never reach another account's connection.\n const candidate = await prisma.mcpConnection.findFirst({\n where: { userId, revokedAt: null, host: null },\n orderBy: { lastActiveAt: \"desc\" },\n select: { id: true },\n });\n if (!candidate) return 0;\n await prisma.mcpConnection.update({\n where: { id: candidate.id },\n data: { host, lastActiveAt: now, revokedAt: null },\n });\n return 1;\n}\n\n/** Every port, over one lazily-resolved Prisma client. */\nexport function createPrismaMcpStores(getPrisma: McpOauthPrismaProvider): McpOauthStores {\n return {\n clients: clientStore(getPrisma),\n refreshTokens: refreshTokenStore(getPrisma),\n connections: connectionStore(getPrisma),\n };\n}\n","import { providerForHostId, type AiProvider } from \"../guide\";\nimport type {\n McpConnectionStore,\n RefreshTokenStore,\n StoredMcpConnection,\n} from \"./stores\";\n\n/**\n * The account surface's connection OPERATIONS (12-48) — the half of the\n * `GET/DELETE /api/account/mcp-connections` endpoints that is contract rather\n * than host vocabulary.\n *\n * The ROUTE stays in the host on purpose: it mixes the host's session\n * resolution, its response envelope, its published plugin URLs and its logger,\n * and injecting all four here would make the config surface bigger than the\n * handler it replaces. What must NOT stay in each host is the disconnect's\n * both-halves rule, because getting it half right LOOKS right:\n *\n * `connections.revokeByHost` ends the connection rows and returns the OAuth\n * client ids behind them — and a host that stops there has revoked nothing that\n * matters. The assistant still holds a live refresh token for each of those\n * clients, rotates it on schedule, and the very next grant records fresh\n * activity: the card the user just disconnected lights green again on its own.\n * So the rule is one function: revoke the rows AND end every live refresh token\n * of each returned client, in the same call, with no way to import one half\n * without the other.\n *\n * Deliberately NOT invalidated here: the assistant's current ACCESS token.\n * Those are self-contained JWTs the server does not track; a just-disconnected\n * host keeps working for at most their TTL (15 minutes by default) and can then\n * obtain nothing further.\n */\n\n/** An active AI connection, narrowed for display. */\nexport interface AiConnectionSnapshot {\n oauthClientId: string;\n clientName: string | null;\n /** The provider this connection is attributed to (`null` = pre-attribution). */\n host: AiProvider | null;\n connectedAt: Date;\n lastActiveAt: Date;\n}\n\n/** The caller the operations act for — always the session's own user. */\nexport interface AiConnectionCaller {\n /** The host's user id — what `mcp_connections` rows are keyed by. */\n userId: string;\n /** The identity refresh tokens are bound to (the AS binds by email). */\n email: string;\n}\n\n/** What one disconnect actually ended, for the host's log and response. */\nexport interface AiDisconnectResult {\n /** OAuth client ids whose connection rows were revoked. */\n disconnectedClientIds: string[];\n /** Live refresh tokens ended across those clients — the half that cuts access. */\n revokedRefreshTokens: number;\n}\n\n/** Narrow a stored `host` string to a known provider, or `null`. */\nfunction asProvider(host: string | null): AiProvider | null {\n return host === null ? null : providerForHostId(host);\n}\n\n/**\n * A user's active connections, most-recently-active first, with the stored open\n * `host` string narrowed to the package's closed {@link AiProvider} union — the\n * store cannot know which assistants have screens, but the union is this\n * package's own vocabulary (`guide.ts`), so the narrowing lives beside it\n * rather than being re-derived in every host.\n */\nexport async function listAiConnections(\n connections: McpConnectionStore,\n userId: string,\n): Promise<AiConnectionSnapshot[]> {\n const rows: StoredMcpConnection[] = await connections.listActive(userId);\n return rows.map((row) => ({ ...row, host: asProvider(row.host) }));\n}\n\n/**\n * Disconnect one provider for this user — BOTH halves, atomically from the\n * caller's point of view (see the module doc for why one half alone is a\n * disconnect that undoes itself).\n *\n * Idempotent: disconnecting a provider that was never connected returns zero\n * counts rather than failing, so a double-click is harmless. Repeat calls also\n * report zero — `revokeLiveForClient` skips already-revoked tokens by contract.\n */\nexport async function disconnectAiHost(\n stores: { connections: McpConnectionStore; refreshTokens: RefreshTokenStore },\n caller: AiConnectionCaller,\n host: AiProvider,\n): Promise<AiDisconnectResult> {\n const disconnectedClientIds = await stores.connections.revokeByHost(caller.userId, host);\n const revoked = await Promise.all(\n disconnectedClientIds.map((clientId) =>\n stores.refreshTokens.revokeLiveForClient(caller.email, clientId),\n ),\n );\n return {\n disconnectedClientIds,\n revokedRefreshTokens: revoked.reduce((total, count) => total + count, 0),\n };\n}\n"],"mappings":"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","names":[]}
|
|
@@ -305,10 +305,12 @@ var AccessTokenError = class extends Error {
|
|
|
305
305
|
__name(this, "AccessTokenError");
|
|
306
306
|
}
|
|
307
307
|
code;
|
|
308
|
-
|
|
309
|
-
|
|
308
|
+
reason;
|
|
309
|
+
constructor(code, reason, message) {
|
|
310
|
+
super(message ?? reason);
|
|
310
311
|
this.name = "AccessTokenError";
|
|
311
312
|
this.code = code;
|
|
313
|
+
this.reason = reason;
|
|
312
314
|
}
|
|
313
315
|
};
|
|
314
316
|
function nowSeconds2(now) {
|
|
@@ -329,9 +331,16 @@ function parseScopes(scope) {
|
|
|
329
331
|
return [...new Set(scope.trim().split(/\s+/))];
|
|
330
332
|
}
|
|
331
333
|
__name(parseScopes, "parseScopes");
|
|
334
|
+
var JWT_EXPIRED_CODE = "ERR_JWT_EXPIRED";
|
|
335
|
+
function isExpiry(error) {
|
|
336
|
+
return typeof error === "object" && error !== null && error.code === JWT_EXPIRED_CODE;
|
|
337
|
+
}
|
|
338
|
+
__name(isExpiry, "isExpiry");
|
|
332
339
|
async function verifiedPayload(loadSigningKey, token, options) {
|
|
333
340
|
const key = await loadSigningKey();
|
|
334
|
-
if (!key)
|
|
341
|
+
if (!key) {
|
|
342
|
+
throw new AccessTokenError("invalid_token", "not_provisioned", "no signing key configured");
|
|
343
|
+
}
|
|
335
344
|
try {
|
|
336
345
|
const { payload } = await jwtVerify2(token, await importJWK2(key.publicJwk, SIGNING_ALG), {
|
|
337
346
|
algorithms: [SIGNING_ALG],
|
|
@@ -341,8 +350,11 @@ async function verifiedPayload(loadSigningKey, token, options) {
|
|
|
341
350
|
currentDate: options.now === void 0 ? void 0 : new Date(options.now)
|
|
342
351
|
});
|
|
343
352
|
return payload;
|
|
344
|
-
} catch {
|
|
345
|
-
|
|
353
|
+
} catch (error) {
|
|
354
|
+
if (isExpiry(error)) {
|
|
355
|
+
throw new AccessTokenError("invalid_token", "expired", "access token expired");
|
|
356
|
+
}
|
|
357
|
+
throw new AccessTokenError("invalid_token", "unverified", "token verification failed");
|
|
346
358
|
}
|
|
347
359
|
}
|
|
348
360
|
__name(verifiedPayload, "verifiedPayload");
|
|
@@ -351,11 +363,16 @@ async function verifyAccessToken(loadSigningKey, token, options) {
|
|
|
351
363
|
const email = typeof payload.email === "string" ? payload.email : null;
|
|
352
364
|
const subject = typeof payload.sub === "string" ? payload.sub : null;
|
|
353
365
|
if (!email || !subject) {
|
|
354
|
-
throw new AccessTokenError(
|
|
366
|
+
throw new AccessTokenError(
|
|
367
|
+
"invalid_token",
|
|
368
|
+
"incomplete",
|
|
369
|
+
"missing subject or email claim"
|
|
370
|
+
);
|
|
355
371
|
}
|
|
356
372
|
const scopes = parseScopes(payload.scope);
|
|
357
373
|
if (options.requiredScope && !scopes.includes(options.requiredScope)) {
|
|
358
374
|
throw new AccessTokenError(
|
|
375
|
+
"insufficient_scope",
|
|
359
376
|
"insufficient_scope",
|
|
360
377
|
`token lacks required scope '${options.requiredScope}'`
|
|
361
378
|
);
|
|
@@ -364,8 +381,115 @@ async function verifyAccessToken(loadSigningKey, token, options) {
|
|
|
364
381
|
}
|
|
365
382
|
__name(verifyAccessToken, "verifyAccessToken");
|
|
366
383
|
|
|
384
|
+
// src/oauth/rotation-grace.ts
|
|
385
|
+
import { createCipheriv, createDecipheriv, hkdfSync, randomBytes as randomBytes2 } from "crypto";
|
|
386
|
+
var ALGORITHM = "aes-256-gcm";
|
|
387
|
+
var IV_BYTES = 12;
|
|
388
|
+
var KEY_BYTES = 32;
|
|
389
|
+
var TAG_BYTES = 16;
|
|
390
|
+
var HKDF_INFO = "12-apps/mcp:refresh-rotation-grace:v1";
|
|
391
|
+
var SEAL_VERSION = "v1";
|
|
392
|
+
var DEFAULT_ROTATION_GRACE_MS = 3e4;
|
|
393
|
+
function sealingKey(parentPlaintext) {
|
|
394
|
+
const derived = hkdfSync(
|
|
395
|
+
"sha256",
|
|
396
|
+
Buffer.from(parentPlaintext, "utf8"),
|
|
397
|
+
Buffer.alloc(0),
|
|
398
|
+
Buffer.from(HKDF_INFO, "utf8"),
|
|
399
|
+
KEY_BYTES
|
|
400
|
+
);
|
|
401
|
+
return Buffer.from(derived);
|
|
402
|
+
}
|
|
403
|
+
__name(sealingKey, "sealingKey");
|
|
404
|
+
function encode(value) {
|
|
405
|
+
return value.toString("base64url");
|
|
406
|
+
}
|
|
407
|
+
__name(encode, "encode");
|
|
408
|
+
function sealSuccessor(parentPlaintext, successorPlaintext, graceUntil) {
|
|
409
|
+
const iv = randomBytes2(IV_BYTES);
|
|
410
|
+
const cipher = createCipheriv(ALGORITHM, sealingKey(parentPlaintext), iv);
|
|
411
|
+
const payload = JSON.stringify({ successor: successorPlaintext, graceUntil });
|
|
412
|
+
const sealed = Buffer.concat([cipher.update(payload, "utf8"), cipher.final()]);
|
|
413
|
+
return [SEAL_VERSION, encode(iv), encode(cipher.getAuthTag()), encode(sealed)].join(".");
|
|
414
|
+
}
|
|
415
|
+
__name(sealSuccessor, "sealSuccessor");
|
|
416
|
+
function parts(seal) {
|
|
417
|
+
const segments = seal.split(".");
|
|
418
|
+
if (segments.length !== 4) return null;
|
|
419
|
+
const [version, iv, tag, body] = segments;
|
|
420
|
+
if (version !== SEAL_VERSION) return null;
|
|
421
|
+
const decoded = {
|
|
422
|
+
iv: Buffer.from(iv ?? "", "base64url"),
|
|
423
|
+
tag: Buffer.from(tag ?? "", "base64url"),
|
|
424
|
+
body: Buffer.from(body ?? "", "base64url")
|
|
425
|
+
};
|
|
426
|
+
if (decoded.iv.length !== IV_BYTES || decoded.tag.length !== TAG_BYTES) return null;
|
|
427
|
+
return decoded;
|
|
428
|
+
}
|
|
429
|
+
__name(parts, "parts");
|
|
430
|
+
function openSuccessor(parentPlaintext, seal) {
|
|
431
|
+
const parsed = parts(seal);
|
|
432
|
+
if (!parsed) return null;
|
|
433
|
+
try {
|
|
434
|
+
const decipher = createDecipheriv(ALGORITHM, sealingKey(parentPlaintext), parsed.iv);
|
|
435
|
+
decipher.setAuthTag(parsed.tag);
|
|
436
|
+
const opened = Buffer.concat([decipher.update(parsed.body), decipher.final()]);
|
|
437
|
+
const payload = JSON.parse(opened.toString("utf8"));
|
|
438
|
+
return readPayload(payload);
|
|
439
|
+
} catch {
|
|
440
|
+
return null;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
__name(openSuccessor, "openSuccessor");
|
|
444
|
+
function readPayload(payload) {
|
|
445
|
+
if (payload === null || typeof payload !== "object") return null;
|
|
446
|
+
const { successor, graceUntil } = payload;
|
|
447
|
+
if (typeof successor !== "string" || successor === "") return null;
|
|
448
|
+
if (typeof graceUntil !== "number" || !Number.isFinite(graceUntil)) return null;
|
|
449
|
+
return { successor, graceUntil };
|
|
450
|
+
}
|
|
451
|
+
__name(readPayload, "readPayload");
|
|
452
|
+
|
|
453
|
+
// src/oauth/refresh.ts
|
|
454
|
+
import { createHash as createHash2, randomBytes as randomBytes3 } from "crypto";
|
|
455
|
+
|
|
456
|
+
// src/oauth/refresh-lineage.ts
|
|
457
|
+
function buildLineageIndex(family) {
|
|
458
|
+
const byHash = /* @__PURE__ */ new Map();
|
|
459
|
+
const childrenOf = /* @__PURE__ */ new Map();
|
|
460
|
+
for (const row of family) {
|
|
461
|
+
byHash.set(row.tokenHash, row);
|
|
462
|
+
if (!row.rotatedFrom) continue;
|
|
463
|
+
const siblings = childrenOf.get(row.rotatedFrom) ?? [];
|
|
464
|
+
siblings.push(row.tokenHash);
|
|
465
|
+
childrenOf.set(row.rotatedFrom, siblings);
|
|
466
|
+
}
|
|
467
|
+
return { byHash, childrenOf };
|
|
468
|
+
}
|
|
469
|
+
__name(buildLineageIndex, "buildLineageIndex");
|
|
470
|
+
function collectLineage(index, seedHash) {
|
|
471
|
+
const lineage = /* @__PURE__ */ new Set();
|
|
472
|
+
const queue = [seedHash];
|
|
473
|
+
while (queue.length > 0) {
|
|
474
|
+
const hash = queue.shift();
|
|
475
|
+
if (!hash || lineage.has(hash)) continue;
|
|
476
|
+
lineage.add(hash);
|
|
477
|
+
const parent = index.byHash.get(hash)?.rotatedFrom ?? null;
|
|
478
|
+
if (parent && !lineage.has(parent)) queue.push(parent);
|
|
479
|
+
const children = (index.childrenOf.get(hash) ?? []).filter((child) => !lineage.has(child));
|
|
480
|
+
queue.push(...children);
|
|
481
|
+
}
|
|
482
|
+
return lineage;
|
|
483
|
+
}
|
|
484
|
+
__name(collectLineage, "collectLineage");
|
|
485
|
+
async function revokeLineage(store, scopedTo, seedHash) {
|
|
486
|
+
const family = await store.listFamily(scopedTo.userEmail, scopedTo.clientId);
|
|
487
|
+
const lineage = collectLineage(buildLineageIndex(family), seedHash);
|
|
488
|
+
await store.revokeHashes([...lineage], /* @__PURE__ */ new Date());
|
|
489
|
+
}
|
|
490
|
+
__name(revokeLineage, "revokeLineage");
|
|
491
|
+
|
|
367
492
|
// src/oauth/refresh.ts
|
|
368
|
-
import { createHash as createHash2, randomBytes as randomBytes2 } from "crypto";
|
|
369
493
|
var REFRESH_TOKEN_BYTES = 32;
|
|
370
494
|
var REFRESH_TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1e3;
|
|
371
495
|
var RefreshTokenError = class extends Error {
|
|
@@ -384,9 +508,13 @@ function hashToken(token) {
|
|
|
384
508
|
}
|
|
385
509
|
__name(hashToken, "hashToken");
|
|
386
510
|
function generateToken() {
|
|
387
|
-
return
|
|
511
|
+
return randomBytes3(REFRESH_TOKEN_BYTES).toString("hex");
|
|
388
512
|
}
|
|
389
513
|
__name(generateToken, "generateToken");
|
|
514
|
+
function graceWindowMs(context) {
|
|
515
|
+
return context.graceMs ?? DEFAULT_ROTATION_GRACE_MS;
|
|
516
|
+
}
|
|
517
|
+
__name(graceWindowMs, "graceWindowMs");
|
|
390
518
|
function expiryOf(context) {
|
|
391
519
|
return new Date(Date.now() + (context.ttlMs ?? REFRESH_TOKEN_TTL_MS));
|
|
392
520
|
}
|
|
@@ -406,40 +534,6 @@ async function issueRefreshToken(context, binding) {
|
|
|
406
534
|
return { refreshToken, scopes: binding.scopes };
|
|
407
535
|
}
|
|
408
536
|
__name(issueRefreshToken, "issueRefreshToken");
|
|
409
|
-
function buildLineageIndex(family) {
|
|
410
|
-
const byHash = /* @__PURE__ */ new Map();
|
|
411
|
-
const childrenOf = /* @__PURE__ */ new Map();
|
|
412
|
-
for (const row of family) {
|
|
413
|
-
byHash.set(row.tokenHash, row);
|
|
414
|
-
if (!row.rotatedFrom) continue;
|
|
415
|
-
const siblings = childrenOf.get(row.rotatedFrom) ?? [];
|
|
416
|
-
siblings.push(row.tokenHash);
|
|
417
|
-
childrenOf.set(row.rotatedFrom, siblings);
|
|
418
|
-
}
|
|
419
|
-
return { byHash, childrenOf };
|
|
420
|
-
}
|
|
421
|
-
__name(buildLineageIndex, "buildLineageIndex");
|
|
422
|
-
function collectLineage(index, seedHash) {
|
|
423
|
-
const lineage = /* @__PURE__ */ new Set();
|
|
424
|
-
const queue = [seedHash];
|
|
425
|
-
while (queue.length > 0) {
|
|
426
|
-
const hash = queue.shift();
|
|
427
|
-
if (!hash || lineage.has(hash)) continue;
|
|
428
|
-
lineage.add(hash);
|
|
429
|
-
const parent = index.byHash.get(hash)?.rotatedFrom ?? null;
|
|
430
|
-
if (parent && !lineage.has(parent)) queue.push(parent);
|
|
431
|
-
const children = (index.childrenOf.get(hash) ?? []).filter((child) => !lineage.has(child));
|
|
432
|
-
queue.push(...children);
|
|
433
|
-
}
|
|
434
|
-
return lineage;
|
|
435
|
-
}
|
|
436
|
-
__name(collectLineage, "collectLineage");
|
|
437
|
-
async function revokeLineage(context, scopedTo, seedHash) {
|
|
438
|
-
const family = await context.store.listFamily(scopedTo.userEmail, scopedTo.clientId);
|
|
439
|
-
const lineage = collectLineage(buildLineageIndex(family), seedHash);
|
|
440
|
-
await context.store.revokeHashes([...lineage], /* @__PURE__ */ new Date());
|
|
441
|
-
}
|
|
442
|
-
__name(revokeLineage, "revokeLineage");
|
|
443
537
|
function narrowedScopes(current, requested) {
|
|
444
538
|
const scopes = requested ?? current.scopes;
|
|
445
539
|
const original = new Set(current.scopes);
|
|
@@ -454,6 +548,42 @@ function narrowedScopes(current, requested) {
|
|
|
454
548
|
return scopes;
|
|
455
549
|
}
|
|
456
550
|
__name(narrowedScopes, "narrowedScopes");
|
|
551
|
+
function sameScopes(left, right) {
|
|
552
|
+
const wanted = new Set(left);
|
|
553
|
+
const held = new Set(right);
|
|
554
|
+
if (wanted.size !== held.size) return false;
|
|
555
|
+
for (const scope of wanted) {
|
|
556
|
+
if (!held.has(scope)) return false;
|
|
557
|
+
}
|
|
558
|
+
return true;
|
|
559
|
+
}
|
|
560
|
+
__name(sameScopes, "sameScopes");
|
|
561
|
+
function retryableSuccessor(family, tokenHash, now) {
|
|
562
|
+
const successors = family.filter((row) => row.rotatedFrom === tokenHash);
|
|
563
|
+
if (successors.length !== 1) return null;
|
|
564
|
+
const [successor] = successors;
|
|
565
|
+
if (!successor?.graceSeal || successor.revokedAt) return null;
|
|
566
|
+
if (successor.expiresAt.getTime() <= now) return null;
|
|
567
|
+
return { seal: successor.graceSeal, scopes: successor.scopes };
|
|
568
|
+
}
|
|
569
|
+
__name(retryableSuccessor, "retryableSuccessor");
|
|
570
|
+
async function graceReissue(context, current, tokenHash, parentPlaintext, requestedScopes) {
|
|
571
|
+
if (graceWindowMs(context) <= 0) return null;
|
|
572
|
+
const now = Date.now();
|
|
573
|
+
const family = await context.store.listFamily(current.userEmail, current.clientId);
|
|
574
|
+
const target = retryableSuccessor(family, tokenHash, now);
|
|
575
|
+
if (!target) return null;
|
|
576
|
+
const opened = openSuccessor(parentPlaintext, target.seal);
|
|
577
|
+
if (!opened || opened.graceUntil <= now) return null;
|
|
578
|
+
if (requestedScopes && !sameScopes(requestedScopes, target.scopes)) {
|
|
579
|
+
throw new RefreshTokenError(
|
|
580
|
+
"invalid_scope",
|
|
581
|
+
"a retry inside the rotation grace window cannot change scope"
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
return { refreshToken: opened.successor, scopes: target.scopes };
|
|
585
|
+
}
|
|
586
|
+
__name(graceReissue, "graceReissue");
|
|
457
587
|
async function rotateRefreshToken(context, plaintext, expectedClientId, newScopes) {
|
|
458
588
|
const tokenHash = hashToken(plaintext);
|
|
459
589
|
const current = await context.store.findByHash(tokenHash);
|
|
@@ -470,10 +600,13 @@ async function rotateRefreshToken(context, plaintext, expectedClientId, newScope
|
|
|
470
600
|
throw new RefreshTokenError("invalid_grant", "refresh token expired");
|
|
471
601
|
}
|
|
472
602
|
if (current.revokedAt || await context.store.hasSuccessor(tokenHash)) {
|
|
603
|
+
const retried = await graceReissue(context, current, tokenHash, plaintext, newScopes);
|
|
604
|
+
if (retried) return retried;
|
|
473
605
|
await replay(context, current, tokenHash);
|
|
474
606
|
}
|
|
475
607
|
const scopes = narrowedScopes(current, newScopes);
|
|
476
608
|
const successorPlaintext = generateToken();
|
|
609
|
+
const grace = graceWindowMs(context);
|
|
477
610
|
const claimed = await context.store.rotate(
|
|
478
611
|
{
|
|
479
612
|
tokenHash: hashToken(successorPlaintext),
|
|
@@ -482,17 +615,26 @@ async function rotateRefreshToken(context, plaintext, expectedClientId, newScope
|
|
|
482
615
|
clientId: current.clientId,
|
|
483
616
|
scopes,
|
|
484
617
|
expiresAt: expiryOf(context),
|
|
485
|
-
rotatedFrom: tokenHash
|
|
618
|
+
rotatedFrom: tokenHash,
|
|
619
|
+
// Sealed under the PARENT the caller just presented, so a retry of this
|
|
620
|
+
// very rotation can be answered with this same token and nothing else can
|
|
621
|
+
// read it. Omitted entirely when the window is off, so the strict rule
|
|
622
|
+
// stores nothing extra.
|
|
623
|
+
graceSeal: grace > 0 ? sealSuccessor(plaintext, successorPlaintext, Date.now() + grace) : null
|
|
486
624
|
},
|
|
487
625
|
tokenHash,
|
|
488
626
|
/* @__PURE__ */ new Date()
|
|
489
627
|
);
|
|
490
|
-
if (!claimed)
|
|
628
|
+
if (!claimed) {
|
|
629
|
+
const retried = await graceReissue(context, current, tokenHash, plaintext, newScopes);
|
|
630
|
+
if (retried) return retried;
|
|
631
|
+
await replay(context, current, tokenHash);
|
|
632
|
+
}
|
|
491
633
|
return { refreshToken: successorPlaintext, scopes };
|
|
492
634
|
}
|
|
493
635
|
__name(rotateRefreshToken, "rotateRefreshToken");
|
|
494
636
|
async function replay(context, current, tokenHash) {
|
|
495
|
-
await revokeLineage(context, current, tokenHash);
|
|
637
|
+
await revokeLineage(context.store, current, tokenHash);
|
|
496
638
|
throw new RefreshTokenError(
|
|
497
639
|
"invalid_grant",
|
|
498
640
|
"refresh token already used (replay) \u2014 lineage revoked"
|
|
@@ -523,7 +665,8 @@ function resolveSurface(config) {
|
|
|
523
665
|
loginPath: config.loginPath ?? "/login",
|
|
524
666
|
loginCallbackParam: config.loginCallbackParam ?? "callbackUrl",
|
|
525
667
|
accessTokenTtlSeconds: config.accessTokenTtlSeconds ?? ACCESS_TOKEN_TTL_SECONDS,
|
|
526
|
-
refreshTokenTtlMs: config.refreshTokenTtlMs ?? REFRESH_TOKEN_TTL_MS
|
|
668
|
+
refreshTokenTtlMs: config.refreshTokenTtlMs ?? REFRESH_TOKEN_TTL_MS,
|
|
669
|
+
refreshRotationGraceMs: config.refreshRotationGraceMs ?? DEFAULT_ROTATION_GRACE_MS
|
|
527
670
|
};
|
|
528
671
|
}
|
|
529
672
|
__name(resolveSurface, "resolveSurface");
|
|
@@ -1053,7 +1196,8 @@ async function handleRefreshToken(context, form, credentials, origin) {
|
|
|
1053
1196
|
const newScopes = requestedScope ? requestedScope.split(/\s+/).filter(Boolean) : void 0;
|
|
1054
1197
|
const refreshContext = {
|
|
1055
1198
|
store: context.stores.refreshTokens,
|
|
1056
|
-
ttlMs: context.refreshTokenTtlMs
|
|
1199
|
+
ttlMs: context.refreshTokenTtlMs,
|
|
1200
|
+
graceMs: context.refreshRotationGraceMs
|
|
1057
1201
|
};
|
|
1058
1202
|
let rotated;
|
|
1059
1203
|
try {
|
|
@@ -1234,6 +1378,7 @@ export {
|
|
|
1234
1378
|
AccessTokenError,
|
|
1235
1379
|
signAccessToken,
|
|
1236
1380
|
verifyAccessToken,
|
|
1381
|
+
DEFAULT_ROTATION_GRACE_MS,
|
|
1237
1382
|
REFRESH_TOKEN_TTL_MS,
|
|
1238
1383
|
RefreshTokenError,
|
|
1239
1384
|
hashToken,
|
|
@@ -1244,4 +1389,4 @@ export {
|
|
|
1244
1389
|
resolveMcpOauthConfig,
|
|
1245
1390
|
createApiMcpOauth
|
|
1246
1391
|
};
|
|
1247
|
-
//# sourceMappingURL=chunk-
|
|
1392
|
+
//# sourceMappingURL=chunk-WCZC4TPX.js.map
|