diff --git a/cmd/login/add.go b/cmd/login/add.go index a391c0b..804945b 100644 --- a/cmd/login/add.go +++ b/cmd/login/add.go @@ -4,6 +4,7 @@ package login import ( + "code.gitea.io/tea/modules/auth" "code.gitea.io/tea/modules/interact" "code.gitea.io/tea/modules/task" @@ -89,6 +90,19 @@ var CmdLoginAdd = cli.Command{ Aliases: []string{"j"}, Usage: "Add helper", }, + &cli.BoolFlag{ + Name: "oauth", + Aliases: []string{"o"}, + Usage: "Use interactive OAuth2 flow for authentication", + }, + &cli.StringFlag{ + Name: "client-id", + Usage: "OAuth client ID (for use with --oauth)", + }, + &cli.StringFlag{ + Name: "redirect-url", + Usage: "OAuth redirect URL (for use with --oauth)", + }, }, Action: runLoginAdd, } @@ -99,6 +113,27 @@ func runLoginAdd(ctx *cli.Context) error { return interact.CreateLogin() } + // if OAuth flag is provided, use OAuth2 PKCE flow + if ctx.Bool("oauth") { + opts := auth.OAuthOptions{ + Name: ctx.String("name"), + URL: ctx.String("url"), + Insecure: ctx.Bool("insecure"), + } + + // Only set clientID if provided + if ctx.String("client-id") != "" { + opts.ClientID = ctx.String("client-id") + } + + // Only set redirect URL if provided + if ctx.String("redirect-url") != "" { + opts.RedirectURL = ctx.String("redirect-url") + } + + return auth.OAuthLoginWithFullOptions(opts) + } + sshAgent := false if ctx.String("ssh-agent-key") != "" || ctx.String("ssh-agent-principal") != "" { sshAgent = true diff --git a/cmd/login/oauth_refresh.go b/cmd/login/oauth_refresh.go new file mode 100644 index 0000000..6f4cdb0 --- /dev/null +++ b/cmd/login/oauth_refresh.go @@ -0,0 +1,58 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package login + +import ( + "fmt" + + "code.gitea.io/tea/modules/auth" + "code.gitea.io/tea/modules/config" + + "github.com/urfave/cli/v2" +) + +// CmdLoginOAuthRefresh represents a command to refresh an OAuth token +var CmdLoginOAuthRefresh = cli.Command{ + Name: "oauth-refresh", + Usage: "Refresh an OAuth token", + Description: "Manually refresh an expired OAuth token. Usually only used when troubleshooting authentication.", + ArgsUsage: "[<login name>]", + Action: runLoginOAuthRefresh, +} + +func runLoginOAuthRefresh(ctx *cli.Context) error { + var loginName string + + // Get login name from args or use default + if ctx.Args().Len() > 0 { + loginName = ctx.Args().First() + } else { + // Get default login + login, err := config.GetDefaultLogin() + if err != nil { + return fmt.Errorf("no login specified and no default login found: %s", err) + } + loginName = login.Name + } + + // Get the login from config + login := config.GetLoginByName(loginName) + if login == nil { + return fmt.Errorf("login '%s' not found", loginName) + } + + // Check if the login has a refresh token + if login.RefreshToken == "" { + return fmt.Errorf("login '%s' does not have a refresh token. It may have been created using a different authentication method", loginName) + } + + // Refresh the token + err := auth.RefreshAccessToken(login) + if err != nil { + return fmt.Errorf("failed to refresh token: %s", err) + } + + fmt.Printf("Successfully refreshed OAuth token for %s\n", loginName) + return nil +} diff --git a/go.mod b/go.mod index 1cfde15..ee128c3 100644 --- a/go.mod +++ b/go.mod @@ -20,6 +20,7 @@ require ( github.com/stretchr/testify v1.10.0 github.com/urfave/cli/v2 v2.27.5 golang.org/x/crypto v0.35.0 + golang.org/x/oauth2 v0.27.0 golang.org/x/term v0.29.0 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/go.sum b/go.sum index 68804a7..502d349 100644 --- a/go.sum +++ b/go.sum @@ -274,6 +274,8 @@ golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= +golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M= +golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/modules/auth/oauth.go b/modules/auth/oauth.go new file mode 100644 index 0000000..4c65d75 --- /dev/null +++ b/modules/auth/oauth.go @@ -0,0 +1,452 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "crypto/tls" + "encoding/base64" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "code.gitea.io/tea/modules/config" + "code.gitea.io/tea/modules/task" + "code.gitea.io/tea/modules/utils" + + "github.com/skratchdot/open-golang/open" + "golang.org/x/oauth2" +) + +// Constants for OAuth2 PKCE flow +const ( + // default client ID included in most Gitea instances + defaultClientID = "d57cb8c4-630c-4168-8324-ec79935e18d4" + + // default scopes to request + defaultScopes = "admin,user,issue,misc,notification,organization,package,repository" + + // length of code verifier + codeVerifierLength = 64 + + // timeout for oauth server response + authTimeout = 60 * time.Second + + // local server settings to receive the callback + redirectPort = 3333 + redirectHost = "127.0.0.1" +) + +// OAuthOptions contains options for the OAuth login flow +type OAuthOptions struct { + Name string + URL string + Insecure bool + ClientID string + RedirectURL string + Port int +} + +// OAuthLogin performs an OAuth2 PKCE login flow to authorize the CLI +func OAuthLogin(name, giteaURL string) error { + return OAuthLoginWithOptions(name, giteaURL, false) +} + +// OAuthLoginWithOptions performs an OAuth2 PKCE login flow with additional options +func OAuthLoginWithOptions(name, giteaURL string, insecure bool) error { + opts := OAuthOptions{ + Name: name, + URL: giteaURL, + Insecure: insecure, + ClientID: defaultClientID, + RedirectURL: fmt.Sprintf("http://%s:%d", redirectHost, redirectPort), + Port: redirectPort, + } + return OAuthLoginWithFullOptions(opts) +} + +// OAuthLoginWithFullOptions performs an OAuth2 PKCE login flow with full options control +func OAuthLoginWithFullOptions(opts OAuthOptions) error { + // Normalize URL + serverURL, err := utils.NormalizeURL(opts.URL) + if err != nil { + return fmt.Errorf("unable to parse URL: %s", err) + } + + // Set defaults if needed + if opts.ClientID == "" { + opts.ClientID = defaultClientID + } + + // If the redirect URL is specified, parse it to extract port if needed + if opts.RedirectURL != "" { + parsedURL, err := url.Parse(opts.RedirectURL) + if err == nil && parsedURL.Port() != "" { + port, err := strconv.Atoi(parsedURL.Port()) + if err == nil { + opts.Port = port + } + } + } else { + // If no redirect URL, ensure we have a port and then set the default redirect URL + if opts.Port == 0 { + opts.Port = redirectPort + } + opts.RedirectURL = fmt.Sprintf("http://%s:%d", redirectHost, opts.Port) + } + + // Double check that port is set + if opts.Port == 0 { + opts.Port = redirectPort + } + + // Generate code verifier (random string) + codeVerifier, err := generateCodeVerifier(codeVerifierLength) + if err != nil { + return fmt.Errorf("failed to generate code verifier: %s", err) + } + + // Generate code challenge (SHA256 hash of code verifier) + codeChallenge := generateCodeChallenge(codeVerifier) + + // Set up the OAuth2 config + ctx := context.Background() + ctx = context.WithValue(ctx, oauth2.HTTPClient, createHTTPClient(opts.Insecure)) + + // Configure the OAuth2 endpoints + authURL := fmt.Sprintf("%s/login/oauth/authorize", serverURL) + tokenURL := fmt.Sprintf("%s/login/oauth/access_token", serverURL) + + oauth2Config := &oauth2.Config{ + ClientID: opts.ClientID, + ClientSecret: "", // No client secret for PKCE + Endpoint: oauth2.Endpoint{ + AuthURL: authURL, + TokenURL: tokenURL, + }, + RedirectURL: opts.RedirectURL, + Scopes: strings.Split(defaultScopes, ","), + } + + // Set up PKCE extension options + authCodeOpts := []oauth2.AuthCodeOption{ + oauth2.SetAuthURLParam("code_challenge", codeChallenge), + oauth2.SetAuthURLParam("code_challenge_method", "S256"), + } + + // Generate state parameter to protect against CSRF + state, err := generateCodeVerifier(32) + if err != nil { + return fmt.Errorf("failed to generate state: %s", err) + } + + // Get the authorization URL + authCodeURL := oauth2Config.AuthCodeURL(state, authCodeOpts...) + + // Start a local server to receive the callback + code, receivedState, err := startLocalServerAndOpenBrowser(authCodeURL, state, opts) + if err != nil { + // Check for redirect URI errors + if strings.Contains(err.Error(), "no authorization code") || + strings.Contains(err.Error(), "redirect_uri") || + strings.Contains(err.Error(), "redirect") { + fmt.Println("\n❌ Error: Redirect URL not registered in Gitea") + fmt.Println("\nTo fix this, you need to register the redirect URL in Gitea:") + fmt.Printf("1. Go to your Gitea instance: %s\n", serverURL) + fmt.Println("2. Sign in and go to Settings > Applications") + fmt.Println("3. Register a new OAuth2 application with:") + fmt.Printf(" - Application Name: tea-cli (or any name)\n") + fmt.Printf(" - Redirect URI: %s\n", opts.RedirectURL) + fmt.Println("4. Copy the Client ID and try again with:") + fmt.Printf(" tea login add --oauth --client-id YOUR_CLIENT_ID --redirect-url %s\n", opts.RedirectURL) + fmt.Println("\nAlternatively, you can use a token-based login: tea login add") + } + return fmt.Errorf("authorization failed: %s", err) + } + + // Verify state to prevent CSRF attacks + if state != receivedState { + return fmt.Errorf("state mismatch, possible CSRF attack") + } + + // Exchange authorization code for token + token, err := oauth2Config.Exchange(ctx, code, oauth2.SetAuthURLParam("code_verifier", codeVerifier)) + if err != nil { + return fmt.Errorf("token exchange failed: %s", err) + } + + // Create login with token data + if err := createLoginFromToken(opts.Name, serverURL.String(), token, opts.Insecure); err != nil { + return err + } + + return nil +} + +// createHTTPClient creates an HTTP client with optional insecure setting +func createHTTPClient(insecure bool) *http.Client { + client := &http.Client{} + if insecure { + client = &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + }, + } + } + return client +} + +// generateCodeVerifier creates a cryptographically random string for PKCE +func generateCodeVerifier(length int) (string, error) { + bytes := make([]byte, length) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(bytes)[:length], nil +} + +// generateCodeChallenge creates a code challenge from the code verifier using SHA256 +func generateCodeChallenge(codeVerifier string) string { + hash := sha256.Sum256([]byte(codeVerifier)) + return base64.RawURLEncoding.EncodeToString(hash[:]) +} + +// startLocalServerAndOpenBrowser starts a local HTTP server to receive the OAuth callback +// and opens the browser to the authorization URL +func startLocalServerAndOpenBrowser(authURL, expectedState string, opts OAuthOptions) (string, string, error) { + // Channel to receive the authorization code + codeChan := make(chan string, 1) + stateChan := make(chan string, 1) + errChan := make(chan error, 1) + + // Parse the redirect URL to get the path + parsedURL, err := url.Parse(opts.RedirectURL) + if err != nil { + return "", "", fmt.Errorf("invalid redirect URL: %s", err) + } + + // Path to listen for in the callback + callbackPath := parsedURL.Path + if callbackPath == "" { + callbackPath = "/" + } + + // Get the hostname from the redirect URL + hostname := parsedURL.Hostname() + if hostname == "" { + hostname = redirectHost + } + + // Ensure we have a valid port + port := opts.Port + if port == 0 { + if parsedPort := parsedURL.Port(); parsedPort != "" { + port, _ = strconv.Atoi(parsedPort) + } + if port == 0 { + port = redirectPort + } + } + + // Server address with explicit port + serverAddr := fmt.Sprintf("%s:%d", hostname, port) + + // Start local server + server := &http.Server{ + Addr: serverAddr, + Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Only process the callback path + if r.URL.Path != callbackPath { + http.NotFound(w, r) + return + } + + // Extract code and state from URL parameters + code := r.URL.Query().Get("code") + state := r.URL.Query().Get("state") + error := r.URL.Query().Get("error") + errorDesc := r.URL.Query().Get("error_description") + + if error != "" { + errMsg := error + if errorDesc != "" { + errMsg += ": " + errorDesc + } + errChan <- fmt.Errorf("authorization error: %s", errMsg) + w.WriteHeader(http.StatusBadRequest) + fmt.Fprintf(w, "Error: %s", errMsg) + return + } + + if code == "" { + errChan <- fmt.Errorf("no authorization code received") + w.WriteHeader(http.StatusBadRequest) + fmt.Fprintf(w, "Error: No authorization code received") + return + } + + // Send success response to browser + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, "Authorization successful! You can close this window and return to the CLI.") + + // Send code to channel + codeChan <- code + stateChan <- state + }), + } + + // Start server in a goroutine + go func() { + fmt.Printf("Starting local server on %s...\n", server.Addr) + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + errChan <- err + } + }() + + // Wait a bit for server to start + time.Sleep(100 * time.Millisecond) + + // Open browser + fmt.Println("Opening browser for authorization...") + if err := openBrowser(authURL); err != nil { + return "", "", fmt.Errorf("failed to open browser: %s", err) + } + + // Wait for code, error, or timeout + select { + case code := <-codeChan: + state := <-stateChan + // Shut down server + go server.Close() + return code, state, nil + case err := <-errChan: + go server.Close() + return "", "", err + case <-time.After(authTimeout): + go server.Close() + return "", "", fmt.Errorf("authentication timed out after %s", authTimeout) + } +} + +// openBrowser opens the default browser to the specified URL +func openBrowser(url string) error { + fmt.Printf("Please authorize the application by visiting this URL in your browser:\n%s\n", url) + + return open.Run(url) +} + +// createLoginFromToken creates a login entry using the obtained access token +func createLoginFromToken(name, serverURL string, token *oauth2.Token, insecure bool) error { + if name == "" { + var err error + name, err = task.GenerateLoginName(serverURL, "") + if err != nil { + return err + } + } + + // Create login object + login := config.Login{ + Name: name, + URL: serverURL, + Token: token.AccessToken, + RefreshToken: token.RefreshToken, + Insecure: insecure, + VersionCheck: true, + Created: time.Now().Unix(), + } + + // Set token expiry if available + if !token.Expiry.IsZero() { + login.TokenExpiry = token.Expiry.Unix() + } + + // Validate token by getting user info + client := login.Client() + u, _, err := client.GetMyUserInfo() + if err != nil { + return fmt.Errorf("failed to validate token: %s", err) + } + + // Set user info + login.User = u.UserName + + // Get SSH host + parsedURL, err := url.Parse(serverURL) + if err != nil { + return err + } + login.SSHHost = parsedURL.Host + + // Add login to config + if err := config.AddLogin(&login); err != nil { + return err + } + + fmt.Printf("Login as %s on %s successful. Added this login as %s\n", login.User, login.URL, login.Name) + return nil +} + +// RefreshAccessToken manually renews an expired access token using the refresh token +// Note: In most cases, tokens are automatically refreshed when using login.Client() +// This function is primarily used for manual refreshes via CLI command +func RefreshAccessToken(login *config.Login) error { + if login.RefreshToken == "" { + return fmt.Errorf("no refresh token available") + } + + // Check if token actually needs refreshing + if login.TokenExpiry > 0 && time.Now().Unix() < login.TokenExpiry { + // Token is still valid, no need to refresh + fmt.Println("Token is still valid, no need to refresh.") + return nil + } + + fmt.Println("Access token expired, refreshing...") + + // Create an expired Token object + expiredToken := &oauth2.Token{ + AccessToken: login.Token, + RefreshToken: login.RefreshToken, + // Set expiry in the past to force refresh + Expiry: time.Unix(login.TokenExpiry, 0), + } + + // Set up the OAuth2 config + ctx := context.Background() + ctx = context.WithValue(ctx, oauth2.HTTPClient, createHTTPClient(login.Insecure)) + + // Configure the OAuth2 endpoints + oauth2Config := &oauth2.Config{ + ClientID: defaultClientID, + Endpoint: oauth2.Endpoint{ + TokenURL: fmt.Sprintf("%s/login/oauth/access_token", login.URL), + }, + } + + // Refresh the token + newToken, err := oauth2Config.TokenSource(ctx, expiredToken).Token() + if err != nil { + return fmt.Errorf("failed to refresh token: %s", err) + } + + // Update login with new token information + login.Token = newToken.AccessToken + + if newToken.RefreshToken != "" { + login.RefreshToken = newToken.RefreshToken + } + + if !newToken.Expiry.IsZero() { + login.TokenExpiry = newToken.Expiry.Unix() + } + + // Save updated login to config + return config.UpdateLogin(login) +} diff --git a/modules/config/login.go b/modules/config/login.go index f5a398d..d40d1b2 100644 --- a/modules/config/login.go +++ b/modules/config/login.go @@ -4,6 +4,7 @@ package config import ( + "context" "crypto/tls" "errors" "fmt" @@ -13,10 +14,12 @@ import ( "net/url" "os" "strings" + "time" "code.gitea.io/sdk/gitea" "code.gitea.io/tea/modules/utils" "github.com/AlecAivazis/survey/v2" + "golang.org/x/oauth2" ) // Login represents a login to a gitea server, you even could add multiple logins for one gitea server @@ -38,6 +41,10 @@ type Login struct { User string `yaml:"user"` // Created is auto created unix timestamp Created int64 `yaml:"created"` + // RefreshToken is used to renew the access token when it expires + RefreshToken string `yaml:"refresh_token"` + // TokenExpiry is when the token expires (unix timestamp) + TokenExpiry int64 `yaml:"token_expiry"` } // GetLogins return all login available by config @@ -168,9 +175,89 @@ func AddLogin(login *Login) error { return saveConfig() } +// UpdateLogin updates an existing login in the config +func UpdateLogin(login *Login) error { + if err := loadConfig(); err != nil { + return err + } + + // Find and update the login + found := false + for i, l := range config.Logins { + if l.Name == login.Name { + config.Logins[i] = *login + found = true + break + } + } + + if !found { + return fmt.Errorf("login %s not found", login.Name) + } + + // Save updated config + return saveConfig() +} + // Client returns a client to operate Gitea API. You may provide additional modifiers // for the client like gitea.SetBasicAuth() for customization func (l *Login) Client(options ...gitea.ClientOption) *gitea.Client { + // Check if token needs refreshing (if we have a refresh token and expiry time) + if l.RefreshToken != "" && l.TokenExpiry > 0 && time.Now().Unix() > l.TokenExpiry { + // Since we can't directly call auth.RefreshAccessToken due to import cycles, + // we'll implement the token refresh logic here. + // Create an expired Token object + expiredToken := &oauth2.Token{ + AccessToken: l.Token, + RefreshToken: l.RefreshToken, + // Set expiry in the past to force refresh + Expiry: time.Unix(l.TokenExpiry, 0), + } + + // Set up the OAuth2 config + ctx := context.Background() + + // Create HTTP client with proper insecure settings + httpClient := &http.Client{} + if l.Insecure { + httpClient = &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + }, + } + } + ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient) + + // Configure the OAuth2 endpoints + oauth2Config := &oauth2.Config{ + ClientID: "d57cb8c4-630c-4168-8324-ec79935e18d4", // defaultClientID from modules/auth/oauth.go + Endpoint: oauth2.Endpoint{ + TokenURL: fmt.Sprintf("%s/login/oauth/access_token", l.URL), + }, + } + + // Refresh the token + newToken, err := oauth2Config.TokenSource(ctx, expiredToken).Token() + if err != nil { + log.Fatalf("Failed to refresh token: %s\nPlease use 'tea login oauth-refresh %s' to manually refresh the token.\n", err, l.Name) + } + // Update login with new token information + l.Token = newToken.AccessToken + + if newToken.RefreshToken != "" { + l.RefreshToken = newToken.RefreshToken + } + + if !newToken.Expiry.IsZero() { + l.TokenExpiry = newToken.Expiry.Unix() + } + + // Save updated login to config + if err := UpdateLogin(l); err != nil { + log.Fatalf("Failed to save refreshed token: %s\n", err) + } + } + httpClient := &http.Client{} if l.Insecure { cookieJar, _ := cookiejar.New(nil) diff --git a/modules/interact/login.go b/modules/interact/login.go index 5c820fa..7bca230 100644 --- a/modules/interact/login.go +++ b/modules/interact/login.go @@ -9,6 +9,7 @@ import ( "strings" "code.gitea.io/sdk/gitea" + "code.gitea.io/tea/modules/auth" "code.gitea.io/tea/modules/task" "github.com/AlecAivazis/survey/v2" @@ -44,12 +45,22 @@ func CreateLogin() error { return err } - loginMethod, err := promptSelectV2("Login with: ", []string{"token", "ssh-key/certificate"}) + loginMethod, err := promptSelectV2("Login with: ", []string{"token", "ssh-key/certificate", "oauth"}) if err != nil { return err } switch loginMethod { + case "oauth": + promptYN := &survey.Confirm{ + Message: "Allow Insecure connections: ", + Default: false, + } + if err = survey.AskOne(promptYN, &insecure); err != nil { + return err + } + + return auth.OAuthLoginWithOptions(name, giteaURL, insecure) default: // token var hasToken bool promptYN := &survey.Confirm{ @@ -161,7 +172,6 @@ func CreateLogin() error { if err = survey.AskOne(promptYN, &versionCheck); err != nil { return err } - } return task.CreateLogin(name, token, user, passwd, otp, scopes, sshKey, giteaURL, sshCertPrincipal, sshKeyFingerprint, insecure, sshAgent, versionCheck, helper)