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
23 changes: 21 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,33 @@ crcl login

## Usage

Show ChatGPT and OpenCode Go usage together:
Show ChatGPT, Anthropic, and OpenCode Go usage together:

```sh
prism usage
```

Each provider is fetched independently, so an unavailable login does not hide
usage from the other provider.
usage from the other providers.

## Anthropic

Register each Claude subscription account separately and show its current quota:

```sh
prism anthropic auth login
prism anthropic auth list
prism anthropic usage
prism anthropic auth remove <credential-id>
```

`prism claude login` is a short alias for `prism anthropic auth login`. The
browser handles account selection, SSO, and MFA. Prism does not import the
credential used by an existing Claude Code login.

This first release stores and refreshes Anthropic credentials for account and
usage management. `prism claude` inference continues to use Prism's existing
providers until native Anthropic routing is added separately.

## ChatGPT

Expand Down
182 changes: 182 additions & 0 deletions internal/anthropic/oauth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
package anthropic

import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"html"
"net"
"net/http"
"net/url"
"os/exec"
"runtime"
"strings"
"sync"
"time"
)

const clientID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
const scope = "user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload"

type AuthorizationGrant struct {
AuthorizationCode string `json:"authorization_code"`
CodeVerifier string `json:"code_verifier"`
RedirectURI string `json:"redirect_uri"`
State string `json:"state"`
}

type BrowserOpener func(string) error

type OAuth struct {
OpenBrowser BrowserOpener
Timeout time.Duration
}

type loginResult struct {
grant AuthorizationGrant
err error
}

func (o OAuth) Login(ctx context.Context) (AuthorizationGrant, error) {
timeout := o.Timeout
if timeout == 0 {
timeout = 5 * time.Minute
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()

verifier, challenge, err := generatePKCE()
if err != nil {
return AuthorizationGrant{}, fmt.Errorf("generate PKCE: %w", err)
}
state, err := randomBase64URL(32)
if err != nil {
return AuthorizationGrant{}, fmt.Errorf("generate OAuth state: %w", err)
}
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return AuthorizationGrant{}, errors.New("could not start the Anthropic OAuth callback")
}
defer listener.Close()

port := listener.Addr().(*net.TCPAddr).Port
redirectURI := fmt.Sprintf("http://localhost:%d/callback", port)
expectedHost := fmt.Sprintf("localhost:%d", port)
Comment on lines +59 to +67

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggestion: The callback listener is bound exclusively to IPv4 127.0.0.1, but the registered redirect uses localhost. On systems where the browser resolves localhost to IPv6 (::1) without falling back to IPv4, the browser cannot reach this listener and every Anthropic login attempt waits until timeout. Bind to a loopback address compatible with the redirect or use an IPv4 redirect hostname consistently. [api mismatch]

Severity Level: Major ⚠️
- ❌ Anthropic browser login can wait five minutes and time out.
- ❌ No Anthropic credential is saved after timeout.
- ⚠️ Failure affects hosts with IPv6-only localhost resolution.

Use CodeAnt Skill

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** internal/anthropic/oauth.go
**Line:** 59:67
**Comment:**
	*Api Mismatch: The callback listener is bound exclusively to IPv4 `127.0.0.1`, but the registered redirect uses `localhost`. On systems where the browser resolves `localhost` to IPv6 (`::1`) without falling back to IPv4, the browser cannot reach this listener and every Anthropic login attempt waits until timeout. Bind to a loopback address compatible with the redirect or use an IPv4 redirect hostname consistently.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

result := make(chan loginResult, 1)
var once sync.Once
mux := http.NewServeMux()
server := &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second}
mux.HandleFunc("/callback", func(response http.ResponseWriter, request *http.Request) {
if request.Method != http.MethodGet || !strings.EqualFold(request.Host, expectedHost) {
http.Error(response, "Invalid callback", http.StatusBadRequest)
return
}
query := request.URL.Query()
if providerError := query.Get("error"); providerError != "" {
message := query.Get("error_description")
if message == "" {
message = providerError
}
once.Do(func() { result <- loginResult{err: fmt.Errorf("Anthropic authorization failed: %s", message)} })
writeCallbackPage(response, "Login failed", "Anthropic authorization did not complete.")
return
}
if query.Get("state") != state {
once.Do(func() { result <- loginResult{err: errors.New("OAuth callback state did not match")} })
http.Error(response, "Invalid callback state", http.StatusBadRequest)
return
}
code := query.Get("code")
if code == "" {
once.Do(func() { result <- loginResult{err: errors.New("OAuth callback did not include a code")} })
http.Error(response, "Missing authorization code", http.StatusBadRequest)
return
}
once.Do(func() {
result <- loginResult{grant: AuthorizationGrant{
AuthorizationCode: code,
CodeVerifier: verifier,
RedirectURI: redirectURI,
State: state,
}}
})
writeCallbackPage(response, "Login complete", "Return to Prism to finish saving this account.")
})
go func() { _ = server.Serve(listener) }()
defer func() {
shutdown, stop := context.WithTimeout(context.Background(), time.Second)
defer stop()
_ = server.Shutdown(shutdown)
}()

opener := o.OpenBrowser
if opener == nil {
opener = openBrowser
}
if err := opener(authorizeURL(redirectURI, challenge, state)); err != nil {
return AuthorizationGrant{}, fmt.Errorf("open Anthropic login: %w", err)
}

select {
case outcome := <-result:
return outcome.grant, outcome.err
case <-ctx.Done():
return AuthorizationGrant{}, errors.New("Anthropic login timed out or was cancelled")
}
}

func authorizeURL(redirectURI string, challenge string, state string) string {
query := url.Values{
"code": {"true"},
"client_id": {clientID},
"response_type": {"code"},
"redirect_uri": {redirectURI},
"scope": {scope},
"code_challenge": {challenge},
"code_challenge_method": {"S256"},
"state": {state},
}
return "https://claude.com/cai/oauth/authorize?" + query.Encode()
}

func generatePKCE() (string, string, error) {
verifier, err := randomBase64URL(48)
if err != nil {
return "", "", err
}
sum := sha256.Sum256([]byte(verifier))
return verifier, base64.RawURLEncoding.EncodeToString(sum[:]), nil
}

func randomBase64URL(size int) (string, error) {
value := make([]byte, size)
if _, err := rand.Read(value); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(value), nil
}

func openBrowser(target string) error {
var command *exec.Cmd
switch runtime.GOOS {
case "darwin":
command = exec.Command("open", target)
case "windows":
command = exec.Command("rundll32", "url.dll,FileProtocolHandler", target)
default:
command = exec.Command("xdg-open", target)
}
return command.Run()
}

func writeCallbackPage(response http.ResponseWriter, title string, message string) {
response.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = fmt.Fprintf(
response,
"<!doctype html><meta charset=utf-8><title>%s</title><h1>%s</h1><p>%s</p><script>setTimeout(()=>window.close(),2000)</script>",
html.EscapeString(title), html.EscapeString(title), html.EscapeString(message),
)
}
87 changes: 87 additions & 0 deletions internal/anthropic/oauth_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package anthropic

import (
"context"
"crypto/sha256"
"encoding/base64"
"io"
"net/http"
"net/url"
"strings"
"testing"
"time"
)

func TestLoginCapturesAValidatedLoopbackAuthorizationCode(t *testing.T) {
var authorize *url.URL
oauth := OAuth{
Timeout: time.Second,
OpenBrowser: func(target string) error {
var err error
authorize, err = url.Parse(target)
if err != nil {
return err
}
redirect := authorize.Query().Get("redirect_uri")
state := authorize.Query().Get("state")
go func() {
response, requestErr := http.Get(redirect + "?code=one-time-code&state=" + url.QueryEscape(state))
if requestErr == nil {
_, _ = io.Copy(io.Discard, response.Body)
_ = response.Body.Close()
}
}()
return nil
},
}

grant, err := oauth.Login(context.Background())
if err != nil {
t.Fatal(err)
}
if authorize.Host != "claude.com" || authorize.Path != "/cai/oauth/authorize" {
t.Fatalf("authorize URL = %s", authorize)
}
query := authorize.Query()
for key, want := range map[string]string{
"code": "true",
"client_id": clientID,
"response_type": "code",
"code_challenge_method": "S256",
"scope": scope,
} {
if query.Get(key) != want {
t.Fatalf("%s = %q, want %q", key, query.Get(key), want)
}
}
if grant.AuthorizationCode != "one-time-code" || grant.State != query.Get("state") || grant.RedirectURI != query.Get("redirect_uri") {
t.Fatalf("grant = %#v", grant)
}
if !strings.HasPrefix(grant.RedirectURI, "http://localhost:") || !strings.HasSuffix(grant.RedirectURI, "/callback") {
t.Fatalf("redirect URI = %q", grant.RedirectURI)
}
sum := sha256.Sum256([]byte(grant.CodeVerifier))
if query.Get("code_challenge") != base64.RawURLEncoding.EncodeToString(sum[:]) {
t.Fatal("PKCE challenge does not match the verifier")
}
}

func TestLoginRejectsACallbackWithTheWrongState(t *testing.T) {
oauth := OAuth{
Timeout: 100 * time.Millisecond,
OpenBrowser: func(target string) error {
authorize, _ := url.Parse(target)
go func() {
response, err := http.Get(authorize.Query().Get("redirect_uri") + "?code=one-time-code&state=wrong")
if err == nil {
_ = response.Body.Close()
}
}()
return nil
},
}
_, err := oauth.Login(context.Background())
if err == nil || !strings.Contains(err.Error(), "state did not match") {
t.Fatalf("error = %v", err)
}
}
3 changes: 2 additions & 1 deletion internal/api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ type UsageAccount struct {
Name string `json:"name"`
Plan *string `json:"plan"`
ObservedAt string `json:"observed_at"`
Status string `json:"status"`
Limits []UsageLimit `json:"limits"`
Error *UsageError `json:"error"`
}
Expand All @@ -55,7 +56,7 @@ type ProviderUsage struct {
}

var providers = map[string]struct{}{
"chatgpt": {}, "copilot": {}, "gemini": {}, "gemini-ai": {},
"chatgpt": {}, "anthropic": {}, "copilot": {}, "gemini": {}, "gemini-ai": {},
"groq": {}, "mistral": {}, "deepseek": {}, "opencode-go": {},
"cloudflare": {}, "vercel": {}, "gemini-app": {},
}
Expand Down
15 changes: 15 additions & 0 deletions internal/cli/claude.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,20 @@ func runClaudeCommand(ctx context.Context, args []string, stdout io.Writer, stde
printClaudeHelp(stdout)
return nil
}
if len(args) > 0 && args[0] == "login" {
options, positionals, err := parseCommonOptions(args[1:])
if err != nil {
return err
}
if len(positionals) != 0 || options.name != "" || options.providerAccountID != "" || options.ownerID != "" {
return errors.New("usage: prism claude login [--profile <name>]")
}
client, err := prismClient(ctx, options)
if err != nil {
return err
}
return loginProvider(ctx, "anthropic", client, stdout)
Comment on lines +33 to +45

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggestion: The login branch parses --help into options.help but never handles it before resolving credentials and invoking loginProvider. As a result, prism claude login --help starts the Anthropic OAuth flow instead of displaying help, unlike the standard provider command path. Check options.help and print the login usage before calling prismClient. [incorrect condition logic]

Severity Level: Major ⚠️
- ⚠️ `prism claude login --help` launches Anthropic OAuth unexpectedly.
- ⚠️ Users cannot inspect alias-specific login usage safely.
- ⚠️ Existing provider help handling correctly avoids credential resolution.

Use CodeAnt Skill

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** internal/cli/claude.go
**Line:** 33:45
**Comment:**
	*Incorrect Condition Logic: The login branch parses `--help` into `options.help` but never handles it before resolving credentials and invoking `loginProvider`. As a result, `prism claude login --help` starts the Anthropic OAuth flow instead of displaying help, unlike the standard provider command path. Check `options.help` and print the login usage before calling `prismClient`.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

}
client, err := prismClient(ctx, commonOptions{})
if err != nil {
return err
Expand Down Expand Up @@ -160,6 +174,7 @@ func claudeEnvironment(environment []string, baseURL string, credential string)

func printClaudeHelp(output io.Writer) {
_, _ = fmt.Fprintln(output, `Usage:
prism claude login [--profile <name>]
prism claude [claude arguments...]

Pass --model with any model supported by Prism.
Expand Down
1 change: 1 addition & 0 deletions internal/cli/claude_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ func TestClaudeHelpDoesNotResolveCredentials(t *testing.T) {
t.Fatal(err)
}
if !strings.Contains(stdout.String(), "prism claude") ||
!strings.Contains(stdout.String(), "prism claude login") ||
!strings.Contains(stdout.String(), "crcl use <profile>") ||
!strings.Contains(stdout.String(), "claude --help") ||
strings.Contains(stdout.String(), "prism claude [--profile") {
Expand Down
Loading
Loading