mirror of
				https://gitea.com/gitea/tea.git
				synced 2025-10-31 17:25:27 +01:00 
			
		
		
		
	Login via oauth2 flow (#725)
Reviewed-on: https://gitea.com/gitea/tea/pulls/725 Co-authored-by: techknowlogick <techknowlogick@gitea.com> Co-committed-by: techknowlogick <techknowlogick@gitea.com>
This commit is contained in:
		 techknowlogick
					techknowlogick
				
			
				
					committed by
					
						 techknowlogick
						techknowlogick
					
				
			
			
				
	
			
			
			 techknowlogick
						techknowlogick
					
				
			
						parent
						
							e82dd9e08d
						
					
				
				
					commit
					62dc1dde95
				
			
							
								
								
									
										473
									
								
								modules/auth/oauth.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										473
									
								
								modules/auth/oauth.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,473 @@ | ||||
| // 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("\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 | ||||
| 	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 { | ||||
| 		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) | ||||
| } | ||||
		Reference in New Issue
	
	Block a user