Skip to content
Draft
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 @@ -182,17 +182,20 @@ The `OAuthProviders` class provides constants for supported providers:
- `OAuthProviders.Notion` - Notion
- `OAuthProviders.GitLab` - GitLab
- `OAuthProviders.Bitbucket` - Bitbucket
- `OAuthProviders.Sliplane` - Sliplane
- `OAuthProviders.Claude` - Claude (Anthropic, claude.ai OAuth)

### Registering Token Handlers

For an OAuth provider to appear in the brokered sessions dictionary, there must be a registered `IAuthTokenHandler` for that provider. Token handlers manage token refresh, validation, and user info retrieval for brokered tokens.

**Built-in Handlers**

Ivy provides pre-built token handlers for Google and GitHub. Simply add the NuGet package to your project:
Ivy provides pre-built token handlers for common OAuth providers (for example Google, GitHub, and Claude). Add the corresponding NuGet package to your project:

- `Ivy.Auth.Google` - Provides `GoogleAuthTokenHandler`
- `Ivy.Auth.GitHub` - Provides `GitHubAuthTokenHandler`
- `Ivy.Auth.Claude` - Provides `ClaudeAuthTokenHandler` (Anthropic claude.ai OAuth)

**Configuration for Google Token Handler**

Expand Down Expand Up @@ -262,6 +265,7 @@ Ivy supports the following authentication providers. Click on any provider for d
- **[Microsoft Entra](02_MicrosoftEntra.md)** - Enterprise SSO, conditional access, and Microsoft Graph integration
- **[Authelia](02_Authelia.md)** - Self-hosted identity provider with LDAP and forward auth
- **[Sliplane](02_Sliplane.md)** - OAuth 2.0 sign-in for apps deployed or integrated with Sliplane
- **[Claude (Anthropic)](02_Claude.md)** - OAuth 2.0 sign-in with a Claude.ai account (PKCE)
- **[Basic Auth](02_BasicAuth.md)** - Simple username/password authentication for development and internal tools

## Examples
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
---
title: Claude (Anthropic)
searchHints:
- claude
- anthropic
- authentication
- oauth
- pkce
- claude.ai
---
# Claude (Anthropic) authentication provider

<Ingress>
Sign in to your Ivy application with Anthropic Claude using OAuth 2.0 and PKCE—the same style of browser login used by Claude Code and Claude on the web.
</Ingress>

## Overview

The Claude provider uses Anthropic’s OAuth endpoints to authenticate users with a **Claude.ai** account. The flow is an **authorization code** grant with **PKCE** (no static client secret required for public clients). Ivy exchanges the authorization code at the token endpoint and stores access (and optional refresh) tokens in the Ivy auth session.

> **Note:** You must register an OAuth client in the [Anthropic Console](https://console.anthropic.com/) and add your Ivy app’s callback URL. Exact console steps may change; follow Anthropic’s current documentation for creating an OAuth application and allowed redirect URIs.

## Configuration

### 1. Register your OAuth client

In the Anthropic Console, create an OAuth application and set the **redirect URI** to your Ivy auth callback, for example:

`https://localhost:5010/ivy/auth/callback`

Use the same scheme, host, port, and path that your app serves (HTTPS in production).

### 2. Install the package

```terminal
dotnet add package Ivy.Auth.Claude
```

### 3. Enable the provider

```csharp
using Ivy.Auth.Claude;

var server = new Server();

server.UseAuth<ClaudeAuthProvider>();

await server.RunAsync();
```

### 4. Secrets or environment variables

Configure these keys via [.NET user secrets](../../02_Concepts/14_Secrets.md) or environment variables.

| Key | Required | Description |
|-----|----------|-------------|
| **Claude:ClientId** | Yes | OAuth client ID from the Anthropic Console |
| **Claude:RedirectUri** | Yes | Must match the registered redirect URI (for example `https://localhost:5010/ivy/auth/callback`) |
| **Claude:ClientSecret** | No | Use if Anthropic issued a confidential client secret |
| **Claude:AuthorizationUrl** | No | Default: `https://claude.ai/oauth/authorize` |
| **Claude:TokenUrl** | No | Default: `https://console.anthropic.com/v1/oauth/token` |
| **Claude:Scope** | No | Default: `user:profile user:inference` |
| **Claude:UserInfoUrl** | No | Default: `https://api.anthropic.com/api/oauth/claude_cli/client_data` (profile JSON for `GetUserInfoAsync`; Anthropic may change this API) |
| **Claude:UserAgent** | Optional | Overrides the `User-Agent` header on HTTP calls (defaults to Ivy’s version string) |

**User secrets (development):**

```terminal
dotnet user-secrets set "Claude:ClientId" "your_client_id"
dotnet user-secrets set "Claude:RedirectUri" "https://localhost:5010/ivy/auth/callback"
```

If your client has a secret:

```terminal
dotnet user-secrets set "Claude:ClientSecret" "your_client_secret"
```

**Environment variables (production):** use double underscores, for example `Claude__ClientId`, `Claude__RedirectUri`.

As elsewhere in Ivy, values in user secrets take precedence over environment variables when both are set.

## Authentication flow

1. The user chooses **Claude** on the Ivy login screen.
2. Ivy opens the authorize URL with PKCE (`code_challenge` / `code_verifier`).
3. The user signs in on Anthropic and approves the app.
4. Anthropic redirects to `/ivy/auth/callback` with an authorization `code`.
5. Ivy exchanges the code at the token endpoint (JSON body, PKCE verifier).
6. Ivy resolves the user profile for `IAuthService` using the configured user-info URL where possible.

## Brokered sessions and token handler

The package includes `ClaudeAuthTokenHandler` registered for `OAuthProviders.Claude`, so brokered account tooling can refresh and validate Claude OAuth tokens when a refresh token is present.

## Troubleshooting

- **Redirect URI mismatch:** The value of **Claude:RedirectUri** must exactly match a redirect URI allowed for your OAuth client (including trailing slashes, `http` vs `https`, and port).
- **PKCE errors:** The code verifier is kept in memory for the login request. If the server restarts between starting OAuth and completing the callback, start sign-in again.
- **Profile or user info:** If `Claude:UserInfoUrl` returns 404 or a new JSON shape, set **Claude:UserInfoUrl** to the URL Anthropic documents for profile access, or rely on Ivy’s fallback identity until you adjust the URL.

## Related documentation

- [Authentication overview](01_AuthenticationOverview.md)
- [GitHub authentication](02_GitHub.md) (similar manual OAuth setup)
- [Sliplane authentication](02_Sliplane.md) (OAuth code flow reference)
1 change: 1 addition & 0 deletions src/Ivy.Test/Auth/ConnectedAccountButtonTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ public void FormatProviderName_HandlesEmptyOrNull(string? input)
[InlineData(OAuthProviders.Twitter, Icons.XTwitter)]
[InlineData(OAuthProviders.Figma, Icons.Figma)]
[InlineData(OAuthProviders.Notion, Icons.Notion)]
[InlineData(OAuthProviders.Claude, Icons.ClaudeCode)]
public void GetProviderIcon_ReturnsCorrectIcon(string provider, Icons expected)
{
Assert.Equal(expected, ConnectedAccountButton.GetProviderIcon(provider));
Expand Down
1 change: 1 addition & 0 deletions src/Ivy/Auth/ConnectedAccountButton.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ internal static string FormatProviderName(string provider) =>
OAuthProviders.Twitter => Icons.XTwitter,
OAuthProviders.Figma => Icons.Figma,
OAuthProviders.Notion => Icons.Notion,
OAuthProviders.Claude => Icons.ClaudeCode,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Sus because of Code part

_ => Icons.Link,
};
}
3 changes: 3 additions & 0 deletions src/Ivy/Auth/OAuthProviders.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,7 @@ public static class OAuthProviders
public const string GitLab = "gitlab";
public const string Bitbucket = "bitbucket";
public const string Sliplane = "sliplane";

/// <summary>Anthropic Claude (claude.ai) OAuth</summary>
public const string Claude = "claude";
}
210 changes: 210 additions & 0 deletions src/auth/Ivy.Auth.Claude/ClaudeAuthProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Ivy.Core;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;

namespace Ivy.Auth.Claude;

/// <summary>Anthropic Claude.ai OAuth 2.0 with PKCE (authorization code flow)</summary>
public class ClaudeAuthProvider : ClaudeAuthTokenHandler, IAuthProvider
{
private readonly string _redirectUri;
private readonly string _authorizationUrl;
private readonly string _scope;
private string? _codeVerifier;

/// <summary>Initialize Claude auth provider</summary>
public ClaudeAuthProvider(IConfiguration configuration, ILogger<ClaudeAuthTokenHandler>? logger = null)
: base(configuration, logger)
{
_redirectUri = configuration.GetValue<string>("Claude:RedirectUri")
?? throw new InvalidOperationException(
"Missing required configuration: 'Claude:RedirectUri'. Must match the callback URL registered for your OAuth client (for example https://localhost:5010/ivy/auth/callback).");

_authorizationUrl = configuration.GetValue<string>("Claude:AuthorizationUrl")
?? "https://claude.ai/oauth/authorize";

_scope = configuration.GetValue<string>("Claude:Scope")
?? "user:profile user:inference";
}

/// <inheritdoc />
public Task<LoginResult> LoginAsync(
IAuthSession authSession,
string email,
string password,
CancellationToken cancellationToken = default)
{
throw new NotSupportedException(
"Claude authentication only supports OAuth. Use GetOAuthUriAsync and HandleOAuthCallbackAsync instead.");
}

/// <inheritdoc />
public Task<Uri> GetOAuthUriAsync(
IAuthSession authSession,
AuthOption option,
WebhookEndpoint callback,
CancellationToken cancellationToken = default)
{
_codeVerifier = GenerateCodeVerifier();
var codeChallenge = GenerateCodeChallenge(_codeVerifier);

var query = string.Join("&", new[]
{
$"code=true",
$"client_id={Uri.EscapeDataString(ClientId)}",
"response_type=code",
$"redirect_uri={Uri.EscapeDataString(_redirectUri)}",
$"scope={Uri.EscapeDataString(_scope)}",
$"code_challenge={Uri.EscapeDataString(codeChallenge)}",
"code_challenge_method=S256",
$"state={Uri.EscapeDataString(callback.Id)}",
});

var uri = new UriBuilder(_authorizationUrl) { Query = query }.Uri;
return Task.FromResult(uri);
}

/// <inheritdoc />
public async Task<AuthToken?> HandleOAuthCallbackAsync(
IAuthSession authSession,
HttpRequest request,
CancellationToken cancellationToken = default)
{
var code = request.Query["code"].ToString();
var error = request.Query["error"].ToString();
var errorDescription = request.Query["error_description"].ToString();

if (error.Length > 0 || errorDescription.Length > 0)
throw new ClaudeOAuthException(error, errorDescription);

if (string.IsNullOrEmpty(code))
{
throw new InvalidOperationException(
"No authorization code in the OAuth callback. The user may have denied access or the redirect URI may be misconfigured.");
}

if (string.IsNullOrEmpty(_codeVerifier))
{
throw new InvalidOperationException(
"PKCE verifier is missing. Start sign-in again from your app (the OAuth flow was not initiated in this process).");
}

try
{
var state = request.Query["state"].ToString();
var exchange = new AuthorizationCodeTokenRequest
{
GrantType = "authorization_code",
ClientId = ClientId,
ClientSecret = ClientSecret,
Code = code,
RedirectUri = _redirectUri,
CodeVerifier = _codeVerifier,
State = string.IsNullOrEmpty(state) ? null : state
};

using var response = await PostJsonTokenRequestAsync(exchange, cancellationToken).ConfigureAwait(false);
var responseContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);

if (!response.IsSuccessStatusCode)
{
throw new HttpRequestException(
$"Claude token exchange failed: {(int)response.StatusCode} {response.ReasonPhrase}. {responseContent}",
null,
response.StatusCode);
}

var tokenResponse = JsonSerializer.Deserialize<ClaudeTokenResponse>(responseContent);
if (tokenResponse?.Error != null)
{
throw new ClaudeOAuthException(tokenResponse.Error, tokenResponse.ErrorDescription);
}

if (string.IsNullOrWhiteSpace(tokenResponse?.AccessToken))
return null;

_codeVerifier = null;

return new AuthToken(
tokenResponse.AccessToken,
tokenResponse.RefreshToken
);
}
catch (HttpRequestException)
{
throw;
}
catch (ClaudeOAuthException)
{
throw;
}
catch (Exception ex)
{
throw new InvalidOperationException($"Claude OAuth token exchange failed: {ex.Message}", ex);
}
Comment on lines +144 to +147
}

/// <inheritdoc />
public Task LogoutAsync(IAuthSession authSession, CancellationToken cancellationToken = default)
=> Task.CompletedTask;

/// <inheritdoc />
public AuthOption[] GetAuthOptions() =>
[new AuthOption(AuthFlow.OAuth, "Claude", OAuthProviders.Claude, Icons.ClaudeCode)];

/// <inheritdoc />
public Task<BrokeredSessionsResult> GetBrokeredSessionsAsync(
IAuthSession authSession,
bool skipCache = false,
CancellationToken cancellationToken = default)
=> Task.FromResult(BrokeredSessionsResult.Success([]));

private static string GenerateCodeVerifier()
{
var bytes = new byte[32];
System.Security.Cryptography.RandomNumberGenerator.Fill(bytes);
return Convert.ToBase64String(bytes)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
}

private static string GenerateCodeChallenge(string codeVerifier)
{
var bytes = System.Text.Encoding.UTF8.GetBytes(codeVerifier);
var hash = System.Security.Cryptography.SHA256.HashData(bytes);
return Convert.ToBase64String(hash)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
}

private sealed class AuthorizationCodeTokenRequest
{
[JsonPropertyName("grant_type")]
public string GrantType { get; init; } = "";

[JsonPropertyName("client_id")]
public string ClientId { get; init; } = "";

[JsonPropertyName("client_secret")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? ClientSecret { get; init; }

[JsonPropertyName("code")]
public string Code { get; init; } = "";

[JsonPropertyName("redirect_uri")]
public string RedirectUri { get; init; } = "";

[JsonPropertyName("code_verifier")]
public string CodeVerifier { get; init; } = "";

[JsonPropertyName("state")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? State { get; init; }
}
}
Loading
Loading