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)