mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-02-03 04:50:52 +08:00
rebuild branch
This commit is contained in:
171
internal/auth/codex/errors.go
Normal file
171
internal/auth/codex/errors.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package codex
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// OAuthError represents an OAuth-specific error.
|
||||
type OAuthError struct {
|
||||
// Code is the OAuth error code.
|
||||
Code string `json:"error"`
|
||||
// Description is a human-readable description of the error.
|
||||
Description string `json:"error_description,omitempty"`
|
||||
// URI is a URI identifying a human-readable web page with information about the error.
|
||||
URI string `json:"error_uri,omitempty"`
|
||||
// StatusCode is the HTTP status code associated with the error.
|
||||
StatusCode int `json:"-"`
|
||||
}
|
||||
|
||||
// Error returns a string representation of the OAuth error.
|
||||
func (e *OAuthError) Error() string {
|
||||
if e.Description != "" {
|
||||
return fmt.Sprintf("OAuth error %s: %s", e.Code, e.Description)
|
||||
}
|
||||
return fmt.Sprintf("OAuth error: %s", e.Code)
|
||||
}
|
||||
|
||||
// NewOAuthError creates a new OAuth error with the specified code, description, and status code.
|
||||
func NewOAuthError(code, description string, statusCode int) *OAuthError {
|
||||
return &OAuthError{
|
||||
Code: code,
|
||||
Description: description,
|
||||
StatusCode: statusCode,
|
||||
}
|
||||
}
|
||||
|
||||
// AuthenticationError represents authentication-related errors.
|
||||
type AuthenticationError struct {
|
||||
// Type is the type of authentication error.
|
||||
Type string `json:"type"`
|
||||
// Message is a human-readable message describing the error.
|
||||
Message string `json:"message"`
|
||||
// Code is the HTTP status code associated with the error.
|
||||
Code int `json:"code"`
|
||||
// Cause is the underlying error that caused this authentication error.
|
||||
Cause error `json:"-"`
|
||||
}
|
||||
|
||||
// Error returns a string representation of the authentication error.
|
||||
func (e *AuthenticationError) Error() string {
|
||||
if e.Cause != nil {
|
||||
return fmt.Sprintf("%s: %s (caused by: %v)", e.Type, e.Message, e.Cause)
|
||||
}
|
||||
return fmt.Sprintf("%s: %s", e.Type, e.Message)
|
||||
}
|
||||
|
||||
// Common authentication error types.
|
||||
var (
|
||||
// ErrTokenExpired = &AuthenticationError{
|
||||
// Type: "token_expired",
|
||||
// Message: "Access token has expired",
|
||||
// Code: http.StatusUnauthorized,
|
||||
// }
|
||||
|
||||
// ErrInvalidState represents an error for invalid OAuth state parameter.
|
||||
ErrInvalidState = &AuthenticationError{
|
||||
Type: "invalid_state",
|
||||
Message: "OAuth state parameter is invalid",
|
||||
Code: http.StatusBadRequest,
|
||||
}
|
||||
|
||||
// ErrCodeExchangeFailed represents an error when exchanging authorization code for tokens fails.
|
||||
ErrCodeExchangeFailed = &AuthenticationError{
|
||||
Type: "code_exchange_failed",
|
||||
Message: "Failed to exchange authorization code for tokens",
|
||||
Code: http.StatusBadRequest,
|
||||
}
|
||||
|
||||
// ErrServerStartFailed represents an error when starting the OAuth callback server fails.
|
||||
ErrServerStartFailed = &AuthenticationError{
|
||||
Type: "server_start_failed",
|
||||
Message: "Failed to start OAuth callback server",
|
||||
Code: http.StatusInternalServerError,
|
||||
}
|
||||
|
||||
// ErrPortInUse represents an error when the OAuth callback port is already in use.
|
||||
ErrPortInUse = &AuthenticationError{
|
||||
Type: "port_in_use",
|
||||
Message: "OAuth callback port is already in use",
|
||||
Code: 13, // Special exit code for port-in-use
|
||||
}
|
||||
|
||||
// ErrCallbackTimeout represents an error when waiting for OAuth callback times out.
|
||||
ErrCallbackTimeout = &AuthenticationError{
|
||||
Type: "callback_timeout",
|
||||
Message: "Timeout waiting for OAuth callback",
|
||||
Code: http.StatusRequestTimeout,
|
||||
}
|
||||
|
||||
// ErrBrowserOpenFailed represents an error when opening the browser for authentication fails.
|
||||
ErrBrowserOpenFailed = &AuthenticationError{
|
||||
Type: "browser_open_failed",
|
||||
Message: "Failed to open browser for authentication",
|
||||
Code: http.StatusInternalServerError,
|
||||
}
|
||||
)
|
||||
|
||||
// NewAuthenticationError creates a new authentication error with a cause based on a base error.
|
||||
func NewAuthenticationError(baseErr *AuthenticationError, cause error) *AuthenticationError {
|
||||
return &AuthenticationError{
|
||||
Type: baseErr.Type,
|
||||
Message: baseErr.Message,
|
||||
Code: baseErr.Code,
|
||||
Cause: cause,
|
||||
}
|
||||
}
|
||||
|
||||
// IsAuthenticationError checks if an error is an authentication error.
|
||||
func IsAuthenticationError(err error) bool {
|
||||
var authenticationError *AuthenticationError
|
||||
ok := errors.As(err, &authenticationError)
|
||||
return ok
|
||||
}
|
||||
|
||||
// IsOAuthError checks if an error is an OAuth error.
|
||||
func IsOAuthError(err error) bool {
|
||||
var oAuthError *OAuthError
|
||||
ok := errors.As(err, &oAuthError)
|
||||
return ok
|
||||
}
|
||||
|
||||
// GetUserFriendlyMessage returns a user-friendly error message based on the error type.
|
||||
func GetUserFriendlyMessage(err error) string {
|
||||
switch {
|
||||
case IsAuthenticationError(err):
|
||||
var authErr *AuthenticationError
|
||||
errors.As(err, &authErr)
|
||||
switch authErr.Type {
|
||||
case "token_expired":
|
||||
return "Your authentication has expired. Please log in again."
|
||||
case "token_invalid":
|
||||
return "Your authentication is invalid. Please log in again."
|
||||
case "authentication_required":
|
||||
return "Please log in to continue."
|
||||
case "port_in_use":
|
||||
return "The required port is already in use. Please close any applications using port 3000 and try again."
|
||||
case "callback_timeout":
|
||||
return "Authentication timed out. Please try again."
|
||||
case "browser_open_failed":
|
||||
return "Could not open your browser automatically. Please copy and paste the URL manually."
|
||||
default:
|
||||
return "Authentication failed. Please try again."
|
||||
}
|
||||
case IsOAuthError(err):
|
||||
var oauthErr *OAuthError
|
||||
errors.As(err, &oauthErr)
|
||||
switch oauthErr.Code {
|
||||
case "access_denied":
|
||||
return "Authentication was cancelled or denied."
|
||||
case "invalid_request":
|
||||
return "Invalid authentication request. Please try again."
|
||||
case "server_error":
|
||||
return "Authentication server error. Please try again later."
|
||||
default:
|
||||
return fmt.Sprintf("Authentication failed: %s", oauthErr.Description)
|
||||
}
|
||||
default:
|
||||
return "An unexpected error occurred. Please try again."
|
||||
}
|
||||
}
|
||||
214
internal/auth/codex/html_templates.go
Normal file
214
internal/auth/codex/html_templates.go
Normal file
@@ -0,0 +1,214 @@
|
||||
package codex
|
||||
|
||||
// LoginSuccessHTML is the HTML template for the page shown after a successful
|
||||
// OAuth2 authentication with Codex. It informs the user that the authentication
|
||||
// was successful and provides a countdown timer to automatically close the window.
|
||||
const LoginSuccessHtml = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Authentication Successful - Codex</title>
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%2310b981'%3E%3Cpath d='M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z'/%3E%3C/svg%3E">
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 1rem;
|
||||
}
|
||||
.container {
|
||||
text-align: center;
|
||||
background: white;
|
||||
padding: 2.5rem;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 25px rgba(0,0,0,0.1);
|
||||
max-width: 480px;
|
||||
width: 100%;
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
.success-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin: 0 auto 1.5rem;
|
||||
background: #10b981;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
h1 {
|
||||
color: #1f2937;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.subtitle {
|
||||
color: #6b7280;
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.setup-notice {
|
||||
background: #fef3c7;
|
||||
border: 1px solid #f59e0b;
|
||||
border-radius: 6px;
|
||||
padding: 1rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
.setup-notice h3 {
|
||||
color: #92400e;
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.setup-notice p {
|
||||
color: #92400e;
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.setup-notice a {
|
||||
color: #1d4ed8;
|
||||
text-decoration: none;
|
||||
}
|
||||
.setup-notice a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
.button {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.button-primary {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
.button-primary:hover {
|
||||
background: #2563eb;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.button-secondary {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
border: 1px solid #d1d5db;
|
||||
}
|
||||
.button-secondary:hover {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
.countdown {
|
||||
color: #9ca3af;
|
||||
font-size: 0.75rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.footer {
|
||||
margin-top: 2rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
color: #9ca3af;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.footer a {
|
||||
color: #3b82f6;
|
||||
text-decoration: none;
|
||||
}
|
||||
.footer a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="success-icon">✓</div>
|
||||
<h1>Authentication Successful!</h1>
|
||||
<p class="subtitle">You have successfully authenticated with Codex. You can now close this window and return to your terminal to continue.</p>
|
||||
|
||||
{{SETUP_NOTICE}}
|
||||
|
||||
<div class="actions">
|
||||
<button class="button button-primary" onclick="window.close()">
|
||||
<span>Close Window</span>
|
||||
</button>
|
||||
<a href="{{PLATFORM_URL}}" target="_blank" class="button button-secondary">
|
||||
<span>Open Platform</span>
|
||||
<span>↗</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="countdown">
|
||||
This window will close automatically in <span id="countdown">10</span> seconds
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>Powered by <a href="https://chatgpt.com" target="_blank">ChatGPT</a></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let countdown = 10;
|
||||
const countdownElement = document.getElementById('countdown');
|
||||
|
||||
const timer = setInterval(() => {
|
||||
countdown--;
|
||||
countdownElement.textContent = countdown;
|
||||
|
||||
if (countdown <= 0) {
|
||||
clearInterval(timer);
|
||||
window.close();
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// Close window when user presses Escape
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
window.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Focus the close button for keyboard accessibility
|
||||
document.querySelector('.button-primary').focus();
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
// SetupNoticeHTML is the HTML template for the section that provides instructions
|
||||
// for additional setup. This is displayed on the success page when further actions
|
||||
// are required from the user.
|
||||
const SetupNoticeHtml = `
|
||||
<div class="setup-notice">
|
||||
<h3>Additional Setup Required</h3>
|
||||
<p>To complete your setup, please visit the <a href="{{PLATFORM_URL}}" target="_blank">Codex</a> to configure your account.</p>
|
||||
</div>`
|
||||
102
internal/auth/codex/jwt_parser.go
Normal file
102
internal/auth/codex/jwt_parser.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package codex
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// JWTClaims represents the claims section of a JSON Web Token (JWT).
|
||||
// It includes standard claims like issuer, subject, and expiration time, as well as
|
||||
// custom claims specific to OpenAI's authentication.
|
||||
type JWTClaims struct {
|
||||
AtHash string `json:"at_hash"`
|
||||
Aud []string `json:"aud"`
|
||||
AuthProvider string `json:"auth_provider"`
|
||||
AuthTime int `json:"auth_time"`
|
||||
Email string `json:"email"`
|
||||
EmailVerified bool `json:"email_verified"`
|
||||
Exp int `json:"exp"`
|
||||
CodexAuthInfo CodexAuthInfo `json:"https://api.openai.com/auth"`
|
||||
Iat int `json:"iat"`
|
||||
Iss string `json:"iss"`
|
||||
Jti string `json:"jti"`
|
||||
Rat int `json:"rat"`
|
||||
Sid string `json:"sid"`
|
||||
Sub string `json:"sub"`
|
||||
}
|
||||
|
||||
// Organizations defines the structure for organization details within the JWT claims.
|
||||
// It holds information about the user's organization, such as ID, role, and title.
|
||||
type Organizations struct {
|
||||
ID string `json:"id"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
Role string `json:"role"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
// CodexAuthInfo contains authentication-related details specific to Codex.
|
||||
// This includes ChatGPT account information, subscription status, and user/organization IDs.
|
||||
type CodexAuthInfo struct {
|
||||
ChatgptAccountID string `json:"chatgpt_account_id"`
|
||||
ChatgptPlanType string `json:"chatgpt_plan_type"`
|
||||
ChatgptSubscriptionActiveStart any `json:"chatgpt_subscription_active_start"`
|
||||
ChatgptSubscriptionActiveUntil any `json:"chatgpt_subscription_active_until"`
|
||||
ChatgptSubscriptionLastChecked time.Time `json:"chatgpt_subscription_last_checked"`
|
||||
ChatgptUserID string `json:"chatgpt_user_id"`
|
||||
Groups []any `json:"groups"`
|
||||
Organizations []Organizations `json:"organizations"`
|
||||
UserID string `json:"user_id"`
|
||||
}
|
||||
|
||||
// ParseJWTToken parses a JWT token string and extracts its claims without performing
|
||||
// cryptographic signature verification. This is useful for introspecting the token's
|
||||
// contents to retrieve user information from an ID token after it has been validated
|
||||
// by the authentication server.
|
||||
func ParseJWTToken(token string) (*JWTClaims, error) {
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 {
|
||||
return nil, fmt.Errorf("invalid JWT token format: expected 3 parts, got %d", len(parts))
|
||||
}
|
||||
|
||||
// Decode the claims (payload) part
|
||||
claimsData, err := base64URLDecode(parts[1])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode JWT claims: %w", err)
|
||||
}
|
||||
|
||||
var claims JWTClaims
|
||||
if err = json.Unmarshal(claimsData, &claims); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal JWT claims: %w", err)
|
||||
}
|
||||
|
||||
return &claims, nil
|
||||
}
|
||||
|
||||
// base64URLDecode decodes a Base64 URL-encoded string, adding padding if necessary.
|
||||
// JWTs use a URL-safe Base64 alphabet and omit padding, so this function ensures
|
||||
// correct decoding by re-adding the padding before decoding.
|
||||
func base64URLDecode(data string) ([]byte, error) {
|
||||
// Add padding if necessary
|
||||
switch len(data) % 4 {
|
||||
case 2:
|
||||
data += "=="
|
||||
case 3:
|
||||
data += "="
|
||||
}
|
||||
|
||||
return base64.URLEncoding.DecodeString(data)
|
||||
}
|
||||
|
||||
// GetUserEmail extracts the user's email address from the JWT claims.
|
||||
func (c *JWTClaims) GetUserEmail() string {
|
||||
return c.Email
|
||||
}
|
||||
|
||||
// GetAccountID extracts the user's account ID (subject) from the JWT claims.
|
||||
// It retrieves the unique identifier for the user's ChatGPT account.
|
||||
func (c *JWTClaims) GetAccountID() string {
|
||||
return c.CodexAuthInfo.ChatgptAccountID
|
||||
}
|
||||
317
internal/auth/codex/oauth_server.go
Normal file
317
internal/auth/codex/oauth_server.go
Normal file
@@ -0,0 +1,317 @@
|
||||
package codex
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// OAuthServer handles the local HTTP server for OAuth callbacks.
|
||||
// It listens for the authorization code response from the OAuth provider
|
||||
// and captures the necessary parameters to complete the authentication flow.
|
||||
type OAuthServer struct {
|
||||
// server is the underlying HTTP server instance
|
||||
server *http.Server
|
||||
// port is the port number on which the server listens
|
||||
port int
|
||||
// resultChan is a channel for sending OAuth results
|
||||
resultChan chan *OAuthResult
|
||||
// errorChan is a channel for sending OAuth errors
|
||||
errorChan chan error
|
||||
// mu is a mutex for protecting server state
|
||||
mu sync.Mutex
|
||||
// running indicates whether the server is currently running
|
||||
running bool
|
||||
}
|
||||
|
||||
// OAuthResult contains the result of the OAuth callback.
|
||||
// It holds either the authorization code and state for successful authentication
|
||||
// or an error message if the authentication failed.
|
||||
type OAuthResult struct {
|
||||
// Code is the authorization code received from the OAuth provider
|
||||
Code string
|
||||
// State is the state parameter used to prevent CSRF attacks
|
||||
State string
|
||||
// Error contains any error message if the OAuth flow failed
|
||||
Error string
|
||||
}
|
||||
|
||||
// NewOAuthServer creates a new OAuth callback server.
|
||||
// It initializes the server with the specified port and creates channels
|
||||
// for handling OAuth results and errors.
|
||||
//
|
||||
// Parameters:
|
||||
// - port: The port number on which the server should listen
|
||||
//
|
||||
// Returns:
|
||||
// - *OAuthServer: A new OAuthServer instance
|
||||
func NewOAuthServer(port int) *OAuthServer {
|
||||
return &OAuthServer{
|
||||
port: port,
|
||||
resultChan: make(chan *OAuthResult, 1),
|
||||
errorChan: make(chan error, 1),
|
||||
}
|
||||
}
|
||||
|
||||
// Start starts the OAuth callback server.
|
||||
// It sets up the HTTP handlers for the callback and success endpoints,
|
||||
// and begins listening on the specified port.
|
||||
//
|
||||
// Returns:
|
||||
// - error: An error if the server fails to start
|
||||
func (s *OAuthServer) Start() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.running {
|
||||
return fmt.Errorf("server is already running")
|
||||
}
|
||||
|
||||
// Check if port is available
|
||||
if !s.isPortAvailable() {
|
||||
return fmt.Errorf("port %d is already in use", s.port)
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/auth/callback", s.handleCallback)
|
||||
mux.HandleFunc("/success", s.handleSuccess)
|
||||
|
||||
s.server = &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", s.port),
|
||||
Handler: mux,
|
||||
ReadTimeout: 10 * time.Second,
|
||||
WriteTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
s.running = true
|
||||
|
||||
// Start server in goroutine
|
||||
go func() {
|
||||
if err := s.server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
s.errorChan <- fmt.Errorf("server failed to start: %w", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Give server a moment to start
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop gracefully stops the OAuth callback server.
|
||||
// It performs a graceful shutdown of the HTTP server with a timeout.
|
||||
//
|
||||
// Parameters:
|
||||
// - ctx: The context for controlling the shutdown process
|
||||
//
|
||||
// Returns:
|
||||
// - error: An error if the server fails to stop gracefully
|
||||
func (s *OAuthServer) Stop(ctx context.Context) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if !s.running || s.server == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Debug("Stopping OAuth callback server")
|
||||
|
||||
// Create a context with timeout for shutdown
|
||||
shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err := s.server.Shutdown(shutdownCtx)
|
||||
s.running = false
|
||||
s.server = nil
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// WaitForCallback waits for the OAuth callback with a timeout.
|
||||
// It blocks until either an OAuth result is received, an error occurs,
|
||||
// or the specified timeout is reached.
|
||||
//
|
||||
// Parameters:
|
||||
// - timeout: The maximum time to wait for the callback
|
||||
//
|
||||
// Returns:
|
||||
// - *OAuthResult: The OAuth result if successful
|
||||
// - error: An error if the callback times out or an error occurs
|
||||
func (s *OAuthServer) WaitForCallback(timeout time.Duration) (*OAuthResult, error) {
|
||||
select {
|
||||
case result := <-s.resultChan:
|
||||
return result, nil
|
||||
case err := <-s.errorChan:
|
||||
return nil, err
|
||||
case <-time.After(timeout):
|
||||
return nil, fmt.Errorf("timeout waiting for OAuth callback")
|
||||
}
|
||||
}
|
||||
|
||||
// handleCallback handles the OAuth callback endpoint.
|
||||
// It extracts the authorization code and state from the callback URL,
|
||||
// validates the parameters, and sends the result to the waiting channel.
|
||||
//
|
||||
// Parameters:
|
||||
// - w: The HTTP response writer
|
||||
// - r: The HTTP request
|
||||
func (s *OAuthServer) handleCallback(w http.ResponseWriter, r *http.Request) {
|
||||
log.Debug("Received OAuth callback")
|
||||
|
||||
// Validate request method
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Extract parameters
|
||||
query := r.URL.Query()
|
||||
code := query.Get("code")
|
||||
state := query.Get("state")
|
||||
errorParam := query.Get("error")
|
||||
|
||||
// Validate required parameters
|
||||
if errorParam != "" {
|
||||
log.Errorf("OAuth error received: %s", errorParam)
|
||||
result := &OAuthResult{
|
||||
Error: errorParam,
|
||||
}
|
||||
s.sendResult(result)
|
||||
http.Error(w, fmt.Sprintf("OAuth error: %s", errorParam), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if code == "" {
|
||||
log.Error("No authorization code received")
|
||||
result := &OAuthResult{
|
||||
Error: "no_code",
|
||||
}
|
||||
s.sendResult(result)
|
||||
http.Error(w, "No authorization code received", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if state == "" {
|
||||
log.Error("No state parameter received")
|
||||
result := &OAuthResult{
|
||||
Error: "no_state",
|
||||
}
|
||||
s.sendResult(result)
|
||||
http.Error(w, "No state parameter received", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Send successful result
|
||||
result := &OAuthResult{
|
||||
Code: code,
|
||||
State: state,
|
||||
}
|
||||
s.sendResult(result)
|
||||
|
||||
// Redirect to success page
|
||||
http.Redirect(w, r, "/success", http.StatusFound)
|
||||
}
|
||||
|
||||
// handleSuccess handles the success page endpoint.
|
||||
// It serves a user-friendly HTML page indicating that authentication was successful.
|
||||
//
|
||||
// Parameters:
|
||||
// - w: The HTTP response writer
|
||||
// - r: The HTTP request
|
||||
func (s *OAuthServer) handleSuccess(w http.ResponseWriter, r *http.Request) {
|
||||
log.Debug("Serving success page")
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
// Parse query parameters for customization
|
||||
query := r.URL.Query()
|
||||
setupRequired := query.Get("setup_required") == "true"
|
||||
platformURL := query.Get("platform_url")
|
||||
if platformURL == "" {
|
||||
platformURL = "https://platform.openai.com"
|
||||
}
|
||||
|
||||
// Generate success page HTML with dynamic content
|
||||
successHTML := s.generateSuccessHTML(setupRequired, platformURL)
|
||||
|
||||
_, err := w.Write([]byte(successHTML))
|
||||
if err != nil {
|
||||
log.Errorf("Failed to write success page: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// generateSuccessHTML creates the HTML content for the success page.
|
||||
// It customizes the page based on whether additional setup is required
|
||||
// and includes a link to the platform.
|
||||
//
|
||||
// Parameters:
|
||||
// - setupRequired: Whether additional setup is required after authentication
|
||||
// - platformURL: The URL to the platform for additional setup
|
||||
//
|
||||
// Returns:
|
||||
// - string: The HTML content for the success page
|
||||
func (s *OAuthServer) generateSuccessHTML(setupRequired bool, platformURL string) string {
|
||||
html := LoginSuccessHtml
|
||||
|
||||
// Replace platform URL placeholder
|
||||
html = strings.Replace(html, "{{PLATFORM_URL}}", platformURL, -1)
|
||||
|
||||
// Add setup notice if required
|
||||
if setupRequired {
|
||||
setupNotice := strings.Replace(SetupNoticeHtml, "{{PLATFORM_URL}}", platformURL, -1)
|
||||
html = strings.Replace(html, "{{SETUP_NOTICE}}", setupNotice, 1)
|
||||
} else {
|
||||
html = strings.Replace(html, "{{SETUP_NOTICE}}", "", 1)
|
||||
}
|
||||
|
||||
return html
|
||||
}
|
||||
|
||||
// sendResult sends the OAuth result to the waiting channel.
|
||||
// It ensures that the result is sent without blocking the handler.
|
||||
//
|
||||
// Parameters:
|
||||
// - result: The OAuth result to send
|
||||
func (s *OAuthServer) sendResult(result *OAuthResult) {
|
||||
select {
|
||||
case s.resultChan <- result:
|
||||
log.Debug("OAuth result sent to channel")
|
||||
default:
|
||||
log.Warn("OAuth result channel is full, result dropped")
|
||||
}
|
||||
}
|
||||
|
||||
// isPortAvailable checks if the specified port is available.
|
||||
// It attempts to listen on the port to determine availability.
|
||||
//
|
||||
// Returns:
|
||||
// - bool: True if the port is available, false otherwise
|
||||
func (s *OAuthServer) isPortAvailable() bool {
|
||||
addr := fmt.Sprintf(":%d", s.port)
|
||||
listener, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer func() {
|
||||
_ = listener.Close()
|
||||
}()
|
||||
return true
|
||||
}
|
||||
|
||||
// IsRunning returns whether the server is currently running.
|
||||
//
|
||||
// Returns:
|
||||
// - bool: True if the server is running, false otherwise
|
||||
func (s *OAuthServer) IsRunning() bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.running
|
||||
}
|
||||
39
internal/auth/codex/openai.go
Normal file
39
internal/auth/codex/openai.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package codex
|
||||
|
||||
// PKCECodes holds the verification codes for the OAuth2 PKCE (Proof Key for Code Exchange) flow.
|
||||
// PKCE is an extension to the Authorization Code flow to prevent CSRF and authorization code injection attacks.
|
||||
type PKCECodes struct {
|
||||
// CodeVerifier is the cryptographically random string used to correlate
|
||||
// the authorization request to the token request
|
||||
CodeVerifier string `json:"code_verifier"`
|
||||
// CodeChallenge is the SHA256 hash of the code verifier, base64url-encoded
|
||||
CodeChallenge string `json:"code_challenge"`
|
||||
}
|
||||
|
||||
// CodexTokenData holds the OAuth token information obtained from OpenAI.
|
||||
// It includes the ID token, access token, refresh token, and associated user details.
|
||||
type CodexTokenData struct {
|
||||
// IDToken is the JWT ID token containing user claims
|
||||
IDToken string `json:"id_token"`
|
||||
// AccessToken is the OAuth2 access token for API access
|
||||
AccessToken string `json:"access_token"`
|
||||
// RefreshToken is used to obtain new access tokens
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
// AccountID is the OpenAI account identifier
|
||||
AccountID string `json:"account_id"`
|
||||
// Email is the OpenAI account email
|
||||
Email string `json:"email"`
|
||||
// Expire is the timestamp of the token expire
|
||||
Expire string `json:"expired"`
|
||||
}
|
||||
|
||||
// CodexAuthBundle aggregates all authentication-related data after the OAuth flow is complete.
|
||||
// This includes the API key, token data, and the timestamp of the last refresh.
|
||||
type CodexAuthBundle struct {
|
||||
// APIKey is the OpenAI API key obtained from token exchange
|
||||
APIKey string `json:"api_key"`
|
||||
// TokenData contains the OAuth tokens from the authentication flow
|
||||
TokenData CodexTokenData `json:"token_data"`
|
||||
// LastRefresh is the timestamp of the last token refresh
|
||||
LastRefresh string `json:"last_refresh"`
|
||||
}
|
||||
286
internal/auth/codex/openai_auth.go
Normal file
286
internal/auth/codex/openai_auth.go
Normal file
@@ -0,0 +1,286 @@
|
||||
// Package codex provides authentication and token management for OpenAI's Codex API.
|
||||
// It handles the OAuth2 flow, including generating authorization URLs, exchanging
|
||||
// authorization codes for tokens, and refreshing expired tokens. The package also
|
||||
// defines data structures for storing and managing Codex authentication credentials.
|
||||
package codex
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
openaiAuthURL = "https://auth.openai.com/oauth/authorize"
|
||||
openaiTokenURL = "https://auth.openai.com/oauth/token"
|
||||
openaiClientID = "app_EMoamEEZ73f0CkXaXp7hrann"
|
||||
redirectURI = "http://localhost:1455/auth/callback"
|
||||
)
|
||||
|
||||
// CodexAuth handles the OpenAI OAuth2 authentication flow.
|
||||
// It manages the HTTP client and provides methods for generating authorization URLs,
|
||||
// exchanging authorization codes for tokens, and refreshing access tokens.
|
||||
type CodexAuth struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewCodexAuth creates a new CodexAuth service instance.
|
||||
// It initializes an HTTP client with proxy settings from the provided configuration.
|
||||
func NewCodexAuth(cfg *config.Config) *CodexAuth {
|
||||
return &CodexAuth{
|
||||
httpClient: util.SetProxy(cfg, &http.Client{}),
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateAuthURL creates the OAuth authorization URL with PKCE (Proof Key for Code Exchange).
|
||||
// It constructs the URL with the necessary parameters, including the client ID,
|
||||
// response type, redirect URI, scopes, and PKCE challenge.
|
||||
func (o *CodexAuth) GenerateAuthURL(state string, pkceCodes *PKCECodes) (string, error) {
|
||||
if pkceCodes == nil {
|
||||
return "", fmt.Errorf("PKCE codes are required")
|
||||
}
|
||||
|
||||
params := url.Values{
|
||||
"client_id": {openaiClientID},
|
||||
"response_type": {"code"},
|
||||
"redirect_uri": {redirectURI},
|
||||
"scope": {"openid email profile offline_access"},
|
||||
"state": {state},
|
||||
"code_challenge": {pkceCodes.CodeChallenge},
|
||||
"code_challenge_method": {"S256"},
|
||||
"prompt": {"login"},
|
||||
"id_token_add_organizations": {"true"},
|
||||
"codex_cli_simplified_flow": {"true"},
|
||||
}
|
||||
|
||||
authURL := fmt.Sprintf("%s?%s", openaiAuthURL, params.Encode())
|
||||
return authURL, nil
|
||||
}
|
||||
|
||||
// ExchangeCodeForTokens exchanges an authorization code for access and refresh tokens.
|
||||
// It performs an HTTP POST request to the OpenAI token endpoint with the provided
|
||||
// authorization code and PKCE verifier.
|
||||
func (o *CodexAuth) ExchangeCodeForTokens(ctx context.Context, code string, pkceCodes *PKCECodes) (*CodexAuthBundle, error) {
|
||||
if pkceCodes == nil {
|
||||
return nil, fmt.Errorf("PKCE codes are required for token exchange")
|
||||
}
|
||||
|
||||
// Prepare token exchange request
|
||||
data := url.Values{
|
||||
"grant_type": {"authorization_code"},
|
||||
"client_id": {openaiClientID},
|
||||
"code": {code},
|
||||
"redirect_uri": {redirectURI},
|
||||
"code_verifier": {pkceCodes.CodeVerifier},
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", openaiTokenURL, strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create token request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := o.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("token exchange request failed: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read token response: %w", err)
|
||||
}
|
||||
// log.Debugf("Token response: %s", string(body))
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("token exchange failed with status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
// Parse token response
|
||||
var tokenResp struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
IDToken string `json:"id_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
|
||||
if err = json.Unmarshal(body, &tokenResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse token response: %w", err)
|
||||
}
|
||||
|
||||
// Extract account ID from ID token
|
||||
claims, err := ParseJWTToken(tokenResp.IDToken)
|
||||
if err != nil {
|
||||
log.Warnf("Failed to parse ID token: %v", err)
|
||||
}
|
||||
|
||||
accountID := ""
|
||||
email := ""
|
||||
if claims != nil {
|
||||
accountID = claims.GetAccountID()
|
||||
email = claims.GetUserEmail()
|
||||
}
|
||||
|
||||
// Create token data
|
||||
tokenData := CodexTokenData{
|
||||
IDToken: tokenResp.IDToken,
|
||||
AccessToken: tokenResp.AccessToken,
|
||||
RefreshToken: tokenResp.RefreshToken,
|
||||
AccountID: accountID,
|
||||
Email: email,
|
||||
Expire: time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second).Format(time.RFC3339),
|
||||
}
|
||||
|
||||
// Create auth bundle
|
||||
bundle := &CodexAuthBundle{
|
||||
TokenData: tokenData,
|
||||
LastRefresh: time.Now().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
return bundle, nil
|
||||
}
|
||||
|
||||
// RefreshTokens refreshes an access token using a refresh token.
|
||||
// This method is called when an access token has expired. It makes a request to the
|
||||
// token endpoint to obtain a new set of tokens.
|
||||
func (o *CodexAuth) RefreshTokens(ctx context.Context, refreshToken string) (*CodexTokenData, error) {
|
||||
if refreshToken == "" {
|
||||
return nil, fmt.Errorf("refresh token is required")
|
||||
}
|
||||
|
||||
data := url.Values{
|
||||
"client_id": {openaiClientID},
|
||||
"grant_type": {"refresh_token"},
|
||||
"refresh_token": {refreshToken},
|
||||
"scope": {"openid profile email"},
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", openaiTokenURL, strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create refresh request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := o.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("token refresh request failed: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read refresh response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("token refresh failed with status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var tokenResp struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
IDToken string `json:"id_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
|
||||
if err = json.Unmarshal(body, &tokenResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse refresh response: %w", err)
|
||||
}
|
||||
|
||||
// Extract account ID from ID token
|
||||
claims, err := ParseJWTToken(tokenResp.IDToken)
|
||||
if err != nil {
|
||||
log.Warnf("Failed to parse refreshed ID token: %v", err)
|
||||
}
|
||||
|
||||
accountID := ""
|
||||
email := ""
|
||||
if claims != nil {
|
||||
accountID = claims.GetAccountID()
|
||||
email = claims.Email
|
||||
}
|
||||
|
||||
return &CodexTokenData{
|
||||
IDToken: tokenResp.IDToken,
|
||||
AccessToken: tokenResp.AccessToken,
|
||||
RefreshToken: tokenResp.RefreshToken,
|
||||
AccountID: accountID,
|
||||
Email: email,
|
||||
Expire: time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second).Format(time.RFC3339),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CreateTokenStorage creates a new CodexTokenStorage from a CodexAuthBundle.
|
||||
// It populates the storage struct with token data, user information, and timestamps.
|
||||
func (o *CodexAuth) CreateTokenStorage(bundle *CodexAuthBundle) *CodexTokenStorage {
|
||||
storage := &CodexTokenStorage{
|
||||
IDToken: bundle.TokenData.IDToken,
|
||||
AccessToken: bundle.TokenData.AccessToken,
|
||||
RefreshToken: bundle.TokenData.RefreshToken,
|
||||
AccountID: bundle.TokenData.AccountID,
|
||||
LastRefresh: bundle.LastRefresh,
|
||||
Email: bundle.TokenData.Email,
|
||||
Expire: bundle.TokenData.Expire,
|
||||
}
|
||||
|
||||
return storage
|
||||
}
|
||||
|
||||
// RefreshTokensWithRetry refreshes tokens with a built-in retry mechanism.
|
||||
// It attempts to refresh the tokens up to a specified maximum number of retries,
|
||||
// with an exponential backoff strategy to handle transient network errors.
|
||||
func (o *CodexAuth) RefreshTokensWithRetry(ctx context.Context, refreshToken string, maxRetries int) (*CodexTokenData, error) {
|
||||
var lastErr error
|
||||
|
||||
for attempt := 0; attempt < maxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
// Wait before retry
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(time.Duration(attempt) * time.Second):
|
||||
}
|
||||
}
|
||||
|
||||
tokenData, err := o.RefreshTokens(ctx, refreshToken)
|
||||
if err == nil {
|
||||
return tokenData, nil
|
||||
}
|
||||
|
||||
lastErr = err
|
||||
log.Warnf("Token refresh attempt %d failed: %v", attempt+1, err)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("token refresh failed after %d attempts: %w", maxRetries, lastErr)
|
||||
}
|
||||
|
||||
// UpdateTokenStorage updates an existing CodexTokenStorage with new token data.
|
||||
// This is typically called after a successful token refresh to persist the new credentials.
|
||||
func (o *CodexAuth) UpdateTokenStorage(storage *CodexTokenStorage, tokenData *CodexTokenData) {
|
||||
storage.IDToken = tokenData.IDToken
|
||||
storage.AccessToken = tokenData.AccessToken
|
||||
storage.RefreshToken = tokenData.RefreshToken
|
||||
storage.AccountID = tokenData.AccountID
|
||||
storage.LastRefresh = time.Now().Format(time.RFC3339)
|
||||
storage.Email = tokenData.Email
|
||||
storage.Expire = tokenData.Expire
|
||||
}
|
||||
56
internal/auth/codex/pkce.go
Normal file
56
internal/auth/codex/pkce.go
Normal file
@@ -0,0 +1,56 @@
|
||||
// Package codex provides authentication and token management functionality
|
||||
// for OpenAI's Codex AI services. It handles OAuth2 PKCE (Proof Key for Code Exchange)
|
||||
// code generation for secure authentication flows.
|
||||
package codex
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// GeneratePKCECodes generates a new pair of PKCE (Proof Key for Code Exchange) codes.
|
||||
// It creates a cryptographically random code verifier and its corresponding
|
||||
// SHA256 code challenge, as specified in RFC 7636. This is a critical security
|
||||
// feature for the OAuth 2.0 authorization code flow.
|
||||
func GeneratePKCECodes() (*PKCECodes, error) {
|
||||
// Generate code verifier: 43-128 characters, URL-safe
|
||||
codeVerifier, err := generateCodeVerifier()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate code verifier: %w", err)
|
||||
}
|
||||
|
||||
// Generate code challenge using S256 method
|
||||
codeChallenge := generateCodeChallenge(codeVerifier)
|
||||
|
||||
return &PKCECodes{
|
||||
CodeVerifier: codeVerifier,
|
||||
CodeChallenge: codeChallenge,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// generateCodeVerifier creates a cryptographically secure random string to be used
|
||||
// as the code verifier in the PKCE flow. The verifier is a high-entropy string
|
||||
// that is later used to prove possession of the client that initiated the
|
||||
// authorization request.
|
||||
func generateCodeVerifier() (string, error) {
|
||||
// Generate 96 random bytes (will result in 128 base64 characters)
|
||||
bytes := make([]byte, 96)
|
||||
_, err := rand.Read(bytes)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to generate random bytes: %w", err)
|
||||
}
|
||||
|
||||
// Encode to URL-safe base64 without padding
|
||||
return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(bytes), nil
|
||||
}
|
||||
|
||||
// generateCodeChallenge creates a code challenge from a given code verifier.
|
||||
// The challenge is derived by taking the SHA256 hash of the verifier and then
|
||||
// Base64 URL-encoding the result. This is sent in the initial authorization
|
||||
// request and later verified against the verifier.
|
||||
func generateCodeChallenge(codeVerifier string) string {
|
||||
hash := sha256.Sum256([]byte(codeVerifier))
|
||||
return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(hash[:])
|
||||
}
|
||||
66
internal/auth/codex/token.go
Normal file
66
internal/auth/codex/token.go
Normal file
@@ -0,0 +1,66 @@
|
||||
// Package codex provides authentication and token management functionality
|
||||
// for OpenAI's Codex AI services. It handles OAuth2 token storage, serialization,
|
||||
// and retrieval for maintaining authenticated sessions with the Codex API.
|
||||
package codex
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
|
||||
)
|
||||
|
||||
// CodexTokenStorage stores OAuth2 token information for OpenAI Codex API authentication.
|
||||
// It maintains compatibility with the existing auth system while adding Codex-specific fields
|
||||
// for managing access tokens, refresh tokens, and user account information.
|
||||
type CodexTokenStorage struct {
|
||||
// IDToken is the JWT ID token containing user claims and identity information.
|
||||
IDToken string `json:"id_token"`
|
||||
// AccessToken is the OAuth2 access token used for authenticating API requests.
|
||||
AccessToken string `json:"access_token"`
|
||||
// RefreshToken is used to obtain new access tokens when the current one expires.
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
// AccountID is the OpenAI account identifier associated with this token.
|
||||
AccountID string `json:"account_id"`
|
||||
// LastRefresh is the timestamp of the last token refresh operation.
|
||||
LastRefresh string `json:"last_refresh"`
|
||||
// Email is the OpenAI account email address associated with this token.
|
||||
Email string `json:"email"`
|
||||
// Type indicates the authentication provider type, always "codex" for this storage.
|
||||
Type string `json:"type"`
|
||||
// Expire is the timestamp when the current access token expires.
|
||||
Expire string `json:"expired"`
|
||||
}
|
||||
|
||||
// SaveTokenToFile serializes the Codex token storage to a JSON file.
|
||||
// This method creates the necessary directory structure and writes the token
|
||||
// data in JSON format to the specified file path for persistent storage.
|
||||
//
|
||||
// Parameters:
|
||||
// - authFilePath: The full path where the token file should be saved
|
||||
//
|
||||
// Returns:
|
||||
// - error: An error if the operation fails, nil otherwise
|
||||
func (ts *CodexTokenStorage) SaveTokenToFile(authFilePath string) error {
|
||||
misc.LogSavingCredentials(authFilePath)
|
||||
ts.Type = "codex"
|
||||
if err := os.MkdirAll(filepath.Dir(authFilePath), 0700); err != nil {
|
||||
return fmt.Errorf("failed to create directory: %v", err)
|
||||
}
|
||||
|
||||
f, err := os.Create(authFilePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create token file: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = f.Close()
|
||||
}()
|
||||
|
||||
if err = json.NewEncoder(f).Encode(ts); err != nil {
|
||||
return fmt.Errorf("failed to write token to file: %w", err)
|
||||
}
|
||||
return nil
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user