techknowlogick 62dc1dde95 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>
2025-03-18 17:01:49 +00:00

474 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("\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)
}