mirror of
https://github.com/router-for-me/CLIProxyAPIPlus.git
synced 2026-03-24 20:57:39 +00:00
486 lines
14 KiB
Go
486 lines
14 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
gitlabauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/gitlab"
|
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/browser"
|
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
|
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
|
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
|
log "github.com/sirupsen/logrus"
|
|
)
|
|
|
|
const (
|
|
gitLabLoginModeMetadataKey = "login_mode"
|
|
gitLabLoginModeOAuth = "oauth"
|
|
gitLabLoginModePAT = "pat"
|
|
gitLabBaseURLMetadataKey = "base_url"
|
|
gitLabOAuthClientIDMetadataKey = "oauth_client_id"
|
|
gitLabOAuthClientSecretMetadataKey = "oauth_client_secret"
|
|
gitLabPersonalAccessTokenMetadataKey = "personal_access_token"
|
|
)
|
|
|
|
var gitLabRefreshLead = 5 * time.Minute
|
|
|
|
type GitLabAuthenticator struct {
|
|
CallbackPort int
|
|
}
|
|
|
|
func NewGitLabAuthenticator() *GitLabAuthenticator {
|
|
return &GitLabAuthenticator{CallbackPort: gitlabauth.DefaultCallbackPort}
|
|
}
|
|
|
|
func (a *GitLabAuthenticator) Provider() string {
|
|
return "gitlab"
|
|
}
|
|
|
|
func (a *GitLabAuthenticator) RefreshLead() *time.Duration {
|
|
return &gitLabRefreshLead
|
|
}
|
|
|
|
func (a *GitLabAuthenticator) Login(ctx context.Context, cfg *config.Config, opts *LoginOptions) (*coreauth.Auth, error) {
|
|
if cfg == nil {
|
|
return nil, fmt.Errorf("cliproxy auth: configuration is required")
|
|
}
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
if opts == nil {
|
|
opts = &LoginOptions{}
|
|
}
|
|
|
|
switch strings.ToLower(strings.TrimSpace(opts.Metadata[gitLabLoginModeMetadataKey])) {
|
|
case "", gitLabLoginModeOAuth:
|
|
return a.loginOAuth(ctx, cfg, opts)
|
|
case gitLabLoginModePAT:
|
|
return a.loginPAT(ctx, cfg, opts)
|
|
default:
|
|
return nil, fmt.Errorf("gitlab auth: unsupported login mode %q", opts.Metadata[gitLabLoginModeMetadataKey])
|
|
}
|
|
}
|
|
|
|
func (a *GitLabAuthenticator) loginOAuth(ctx context.Context, cfg *config.Config, opts *LoginOptions) (*coreauth.Auth, error) {
|
|
client := gitlabauth.NewAuthClient(cfg)
|
|
baseURL := a.resolveString(opts, gitLabBaseURLMetadataKey, gitlabauth.DefaultBaseURL)
|
|
clientID, err := a.requireInput(opts, gitLabOAuthClientIDMetadataKey, "Enter GitLab OAuth application client ID: ")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
clientSecret, err := a.optionalInput(opts, gitLabOAuthClientSecretMetadataKey, "Enter GitLab OAuth application client secret (press Enter for public PKCE app): ")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
callbackPort := a.CallbackPort
|
|
if opts.CallbackPort > 0 {
|
|
callbackPort = opts.CallbackPort
|
|
}
|
|
redirectURI := gitlabauth.RedirectURL(callbackPort)
|
|
|
|
pkceCodes, err := gitlabauth.GeneratePKCECodes()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
state, err := misc.GenerateRandomState()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("gitlab state generation failed: %w", err)
|
|
}
|
|
|
|
oauthServer := gitlabauth.NewOAuthServer(callbackPort)
|
|
if err := oauthServer.Start(); err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() {
|
|
stopCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
if stopErr := oauthServer.Stop(stopCtx); stopErr != nil {
|
|
log.Warnf("gitlab oauth server stop error: %v", stopErr)
|
|
}
|
|
}()
|
|
|
|
authURL, err := client.GenerateAuthURL(baseURL, clientID, redirectURI, state, pkceCodes)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if !opts.NoBrowser {
|
|
fmt.Println("Opening browser for GitLab Duo authentication")
|
|
if !browser.IsAvailable() {
|
|
log.Warn("No browser available; please open the URL manually")
|
|
util.PrintSSHTunnelInstructions(callbackPort)
|
|
fmt.Printf("Visit the following URL to continue authentication:\n%s\n", authURL)
|
|
} else if err = browser.OpenURL(authURL); err != nil {
|
|
log.Warnf("Failed to open browser automatically: %v", err)
|
|
util.PrintSSHTunnelInstructions(callbackPort)
|
|
fmt.Printf("Visit the following URL to continue authentication:\n%s\n", authURL)
|
|
}
|
|
} else {
|
|
util.PrintSSHTunnelInstructions(callbackPort)
|
|
fmt.Printf("Visit the following URL to continue authentication:\n%s\n", authURL)
|
|
}
|
|
|
|
fmt.Println("Waiting for GitLab OAuth callback...")
|
|
|
|
callbackCh := make(chan *gitlabauth.OAuthResult, 1)
|
|
callbackErrCh := make(chan error, 1)
|
|
go func() {
|
|
result, waitErr := oauthServer.WaitForCallback(5 * time.Minute)
|
|
if waitErr != nil {
|
|
callbackErrCh <- waitErr
|
|
return
|
|
}
|
|
callbackCh <- result
|
|
}()
|
|
|
|
var result *gitlabauth.OAuthResult
|
|
var manualPromptTimer *time.Timer
|
|
var manualPromptC <-chan time.Time
|
|
if opts.Prompt != nil {
|
|
manualPromptTimer = time.NewTimer(15 * time.Second)
|
|
manualPromptC = manualPromptTimer.C
|
|
defer manualPromptTimer.Stop()
|
|
}
|
|
|
|
waitForCallback:
|
|
for {
|
|
select {
|
|
case result = <-callbackCh:
|
|
break waitForCallback
|
|
case err = <-callbackErrCh:
|
|
return nil, err
|
|
case <-manualPromptC:
|
|
manualPromptC = nil
|
|
if manualPromptTimer != nil {
|
|
manualPromptTimer.Stop()
|
|
}
|
|
input, promptErr := opts.Prompt("Paste the GitLab callback URL (or press Enter to keep waiting): ")
|
|
if promptErr != nil {
|
|
return nil, promptErr
|
|
}
|
|
parsed, parseErr := misc.ParseOAuthCallback(input)
|
|
if parseErr != nil {
|
|
return nil, parseErr
|
|
}
|
|
if parsed == nil {
|
|
continue
|
|
}
|
|
result = &gitlabauth.OAuthResult{
|
|
Code: parsed.Code,
|
|
State: parsed.State,
|
|
Error: parsed.Error,
|
|
}
|
|
break waitForCallback
|
|
}
|
|
}
|
|
|
|
if result.Error != "" {
|
|
return nil, fmt.Errorf("gitlab oauth returned error: %s", result.Error)
|
|
}
|
|
if result.State != state {
|
|
return nil, fmt.Errorf("gitlab auth: state mismatch")
|
|
}
|
|
|
|
tokenResp, err := client.ExchangeCodeForTokens(ctx, baseURL, clientID, clientSecret, redirectURI, result.Code, pkceCodes.CodeVerifier)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
accessToken := strings.TrimSpace(tokenResp.AccessToken)
|
|
if accessToken == "" {
|
|
return nil, fmt.Errorf("gitlab auth: missing access token")
|
|
}
|
|
|
|
user, err := client.GetCurrentUser(ctx, baseURL, accessToken)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
direct, err := client.FetchDirectAccess(ctx, baseURL, accessToken)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
identifier := gitLabAccountIdentifier(user)
|
|
fileName := fmt.Sprintf("gitlab-%s.json", sanitizeGitLabFileName(identifier))
|
|
metadata := buildGitLabAuthMetadata(baseURL, gitLabLoginModeOAuth, tokenResp, direct)
|
|
metadata["auth_kind"] = "oauth"
|
|
metadata[gitLabOAuthClientIDMetadataKey] = clientID
|
|
if strings.TrimSpace(clientSecret) != "" {
|
|
metadata[gitLabOAuthClientSecretMetadataKey] = clientSecret
|
|
}
|
|
metadata["username"] = strings.TrimSpace(user.Username)
|
|
if email := strings.TrimSpace(primaryGitLabEmail(user)); email != "" {
|
|
metadata["email"] = email
|
|
}
|
|
metadata["name"] = strings.TrimSpace(user.Name)
|
|
|
|
fmt.Println("GitLab Duo authentication successful")
|
|
|
|
return &coreauth.Auth{
|
|
ID: fileName,
|
|
Provider: a.Provider(),
|
|
FileName: fileName,
|
|
Label: identifier,
|
|
Metadata: metadata,
|
|
}, nil
|
|
}
|
|
|
|
func (a *GitLabAuthenticator) loginPAT(ctx context.Context, cfg *config.Config, opts *LoginOptions) (*coreauth.Auth, error) {
|
|
client := gitlabauth.NewAuthClient(cfg)
|
|
baseURL := a.resolveString(opts, gitLabBaseURLMetadataKey, gitlabauth.DefaultBaseURL)
|
|
token, err := a.requireInput(opts, gitLabPersonalAccessTokenMetadataKey, "Enter GitLab personal access token: ")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
user, err := client.GetCurrentUser(ctx, baseURL, token)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
_, err = client.GetPersonalAccessTokenSelf(ctx, baseURL, token)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
direct, err := client.FetchDirectAccess(ctx, baseURL, token)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
identifier := gitLabAccountIdentifier(user)
|
|
fileName := fmt.Sprintf("gitlab-%s-pat.json", sanitizeGitLabFileName(identifier))
|
|
metadata := buildGitLabAuthMetadata(baseURL, gitLabLoginModePAT, nil, direct)
|
|
metadata["auth_kind"] = "personal_access_token"
|
|
metadata[gitLabPersonalAccessTokenMetadataKey] = strings.TrimSpace(token)
|
|
metadata["token_preview"] = maskGitLabToken(token)
|
|
metadata["username"] = strings.TrimSpace(user.Username)
|
|
if email := strings.TrimSpace(primaryGitLabEmail(user)); email != "" {
|
|
metadata["email"] = email
|
|
}
|
|
metadata["name"] = strings.TrimSpace(user.Name)
|
|
|
|
fmt.Println("GitLab Duo PAT authentication successful")
|
|
|
|
return &coreauth.Auth{
|
|
ID: fileName,
|
|
Provider: a.Provider(),
|
|
FileName: fileName,
|
|
Label: identifier + " (PAT)",
|
|
Metadata: metadata,
|
|
}, nil
|
|
}
|
|
|
|
func buildGitLabAuthMetadata(baseURL, mode string, tokenResp *gitlabauth.TokenResponse, direct *gitlabauth.DirectAccessResponse) map[string]any {
|
|
metadata := map[string]any{
|
|
"type": "gitlab",
|
|
"auth_method": strings.TrimSpace(mode),
|
|
gitLabBaseURLMetadataKey: gitlabauth.NormalizeBaseURL(baseURL),
|
|
"last_refresh": time.Now().UTC().Format(time.RFC3339),
|
|
"refresh_interval_seconds": 240,
|
|
}
|
|
if tokenResp != nil {
|
|
metadata["access_token"] = strings.TrimSpace(tokenResp.AccessToken)
|
|
if refreshToken := strings.TrimSpace(tokenResp.RefreshToken); refreshToken != "" {
|
|
metadata["refresh_token"] = refreshToken
|
|
}
|
|
if tokenType := strings.TrimSpace(tokenResp.TokenType); tokenType != "" {
|
|
metadata["token_type"] = tokenType
|
|
}
|
|
if scope := strings.TrimSpace(tokenResp.Scope); scope != "" {
|
|
metadata["scope"] = scope
|
|
}
|
|
if expiry := gitlabauth.TokenExpiry(time.Now(), tokenResp); !expiry.IsZero() {
|
|
metadata["oauth_expires_at"] = expiry.Format(time.RFC3339)
|
|
}
|
|
}
|
|
mergeGitLabDirectAccessMetadata(metadata, direct)
|
|
return metadata
|
|
}
|
|
|
|
func mergeGitLabDirectAccessMetadata(metadata map[string]any, direct *gitlabauth.DirectAccessResponse) {
|
|
if metadata == nil || direct == nil {
|
|
return
|
|
}
|
|
if base := strings.TrimSpace(direct.BaseURL); base != "" {
|
|
metadata["duo_gateway_base_url"] = base
|
|
}
|
|
if token := strings.TrimSpace(direct.Token); token != "" {
|
|
metadata["duo_gateway_token"] = token
|
|
}
|
|
if direct.ExpiresAt > 0 {
|
|
expiry := time.Unix(direct.ExpiresAt, 0).UTC()
|
|
metadata["duo_gateway_expires_at"] = expiry.Format(time.RFC3339)
|
|
now := time.Now().UTC()
|
|
if ttl := expiry.Sub(now); ttl > 0 {
|
|
interval := int(ttl.Seconds()) / 2
|
|
switch {
|
|
case interval < 60:
|
|
interval = 60
|
|
case interval > 240:
|
|
interval = 240
|
|
}
|
|
metadata["refresh_interval_seconds"] = interval
|
|
}
|
|
}
|
|
if len(direct.Headers) > 0 {
|
|
headers := make(map[string]string, len(direct.Headers))
|
|
for key, value := range direct.Headers {
|
|
key = strings.TrimSpace(key)
|
|
value = strings.TrimSpace(value)
|
|
if key == "" || value == "" {
|
|
continue
|
|
}
|
|
headers[key] = value
|
|
}
|
|
if len(headers) > 0 {
|
|
metadata["duo_gateway_headers"] = headers
|
|
}
|
|
}
|
|
if direct.ModelDetails != nil {
|
|
modelDetails := map[string]any{}
|
|
if provider := strings.TrimSpace(direct.ModelDetails.ModelProvider); provider != "" {
|
|
modelDetails["model_provider"] = provider
|
|
metadata["model_provider"] = provider
|
|
}
|
|
if model := strings.TrimSpace(direct.ModelDetails.ModelName); model != "" {
|
|
modelDetails["model_name"] = model
|
|
metadata["model_name"] = model
|
|
}
|
|
if len(modelDetails) > 0 {
|
|
metadata["model_details"] = modelDetails
|
|
}
|
|
}
|
|
}
|
|
|
|
func (a *GitLabAuthenticator) resolveString(opts *LoginOptions, key, fallback string) string {
|
|
if opts != nil && opts.Metadata != nil {
|
|
if value := strings.TrimSpace(opts.Metadata[key]); value != "" {
|
|
return value
|
|
}
|
|
}
|
|
for _, envKey := range gitLabEnvKeys(key) {
|
|
if raw, ok := os.LookupEnv(envKey); ok {
|
|
if trimmed := strings.TrimSpace(raw); trimmed != "" {
|
|
return trimmed
|
|
}
|
|
}
|
|
}
|
|
if strings.TrimSpace(fallback) != "" {
|
|
return fallback
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (a *GitLabAuthenticator) requireInput(opts *LoginOptions, key, prompt string) (string, error) {
|
|
if value := a.resolveString(opts, key, ""); value != "" {
|
|
return value, nil
|
|
}
|
|
if opts != nil && opts.Prompt != nil {
|
|
value, err := opts.Prompt(prompt)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
|
return trimmed, nil
|
|
}
|
|
}
|
|
return "", fmt.Errorf("gitlab auth: missing required %s", key)
|
|
}
|
|
|
|
func (a *GitLabAuthenticator) optionalInput(opts *LoginOptions, key, prompt string) (string, error) {
|
|
if value := a.resolveString(opts, key, ""); value != "" {
|
|
return value, nil
|
|
}
|
|
if opts != nil && opts.Prompt != nil {
|
|
value, err := opts.Prompt(prompt)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return strings.TrimSpace(value), nil
|
|
}
|
|
return "", nil
|
|
}
|
|
|
|
func primaryGitLabEmail(user *gitlabauth.User) string {
|
|
if user == nil {
|
|
return ""
|
|
}
|
|
if value := strings.TrimSpace(user.Email); value != "" {
|
|
return value
|
|
}
|
|
return strings.TrimSpace(user.PublicEmail)
|
|
}
|
|
|
|
func gitLabAccountIdentifier(user *gitlabauth.User) string {
|
|
if user == nil {
|
|
return "user"
|
|
}
|
|
for _, value := range []string{user.Username, primaryGitLabEmail(user), user.Name} {
|
|
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
|
return trimmed
|
|
}
|
|
}
|
|
return "user"
|
|
}
|
|
|
|
func sanitizeGitLabFileName(value string) string {
|
|
value = strings.TrimSpace(strings.ToLower(value))
|
|
if value == "" {
|
|
return "user"
|
|
}
|
|
var builder strings.Builder
|
|
lastDash := false
|
|
for _, r := range value {
|
|
switch {
|
|
case r >= 'a' && r <= 'z':
|
|
builder.WriteRune(r)
|
|
lastDash = false
|
|
case r >= '0' && r <= '9':
|
|
builder.WriteRune(r)
|
|
lastDash = false
|
|
case r == '-' || r == '_' || r == '.':
|
|
builder.WriteRune(r)
|
|
lastDash = false
|
|
default:
|
|
if !lastDash {
|
|
builder.WriteRune('-')
|
|
lastDash = true
|
|
}
|
|
}
|
|
}
|
|
result := strings.Trim(builder.String(), "-")
|
|
if result == "" {
|
|
return "user"
|
|
}
|
|
return result
|
|
}
|
|
|
|
func maskGitLabToken(token string) string {
|
|
trimmed := strings.TrimSpace(token)
|
|
if trimmed == "" {
|
|
return ""
|
|
}
|
|
if len(trimmed) <= 8 {
|
|
return trimmed
|
|
}
|
|
return trimmed[:4] + "..." + trimmed[len(trimmed)-4:]
|
|
}
|
|
|
|
func gitLabEnvKeys(key string) []string {
|
|
switch strings.TrimSpace(key) {
|
|
case gitLabBaseURLMetadataKey:
|
|
return []string{"GITLAB_BASE_URL"}
|
|
case gitLabOAuthClientIDMetadataKey:
|
|
return []string{"GITLAB_OAUTH_CLIENT_ID"}
|
|
case gitLabOAuthClientSecretMetadataKey:
|
|
return []string{"GITLAB_OAUTH_CLIENT_SECRET"}
|
|
case gitLabPersonalAccessTokenMetadataKey:
|
|
return []string{"GITLAB_PERSONAL_ACCESS_TOKEN"}
|
|
default:
|
|
return nil
|
|
}
|
|
}
|