gitea.tea/modules/config/login.go
GyeongHo Kim 511884b2af
fix(login): default SSH host to URL hostname
Use the server URL hostname when a login has no explicit SSHHost configured, so an HTTP(S) port from the Gitea URL is not reused as the SSH endpoint.

Add shared SSH host normalization and resolution helpers with tests for host and host:port values.

Signed-off-by: GyeongHo Kim <gyeongho.dev@proton.me>
2026-07-29 14:05:35 +09:00

533 lines
15 KiB
Go

// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package config
import (
"context"
"crypto/tls"
"errors"
"fmt"
"net/http"
"net/http/cookiejar"
"net/url"
"os"
"strings"
"time"
gitea "gitea.dev/sdk"
"gitea.dev/tea/modules/debug"
"gitea.dev/tea/modules/httputil"
"gitea.dev/tea/modules/theme"
"gitea.dev/tea/modules/utils"
"charm.land/huh/v2"
"golang.org/x/oauth2"
)
// TokenRefreshThreshold is how far before expiry we should refresh OAuth tokens.
// This is used by config.Login.Client() for automatic token refresh.
const TokenRefreshThreshold = 5 * time.Minute
// DefaultClientID is the default OAuth2 client ID included in most Gitea instances
const DefaultClientID = "d57cb8c4-630c-4168-8324-ec79935e18d4"
// AuthMethodOAuth marks a login as using OAuth with secure credential storage.
const AuthMethodOAuth = "oauth"
// Login represents a login to a gitea server, you even could add multiple logins for one gitea server
type Login struct {
Name string `yaml:"name"`
URL string `yaml:"url"`
Token string `yaml:"token,omitempty"`
Default bool `yaml:"default"`
SSHHost string `yaml:"ssh_host"`
// optional path to the private key
SSHKey string `yaml:"ssh_key"`
Insecure bool `yaml:"insecure"`
SSHCertPrincipal string `yaml:"ssh_certificate_principal"`
SSHAgent bool `yaml:"ssh_agent"`
SSHKeyFingerprint string `yaml:"ssh_key_agent_pub"`
SSHPassphrase string `yaml:"-"`
VersionCheck bool `yaml:"version_check"`
// User is username from gitea
User string `yaml:"user"`
// Created is auto created unix timestamp
Created int64 `yaml:"created"`
// AuthMethod indicates the authentication method ("oauth" for OAuth with credstore)
AuthMethod string `yaml:"auth_method,omitempty"`
// RefreshToken is used to renew the access token when it expires
RefreshToken string `yaml:"refresh_token,omitempty"`
// TokenExpiry is when the token expires (unix timestamp)
TokenExpiry int64 `yaml:"token_expiry,omitempty"`
}
// IsOAuth returns true if this login uses OAuth with secure credential storage.
func (l *Login) IsOAuth() bool {
return l.AuthMethod == AuthMethodOAuth
}
// loadOAuthToken loads the OAuth token from credstore, returning nil if
// this is not an OAuth login or if the load fails (caller should fallback).
func (l *Login) loadOAuthToken() *OAuthToken {
if !l.IsOAuth() {
return nil
}
tok, err := LoadOAuthToken(l.Name)
if err != nil {
return nil
}
return &OAuthToken{
AccessToken: tok.AccessToken,
RefreshToken: tok.RefreshToken,
ExpiresAt: tok.ExpiresAt,
}
}
// OAuthToken holds the token fields loaded from credstore.
type OAuthToken struct {
AccessToken string
RefreshToken string
ExpiresAt time.Time
}
// GetAccessToken returns the effective access token.
// For OAuth logins, reads from credstore. For others, returns l.Token directly.
func (l *Login) GetAccessToken() string {
if tok := l.loadOAuthToken(); tok != nil {
return tok.AccessToken
}
return l.Token
}
// GetRefreshToken returns the refresh token.
func (l *Login) GetRefreshToken() string {
if tok := l.loadOAuthToken(); tok != nil {
return tok.RefreshToken
}
return l.RefreshToken
}
// GetTokenExpiry returns the token expiry time.
func (l *Login) GetTokenExpiry() time.Time {
if tok := l.loadOAuthToken(); tok != nil {
return tok.ExpiresAt
}
return time.Unix(l.TokenExpiry, 0)
}
// GetLogins return all login available by config
func GetLogins() ([]Login, error) {
if err := loadConfig(); err != nil {
return nil, err
}
return config.Logins, nil
}
// GetDefaultLogin return the default login
func GetDefaultLogin() (*Login, error) {
if err := loadConfig(); err != nil {
return nil, err
}
if len(config.Logins) == 0 {
return nil, errors.New("no available login")
}
for _, l := range config.Logins {
if l.Default {
return &l, nil
}
}
return &config.Logins[0], nil
}
// SetDefaultLogin set the default login by name (case insensitive)
func SetDefaultLogin(name string) error {
return withConfigLock(func() error {
loginExist := false
for i := range config.Logins {
config.Logins[i].Default = false
if strings.EqualFold(config.Logins[i].Name, name) {
config.Logins[i].Default = true
loginExist = true
}
}
if !loginExist {
return fmt.Errorf("login '%s' not found", name)
}
return saveConfigUnsafe()
})
}
// GetLoginByName get login by name (case insensitive)
func GetLoginByName(name string) (*Login, error) {
if err := loadConfig(); err != nil {
return nil, err
}
for i := range config.Logins {
if strings.EqualFold(config.Logins[i].Name, name) {
return &config.Logins[i], nil
}
}
return nil, nil
}
// GetLoginByToken get login by token
func GetLoginByToken(token string) (*Login, error) {
if token == "" {
return nil, nil
}
if err := loadConfig(); err != nil {
return nil, err
}
for _, l := range config.Logins {
if l.Token == token {
return &l, nil
}
}
return nil, nil
}
// GetLoginByHost finds a login by its server URL
func GetLoginByHost(host string) (*Login, error) {
logins, err := GetLoginsByHost(host)
if err != nil {
return nil, err
}
if len(logins) > 0 {
return logins[0], nil
}
return nil, nil
}
// GetLoginsByHost returns all logins matching a host
func GetLoginsByHost(host string) ([]*Login, error) {
if err := loadConfig(); err != nil {
return nil, err
}
var matches []*Login
for i := range config.Logins {
loginURL, err := url.Parse(config.Logins[i].URL)
if err != nil {
return nil, err
}
if loginURL.Host == host {
matches = append(matches, &config.Logins[i])
}
}
return matches, nil
}
// DeleteLogin delete a login by name from config
func DeleteLogin(name string) error {
return withConfigLock(func() error {
idx := -1
for i, l := range config.Logins {
if strings.EqualFold(l.Name, name) {
idx = i
break
}
}
if idx == -1 {
return fmt.Errorf("can not delete login '%s', does not exist", name)
}
isOAuth := config.Logins[idx].IsOAuth()
config.Logins = append(config.Logins[:idx], config.Logins[idx+1:]...)
// Clean up credstore tokens for OAuth logins
if isOAuth {
_ = DeleteOAuthToken(name)
}
return saveConfigUnsafe()
})
}
// AddLogin save a login to config
func AddLogin(login *Login) error {
return withConfigLock(func() error {
// Check for duplicate login names
for _, existing := range config.Logins {
if strings.EqualFold(existing.Name, login.Name) {
return fmt.Errorf("login name '%s' already exists", login.Name)
}
}
// save login to global var
config.Logins = append(config.Logins, *login)
// save login to config file
return saveConfigUnsafe()
})
}
// AddOAuthLogin saves the OAuth token and login profile as one operation.
// The profile is only written after secure token storage succeeds.
func AddOAuthLogin(login *Login, accessToken, refreshToken string, expiresAt time.Time) error {
return withConfigLock(func() error {
// Check for duplicate login names before touching credential storage.
for _, existing := range config.Logins {
if strings.EqualFold(existing.Name, login.Name) {
return fmt.Errorf("login name '%s' already exists", login.Name)
}
}
if err := SaveOAuthToken(login.Name, accessToken, refreshToken, expiresAt); err != nil {
return fmt.Errorf("failed to save token to secure store: %w", err)
}
config.Logins = append(config.Logins, *login)
if err := saveConfigUnsafe(); err != nil {
config.Logins = config.Logins[:len(config.Logins)-1]
if deleteErr := DeleteOAuthToken(login.Name); deleteErr != nil {
return errors.Join(err, fmt.Errorf("failed to clean up OAuth token after config save failure: %w", deleteErr))
}
return err
}
return nil
})
}
// SaveLoginTokens updates the token fields for an existing login.
// This is used after browser-based re-authentication to save new tokens.
func SaveLoginTokens(login *Login) error {
if login.IsOAuth() {
return SaveOAuthToken(login.Name, login.GetAccessToken(), login.GetRefreshToken(), login.GetTokenExpiry())
}
return withConfigLock(func() error {
for i, l := range config.Logins {
if strings.EqualFold(l.Name, login.Name) {
config.Logins[i].Token = login.Token
config.Logins[i].RefreshToken = login.RefreshToken
config.Logins[i].TokenExpiry = login.TokenExpiry
return saveConfigUnsafe()
}
}
return fmt.Errorf("login %s not found", login.Name)
})
}
// RefreshOAuthTokenIfNeeded refreshes the OAuth token if it's expired or near expiry.
// Returns nil without doing anything if no refresh is needed.
func (l *Login) RefreshOAuthTokenIfNeeded() error {
// Load once to avoid multiple credstore reads
if tok := l.loadOAuthToken(); tok != nil {
if tok.RefreshToken == "" || tok.ExpiresAt.IsZero() {
return nil
}
if time.Now().Add(TokenRefreshThreshold).After(tok.ExpiresAt) {
return l.RefreshOAuthToken()
}
return nil
}
// Non-OAuth path: use YAML fields
if l.RefreshToken == "" || l.TokenExpiry == 0 {
return nil
}
if time.Now().Add(TokenRefreshThreshold).After(time.Unix(l.TokenExpiry, 0)) {
return l.RefreshOAuthToken()
}
return nil
}
// RefreshOAuthToken refreshes the OAuth access token using the refresh token.
// It updates the login with new token information and saves it to config.
// Uses double-checked locking to avoid unnecessary refresh calls when multiple
// processes race to refresh the same token.
func (l *Login) RefreshOAuthToken() error {
if l.GetRefreshToken() == "" {
return fmt.Errorf("no refresh token available")
}
return withConfigLock(func() error {
// Double-check: after acquiring lock, re-read config and check if
// another process already refreshed the token
for i, login := range config.Logins {
if login.Name == l.Name {
// Check if token was refreshed by another process
currentExpiry := login.GetTokenExpiry()
ourExpiry := l.GetTokenExpiry()
if currentExpiry != ourExpiry && !currentExpiry.IsZero() {
if time.Now().Add(TokenRefreshThreshold).Before(currentExpiry) {
// Token was refreshed by another process, update our copy
if !login.IsOAuth() {
l.Token = login.Token
l.RefreshToken = login.RefreshToken
l.TokenExpiry = login.TokenExpiry
}
// For OAuth logins, credstore already has the latest tokens
return nil
}
}
// Still need to refresh - proceed with OAuth call
newToken, err := doOAuthRefresh(context.Background(), l)
if err != nil {
return err
}
if l.IsOAuth() {
// Save tokens to credstore; no YAML changes needed
return SaveOAuthTokenFromOAuth2(l.Name, newToken, l)
}
// Update login with new token information (legacy path)
l.Token = newToken.AccessToken
if newToken.RefreshToken != "" {
l.RefreshToken = newToken.RefreshToken
}
if !newToken.Expiry.IsZero() {
l.TokenExpiry = newToken.Expiry.Unix()
}
config.Logins[i] = *l
return saveConfigUnsafe()
}
}
return fmt.Errorf("login %s not found", l.Name)
})
}
// doOAuthRefresh performs the actual OAuth token refresh API call.
func doOAuthRefresh(ctx context.Context, l *Login) (*oauth2.Token, error) {
// Build current token from credstore (single load) or YAML fields
var accessToken, refreshToken string
var expiry time.Time
if tok := l.loadOAuthToken(); tok != nil {
accessToken = tok.AccessToken
refreshToken = tok.RefreshToken
expiry = tok.ExpiresAt
} else {
accessToken = l.Token
refreshToken = l.RefreshToken
expiry = time.Unix(l.TokenExpiry, 0)
}
currentToken := &oauth2.Token{
AccessToken: accessToken,
RefreshToken: refreshToken,
Expiry: expiry,
}
httpClient := &http.Client{
Transport: httputil.WrapTransport(&tls.Config{InsecureSkipVerify: l.Insecure}),
}
ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient)
oauth2Config := &oauth2.Config{
ClientID: DefaultClientID,
Endpoint: oauth2.Endpoint{
TokenURL: fmt.Sprintf("%s/login/oauth/access_token", l.URL),
},
}
newToken, err := oauth2Config.TokenSource(ctx, currentToken).Token()
if err != nil {
return nil, fmt.Errorf("failed to refresh token: %w", err)
}
return newToken, nil
}
// 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 {
// Refresh OAuth token if expired or near expiry
if err := l.RefreshOAuthTokenIfNeeded(); err != nil {
fmt.Fprintf(os.Stderr, "Failed to refresh token: %s\nPlease use 'tea login oauth-refresh %s' to manually refresh the token.\n", err, l.Name)
os.Exit(1)
}
// Configure transport-level timeouts so a stalled or unresponsive server
// fails fast instead of hanging forever. These bound connection setup and
// time-to-first-response-byte only, so slow-but-progressing transfers (e.g.
// large attachment uploads) are unaffected.
httpClient := &http.Client{
Transport: httputil.WrapTransport(nil),
}
if l.Insecure {
cookieJar, _ := cookiejar.New(nil) // New with nil options never returns an error
httpClient = &http.Client{
Jar: cookieJar,
Transport: httputil.WrapTransport(&tls.Config{InsecureSkipVerify: true}),
}
}
// versioncheck must be prepended in options to make sure we don't hit any version checks in the sdk
if !l.VersionCheck {
options = append([]gitea.ClientOption{gitea.SetGiteaVersion("")}, options...)
}
// SetUserAgent is intentionally redundant with the User-Agent the WrapTransport
// transport already sets: this is the SDK's own guarantee, so the UA survives
// even if the client is ever given a transport that didn't come from WrapTransport.
// Both resolve to httputil.UserAgent(), so the duplicate Header.Set is a no-op.
options = append(options, gitea.SetToken(l.GetAccessToken()), gitea.SetHTTPClient(httpClient), gitea.SetUserAgent(httputil.UserAgent()))
if debug.IsDebug() {
options = append(options, gitea.SetDebugMode())
}
if l.SSHCertPrincipal != "" {
if err := l.askForSSHPassphrase(); err != nil {
fmt.Fprintf(os.Stderr, "Failed to read SSH passphrase: %s\n", err)
os.Exit(1)
}
options = append(options, gitea.UseSSHCert(l.SSHCertPrincipal, l.SSHKey, l.SSHPassphrase))
}
if l.SSHKeyFingerprint != "" {
if err := l.askForSSHPassphrase(); err != nil {
fmt.Fprintf(os.Stderr, "Failed to read SSH passphrase: %s\n", err)
os.Exit(1)
}
options = append(options, gitea.UseSSHPubkey(l.SSHKeyFingerprint, l.SSHKey, l.SSHPassphrase))
}
client, err := gitea.NewClient(l.URL, options...)
if err != nil {
var versionError *gitea.ErrUnknownVersion
if !errors.As(err, &versionError) {
fmt.Fprintf(os.Stderr, "Failed to create Gitea client: %s\n", err)
os.Exit(1)
}
fmt.Fprintf(os.Stderr, "WARNING: could not detect gitea version: %s\nINFO: set gitea version: to last supported one\n", versionError)
}
return client
}
func (l *Login) askForSSHPassphrase() error {
if ok, err := utils.IsKeyEncrypted(l.SSHKey); ok && err == nil && l.SSHPassphrase == "" {
return huh.NewInput().
Title("ssh-key is encrypted please enter the passphrase: ").
Validate(huh.ValidateNotEmpty()).
EchoMode(huh.EchoModePassword).
Value(&l.SSHPassphrase).
WithTheme(theme.GetTheme()).
Run()
}
return nil
}
// GetSSHHost returns SSH host name
func (l *Login) GetSSHHost() string {
if l.SSHHost != "" {
return l.SSHHost
}
u, err := url.Parse(l.URL)
if err != nil {
return ""
}
return u.Hostname()
}