From aba8e2a697eb854fd0eb09361788e7bfe1d25125 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 18:58:58 +0000 Subject: [PATCH 1/3] fix(auth): treat null optional fields in token responses as absent Some OAuth authorization servers serialize absent optional members as JSON null (e.g. "refresh_token": null). RFC 6749 does not sanction null values, but such servers exist in the wild, and OAuthTokensSchema previously rejected these responses (null string fields failed validation) or mishandled them (expires_in: null coerced to 0 via Number(null), yielding an instantly-expired token). This broke token exchange and refresh in exchangeAuthorization/refreshAuthorization. Normalize null-valued optional members (id_token, expires_in, scope, refresh_token) to absent in a preprocess step before validation. The inferred OAuthTokens output type is unchanged, and null members are truly absent from the parsed output (not present as undefined), so spreads that preserve a previous refresh_token keep working. Related: #754 (same null-emitting-server pattern in the registration response schema). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KXnNnp3fxQYR5HF9BUVhYP --- src/shared/auth.ts | 41 +++++++++++---- test/shared/auth.test.ts | 106 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 137 insertions(+), 10 deletions(-) diff --git a/src/shared/auth.ts b/src/shared/auth.ts index c546c8608b..f9d44d4c65 100644 --- a/src/shared/auth.ts +++ b/src/shared/auth.ts @@ -126,17 +126,38 @@ export const OpenIdProviderDiscoveryMetadataSchema = z.object({ /** * OAuth 2.1 token response + * + * Some authorization servers serialize absent optional members as JSON null + * (e.g. `"refresh_token": null`). RFC 6749 does not sanction null values, but + * to interoperate with such servers, null-valued optional members are + * normalized to absent before validation. The inferred output type is + * unchanged. Without this, `expires_in: null` would coerce to 0 (an + * instantly-expired token) and null string members would fail validation. */ -export const OAuthTokensSchema = z - .object({ - access_token: z.string(), - id_token: z.string().optional(), // Optional for OAuth 2.1, but necessary in OpenID Connect - token_type: z.string(), - expires_in: z.coerce.number().optional(), - scope: z.string().optional(), - refresh_token: z.string().optional() - }) - .strip(); +export const OAuthTokensSchema = z.preprocess( + data => { + if (data && typeof data === 'object' && !Array.isArray(data)) { + const normalized: Record = { ...data }; + for (const key of ['id_token', 'expires_in', 'scope', 'refresh_token']) { + if (normalized[key] === null) { + delete normalized[key]; + } + } + return normalized; + } + return data; + }, + z + .object({ + access_token: z.string(), + id_token: z.string().optional(), // Optional for OAuth 2.1, but necessary in OpenID Connect + token_type: z.string(), + expires_in: z.coerce.number().optional(), + scope: z.string().optional(), + refresh_token: z.string().optional() + }) + .strip() +); /** * OAuth 2.1 error response diff --git a/test/shared/auth.test.ts b/test/shared/auth.test.ts index c4ecab59d5..01ab1ddf32 100644 --- a/test/shared/auth.test.ts +++ b/test/shared/auth.test.ts @@ -3,6 +3,7 @@ import { OAuthMetadataSchema, OpenIdProviderMetadataSchema, OAuthClientMetadataSchema, + OAuthTokensSchema, OptionalSafeUrlSchema } from '../../src/shared/auth.js'; @@ -100,6 +101,111 @@ describe('OpenIdProviderMetadataSchema', () => { }); }); +describe('OAuthTokensSchema', () => { + it('round-trips a fully-populated token response', () => { + const tokens = OAuthTokensSchema.parse({ + access_token: 'access-123', + id_token: 'id-456', + token_type: 'Bearer', + expires_in: 3600, + scope: 'read write', + refresh_token: 'refresh-789' + }); + + expect(tokens).toEqual({ + access_token: 'access-123', + id_token: 'id-456', + token_type: 'Bearer', + expires_in: 3600, + scope: 'read write', + refresh_token: 'refresh-789' + }); + }); + + it('accepts a token response with optional fields absent', () => { + const tokens = OAuthTokensSchema.parse({ + access_token: 'access-123', + token_type: 'Bearer' + }); + + expect(tokens.access_token).toBe('access-123'); + expect(tokens.token_type).toBe('Bearer'); + expect(tokens.id_token).toBeUndefined(); + expect(tokens.expires_in).toBeUndefined(); + expect(tokens.scope).toBeUndefined(); + expect(tokens.refresh_token).toBeUndefined(); + }); + + it('treats null refresh_token as absent', () => { + const tokens = OAuthTokensSchema.parse({ + access_token: 'access-123', + token_type: 'Bearer', + refresh_token: null + }); + + expect(tokens.refresh_token).toBeUndefined(); + }); + + it('treats null scope as absent', () => { + const tokens = OAuthTokensSchema.parse({ + access_token: 'access-123', + token_type: 'Bearer', + scope: null + }); + + expect(tokens.scope).toBeUndefined(); + }); + + it('treats null id_token as absent', () => { + const tokens = OAuthTokensSchema.parse({ + access_token: 'access-123', + token_type: 'Bearer', + id_token: null + }); + + expect(tokens.id_token).toBeUndefined(); + }); + + it('treats null expires_in as absent instead of coercing it to 0', () => { + const tokens = OAuthTokensSchema.parse({ + access_token: 'access-123', + token_type: 'Bearer', + expires_in: null + }); + + expect(tokens.expires_in).toBeUndefined(); + expect(tokens.expires_in).not.toBe(0); + }); + + it('accepts a token response with all optional fields null', () => { + const tokens = OAuthTokensSchema.parse({ + access_token: 'access-123', + token_type: 'Bearer', + id_token: null, + expires_in: null, + scope: null, + refresh_token: null + }); + + // toStrictEqual: the null members must be truly absent from the + // output, not present with an undefined value, so that spreads like + // `{ refresh_token: previous, ...tokens }` keep the previous value. + expect(tokens).toStrictEqual({ + access_token: 'access-123', + token_type: 'Bearer' + }); + }); + + it('still rejects a null access_token', () => { + expect(() => + OAuthTokensSchema.parse({ + access_token: null, + token_type: 'Bearer' + }) + ).toThrow(); + }); +}); + describe('OAuthClientMetadataSchema', () => { it('validates client metadata with safe URLs', () => { const metadata = { From 08ce3d249af39bd581b2641ce92dd351f6879cc9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 20:48:08 +0000 Subject: [PATCH 2/3] fix(auth): normalize null token-response fields at parse sites, not in OAuthTokensSchema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rework of the previous commit after review: wrapping the exported OAuthTokensSchema in z.preprocess changed its class (ZodObject -> ZodEffects/ZodPipe), breaking downstream .shape/.extend users on the stable 1.x line. - Revert OAuthTokensSchema to its original plain object definition (byte-identical to v1.x; class, .shape, .extend, z.input and emitted d.ts verified unchanged on zod 3.25.x and 4.x). - Add OAuthTokenResponseSchema, a z.preprocess wrapper that normalizes JSON null values in optional members to absent before validation (RFC 6749 §5.1 does not sanction nulls; expires_in: null must never reach z.coerce.number(), which would coerce it to 0). Optional keys are derived from the schema shape, not hardcoded, so future optional members are covered automatically. - Use the new schema at the SDK-owned parse sites: executeTokenRequest (client) and ProxyOAuthServerProvider token exchange/refresh. - Harden refreshAuthorization's refresh-token preservation: { ...tokens, refresh_token: tokens.refresh_token ?? refreshToken } so preservation holds regardless of key presence. - Tests: strict key-absence assertions, string expires_in coercion, null access_token / missing token_type rejection, a regression test pinning OAuthTokensSchema's class and strictness, a drift guard over every optional shape member, and e2e exchangeAuthorization / refreshAuthorization coverage (including refresh_token: null preserving the original refresh token). - Add the missing changeset (patch). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KXnNnp3fxQYR5HF9BUVhYP --- ...uth-token-response-null-optional-fields.md | 7 ++ src/client/auth.ts | 6 +- src/server/auth/providers/proxyProvider.ts | 6 +- src/shared/auth.ts | 62 +++++++------ test/client/auth.test.ts | 54 +++++++++++ test/shared/auth.test.ts | 93 ++++++++++++++----- 6 files changed, 173 insertions(+), 55 deletions(-) create mode 100644 .changeset/oauth-token-response-null-optional-fields.md diff --git a/.changeset/oauth-token-response-null-optional-fields.md b/.changeset/oauth-token-response-null-optional-fields.md new file mode 100644 index 0000000000..044bb8b707 --- /dev/null +++ b/.changeset/oauth-token-response-null-optional-fields.md @@ -0,0 +1,7 @@ +--- +'@modelcontextprotocol/sdk': patch +--- + +The client (and the proxy server provider) now normalize JSON `null` values in optional members of OAuth token responses to absent before validation, via a new exported `OAuthTokenResponseSchema` used at the SDK's own parse sites. Some authorization servers serialize absent +optional members as `null` (nonconformant with RFC 6749 §5.1); previously such responses failed validation (`refresh_token`, `scope`, `id_token`) or coerced `expires_in: null` to `0`, an instantly-expired token. The exported `OAuthTokensSchema` is unchanged. +`refreshAuthorization` now also preserves the previous refresh token whenever the response does not carry a new one, regardless of how the `refresh_token` key is serialized. diff --git a/src/client/auth.ts b/src/client/auth.ts index 85398340b0..617fd4bf29 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -16,7 +16,7 @@ import { OAuthClientInformationFullSchema, OAuthMetadataSchema, OAuthProtectedResourceMetadataSchema, - OAuthTokensSchema + OAuthTokenResponseSchema } from '../shared/auth.js'; import { checkResourceAllowed, resourceUrlFromServerUrl } from '../shared/auth-utils.js'; import { @@ -1255,7 +1255,7 @@ async function executeTokenRequest( throw await parseErrorResponse(response); } - return OAuthTokensSchema.parse(await response.json()); + return OAuthTokenResponseSchema.parse(await response.json()); } /** @@ -1349,7 +1349,7 @@ export async function refreshAuthorization( }); // Preserve original refresh token if server didn't return a new one - return { refresh_token: refreshToken, ...tokens }; + return { ...tokens, refresh_token: tokens.refresh_token ?? refreshToken }; } /** diff --git a/src/server/auth/providers/proxyProvider.ts b/src/server/auth/providers/proxyProvider.ts index 855856c89e..9b9129be5b 100644 --- a/src/server/auth/providers/proxyProvider.ts +++ b/src/server/auth/providers/proxyProvider.ts @@ -5,7 +5,7 @@ import { OAuthClientInformationFullSchema, OAuthTokenRevocationRequest, OAuthTokens, - OAuthTokensSchema + OAuthTokenResponseSchema } from '../../../shared/auth.js'; import { AuthInfo } from '../types.js'; import { AuthorizationParams, OAuthServerProvider } from '../provider.js'; @@ -188,7 +188,7 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider { } const data = await response.json(); - return OAuthTokensSchema.parse(data); + return OAuthTokenResponseSchema.parse(data); } async exchangeRefreshToken( @@ -229,7 +229,7 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider { } const data = await response.json(); - return OAuthTokensSchema.parse(data); + return OAuthTokenResponseSchema.parse(data); } async verifyAccessToken(token: string): Promise { diff --git a/src/shared/auth.ts b/src/shared/auth.ts index f9d44d4c65..08c9ccfce9 100644 --- a/src/shared/auth.ts +++ b/src/shared/auth.ts @@ -126,38 +126,44 @@ export const OpenIdProviderDiscoveryMetadataSchema = z.object({ /** * OAuth 2.1 token response + */ +export const OAuthTokensSchema = z + .object({ + access_token: z.string(), + id_token: z.string().optional(), // Optional for OAuth 2.1, but necessary in OpenID Connect + token_type: z.string(), + expires_in: z.coerce.number().optional(), + scope: z.string().optional(), + refresh_token: z.string().optional() + }) + .strip(); + +/** + * Schema for parsing OAuth 2.1 token responses received from an authorization + * server. * * Some authorization servers serialize absent optional members as JSON null - * (e.g. `"refresh_token": null`). RFC 6749 does not sanction null values, but - * to interoperate with such servers, null-valued optional members are - * normalized to absent before validation. The inferred output type is - * unchanged. Without this, `expires_in: null` would coerce to 0 (an - * instantly-expired token) and null string members would fail validation. + * (e.g. `"refresh_token": null`), which is nonconformant with RFC 6749 §5.1. + * We normalize null to undefined for leniency: null values in optional + * members are normalized to absent before validation, so that (a) otherwise + * valid responses from such servers parse, and (b) `expires_in: null` never + * reaches `z.coerce.number()`, which would coerce it to 0 — an + * instantly-expired token. Null values in required members are still + * rejected, non-object input is passed through unchanged, and the strict + * {@link OAuthTokensSchema} is unaffected. */ -export const OAuthTokensSchema = z.preprocess( - data => { - if (data && typeof data === 'object' && !Array.isArray(data)) { - const normalized: Record = { ...data }; - for (const key of ['id_token', 'expires_in', 'scope', 'refresh_token']) { - if (normalized[key] === null) { - delete normalized[key]; - } - } - return normalized; - } +export const OAuthTokenResponseSchema = z.preprocess(data => { + if (data === null || typeof data !== 'object' || Array.isArray(data)) { return data; - }, - z - .object({ - access_token: z.string(), - id_token: z.string().optional(), // Optional for OAuth 2.1, but necessary in OpenID Connect - token_type: z.string(), - expires_in: z.coerce.number().optional(), - scope: z.string().optional(), - refresh_token: z.string().optional() - }) - .strip() -); + } + const normalized: Record = { ...data }; + for (const [key, fieldSchema] of Object.entries(OAuthTokensSchema.shape)) { + if (normalized[key] === null && fieldSchema.safeParse(undefined).success) { + delete normalized[key]; + } + } + return normalized; +}, OAuthTokensSchema); /** * OAuth 2.1 error response diff --git a/test/client/auth.test.ts b/test/client/auth.test.ts index 6b70fbe942..ec5c678485 100644 --- a/test/client/auth.test.ts +++ b/test/client/auth.test.ts @@ -1582,6 +1582,36 @@ describe('OAuth Authorization', () => { expect(body.get('redirect_uri')).toBe('http://localhost:3000/callback'); expect(body.get('resource')).toBe('https://api.example.com/mcp-server'); }); + + it('accepts a token response with all optional fields null', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + access_token: 'access123', + token_type: 'Bearer', + id_token: null, + expires_in: null, + scope: null, + refresh_token: null + }) + }); + + const tokens = await exchangeAuthorization('https://auth.example.com', { + clientInformation: validClientInfo, + authorizationCode: 'code123', + codeVerifier: 'verifier123', + redirectUri: 'http://localhost:3000/callback' + }); + + // The null members must be truly absent from the result, not + // present with an undefined value. + expect(tokens).toStrictEqual({ + access_token: 'access123', + token_type: 'Bearer' + }); + }); + it('exchanges code for tokens with auth', async () => { mockFetch.mockResolvedValueOnce({ ok: true, @@ -1832,6 +1862,30 @@ describe('OAuth Authorization', () => { expect(tokens).toEqual({ refresh_token: refreshToken, ...validTokens }); }); + it('keeps the existing refresh token if the server returns a null refresh_token', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + access_token: 'newaccess123', + token_type: 'Bearer', + refresh_token: null + }) + }); + + const refreshToken = 'refresh123'; + const tokens = await refreshAuthorization('https://auth.example.com', { + clientInformation: validClientInfo, + refreshToken + }); + + expect(tokens).toStrictEqual({ + access_token: 'newaccess123', + token_type: 'Bearer', + refresh_token: refreshToken + }); + }); + it('validates token response schema', async () => { mockFetch.mockResolvedValueOnce({ ok: true, diff --git a/test/shared/auth.test.ts b/test/shared/auth.test.ts index 01ab1ddf32..628add5573 100644 --- a/test/shared/auth.test.ts +++ b/test/shared/auth.test.ts @@ -4,8 +4,10 @@ import { OpenIdProviderMetadataSchema, OAuthClientMetadataSchema, OAuthTokensSchema, + OAuthTokenResponseSchema, OptionalSafeUrlSchema } from '../../src/shared/auth.js'; +import * as z from 'zod/v4'; describe('SafeUrlSchema', () => { it('accepts valid HTTPS URLs', () => { @@ -136,49 +138,68 @@ describe('OAuthTokensSchema', () => { expect(tokens.refresh_token).toBeUndefined(); }); - it('treats null refresh_token as absent', () => { - const tokens = OAuthTokensSchema.parse({ + // The strict schema is deliberately unchanged: null optional members are + // only normalized by OAuthTokenResponseSchema below. + it.each(['refresh_token', 'scope', 'id_token'])('still rejects a null %s', field => { + expect(() => + OAuthTokensSchema.parse({ + access_token: 'access-123', + token_type: 'Bearer', + [field]: null + }) + ).toThrow(); + }); + + it('remains an object schema usable with .shape and .extend', () => { + // Regression test: wrapping the exported schema (e.g. in + // z.preprocess) would change its class and break downstream + // consumers that call .extend() or introspect .shape. + expect(OAuthTokensSchema.shape.access_token).toBeDefined(); + + const extended = OAuthTokensSchema.extend({ example: z.string() }); + const parsed = extended.parse({ access_token: 'access-123', token_type: 'Bearer', - refresh_token: null + example: 'value' }); - - expect(tokens.refresh_token).toBeUndefined(); + expect(parsed.example).toBe('value'); }); +}); - it('treats null scope as absent', () => { - const tokens = OAuthTokensSchema.parse({ +describe('OAuthTokenResponseSchema', () => { + it.each(['refresh_token', 'scope', 'id_token'])('treats null %s as absent', field => { + const tokens = OAuthTokenResponseSchema.parse({ access_token: 'access-123', token_type: 'Bearer', - scope: null + [field]: null }); - expect(tokens.scope).toBeUndefined(); + expect(field in tokens).toBe(false); }); - it('treats null id_token as absent', () => { - const tokens = OAuthTokensSchema.parse({ + it('treats null expires_in as absent instead of coercing it to 0', () => { + const tokens = OAuthTokenResponseSchema.parse({ access_token: 'access-123', token_type: 'Bearer', - id_token: null + expires_in: null }); - expect(tokens.id_token).toBeUndefined(); + expect('expires_in' in tokens).toBe(false); + expect(tokens.expires_in).not.toBe(0); }); - it('treats null expires_in as absent instead of coercing it to 0', () => { - const tokens = OAuthTokensSchema.parse({ + it('still coerces a string expires_in to a number', () => { + const tokens = OAuthTokenResponseSchema.parse({ access_token: 'access-123', token_type: 'Bearer', - expires_in: null + expires_in: '3600' }); - expect(tokens.expires_in).toBeUndefined(); - expect(tokens.expires_in).not.toBe(0); + expect(tokens.expires_in).toBe(3600); }); it('accepts a token response with all optional fields null', () => { - const tokens = OAuthTokensSchema.parse({ + const tokens = OAuthTokenResponseSchema.parse({ access_token: 'access-123', token_type: 'Bearer', id_token: null, @@ -189,7 +210,8 @@ describe('OAuthTokensSchema', () => { // toStrictEqual: the null members must be truly absent from the // output, not present with an undefined value, so that spreads like - // `{ refresh_token: previous, ...tokens }` keep the previous value. + // `{ ...tokens, refresh_token: tokens.refresh_token ?? previous }` + // (and plain spreads over defaults) keep the previous value. expect(tokens).toStrictEqual({ access_token: 'access-123', token_type: 'Bearer' @@ -198,12 +220,41 @@ describe('OAuthTokensSchema', () => { it('still rejects a null access_token', () => { expect(() => - OAuthTokensSchema.parse({ + OAuthTokenResponseSchema.parse({ access_token: null, token_type: 'Bearer' }) ).toThrow(); }); + + it('still rejects a missing token_type', () => { + expect(() => + OAuthTokenResponseSchema.parse({ + access_token: 'access-123' + }) + ).toThrow(); + }); + + it('treats null as absent for every optional member of OAuthTokensSchema', () => { + // Drift guard: if a new optional member is added to + // OAuthTokensSchema, it is automatically covered by the + // null-normalization without updating any field list. + const optionalFields = Object.entries(OAuthTokensSchema.shape) + .filter(([, fieldSchema]) => fieldSchema.safeParse(undefined).success) + .map(([field]) => field); + + expect(optionalFields.length).toBeGreaterThan(0); + + for (const field of optionalFields) { + const tokens = OAuthTokenResponseSchema.parse({ + access_token: 'access-123', + token_type: 'Bearer', + [field]: null + }); + + expect(field in tokens).toBe(false); + } + }); }); describe('OAuthClientMetadataSchema', () => { From 1018d9e26622b0848766edffdbdb47eb8d1eb25b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 22:22:14 +0000 Subject: [PATCH 3/3] =?UTF-8?q?docs:=20note=20RFC=206749=20=C2=A75.1=20sco?= =?UTF-8?q?pe-absence=20semantics=20of=20null=20stripping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stripping a null scope from a token response makes it indistinguishable from an omitted scope, which RFC 6749 §5.1 defines as an assertion that the granted scope is identical to the requested scope. Document that consumers must not infer the granted scope from its absence and should use token introspection for the authoritative grant. --- .changeset/oauth-token-response-null-optional-fields.md | 5 +++-- src/shared/auth.ts | 9 +++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/.changeset/oauth-token-response-null-optional-fields.md b/.changeset/oauth-token-response-null-optional-fields.md index 044bb8b707..9dde5116ec 100644 --- a/.changeset/oauth-token-response-null-optional-fields.md +++ b/.changeset/oauth-token-response-null-optional-fields.md @@ -3,5 +3,6 @@ --- The client (and the proxy server provider) now normalize JSON `null` values in optional members of OAuth token responses to absent before validation, via a new exported `OAuthTokenResponseSchema` used at the SDK's own parse sites. Some authorization servers serialize absent -optional members as `null` (nonconformant with RFC 6749 §5.1); previously such responses failed validation (`refresh_token`, `scope`, `id_token`) or coerced `expires_in: null` to `0`, an instantly-expired token. The exported `OAuthTokensSchema` is unchanged. -`refreshAuthorization` now also preserves the previous refresh token whenever the response does not carry a new one, regardless of how the `refresh_token` key is serialized. +optional members as `null` (nonconformant with RFC 6749 §5.1); previously such responses failed validation (`refresh_token`, `scope`, `id_token`) or coerced `expires_in: null` to `0`, an instantly-expired token. The exported `OAuthTokensSchema` is unchanged. Note that a stripped +null `scope` is thereafter indistinguishable from an omitted `scope` — which RFC 6749 §5.1 defines as an assertion that the granted scope is identical to the requested scope — so consumers should not infer the granted scope from its absence. `refreshAuthorization` now also +preserves the previous refresh token whenever the response does not carry a new one, regardless of how the `refresh_token` key is serialized. diff --git a/src/shared/auth.ts b/src/shared/auth.ts index 08c9ccfce9..604b504469 100644 --- a/src/shared/auth.ts +++ b/src/shared/auth.ts @@ -151,6 +151,15 @@ export const OAuthTokensSchema = z * instantly-expired token. Null values in required members are still * rejected, non-object input is passed through unchanged, and the strict * {@link OAuthTokensSchema} is unaffected. + * + * Per RFC 6749 §5.1, an absent `scope` member is a positive assertion that + * the granted scope is identical to the scope the client requested, so + * stripping `scope: null` converts a response with undefined semantics into + * that assertion. This has no bearing on enforcement — the SDK never uses + * `tokens.scope` for authorization decisions, and the resource server remains + * authoritative — but consumers must not derive granted-scope conclusions + * from the member's absence. Consumers that need the authoritative grant + * should use token introspection instead. */ export const OAuthTokenResponseSchema = z.preprocess(data => { if (data === null || typeof data !== 'object' || Array.isArray(data)) {