@12-apps/mcp 1.19.0 → 2.0.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/src/guide.ts CHANGED
@@ -99,104 +99,125 @@ const CONNECTOR_TAIL: readonly string[] = [
99
99
  * stage 2 creates the connector and signs in — which registers the connection on
100
100
  * the store side, so no prompt needs to be pasted afterwards.
101
101
  */
102
- const CHATGPT_CONFIGURE_STAGES: readonly AiHostConfigureStage[] = [
103
- {
104
- id: "enable-dev-mode",
105
- label: "enable developer mode",
106
- link: {
107
- url: "https://chatgpt.com/plugins#settings/Security",
108
- label: "Abrir Segurança e login",
102
+ function chatgptConfigureStages(
103
+ platformName: string,
104
+ ): readonly AiHostConfigureStage[] {
105
+ return [
106
+ {
107
+ id: "enable-dev-mode",
108
+ label: "enable developer mode",
109
+ link: {
110
+ url: "https://chatgpt.com/plugins#settings/Security",
111
+ label: "Abrir Segurança e login",
112
+ },
113
+ steps: [
114
+ "Ative o Modo desenvolvedor em Settings › Security and login (Segurança e login).",
115
+ ],
109
116
  },
110
- steps: [
111
- "Ative o Modo desenvolvedor em Settings › Security and login (Segurança e login).",
112
- ],
113
- },
114
- {
115
- id: "configurar",
116
- label: "configurar",
117
- link: {
118
- url: "https://chatgpt.com/plugins#settings/Connectors?create-connector=true&redirectAfter=%2Fplugins",
119
- label: "Criar o conector",
117
+ {
118
+ id: "configurar",
119
+ label: "configurar",
120
+ link: {
121
+ url: "https://chatgpt.com/plugins#settings/Connectors?create-connector=true&redirectAfter=%2Fplugins",
122
+ label: "Criar o conector",
123
+ },
124
+ steps: [
125
+ "Isso vai abrir um popup para você criar um plugin novo. Coloque como nome o nome da sua loja e, no campo MCP, o link copiado no passo anterior.",
126
+ 'Marque a caixa "I understand and want to continue" — a OpenAI não revisou este servidor MCP; ela avisa que sites podem tentar roubar seus dados ou induzir o modelo a ações indevidas, incluindo destruir dados.',
127
+ `Clique em "Sign in with ${platformName}" e entre com a sua conta de lojista para autorizar o acesso. Pronto: a conexão é registrada automaticamente.`,
128
+ ],
120
129
  },
121
- steps: [
122
- "Isso vai abrir um popup para você criar um plugin novo. Coloque como nome o nome da sua loja e, no campo MCP, o link copiado no passo anterior.",
123
- 'Marque a caixa "I understand and want to continue" — a OpenAI não revisou este servidor MCP; ela avisa que sites podem tentar roubar seus dados ou induzir o modelo a ações indevidas, incluindo destruir dados.',
124
- 'Clique em "Sign in with Future Drink" e entre com a sua conta de lojista para autorizar o acesso. Pronto: a conexão é registrada automaticamente.',
125
- ],
126
- },
127
- ];
130
+ ];
131
+ }
128
132
 
129
133
  /**
130
134
  * The AI hosts a store owner can connect, in recommended order. Same OAuth flow
131
135
  * everywhere (the host drives it) — only the menu path differs per app.
136
+ *
137
+ * A FUNCTION of the platform's name, because one step is not generic: the
138
+ * ChatGPT connector's consent screen shows an OAuth button labelled with
139
+ * whoever operates the server, and the owner is told which button to click. It
140
+ * used to name one particular STORE on one particular deployment — not even the
141
+ * product, a tenant of it — so every other adopter instructed its owners to
142
+ * click a button that does not exist.
132
143
  */
133
- export const AI_HOST_GUIDES: readonly AiHostGuide[] = [
134
- {
135
- id: "claude",
136
- label: "Claude.ai",
137
- brand: "claude",
138
- kind: "No navegador",
139
- link: {
140
- url: "https://claude.ai/new?modal=add-custom-connector#settings/customize-connectors",
141
- label: "Abrir os conectores do Claude",
142
- },
143
- docs: {
144
- url: "https://support.anthropic.com/en/articles/11175166-how-do-i-connect-mcp-servers-to-claude-ai",
145
- label: "documentação oficial da Anthropic — conectores personalizados",
144
+ export function aiHostGuides(platformName: string): readonly AiHostGuide[] {
145
+ const chatgptStages = chatgptConfigureStages(platformName);
146
+ return [
147
+ {
148
+ id: "claude",
149
+ label: "Claude.ai",
150
+ brand: "claude",
151
+ kind: "No navegador",
152
+ link: {
153
+ url: "https://claude.ai/new?modal=add-custom-connector#settings/customize-connectors",
154
+ label: "Abrir os conectores do Claude",
155
+ },
156
+ docs: {
157
+ url: "https://support.anthropic.com/en/articles/11175166-how-do-i-connect-mcp-servers-to-claude-ai",
158
+ label: "documentação oficial da Anthropic — conectores personalizados",
159
+ },
160
+ steps: [
161
+ "Clique no botão acima (ou vá em Settings › Customize › Connectors) e escolha Add custom connector.",
162
+ "Dê um nome ao conector (ex.: o nome da sua loja) e cole a URL do servidor MCP da sua loja (copie acima) no campo de URL.",
163
+ ...CONNECTOR_TAIL,
164
+ ],
146
165
  },
147
- steps: [
148
- "Clique no botão acima (ou vá em Settings › Customize › Connectors) e escolha Add custom connector.",
149
- " um nome ao conector (ex.: o nome da sua loja) e cole a URL do servidor MCP da sua loja (copie acima) no campo de URL.",
150
- ...CONNECTOR_TAIL,
151
- ],
152
- },
153
- {
154
- id: "claude-desktop",
155
- label: "Claude Desktop",
156
- brand: "claude",
157
- kind: "Aplicativo (Windows/Mac)",
158
- docs: {
159
- url: "https://support.anthropic.com/en/articles/11175166-how-do-i-connect-mcp-servers-to-claude-ai",
160
- label: "documentação oficial da Anthropic — conectores personalizados",
166
+ {
167
+ id: "claude-desktop",
168
+ label: "Claude Desktop",
169
+ brand: "claude",
170
+ kind: "Aplicativo (Windows/Mac)",
171
+ docs: {
172
+ url: "https://support.anthropic.com/en/articles/11175166-how-do-i-connect-mcp-servers-to-claude-ai",
173
+ label: "documentação oficial da Anthropic — conectores personalizados",
174
+ },
175
+ steps: [
176
+ "Abra o Claude Desktop e vá em Settings (⚙️) › Connectors.",
177
+ "Clique em Add custom connector e cole a URL do servidor MCP da sua loja (copie acima).",
178
+ ...CONNECTOR_TAIL,
179
+ ],
161
180
  },
162
- steps: [
163
- "Abra o Claude Desktop e vá em Settings (⚙️) › Connectors.",
164
- "Clique em Add custom connector e cole a URL do servidor MCP da sua loja (copie acima).",
165
- ...CONNECTOR_TAIL,
166
- ],
167
- },
168
- {
169
- id: "chatgpt",
170
- label: "ChatGPT",
171
- brand: "openai",
172
- kind: "No navegador",
173
- link: { url: "https://chatgpt.com/plugins", label: "Abrir os plugins do ChatGPT" },
174
- docs: {
175
- url: "https://developers.openai.com/apps-sdk/deploy/connect-chatgpt",
176
- label: "documentação oficial da OpenAI — conectar um servidor MCP ao ChatGPT",
181
+ {
182
+ id: "chatgpt",
183
+ label: "ChatGPT",
184
+ brand: "openai",
185
+ kind: "No navegador",
186
+ link: {
187
+ url: "https://chatgpt.com/plugins",
188
+ label: "Abrir os plugins do ChatGPT",
189
+ },
190
+ docs: {
191
+ url: "https://developers.openai.com/apps-sdk/deploy/connect-chatgpt",
192
+ label:
193
+ "documentação oficial da OpenAI — conectar um servidor MCP ao ChatGPT",
194
+ },
195
+ configureStages: chatgptStages,
196
+ // Mirrors the flattened stage instructions so the MCP connect guide
197
+ // (`connectToChatGpt`) can never drift from what owners see in the wizard.
198
+ steps: chatgptStages.flatMap((stage) => stage.steps),
177
199
  },
178
- configureStages: CHATGPT_CONFIGURE_STAGES,
179
- // Mirrors the flattened stage instructions so the MCP connect guide
180
- // (`connectToChatGpt`) can never drift from what owners see in the wizard.
181
- steps: CHATGPT_CONFIGURE_STAGES.flatMap((stage) => stage.steps),
182
- },
183
- {
184
- id: "codex",
185
- label: "Codex",
186
- brand: "openai",
187
- kind: "App / CLI de desenvolvedor",
188
- link: { url: "https://developers.openai.com/codex", label: "Documentação do Codex" },
189
- docs: {
190
- url: "https://developers.openai.com/apps-sdk/deploy/connect-chatgpt",
191
- label: "documentação oficial da OpenAI — conectar um servidor MCP",
200
+ {
201
+ id: "codex",
202
+ label: "Codex",
203
+ brand: "openai",
204
+ kind: "App / CLI de desenvolvedor",
205
+ link: {
206
+ url: "https://developers.openai.com/codex",
207
+ label: "Documentação do Codex",
208
+ },
209
+ docs: {
210
+ url: "https://developers.openai.com/apps-sdk/deploy/connect-chatgpt",
211
+ label: "documentação oficial da OpenAI — conectar um servidor MCP",
212
+ },
213
+ steps: [
214
+ "No Codex, abra as configurações de MCP (Settings › MCP no app, ou o arquivo de configuração na CLI).",
215
+ "Adicione um servidor MCP e cole a URL do servidor MCP da sua loja (copie acima) como um conector remoto (HTTP).",
216
+ ...CONNECTOR_TAIL,
217
+ ],
192
218
  },
193
- steps: [
194
- "No Codex, abra as configurações de MCP (Settings › MCP no app, ou o arquivo de configuração na CLI).",
195
- "Adicione um servidor MCP e cole a URL do servidor MCP da sua loja (copie acima) como um conector remoto (HTTP).",
196
- ...CONNECTOR_TAIL,
197
- ],
198
- },
199
- ];
219
+ ];
220
+ }
200
221
 
201
222
  export interface AiCapability {
202
223
  /** Stable id — maps to an icon in the component. */
@@ -217,7 +238,8 @@ export const AI_CAPABILITIES: readonly AiCapability[] = [
217
238
  {
218
239
  id: "inventory",
219
240
  title: "Controle o estoque",
220
- detail: '"Quanto ainda tenho do produto X? Registre a entrada de 20 unidades."',
241
+ detail:
242
+ '"Quanto ainda tenho do produto X? Registre a entrada de 20 unidades."',
221
243
  },
222
244
  {
223
245
  id: "catalog",
@@ -239,15 +261,39 @@ export const AI_CAPABILITIES: readonly AiCapability[] = [
239
261
  export const AI_PERMISSION_MODEL =
240
262
  "O assistente age em seu nome, com exatamente as suas permissões: ele pode fazer o que você pode fazer na sua loja — nada além disso. Não é preciso criar nenhuma chave ou credencial extra; a autorização usa o seu próprio login.";
241
263
 
264
+ /** The two tools the paste-in prompt drives, and what to call the store id. */
265
+ export interface AiConnectPromptSpec {
266
+ /**
267
+ * The tool that REGISTERS the connection server-side, so the store learns
268
+ * which assistant connected.
269
+ */
270
+ announceTool: string;
271
+ /** A real READ tool, called straight after, to prove the access works. */
272
+ probeTool: string;
273
+ /** What that read returns, in the owner's own words ("o estoque da loja"). */
274
+ probeSubject: string;
275
+ /** What the assistant should ask for if it needs to identify the store. */
276
+ identifierName: string;
277
+ }
278
+
242
279
  /**
243
- * The message the owner pastes into the assistant's chat right after connecting.
244
- * It makes the assistant (1) call `announceAiConnection` reporting which host it
245
- * is (chatgpt / claude / codex) — the tool that registers the connection on the
246
- * server side so the store learns which assistant connected — and (2) call a
247
- * real read tool to confirm access. The owner types nothing.
280
+ * The message the owner pastes into the assistant's chat right after
281
+ * connecting: announce the connection, then read something real to prove it
282
+ * works. The owner types nothing.
283
+ *
284
+ * BUILT from the host's tool names rather than shipped with them. This was a
285
+ * constant naming two tools — `announceAiConnection` and `listInventory` — that
286
+ * THIS PACKAGE does not define or serve; they belong to one adopter's surface.
287
+ * Any other host handed its owner a prompt instructing the assistant to call
288
+ * two tools that do not exist, and because nothing registered the connection,
289
+ * the wizard's confirm step then waited forever for a state that could never
290
+ * arrive.
248
291
  */
249
- export const AI_CONNECT_PROMPT =
250
- "Você agora tem acesso ao conector MCP da minha loja. Faça, nesta ordem:\n" +
251
- '1) Execute a ferramenta announceAiConnection informando qual assistente você é (host: "chatgpt", "claude" ou "codex") para registrar a conexão com a minha loja.\n' +
252
- "2) Execute a ferramenta listInventory para confirmar o acesso ao estoque da minha loja.\n" +
253
- "Se precisar do identificador da loja, me pergunte o tenantSlug.";
292
+ export function aiConnectPrompt(spec: AiConnectPromptSpec): string {
293
+ return (
294
+ "Você agora tem acesso ao conector MCP da minha loja. Faça, nesta ordem:\n" +
295
+ `1) Execute a ferramenta ${spec.announceTool} informando qual assistente você é (host: "chatgpt", "claude" ou "codex") para registrar a conexão com a minha loja.\n` +
296
+ `2) Execute a ferramenta ${spec.probeTool} para confirmar o acesso a ${spec.probeSubject}.\n` +
297
+ `Se precisar do identificador da loja, me pergunte o ${spec.identifierName}.`
298
+ );
299
+ }
@@ -0,0 +1,43 @@
1
+ import { Hono } from "hono";
2
+ import type { Context } from "hono";
3
+
4
+ import {
5
+ createApiMcpOauth,
6
+ type ApiMcpOauth,
7
+ } from "../oauth/create-api-mcp-oauth";
8
+ import type { McpOauthConfig } from "../oauth/context";
9
+
10
+ /**
11
+ * `@12-apps/mcp/hono` — the OAuth authorization server as a mountable router.
12
+ *
13
+ * Behind its own subpath with `hono` as an OPTIONAL peer (the report-builder / rbac
14
+ * precedent), so a host on another framework — or one importing only the tool
15
+ * generator — never resolves it.
16
+ *
17
+ * Mounted at the ORIGIN ROOT, because two of the six paths are `.well-known`
18
+ * documents and a connector reads them from the origin, not from a prefix:
19
+ *
20
+ * const oauth = mcpOauthRouter({ stores, resolveSession });
21
+ * app.route('/', oauth.router);
22
+ *
23
+ * The adapter is deliberately thin: each handler already answers a Fetch `Response`
24
+ * (a 302 with a `Location`, RFC 6749 JSON, an RFC 8414 document), and those shapes
25
+ * are fixed by specification — there is no envelope to apply, and applying one
26
+ * would break every client.
27
+ */
28
+ export interface McpOauthHono extends ApiMcpOauth {
29
+ router: Hono;
30
+ }
31
+
32
+ export function mcpOauthRouter(config: McpOauthConfig): McpOauthHono {
33
+ const api = createApiMcpOauth(config);
34
+ const router = new Hono();
35
+
36
+ for (const route of api.routes) {
37
+ const handler = (c: Context): Promise<Response> => route.handle(c.req.raw);
38
+ if (route.method === "GET") router.get(route.path, handler);
39
+ else router.post(route.path, handler);
40
+ }
41
+
42
+ return { ...api, router };
43
+ }
package/src/index.ts CHANGED
@@ -13,10 +13,11 @@ export * from "./types";
13
13
  // truth for BOTH the `@12-apps/mcp/react` onboarding UI and the server-side connect
14
14
  // tools, so what an agent reads via MCP cannot drift from what owners see.
15
15
  export {
16
- AI_HOST_GUIDES,
16
+ aiHostGuides,
17
17
  AI_CAPABILITIES,
18
18
  AI_PERMISSION_MODEL,
19
- AI_CONNECT_PROMPT,
19
+ aiConnectPrompt,
20
+ type AiConnectPromptSpec,
20
21
  providerForHostId,
21
22
  type AiHostBrand,
22
23
  type AiHostLink,
@@ -73,4 +74,5 @@ export {
73
74
  buildAuthorizationServerMetadata,
74
75
  type AuthorizationServerMetadata,
75
76
  type AuthorizationServerMetadataInput,
77
+ type AuthorizationServerPaths,
76
78
  } from "./auth/authorization-server-metadata";
@@ -0,0 +1,186 @@
1
+ import { SignJWT, jwtVerify, importJWK, type JWTPayload } from "jose";
2
+
3
+ import { issuer, resourceAudience, DEFAULT_MCP_RESOURCE_PATH, type McpScope } from "./config";
4
+ import { SIGNING_ALG, type McpSigningKeyProvider } from "./keys";
5
+
6
+ /**
7
+ * JWT access-token issuer + verifier (12-23, ported from future-pay's
8
+ * `lib/mcp/oauth/jwt.ts` — behaviour unchanged; the signing key arrives through a
9
+ * provider and the resource path is config).
10
+ *
11
+ * The access token is a short-lived, ES256-signed JWT bound to the signed-in
12
+ * user. It carries the claims the resource server checks LOCALLY against the
13
+ * published JWKS (no introspection round-trip): `iss` (the issuer origin), `aud`
14
+ * (`${origin}${resourcePath}`), `sub`, `email`, `scope` (space-delimited), `iat`,
15
+ * `exp` (short TTL), and `jti`; the JWT header carries `kid` so the verifier can
16
+ * select the public key during rotation.
17
+ *
18
+ * Failures are typed so the caller maps them to the right OAuth challenge
19
+ * (`invalid_token` vs `insufficient_scope`).
20
+ */
21
+
22
+ /** Access-token lifetime — short-lived (15 min) per the spec. */
23
+ export const ACCESS_TOKEN_TTL_SECONDS = 15 * 60;
24
+
25
+ /** Clock skew tolerated on `exp`/`iat` validation, in seconds. */
26
+ const CLOCK_TOLERANCE_SECONDS = 5;
27
+
28
+ /** The identity a verified access token resolves to. */
29
+ export interface VerifiedAccessToken {
30
+ email: string;
31
+ subject: string;
32
+ scopes: string[];
33
+ }
34
+
35
+ /** Distinct verification failure reasons the caller maps to OAuth challenges. */
36
+ export type AccessTokenErrorCode = "invalid_token" | "insufficient_scope";
37
+
38
+ /** A typed verification failure — `code` drives the `WWW-Authenticate` challenge. */
39
+ export class AccessTokenError extends Error {
40
+ readonly code: AccessTokenErrorCode;
41
+
42
+ constructor(code: AccessTokenErrorCode, message?: string) {
43
+ super(message ?? code);
44
+ this.name = "AccessTokenError";
45
+ this.code = code;
46
+ }
47
+ }
48
+
49
+ /** Inputs bound into a minted access token. */
50
+ export interface SignAccessTokenInput {
51
+ email: string;
52
+ subject: string;
53
+ scopes: readonly McpScope[] | readonly string[];
54
+ origin: string;
55
+ /** Where the MCP resource is mounted. Default `/api/mcp`. */
56
+ resourcePath?: string;
57
+ /** Token lifetime in seconds. Default 15 minutes. */
58
+ ttlSeconds?: number;
59
+ }
60
+
61
+ /** Deterministic-clock option shared by mint + verify. */
62
+ interface ClockOption {
63
+ /** Epoch milliseconds; defaults to `Date.now()`. Injected for deterministic tests. */
64
+ now?: number;
65
+ }
66
+
67
+ /** The full access-token claim set (beyond the registered JWT claims). */
68
+ interface AccessTokenClaims extends JWTPayload {
69
+ email: string;
70
+ scope: string;
71
+ }
72
+
73
+ function nowSeconds(now?: number): number {
74
+ return Math.floor((now ?? Date.now()) / 1000);
75
+ }
76
+
77
+ /**
78
+ * Mint an ES256-signed access token bound to the user.
79
+ *
80
+ * Returns `null` when no signing key is configured (safe-by-default: the AS
81
+ * refuses to issue rather than falling back to a weaker mode). Sets the `kid`
82
+ * header from the loaded key so the verifier can resolve the public JWK during
83
+ * rotation.
84
+ */
85
+ export async function signAccessToken(
86
+ loadSigningKey: McpSigningKeyProvider,
87
+ input: SignAccessTokenInput,
88
+ options?: ClockOption,
89
+ ): Promise<string | null> {
90
+ const key = await loadSigningKey();
91
+ if (!key) return null;
92
+
93
+ const iat = nowSeconds(options?.now);
94
+ const exp = iat + (input.ttlSeconds ?? ACCESS_TOKEN_TTL_SECONDS);
95
+ const scope = input.scopes.join(" ");
96
+
97
+ return new SignJWT({ email: input.email, scope } satisfies AccessTokenClaims)
98
+ .setProtectedHeader({ alg: SIGNING_ALG, kid: key.kid })
99
+ .setIssuer(issuer(input.origin))
100
+ .setAudience(resourceAudience(input.origin, input.resourcePath ?? DEFAULT_MCP_RESOURCE_PATH))
101
+ .setSubject(input.subject)
102
+ .setIssuedAt(iat)
103
+ .setExpirationTime(exp)
104
+ .setJti(crypto.randomUUID())
105
+ .sign(key.privateKey);
106
+ }
107
+
108
+ /** Options for {@link verifyAccessToken}. */
109
+ export interface VerifyAccessTokenOptions extends ClockOption {
110
+ /** The deployment origin — derives the expected `iss` and `aud`. */
111
+ origin: string;
112
+ /** Where the MCP resource is mounted. Default `/api/mcp`. */
113
+ resourcePath?: string;
114
+ /** When set, the token must carry this scope or verification fails `insufficient_scope`. */
115
+ requiredScope?: McpScope | string;
116
+ }
117
+
118
+ /** Parse the space-delimited `scope` claim into a de-duplicated string array. */
119
+ function parseScopes(scope: unknown): string[] {
120
+ if (typeof scope !== "string" || scope.trim() === "") return [];
121
+ return [...new Set(scope.trim().split(/\s+/))];
122
+ }
123
+
124
+ /**
125
+ * Verify a bearer access token locally against the published JWKS public key.
126
+ *
127
+ * Checks signature (via the public JWK selected by the token's `kid`), `iss`,
128
+ * `aud`, and `exp`. When `requiredScope` is supplied, also enforces scope. On
129
+ * success returns `{ email, subject, scopes }`; on failure throws an
130
+ * {@link AccessTokenError} whose `code` distinguishes `invalid_token` (bad
131
+ * signature / wrong issuer / wrong audience / expired / malformed / unconfigured
132
+ * key) from `insufficient_scope` (a valid token lacking the required scope).
133
+ */
134
+ /**
135
+ * The cryptographic half: signature, `iss`, `aud`, `exp`.
136
+ *
137
+ * Every jose failure — bad signature, wrong issuer, wrong audience, expiry,
138
+ * malformed token, unknown key — collapses into ONE opaque `invalid_token`. A
139
+ * message naming the failed claim would be an oracle for the next attempt.
140
+ */
141
+ async function verifiedPayload(
142
+ loadSigningKey: McpSigningKeyProvider,
143
+ token: string,
144
+ options: VerifyAccessTokenOptions,
145
+ ): Promise<JWTPayload> {
146
+ const key = await loadSigningKey();
147
+ // No signing key configured → nothing can verify (safe-by-default).
148
+ if (!key) throw new AccessTokenError("invalid_token", "no signing key configured");
149
+
150
+ try {
151
+ const { payload } = await jwtVerify(token, await importJWK(key.publicJwk, SIGNING_ALG), {
152
+ algorithms: [SIGNING_ALG],
153
+ issuer: issuer(options.origin),
154
+ audience: resourceAudience(options.origin, options.resourcePath ?? DEFAULT_MCP_RESOURCE_PATH),
155
+ clockTolerance: CLOCK_TOLERANCE_SECONDS,
156
+ currentDate: options.now === undefined ? undefined : new Date(options.now),
157
+ });
158
+ return payload;
159
+ } catch {
160
+ throw new AccessTokenError("invalid_token", "token verification failed");
161
+ }
162
+ }
163
+
164
+ export async function verifyAccessToken(
165
+ loadSigningKey: McpSigningKeyProvider,
166
+ token: string,
167
+ options: VerifyAccessTokenOptions,
168
+ ): Promise<VerifiedAccessToken> {
169
+ const payload = await verifiedPayload(loadSigningKey, token, options);
170
+
171
+ const email = typeof payload.email === "string" ? payload.email : null;
172
+ const subject = typeof payload.sub === "string" ? payload.sub : null;
173
+ if (!email || !subject) {
174
+ throw new AccessTokenError("invalid_token", "missing subject or email claim");
175
+ }
176
+
177
+ const scopes = parseScopes(payload.scope);
178
+ if (options.requiredScope && !scopes.includes(options.requiredScope)) {
179
+ throw new AccessTokenError(
180
+ "insufficient_scope",
181
+ `token lacks required scope '${options.requiredScope}'`,
182
+ );
183
+ }
184
+
185
+ return { email, subject, scopes };
186
+ }