Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -1253,6 +1253,20 @@ public sealed record AuthenticateParams
/// <summary>Bearer token obtained from the resource's authorization server</summary>
public required string Token { get; init; }

/// <summary>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.</summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public long? ExpiresIn { get; init; }

/// <summary>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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ public enum AuthRequiredReason
/// <summary>The client has not yet authenticated for the resource</summary>
[WireValue("required")]
Required,
/// <summary>A previously valid token has expired or been revoked</summary>
/// <summary>A previously valid token has expired or been revoked. The client must
/// acquire or renew the credential rather than replaying the challenged token.</summary>
[WireValue("expired")]
Expired,
}
Expand Down Expand Up @@ -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.</summary>
/// 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.</summary>
public sealed record AuthRequiredParams
{
/// <summary>Channel URI this notification belongs to</summary>
Expand Down
12 changes: 12 additions & 0 deletions clients/go/ahptypes/commands.generated.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 5 additions & 3 deletions clients/go/ahptypes/notifications.generated.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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"`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
13 changes: 13 additions & 0 deletions clients/rust/crates/ahp-types/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<i64>,
/// 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
Expand Down
8 changes: 5 additions & 3 deletions clients/rust/crates/ahp-types/src/notifications.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -1478,6 +1490,7 @@ public struct AuthenticateParams: Codable, Sendable {
case meta = "_meta"
case resource
case token
case expiresIn
case scopes
}

Expand All @@ -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
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions docs/.changes/20260901-authenticate-token-lifetime.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"type": "added",
"message": "`AuthenticateParams.expiresIn` carries an OAuth access token's remaining lifetime in seconds."
}
15 changes: 13 additions & 2 deletions docs/specification/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"]
}
}
Expand All @@ -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`).
Expand Down Expand Up @@ -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.

Expand All @@ -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.
5 changes: 5 additions & 0 deletions schema/commands.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -875,6 +875,11 @@
"type": "string",
"description": "Bearer token obtained from the resource's authorization server"
},
"expiresIn": {
"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
},
"scopes": {
"type": "array",
"items": {
Expand Down
5 changes: 5 additions & 0 deletions schema/errors.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -6527,6 +6527,11 @@
"type": "string",
"description": "Bearer token obtained from the resource's authorization server"
},
"expiresIn": {
"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
},
"scopes": {
"type": "array",
"items": {
Expand Down
2 changes: 1 addition & 1 deletion schema/notifications.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
17 changes: 17 additions & 0 deletions scripts/generate-json-schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,23 @@ 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<string, Record<string, unknown>>;
const authenticate = defs.AuthenticateParams;
const properties = authenticate.properties as Record<string, Record<string, unknown>>;
const required = authenticate.required as string[];
const expiresIn = properties.expiresIn;

assert.equal(expiresIn.type, 'integer');
assert.equal(expiresIn.minimum, 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<string, Record<string, unknown>>;
const chatOrigin = defs.ChatOrigin;
Expand Down
Loading