mirror of
https://github.com/patriceckhart/zot.git
synced 2026-06-27 05:46:34 +02:00
270 lines
8.4 KiB
Go
270 lines
8.4 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// OAuthProvider configures a single subscription OAuth flow.
|
|
//
|
|
// WARNING: the client ids used here belong to the official first-party
|
|
// CLIs (Claude Code for Anthropic, Codex CLI for OpenAI). Using them from
|
|
// third-party software is against the respective terms of service and
|
|
// may be revoked at any time. zot exposes this only behind
|
|
// --experimental-oauth.
|
|
type OAuthProvider struct {
|
|
Name string
|
|
AuthURL string
|
|
TokenURL string
|
|
ClientID string
|
|
Scopes []string
|
|
RedirectHost string // usually "localhost"
|
|
RedirectPort int // must match the client registration at the provider
|
|
RedirectPath string // e.g. "/callback" or "/auth/callback"
|
|
ExtraAuthArgs map[string]string
|
|
ExtraTokenArgs map[string]string
|
|
// TokenBodyJSON selects JSON body vs application/x-www-form-urlencoded
|
|
// for token requests. Anthropic requires JSON; OpenAI requires form.
|
|
TokenBodyJSON bool
|
|
// StateEqualsVerifier: if true, the OAuth state parameter is set to the
|
|
// PKCE verifier instead of a random value (Anthropic's Claude Code does this).
|
|
StateEqualsVerifier bool
|
|
// IncludeStateInTokenRequest: if true, the state value is included in
|
|
// the token-exchange payload (Anthropic requires this; OpenAI does not).
|
|
IncludeStateInTokenRequest bool
|
|
}
|
|
|
|
// RedirectURI returns the full redirect URI for this provider.
|
|
func (p OAuthProvider) RedirectURI() string {
|
|
return fmt.Sprintf("http://%s:%d%s", p.RedirectHost, p.RedirectPort, p.RedirectPath)
|
|
}
|
|
|
|
// Built-in providers. Values mirror Anthropic's Claude Code CLI and
|
|
// OpenAI's Codex CLI. Changing any of these fields will most likely
|
|
// cause the flow to fail with redirect_uri_mismatch or invalid_client.
|
|
var (
|
|
// Anthropic Claude Pro/Max: used by the Claude Code CLI.
|
|
AnthropicOAuth = OAuthProvider{
|
|
Name: "anthropic",
|
|
AuthURL: "https://claude.ai/oauth/authorize",
|
|
TokenURL: "https://platform.claude.com/v1/oauth/token",
|
|
ClientID: "9d1c250a-e61b-44d9-88ed-5944d1962f5e",
|
|
Scopes: []string{"org:create_api_key", "user:profile", "user:inference"},
|
|
RedirectHost: "localhost",
|
|
RedirectPort: 53692,
|
|
RedirectPath: "/callback",
|
|
ExtraAuthArgs: map[string]string{
|
|
"code": "true",
|
|
},
|
|
TokenBodyJSON: true,
|
|
StateEqualsVerifier: true,
|
|
IncludeStateInTokenRequest: true,
|
|
}
|
|
|
|
// OpenAI ChatGPT subscription: used by Codex CLI.
|
|
OpenAIOAuth = OAuthProvider{
|
|
Name: "openai",
|
|
AuthURL: "https://auth.openai.com/oauth/authorize",
|
|
TokenURL: "https://auth.openai.com/oauth/token",
|
|
ClientID: "app_EMoamEEZ73f0CkXaXp7hrann",
|
|
Scopes: []string{"openid", "profile", "email", "offline_access"},
|
|
RedirectHost: "localhost",
|
|
RedirectPort: 1455,
|
|
RedirectPath: "/auth/callback",
|
|
ExtraAuthArgs: map[string]string{
|
|
"id_token_add_organizations": "true",
|
|
"codex_cli_simplified_flow": "true",
|
|
"originator": "zot",
|
|
},
|
|
}
|
|
)
|
|
|
|
// PKCE holds a verifier/challenge pair.
|
|
type PKCE struct {
|
|
Verifier string
|
|
Challenge string
|
|
}
|
|
|
|
// NewPKCE generates a random PKCE pair using SHA-256.
|
|
func NewPKCE() (PKCE, error) {
|
|
buf := make([]byte, 32)
|
|
if _, err := rand.Read(buf); err != nil {
|
|
return PKCE{}, err
|
|
}
|
|
verifier := base64.RawURLEncoding.EncodeToString(buf)
|
|
sum := sha256.Sum256([]byte(verifier))
|
|
challenge := base64.RawURLEncoding.EncodeToString(sum[:])
|
|
return PKCE{Verifier: verifier, Challenge: challenge}, nil
|
|
}
|
|
|
|
// RandomHex returns a random hex string, used as an OAuth state value.
|
|
func RandomHex(n int) (string, error) {
|
|
buf := make([]byte, n)
|
|
if _, err := rand.Read(buf); err != nil {
|
|
return "", err
|
|
}
|
|
return hex.EncodeToString(buf), nil
|
|
}
|
|
|
|
// AuthorizeURL builds the browser URL for the initial authorization
|
|
// request. The returned state value is what we expect to see on the
|
|
// callback and what the caller should persist until that arrives.
|
|
func (p OAuthProvider) AuthorizeURL(pkce PKCE) (authURL, state string, err error) {
|
|
if p.StateEqualsVerifier {
|
|
state = pkce.Verifier
|
|
} else {
|
|
state, err = RandomHex(16)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
}
|
|
q := url.Values{}
|
|
q.Set("response_type", "code")
|
|
q.Set("client_id", p.ClientID)
|
|
q.Set("redirect_uri", p.RedirectURI())
|
|
q.Set("scope", strings.Join(p.Scopes, " "))
|
|
q.Set("code_challenge", pkce.Challenge)
|
|
q.Set("code_challenge_method", "S256")
|
|
q.Set("state", state)
|
|
for k, v := range p.ExtraAuthArgs {
|
|
q.Set(k, v)
|
|
}
|
|
return p.AuthURL + "?" + q.Encode(), state, nil
|
|
}
|
|
|
|
// Exchange swaps an authorization code for an OAuthToken.
|
|
func (p OAuthProvider) Exchange(ctx context.Context, code, state string, pkce PKCE) (*OAuthToken, error) {
|
|
payload := map[string]string{
|
|
"grant_type": "authorization_code",
|
|
"client_id": p.ClientID,
|
|
"code": code,
|
|
"redirect_uri": p.RedirectURI(),
|
|
"code_verifier": pkce.Verifier,
|
|
}
|
|
if p.IncludeStateInTokenRequest {
|
|
payload["state"] = state
|
|
}
|
|
for k, v := range p.ExtraTokenArgs {
|
|
payload[k] = v
|
|
}
|
|
return p.doTokenRequest(ctx, payload)
|
|
}
|
|
|
|
// Refresh uses a refresh_token to obtain a new access token.
|
|
func (p OAuthProvider) Refresh(ctx context.Context, refreshToken string) (*OAuthToken, error) {
|
|
payload := map[string]string{
|
|
"grant_type": "refresh_token",
|
|
"client_id": p.ClientID,
|
|
"refresh_token": refreshToken,
|
|
}
|
|
for k, v := range p.ExtraTokenArgs {
|
|
payload[k] = v
|
|
}
|
|
return p.doTokenRequest(ctx, payload)
|
|
}
|
|
|
|
// ExtractOpenAIAccountID parses the chatgpt_account_id claim from an
|
|
// OpenAI id_token JWT. The claim sits under "https://api.openai.com/auth"
|
|
// in the JWT payload. Returns "" if the token is malformed or lacks
|
|
// the claim.
|
|
func ExtractOpenAIAccountID(idToken string) string {
|
|
parts := strings.Split(idToken, ".")
|
|
if len(parts) < 2 {
|
|
return ""
|
|
}
|
|
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
|
if err != nil {
|
|
// Some tokens use standard base64 with padding.
|
|
payload, err = base64.StdEncoding.DecodeString(parts[1])
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
}
|
|
var body map[string]interface{}
|
|
if err := json.Unmarshal(payload, &body); err != nil {
|
|
return ""
|
|
}
|
|
authClaim, _ := body["https://api.openai.com/auth"].(map[string]interface{})
|
|
if authClaim == nil {
|
|
return ""
|
|
}
|
|
id, _ := authClaim["chatgpt_account_id"].(string)
|
|
return id
|
|
}
|
|
|
|
func (p OAuthProvider) doTokenRequest(ctx context.Context, payload map[string]string) (*OAuthToken, error) {
|
|
var (
|
|
req *http.Request
|
|
err error
|
|
contentType string
|
|
)
|
|
if p.TokenBodyJSON {
|
|
body, _ := json.Marshal(payload)
|
|
req, err = http.NewRequestWithContext(ctx, "POST", p.TokenURL, strings.NewReader(string(body)))
|
|
contentType = "application/json"
|
|
} else {
|
|
form := url.Values{}
|
|
for k, v := range payload {
|
|
form.Set(k, v)
|
|
}
|
|
req, err = http.NewRequestWithContext(ctx, "POST", p.TokenURL, strings.NewReader(form.Encode()))
|
|
contentType = "application/x-www-form-urlencoded"
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("content-type", contentType)
|
|
req.Header.Set("accept", "application/json")
|
|
|
|
client := &http.Client{Timeout: 30 * time.Second}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("token request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
respBody, _ := io.ReadAll(resp.Body)
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return nil, fmt.Errorf("token http %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody)))
|
|
}
|
|
var raw struct {
|
|
AccessToken string `json:"access_token"`
|
|
RefreshToken string `json:"refresh_token"`
|
|
TokenType string `json:"token_type"`
|
|
ExpiresIn int `json:"expires_in"`
|
|
Scope string `json:"scope"`
|
|
IDToken string `json:"id_token"`
|
|
}
|
|
if err := json.Unmarshal(respBody, &raw); err != nil {
|
|
return nil, fmt.Errorf("parse token response: %w: %s", err, string(respBody))
|
|
}
|
|
if raw.AccessToken == "" {
|
|
return nil, fmt.Errorf("empty access token in response: %s", string(respBody))
|
|
}
|
|
tok := &OAuthToken{
|
|
AccessToken: raw.AccessToken,
|
|
RefreshToken: raw.RefreshToken,
|
|
TokenType: raw.TokenType,
|
|
Scope: raw.Scope,
|
|
ClientID: p.ClientID,
|
|
}
|
|
if raw.ExpiresIn > 0 {
|
|
tok.Expiry = time.Now().Add(time.Duration(raw.ExpiresIn) * time.Second)
|
|
}
|
|
// Capture id_token & account id for providers that use OIDC (OpenAI).
|
|
// This lets the OpenAI Codex provider send chatgpt-account-id.
|
|
if raw.IDToken != "" {
|
|
tok.IDToken = raw.IDToken
|
|
tok.AccountID = ExtractOpenAIAccountID(raw.IDToken)
|
|
}
|
|
return tok, nil
|
|
}
|