mirror of
https://gitea.com/gitea/tea.git
synced 2025-09-03 02:18:30 +02:00

When users login gitea on a headless server via ssh, xdg-open might not be installed on that machine. So tea may fail to open URL itself. In this case, users can use the other machine to open the URL for authentication. Github CLI act like this, too. Signed-off-by: Chen Linxuan <me@black-desk.cn> Reviewed-on: https://gitea.com/gitea/tea/pulls/794 Reviewed-by: blumia <blumia@noreply.gitea.com> Co-authored-by: Chen Linxuan <me@black-desk.cn> Co-committed-by: Chen Linxuan <me@black-desk.cn>
469 lines
13 KiB
Go
469 lines
13 KiB
Go
// 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"
|
|
"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 = 0
|
|
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("\nError: 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
|
|
return createLoginFromToken(opts.Name, serverURL.String(), token, opts.Insecure)
|
|
}
|
|
|
|
// 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)
|
|
portChan := make(chan int, 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)
|
|
}
|
|
}
|
|
|
|
// Server address with port (may be dynamic if port=0)
|
|
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
|
|
}),
|
|
}
|
|
|
|
// Listener for getting the actual port when using port 0
|
|
listener, err := net.Listen("tcp", serverAddr)
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("failed to start local server: %s", err)
|
|
}
|
|
|
|
// Get the actual port if we used port 0
|
|
if port == 0 {
|
|
addr := listener.Addr().(*net.TCPAddr)
|
|
port = addr.Port
|
|
portChan <- port
|
|
|
|
// Update redirect URL with actual port
|
|
parsedURL.Host = fmt.Sprintf("%s:%d", hostname, port)
|
|
opts.RedirectURL = parsedURL.String()
|
|
|
|
// Update the auth URL with the new redirect URL
|
|
authURLParsed, err := url.Parse(authURL)
|
|
if err == nil {
|
|
query := authURLParsed.Query()
|
|
query.Set("redirect_uri", opts.RedirectURL)
|
|
authURLParsed.RawQuery = query.Encode()
|
|
authURL = authURLParsed.String()
|
|
}
|
|
}
|
|
|
|
// Start server in a goroutine
|
|
go func() {
|
|
fmt.Printf("Starting local server on %s:%d...\n", hostname, port)
|
|
if err := server.Serve(listener); 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 {
|
|
fmt.Println("Failed to open browser: ", 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
|
|
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
|
|
return nil
|
|
}
|
|
|
|
// 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)
|
|
}
|