-
Notifications
You must be signed in to change notification settings - Fork 0
feat: add Anthropic account auth and usage #20
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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) | ||
| 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), | ||
| ) | ||
| } | ||
| 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) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Suggestion: The login branch parses Severity Level: Major
|
||
| } | ||
| client, err := prismClient(ctx, commonOptions{}) | ||
| if err != nil { | ||
| return err | ||
|
|
@@ -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. | ||
|
|
||
There was a problem hiding this comment.
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 useslocalhost. On systems where the browser resolveslocalhostto 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⚠️
Prompt for AI Agent 🤖