Skip to content

Fix ClientOAuthProvider "Client ID is not available" on cold start#1705

Open
halter73 wants to merge 1 commit into
mainfrom
halter73-fix-clientoauthprovider-cold-start-clien
Open

Fix ClientOAuthProvider "Client ID is not available" on cold start#1705
halter73 wants to merge 1 commit into
mainfrom
halter73-fix-clientoauthprovider-cold-start-clien

Conversation

@halter73

Copy link
Copy Markdown
Contributor

Fixes #1658.

Problem

On a cold start where a durable ITokenCache returns a persisted refresh token but no client ID has been assigned yet (i.e. clients relying on dynamic client registration (DCR) or a client-id metadata document (CIMD) rather than a pre-registered ClientId), ClientOAuthProvider.GetAccessTokenAsync attempts a token refresh before assigning a client ID.

RefreshTokensAsyncCreateTokenRequestGetClientIdOrThrow() then throws:

System.InvalidOperationException: Client ID is not available. This may indicate an issue with dynamic client registration.

The message is misleading — DCR hasn't failed, it simply hasn't been given the chance to run yet because the refresh attempt is sequenced ahead of the client-ID assignment block. The sibling GetAccessTokenSilentAsync path is already guarded against an analogous case (it only refreshes when _authServerMetadata is not null); GetAccessTokenAsync had no equivalent guard for a missing client ID.

Fix

A single durable ITokenCache should be enough to survive a process restart. To make that work, the client registration is now persisted alongside the tokens and restored on a cold start:

  • TokenContainer: added optional ClientId, ClientSecret, and TokenEndpointAuthMethod properties. These are additive and AOT-safe (TokenContainer isn't part of the SDK's source-gen JSON context and is never serialized to the wire), so ITokenCache stays unchanged — no breaking interface change.
  • ClientOAuthProvider:
    • Stamps the current _clientId / _clientSecret / _tokenEndpointAuthMethod onto the TokenContainer before storing tokens.
    • Restores those values on a cold start (when _clientId is null) before the refresh decision, so a persisted refresh token can actually be used. An explicitly configured options.ClientId always takes precedence.
    • Guards the refresh block with !string.IsNullOrEmpty(_clientId) as a safety net, so if credentials aren't available in the cache the flow falls through to client-ID assignment and the authorization-code flow instead of throwing.

Resulting behavior

  • Durable ITokenCache only → cold start restores the client ID/secret/auth method, the persisted refresh token is honored, no re-prompt and no redundant DCR.
  • Credentials absent from the cache (e.g. an entry written by an older version) → the guard skips the refresh and re-authenticates, rather than throwing.
  • Explicit options.ClientId → unchanged.

This coexists with the existing DynamicClientRegistration.ResponseDelegate mechanism; it just removes the requirement to use it purely to survive a cold-start refresh.

Tests

Two regression tests added to TokenCacheTests:

  • GetTokenAsync_ColdStartWithDynamicRegistration_RefreshesUsingPersistedCredentials — a DCR client persists tokens + credentials, then a fresh provider sharing the same cache refreshes on cold start without re-prompting.
  • GetTokenAsync_ColdStartWithoutPersistedClientId_FallsBackToReauthorization — a cache holding tokens but no persisted credentials re-authorizes instead of throwing.

Full solution builds clean (warnings-as-errors) and the OAuth/Auth test suite passes.

…sted

On a cold start where a durable ITokenCache returns a persisted refresh
token but no client ID has been assigned yet (DCR/CIMD clients),
GetAccessTokenAsync attempted a token refresh before assigning a client
ID, causing CreateTokenRequest -> GetClientIdOrThrow() to throw
'Client ID is not available'.

Persist the client registration (client ID, secret, token endpoint auth
method) alongside the tokens so a single durable ITokenCache can use the
refresh token after a restart, and restore it on a cold start before the
refresh attempt. Also guard the refresh block with a client-ID check so
the flow falls through to client-ID assignment and the authorization-code
flow instead of throwing when no credentials are available.

Fixes #1658

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes a cold-start OAuth flow bug where a durable ITokenCache can restore a refresh token but ClientOAuthProvider attempts refresh before a client ID is available, causing a misleading "Client ID is not available" exception. The PR persists client registration details alongside cached tokens and restores them early enough to enable refresh-token usage after process restart, with regression coverage.

Changes:

  • Persist optional client registration fields (ClientId, ClientSecret, TokenEndpointAuthMethod) in TokenContainer.
  • Restore cached client credentials in ClientOAuthProvider before refresh decisions and guard refresh on presence of a client ID.
  • Add regression tests covering cold-start refresh with persisted DCR credentials and fallback reauthorization when credentials are absent.
Show a summary per file
File Description
tests/ModelContextProtocol.AspNetCore.Tests/OAuth/TokenCacheTests.cs Adds regression tests for cold-start refresh behavior with/without persisted client credentials.
src/ModelContextProtocol.Core/Authentication/TokenContainer.cs Extends cached token model to optionally persist client registration details.
src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs Restores persisted client credentials on cold start, guards refresh, and stamps registration details when storing tokens.

Review details

  • Files reviewed: 3/3 changed files
  • Comments generated: 4
  • Review effort level: Low

Comment on lines +1175 to +1177
_clientId = tokens!.ClientId;
_clientSecret ??= tokens.ClientSecret;
_tokenEndpointAuthMethod ??= tokens.TokenEndpointAuthMethod;
Comment on lines +45 to +47
/// the user to re-authorize. It is populated automatically when the client ID was obtained via dynamic
/// client registration or a client-id metadata document; it is left unset for explicitly configured
/// client IDs, which are supplied via configuration instead.

// Only attempt a token refresh if we haven't attempted to already for this request.
// Also only attempt a token refresh for a 401 Unauthorized responses. Other response status codes
// should not be used for expired access tokens. This is important because 403 forbiden responses can
// Only attempt a token refresh if we haven't attempted to already for this request.
// Also only attempt a token refresh for a 401 Unauthorized responses. Other response status codes
// should not be used for expired access tokens. This is important because 403 forbiden responses can
// be used for incremental consent which cannot be acheived with a simple refresh.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ClientOAuthProvider throws "Client ID is not available" on cold start when a refresh token is persisted but client ID is not

2 participants