From b01109c3ffd1a8bbe8dfefdd7efa3ad3aa529d1b Mon Sep 17 00:00:00 2001 From: "Tyler Leonhardt (vscode)" Date: Tue, 1 Sep 2026 11:26:09 -0700 Subject: [PATCH 1/2] auth: propagate OAuth token lifetime Add optional expiresIn semantics to authenticate, constrain it to positive whole seconds in generated schemas, and regenerate every language client. Clarify expired credential handling and document the OAuth mapping. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Generated/Commands.generated.cs | 14 ++++++++ .../Generated/Notifications.generated.cs | 8 +++-- clients/go/ahptypes/commands.generated.go | 12 +++++++ .../go/ahptypes/notifications.generated.go | 8 +++-- .../generated/Commands.generated.kt | 14 ++++++++ .../generated/Notifications.generated.kt | 3 +- clients/rust/crates/ahp-types/src/commands.rs | 13 +++++++ .../crates/ahp-types/src/notifications.rs | 8 +++-- .../Generated/Commands.generated.swift | 15 ++++++++ .../Generated/Notifications.generated.swift | 3 +- .../20260901-authenticate-token-lifetime.json | 4 +++ docs/specification/authentication.md | 15 ++++++-- schema/commands.schema.json | 6 ++++ schema/errors.schema.json | 6 ++++ schema/notifications.schema.json | 2 +- scripts/generate-json-schema.test.ts | 18 ++++++++++ scripts/generate-json-schema.ts | 36 +++++++++++++++++++ types/common/commands.ts | 20 ++++++++++- types/common/notifications.ts | 10 ++++-- 19 files changed, 197 insertions(+), 18 deletions(-) create mode 100644 docs/.changes/20260901-authenticate-token-lifetime.json diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Commands.generated.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Commands.generated.cs index 1c69bf74..0ef02d8b 100644 --- a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Commands.generated.cs +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Commands.generated.cs @@ -1253,6 +1253,20 @@ public sealed record AuthenticateParams /// Bearer token obtained from the resource's authorization server public required string Token { get; init; } + /// The access token's remaining lifetime, in seconds, when this + /// `authenticate` request is sent. This corresponds to `expires_in` in an + /// OAuth 2.0 token response (RFC 6749 section 5.1). + /// + /// If the client retained the original token response, it MUST subtract the + /// elapsed time before forwarding this value. Omit this field when the + /// authorization server did not supply an expiry or the expiry is otherwise + /// unknown. When supplied, the value MUST be a positive integer. + /// + /// This field is irrelevant when `token` is empty to revoke authentication + /// and SHOULD be omitted in that case. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? ExpiresIn { get; init; } + /// OAuth scopes the token grants, when known. Lets the server determine /// whether a specific challenge — e.g. the `requiredScopes` on a live /// `McpServerAuthRequiredState` or `ToolCallAuthRequiredState.auth` — is diff --git a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Notifications.generated.cs b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Notifications.generated.cs index fd3a7f2e..9b5bd1a1 100644 --- a/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Notifications.generated.cs +++ b/clients/dotnet/src/AgentHostProtocol.Abstractions/Generated/Notifications.generated.cs @@ -16,7 +16,8 @@ public enum AuthRequiredReason /// The client has not yet authenticated for the resource [WireValue("required")] Required, - /// A previously valid token has expired or been revoked + /// A previously valid token has expired or been revoked. The client must + /// acquire or renew the credential rather than replaying the challenged token. [WireValue("expired")] Expired, } @@ -151,8 +152,9 @@ public sealed record ProgressParams /// to; the `resource` field carries the complete OAuth protected resource /// metadata (per RFC 9728). /// -/// Clients should obtain a fresh token and push it via the `authenticate` -/// command. +/// Clients should obtain or renew the credential and push the resulting token +/// via the `authenticate` command. When `reason` is `expired`, clients MUST NOT +/// blindly replay the challenged token. public sealed record AuthRequiredParams { /// Channel URI this notification belongs to diff --git a/clients/go/ahptypes/commands.generated.go b/clients/go/ahptypes/commands.generated.go index 9e7b6d9a..9ef81b6f 100644 --- a/clients/go/ahptypes/commands.generated.go +++ b/clients/go/ahptypes/commands.generated.go @@ -997,6 +997,18 @@ type AuthenticateParams struct { Resource string `json:"resource"` // Bearer token obtained from the resource's authorization server Token string `json:"token"` + // The access token's remaining lifetime, in seconds, when this + // `authenticate` request is sent. This corresponds to `expires_in` in an + // OAuth 2.0 token response (RFC 6749 section 5.1). + // + // If the client retained the original token response, it MUST subtract the + // elapsed time before forwarding this value. Omit this field when the + // authorization server did not supply an expiry or the expiry is otherwise + // unknown. When supplied, the value MUST be a positive integer. + // + // This field is irrelevant when `token` is empty to revoke authentication + // and SHOULD be omitted in that case. + ExpiresIn *int64 `json:"expiresIn,omitempty"` // OAuth scopes the token grants, when known. Lets the server determine // whether a specific challenge — e.g. the `requiredScopes` on a live // `McpServerAuthRequiredState` or `ToolCallAuthRequiredState.auth` — is diff --git a/clients/go/ahptypes/notifications.generated.go b/clients/go/ahptypes/notifications.generated.go index b92501f6..62db00de 100644 --- a/clients/go/ahptypes/notifications.generated.go +++ b/clients/go/ahptypes/notifications.generated.go @@ -21,7 +21,8 @@ type AuthRequiredReason string const ( // The client has not yet authenticated for the resource AuthRequiredReasonRequired AuthRequiredReason = "required" - // A previously valid token has expired or been revoked + // A previously valid token has expired or been revoked. The client must + // acquire or renew the credential rather than replaying the challenged token. AuthRequiredReasonExpired AuthRequiredReason = "expired" ) @@ -141,8 +142,9 @@ type ProgressParams struct { // to; the `resource` field carries the complete OAuth protected resource // metadata (per RFC 9728). // -// Clients should obtain a fresh token and push it via the `authenticate` -// command. +// Clients should obtain or renew the credential and push the resulting token +// via the `authenticate` command. When `reason` is `expired`, clients MUST NOT +// blindly replay the challenged token. type AuthRequiredParams struct { // Channel URI this notification belongs to Channel URI `json:"channel"` diff --git a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Commands.generated.kt b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Commands.generated.kt index ad5ac527..01fb0e15 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Commands.generated.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Commands.generated.kt @@ -1220,6 +1220,20 @@ data class AuthenticateParams( * Bearer token obtained from the resource's authorization server */ val token: String, + /** + * The access token's remaining lifetime, in seconds, when this + * `authenticate` request is sent. This corresponds to `expires_in` in an + * OAuth 2.0 token response (RFC 6749 section 5.1). + * + * If the client retained the original token response, it MUST subtract the + * elapsed time before forwarding this value. Omit this field when the + * authorization server did not supply an expiry or the expiry is otherwise + * unknown. When supplied, the value MUST be a positive integer. + * + * This field is irrelevant when `token` is empty to revoke authentication + * and SHOULD be omitted in that case. + */ + val expiresIn: Long? = null, /** * OAuth scopes the token grants, when known. Lets the server determine * whether a specific challenge — e.g. the `requiredScopes` on a live diff --git a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Notifications.generated.kt b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Notifications.generated.kt index 193a72f4..21d0f0ff 100644 --- a/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Notifications.generated.kt +++ b/clients/kotlin/src/main/kotlin/com/microsoft/agenthostprotocol/generated/Notifications.generated.kt @@ -34,7 +34,8 @@ value class AuthRequiredReason(val rawValue: String) { */ val REQUIRED: AuthRequiredReason = AuthRequiredReason("required") /** - * A previously valid token has expired or been revoked + * A previously valid token has expired or been revoked. The client must + * acquire or renew the credential rather than replaying the challenged token. */ val EXPIRED: AuthRequiredReason = AuthRequiredReason("expired") } diff --git a/clients/rust/crates/ahp-types/src/commands.rs b/clients/rust/crates/ahp-types/src/commands.rs index 37e3b642..e3dfe255 100644 --- a/clients/rust/crates/ahp-types/src/commands.rs +++ b/clients/rust/crates/ahp-types/src/commands.rs @@ -1288,6 +1288,19 @@ pub struct AuthenticateParams { pub resource: String, /// Bearer token obtained from the resource's authorization server pub token: String, + /// The access token's remaining lifetime, in seconds, when this + /// `authenticate` request is sent. This corresponds to `expires_in` in an + /// OAuth 2.0 token response (RFC 6749 section 5.1). + /// + /// If the client retained the original token response, it MUST subtract the + /// elapsed time before forwarding this value. Omit this field when the + /// authorization server did not supply an expiry or the expiry is otherwise + /// unknown. When supplied, the value MUST be a positive integer. + /// + /// This field is irrelevant when `token` is empty to revoke authentication + /// and SHOULD be omitted in that case. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expires_in: Option, /// OAuth scopes the token grants, when known. Lets the server determine /// whether a specific challenge — e.g. the `requiredScopes` on a live /// `McpServerAuthRequiredState` or `ToolCallAuthRequiredState.auth` — is diff --git a/clients/rust/crates/ahp-types/src/notifications.rs b/clients/rust/crates/ahp-types/src/notifications.rs index d50cc301..3fa4cce5 100644 --- a/clients/rust/crates/ahp-types/src/notifications.rs +++ b/clients/rust/crates/ahp-types/src/notifications.rs @@ -24,7 +24,8 @@ use crate::state::{ pub enum AuthRequiredReason { /// The client has not yet authenticated for the resource Required, - /// A previously valid token has expired or been revoked + /// A previously valid token has expired or been revoked. The client must + /// acquire or renew the credential rather than replaying the challenged token. Expired, /// Unknown raw value from a newer protocol version, preserved verbatim. Unknown(String), @@ -183,8 +184,9 @@ pub struct ProgressParams { /// to; the `resource` field carries the complete OAuth protected resource /// metadata (per RFC 9728). /// -/// Clients should obtain a fresh token and push it via the `authenticate` -/// command. +/// Clients should obtain or renew the credential and push the resulting token +/// via the `authenticate` command. When `reason` is `expired`, clients MUST NOT +/// blindly replay the challenged token. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AuthRequiredParams { diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift index bb6ee682..9d9ca3e2 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Commands.generated.swift @@ -1465,6 +1465,18 @@ public struct AuthenticateParams: Codable, Sendable { public var resource: String /// Bearer token obtained from the resource's authorization server public var token: String + /// The access token's remaining lifetime, in seconds, when this + /// `authenticate` request is sent. This corresponds to `expires_in` in an + /// OAuth 2.0 token response (RFC 6749 section 5.1). + /// + /// If the client retained the original token response, it MUST subtract the + /// elapsed time before forwarding this value. Omit this field when the + /// authorization server did not supply an expiry or the expiry is otherwise + /// unknown. When supplied, the value MUST be a positive integer. + /// + /// This field is irrelevant when `token` is empty to revoke authentication + /// and SHOULD be omitted in that case. + public var expiresIn: Int? /// OAuth scopes the token grants, when known. Lets the server determine /// whether a specific challenge — e.g. the `requiredScopes` on a live /// `McpServerAuthRequiredState` or `ToolCallAuthRequiredState.auth` — is @@ -1478,6 +1490,7 @@ public struct AuthenticateParams: Codable, Sendable { case meta = "_meta" case resource case token + case expiresIn case scopes } @@ -1486,12 +1499,14 @@ public struct AuthenticateParams: Codable, Sendable { meta: [String: AnyCodable]? = nil, resource: String, token: String, + expiresIn: Int? = nil, scopes: [String]? = nil ) { self.channel = channel self.meta = meta self.resource = resource self.token = token + self.expiresIn = expiresIn self.scopes = scopes } } diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Notifications.generated.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Notifications.generated.swift index 7040fdf8..f865befd 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Notifications.generated.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/Generated/Notifications.generated.swift @@ -8,7 +8,8 @@ import Foundation public enum AuthRequiredReason: Codable, Sendable, Equatable { /// The client has not yet authenticated for the resource case required - /// A previously valid token has expired or been revoked + /// A previously valid token has expired or been revoked. The client must + /// acquire or renew the credential rather than replaying the challenged token. case expired /// Unknown raw value from a newer protocol version, preserved verbatim. case unknown(String) diff --git a/docs/.changes/20260901-authenticate-token-lifetime.json b/docs/.changes/20260901-authenticate-token-lifetime.json new file mode 100644 index 00000000..5014ccfc --- /dev/null +++ b/docs/.changes/20260901-authenticate-token-lifetime.json @@ -0,0 +1,4 @@ +{ + "type": "added", + "message": "`AuthenticateParams.expiresIn` carries an OAuth access token's remaining lifetime in seconds." +} diff --git a/docs/specification/authentication.md b/docs/specification/authentication.md index 294ec13f..a52fc76a 100644 --- a/docs/specification/authentication.md +++ b/docs/specification/authentication.md @@ -18,7 +18,7 @@ sequenceDiagram C->>AS: OAuth token request AS-->>C: Token - C->>S: authenticate({ resource, token }) + C->>S: authenticate({ resource, token, expiresIn }) S-->>C: {} C->>S: createSession / other commands @@ -99,6 +99,7 @@ Clients push Bearer tokens to the server using the [`authenticate`](/reference/c "channel": "ahp-root://", "resource": "https://api.github.com", "token": "gho_xxxxxxxxxxxx", + "expiresIn": 3540, "scopes": ["read:user", "user:email"] } } @@ -111,6 +112,10 @@ Clients push Bearer tokens to the server using the [`authenticate`](/reference/c } ``` +`expiresIn` is optional and corresponds to the `expires_in` field in an OAuth 2.0 token response, as defined by [RFC 6749 section 5.1](https://datatracker.ietf.org/doc/html/rfc6749#section-5.1). It is the access token's remaining lifetime in seconds when the client sends the `authenticate` request. When supplied, it MUST be a positive integer. + +If the client retained the original token response, it MUST subtract elapsed time from the original `expires_in` value before forwarding it. The client MUST omit `expiresIn` when the authorization server did not supply an expiry or the expiry is otherwise unknown. An empty `token` revokes authentication for the resource; `expiresIn` is irrelevant and SHOULD be omitted in that request. + `scopes` is optional and lets the client tell the server which OAuth scopes the pushed token actually grants — useful when resolving a `requiredScopes` challenge (from a live `McpServerAuthRequiredState` or `ToolCallAuthRequiredState.auth`) without the server needing to decode an opaque token. If the token is invalid or the resource is unrecognized, the server MUST return a JSON-RPC error (e.g. `AuthRequired` `-32007` or `InvalidParams` `-32602`). @@ -190,7 +195,9 @@ The `resource` field carries the complete [`ProtectedResourceMetadata`](/referen | Value | Description | |---|---| | `required` | The client has not yet authenticated for the resource | -| `expired` | A previously valid token has expired or been revoked | +| `expired` | A previously valid token has expired or been revoked; the client must acquire or renew the credential | + +When `reason` is `expired`, the client MUST acquire a new credential or renew the existing credential before calling `authenticate` again. It MUST NOT blindly replay the challenged token. Like all protocol notifications, `auth/required` is ephemeral and is **not** replayed on reconnection. Clients SHOULD re-check authentication requirements after reconnecting. @@ -211,6 +218,10 @@ Using the standard OAuth 2.0 Protected Resource Metadata format means: - Tokens can be refreshed or rotated without re-initializing the connection - Not all clients need to authenticate (some agents may not require auth) +### Why `expiresIn` instead of `expiresAt`? + +`expiresIn` follows the OAuth token endpoint's existing `expires_in` vocabulary and reports the lifetime relative to the `authenticate` request. A relative lifetime does not require the client and host clocks to be synchronized. + ### Why not store auth status in root state? Root state is global and visible to all subscribed clients. Authentication status is per-connection (each client authenticates independently), so it is kept imperative via commands and notifications rather than polluting the shared state tree. diff --git a/schema/commands.schema.json b/schema/commands.schema.json index 6b792f21..0ea8ba12 100644 --- a/schema/commands.schema.json +++ b/schema/commands.schema.json @@ -875,6 +875,12 @@ "type": "string", "description": "Bearer token obtained from the resource's authorization server" }, + "expiresIn": { + "type": "number", + "description": "The access token's remaining lifetime, in seconds, when this\n`authenticate` request is sent. This corresponds to `expires_in` in an\nOAuth 2.0 token response (RFC 6749 section 5.1).\n\nIf the client retained the original token response, it MUST subtract the\nelapsed time before forwarding this value. Omit this field when the\nauthorization server did not supply an expiry or the expiry is otherwise\nunknown. When supplied, the value MUST be a positive integer.\n\nThis field is irrelevant when `token` is empty to revoke authentication\nand SHOULD be omitted in that case.", + "minimum": 1, + "multipleOf": 1 + }, "scopes": { "type": "array", "items": { diff --git a/schema/errors.schema.json b/schema/errors.schema.json index 46c00019..0cc2d5a1 100644 --- a/schema/errors.schema.json +++ b/schema/errors.schema.json @@ -6527,6 +6527,12 @@ "type": "string", "description": "Bearer token obtained from the resource's authorization server" }, + "expiresIn": { + "type": "number", + "description": "The access token's remaining lifetime, in seconds, when this\n`authenticate` request is sent. This corresponds to `expires_in` in an\nOAuth 2.0 token response (RFC 6749 section 5.1).\n\nIf the client retained the original token response, it MUST subtract the\nelapsed time before forwarding this value. Omit this field when the\nauthorization server did not supply an expiry or the expiry is otherwise\nunknown. When supplied, the value MUST be a positive integer.\n\nThis field is irrelevant when `token` is empty to revoke authentication\nand SHOULD be omitted in that case.", + "minimum": 1, + "multipleOf": 1 + }, "scopes": { "type": "array", "items": { diff --git a/schema/notifications.schema.json b/schema/notifications.schema.json index edf2ca34..01271335 100644 --- a/schema/notifications.schema.json +++ b/schema/notifications.schema.json @@ -7,7 +7,7 @@ "$defs": { "AuthRequiredParams": { "type": "object", - "description": "Sent by the server when a protected resource requires (re-)authentication.\n\nThis notification MAY be associated with any channel — for example, an\nagent advertised on the root channel, or a per-session resource. The\n`channel` field identifies the subscription the auth requirement belongs\nto; the `resource` field carries the complete OAuth protected resource\nmetadata (per RFC 9728).\n\nClients should obtain a fresh token and push it via the `authenticate`\ncommand.", + "description": "Sent by the server when a protected resource requires (re-)authentication.\n\nThis notification MAY be associated with any channel — for example, an\nagent advertised on the root channel, or a per-session resource. The\n`channel` field identifies the subscription the auth requirement belongs\nto; the `resource` field carries the complete OAuth protected resource\nmetadata (per RFC 9728).\n\nClients should obtain or renew the credential and push the resulting token\nvia the `authenticate` command. When `reason` is `expired`, clients MUST NOT\nblindly replay the challenged token.", "properties": { "channel": { "$ref": "#/$defs/URI", diff --git a/scripts/generate-json-schema.test.ts b/scripts/generate-json-schema.test.ts index 5ede4cd1..421a3405 100644 --- a/scripts/generate-json-schema.test.ts +++ b/scripts/generate-json-schema.test.ts @@ -183,6 +183,24 @@ describe('generated JSON schemas', () => { assert.equal(properties.minIntervalMinutes.type, 'number'); }); + it('exposes the optional authenticate token lifetime', () => { + if (file !== 'commands.schema.json') { + return; + } + const defs = schema.$defs as Record>; + const authenticate = defs.AuthenticateParams; + const properties = authenticate.properties as Record>; + const required = authenticate.required as string[]; + const expiresIn = properties.expiresIn; + + assert.equal(expiresIn.type, 'number'); + assert.equal(expiresIn.minimum, 1); + assert.equal(expiresIn.multipleOf, 1); + assert.equal(required.includes('expiresIn'), false); + assert.match(expiresIn.description as string, /remaining lifetime, in seconds/); + assert.match(expiresIn.description as string, /MUST be a positive integer/); + }); + it('constrains every ChatOrigin branch to a distinct kind', () => { const defs = schema.$defs as Record>; const chatOrigin = defs.ChatOrigin; diff --git a/scripts/generate-json-schema.ts b/scripts/generate-json-schema.ts index 5d023770..6f083c70 100644 --- a/scripts/generate-json-schema.ts +++ b/scripts/generate-json-schema.ts @@ -32,6 +32,8 @@ interface JsonSchema { items?: JsonSchema; enum?: Array; const?: string | number | boolean; + minimum?: number; + multipleOf?: number; oneOf?: JsonSchema[]; allOf?: JsonSchema[]; anyOf?: JsonSchema[]; @@ -55,6 +57,22 @@ function getPropertyDescription(prop: PropertySignature): string { return normalizeDescription(jsDocs[0].getDescription()); } +function getNumericPropertyTag(prop: PropertySignature, tagName: string): number | undefined { + const tag = prop.getJsDocs() + .flatMap(doc => doc.getTags()) + .find(candidate => candidate.getTagName() === tagName); + if (!tag) return undefined; + + const text = tag.getCommentText()?.trim(); + const value = text === undefined ? Number.NaN : Number(text); + if (!Number.isFinite(value)) { + throw new Error( + `${prop.getSourceFile().getFilePath()}: ${prop.getName()} has invalid @${tagName} value ${JSON.stringify(text)}`, + ); + } + return value; +} + function getInterfaceDescription(node: InterfaceDeclaration): string { const jsDocs = node.getJsDocs(); if (jsDocs.length === 0) return ''; @@ -336,6 +354,24 @@ function interfaceToSchema(iface: InterfaceDeclaration, project: Project): JsonS const desc = getPropertyDescription(prop); const propSchema = typeTextToSchema(typeText, project); if (desc) propSchema.description = desc; + const minimum = getNumericPropertyTag(prop, 'minimum'); + const multipleOf = getNumericPropertyTag(prop, 'multipleOf'); + if (minimum !== undefined || multipleOf !== undefined) { + if (propSchema.type !== 'number') { + throw new Error( + `${prop.getSourceFile().getFilePath()}: ${name} uses a numeric schema constraint on ${typeText}`, + ); + } + if (multipleOf !== undefined && multipleOf <= 0) { + throw new Error( + `${prop.getSourceFile().getFilePath()}: ${name} has non-positive @multipleOf value ${multipleOf}`, + ); + } + propSchema.minimum = minimum; + propSchema.multipleOf = multipleOf; + if (minimum === undefined) delete propSchema.minimum; + if (multipleOf === undefined) delete propSchema.multipleOf; + } schema.properties![name] = propSchema; if (!prop.hasQuestionToken() && !typeAdmitsUndefined(typeText)) { if (!schema.required!.includes(name)) { diff --git a/types/common/commands.ts b/types/common/commands.ts index 14d18144..c4f0b590 100644 --- a/types/common/commands.ts +++ b/types/common/commands.ts @@ -1136,7 +1136,8 @@ export interface ResourceMkdirResult { * ```jsonc * // Client → Server * { "jsonrpc": "2.0", "id": 3, "method": "authenticate", - * "params": { "channel": "ahp-root://", "resource": "https://api.github.com", "token": "gho_xxxx" } } + * "params": { "channel": "ahp-root://", "resource": "https://api.github.com", + * "token": "gho_xxxx", "expiresIn": 3540 } } * * // Server → Client (success) * { "jsonrpc": "2.0", "id": 3, "result": {} } @@ -1156,6 +1157,23 @@ export interface AuthenticateParams extends BaseParams { resource: string; /** Bearer token obtained from the resource's authorization server */ token: string; + /** + * The access token's remaining lifetime, in seconds, when this + * `authenticate` request is sent. This corresponds to `expires_in` in an + * OAuth 2.0 token response (RFC 6749 section 5.1). + * + * If the client retained the original token response, it MUST subtract the + * elapsed time before forwarding this value. Omit this field when the + * authorization server did not supply an expiry or the expiry is otherwise + * unknown. When supplied, the value MUST be a positive integer. + * + * This field is irrelevant when `token` is empty to revoke authentication + * and SHOULD be omitted in that case. + * + * @minimum 1 + * @multipleOf 1 + */ + expiresIn?: number; /** * OAuth scopes the token grants, when known. Lets the server determine * whether a specific challenge — e.g. the `requiredScopes` on a live diff --git a/types/common/notifications.ts b/types/common/notifications.ts index 9a33de69..330f9f0f 100644 --- a/types/common/notifications.ts +++ b/types/common/notifications.ts @@ -16,7 +16,10 @@ import type { ProtectedResourceMetadata, URI } from './state.js'; export const enum AuthRequiredReason { /** The client has not yet authenticated for the resource */ Required = 'required', - /** A previously valid token has expired or been revoked */ + /** + * A previously valid token has expired or been revoked. The client must + * acquire or renew the credential rather than replaying the challenged token. + */ Expired = 'expired', } @@ -31,8 +34,9 @@ export const enum AuthRequiredReason { * to; the `resource` field carries the complete OAuth protected resource * metadata (per RFC 9728). * - * Clients should obtain a fresh token and push it via the `authenticate` - * command. + * Clients should obtain or renew the credential and push the resulting token + * via the `authenticate` command. When `reason` is `expired`, clients MUST NOT + * blindly replay the challenged token. * * @category Protocol Notifications * @method auth/required From 875ea67cb6c777dc857a35f2e610992f8bc2a66d Mon Sep 17 00:00:00 2001 From: "Tyler Leonhardt (vscode)" Date: Tue, 1 Sep 2026 11:45:31 -0700 Subject: [PATCH 2/2] schema: mark token lifetime as an integer Use an explicit @integer schema annotation for expiresIn while retaining the positive minimum constraint. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- schema/commands.schema.json | 5 ++--- schema/errors.schema.json | 5 ++--- scripts/generate-json-schema.test.ts | 3 +-- scripts/generate-json-schema.ts | 24 ++++++++++++++---------- types/common/commands.ts | 2 +- 5 files changed, 20 insertions(+), 19 deletions(-) diff --git a/schema/commands.schema.json b/schema/commands.schema.json index 0ea8ba12..22902368 100644 --- a/schema/commands.schema.json +++ b/schema/commands.schema.json @@ -876,10 +876,9 @@ "description": "Bearer token obtained from the resource's authorization server" }, "expiresIn": { - "type": "number", + "type": "integer", "description": "The access token's remaining lifetime, in seconds, when this\n`authenticate` request is sent. This corresponds to `expires_in` in an\nOAuth 2.0 token response (RFC 6749 section 5.1).\n\nIf the client retained the original token response, it MUST subtract the\nelapsed time before forwarding this value. Omit this field when the\nauthorization server did not supply an expiry or the expiry is otherwise\nunknown. When supplied, the value MUST be a positive integer.\n\nThis field is irrelevant when `token` is empty to revoke authentication\nand SHOULD be omitted in that case.", - "minimum": 1, - "multipleOf": 1 + "minimum": 1 }, "scopes": { "type": "array", diff --git a/schema/errors.schema.json b/schema/errors.schema.json index 0cc2d5a1..71cb746b 100644 --- a/schema/errors.schema.json +++ b/schema/errors.schema.json @@ -6528,10 +6528,9 @@ "description": "Bearer token obtained from the resource's authorization server" }, "expiresIn": { - "type": "number", + "type": "integer", "description": "The access token's remaining lifetime, in seconds, when this\n`authenticate` request is sent. This corresponds to `expires_in` in an\nOAuth 2.0 token response (RFC 6749 section 5.1).\n\nIf the client retained the original token response, it MUST subtract the\nelapsed time before forwarding this value. Omit this field when the\nauthorization server did not supply an expiry or the expiry is otherwise\nunknown. When supplied, the value MUST be a positive integer.\n\nThis field is irrelevant when `token` is empty to revoke authentication\nand SHOULD be omitted in that case.", - "minimum": 1, - "multipleOf": 1 + "minimum": 1 }, "scopes": { "type": "array", diff --git a/scripts/generate-json-schema.test.ts b/scripts/generate-json-schema.test.ts index 421a3405..53985809 100644 --- a/scripts/generate-json-schema.test.ts +++ b/scripts/generate-json-schema.test.ts @@ -193,9 +193,8 @@ describe('generated JSON schemas', () => { const required = authenticate.required as string[]; const expiresIn = properties.expiresIn; - assert.equal(expiresIn.type, 'number'); + assert.equal(expiresIn.type, 'integer'); assert.equal(expiresIn.minimum, 1); - assert.equal(expiresIn.multipleOf, 1); assert.equal(required.includes('expiresIn'), false); assert.match(expiresIn.description as string, /remaining lifetime, in seconds/); assert.match(expiresIn.description as string, /MUST be a positive integer/); diff --git a/scripts/generate-json-schema.ts b/scripts/generate-json-schema.ts index 6f083c70..0ac0fb4f 100644 --- a/scripts/generate-json-schema.ts +++ b/scripts/generate-json-schema.ts @@ -33,7 +33,6 @@ interface JsonSchema { enum?: Array; const?: string | number | boolean; minimum?: number; - multipleOf?: number; oneOf?: JsonSchema[]; allOf?: JsonSchema[]; anyOf?: JsonSchema[]; @@ -57,6 +56,12 @@ function getPropertyDescription(prop: PropertySignature): string { return normalizeDescription(jsDocs[0].getDescription()); } +function hasPropertyTag(prop: PropertySignature, tagName: string): boolean { + return prop.getJsDocs() + .flatMap(doc => doc.getTags()) + .some(tag => tag.getTagName() === tagName); +} + function getNumericPropertyTag(prop: PropertySignature, tagName: string): number | undefined { const tag = prop.getJsDocs() .flatMap(doc => doc.getTags()) @@ -354,23 +359,22 @@ function interfaceToSchema(iface: InterfaceDeclaration, project: Project): JsonS const desc = getPropertyDescription(prop); const propSchema = typeTextToSchema(typeText, project); if (desc) propSchema.description = desc; - const minimum = getNumericPropertyTag(prop, 'minimum'); - const multipleOf = getNumericPropertyTag(prop, 'multipleOf'); - if (minimum !== undefined || multipleOf !== undefined) { + if (hasPropertyTag(prop, 'integer')) { if (propSchema.type !== 'number') { throw new Error( - `${prop.getSourceFile().getFilePath()}: ${name} uses a numeric schema constraint on ${typeText}`, + `${prop.getSourceFile().getFilePath()}: ${name} uses @integer on ${typeText}`, ); } - if (multipleOf !== undefined && multipleOf <= 0) { + propSchema.type = 'integer'; + } + const minimum = getNumericPropertyTag(prop, 'minimum'); + if (minimum !== undefined) { + if (propSchema.type !== 'number' && propSchema.type !== 'integer') { throw new Error( - `${prop.getSourceFile().getFilePath()}: ${name} has non-positive @multipleOf value ${multipleOf}`, + `${prop.getSourceFile().getFilePath()}: ${name} uses a numeric schema constraint on ${typeText}`, ); } propSchema.minimum = minimum; - propSchema.multipleOf = multipleOf; - if (minimum === undefined) delete propSchema.minimum; - if (multipleOf === undefined) delete propSchema.multipleOf; } schema.properties![name] = propSchema; if (!prop.hasQuestionToken() && !typeAdmitsUndefined(typeText)) { diff --git a/types/common/commands.ts b/types/common/commands.ts index c4f0b590..d2bff1e8 100644 --- a/types/common/commands.ts +++ b/types/common/commands.ts @@ -1170,8 +1170,8 @@ export interface AuthenticateParams extends BaseParams { * This field is irrelevant when `token` is empty to revoke authentication * and SHOULD be omitted in that case. * + * @integer * @minimum 1 - * @multipleOf 1 */ expiresIn?: number; /**