feat: tstudio v0.4.0 — browser OAuth2 login, getting-started page
Build tstudio CLI / build (push) Waiting to run
Build tinqs-git / build (push) Waiting to run

Login rewritten to use OAuth2 PKCE browser flow:
- Opens browser → user authenticates on tinqs.com → callback to localhost
- No passwords in terminal, same UX as gcloud/gh auth login
- PKCE (RFC 7636) with S256 code challenge
- Stores refresh_token for silent token renewal
- Logout clears git credentials

Server-side:
- Registered tstudio-cli as built-in OAuth2 app (models/auth/oauth2.go)
- Added to default applications list (modules/setting/oauth2.go)
- New /cli/getting-started route + public onboarding page
- Teaches Cursor setup, agent workflow, git operations

First login redirects to getting-started page automatically.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-22 09:40:36 +01:00
parent 0488cdda72
commit 7164080fe5
7 changed files with 402 additions and 83 deletions
+11 -5
View File
@@ -10,14 +10,20 @@ import (
"runtime"
)
const DefaultInstance = "https://git.tinqs.com"
const DefaultInstance = "https://tinqs.com"
// OAuthClientID is the built-in OAuth2 application for tstudio CLI.
// Registered in models/auth/oauth2.go BuiltinApplications() — public client, no secret.
const OAuthClientID = "b8f4e9a1-7c3d-4f2e-a1d5-6e8b9c0f3a2d"
// Config holds all stored state for tstudio.
type Config struct {
Instance string `json:"instance"` // e.g. "https://git.tinqs.com"
Username string `json:"username"` // e.g. "ozan"
Token string `json:"token"` // Gitea API token
TokenID int64 `json:"token_id,omitempty"` // Gitea token ID (for revocation)
Instance string `json:"instance"` // e.g. "https://tinqs.com"
Username string `json:"username"` // e.g. "ozan"
Token string `json:"token"` // OAuth2 access token
RefreshToken string `json:"refresh_token,omitempty"` // OAuth2 refresh token
TokenID int64 `json:"token_id,omitempty"` // legacy: Gitea token ID
FirstLogin bool `json:"first_login,omitempty"` // true on very first login on this machine
}
func configDir() string {
+233 -77
View File
@@ -4,18 +4,27 @@
package main
import (
"bufio"
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"flag"
"fmt"
"net"
"net/http"
"net/url"
"os"
"os/exec"
"runtime"
"strings"
"syscall"
"time"
)
"golang.org/x/term"
const (
oauthScopes = "all"
codeVerifierLength = 64
authTimeout = 5 * time.Minute
)
func cmdLogin(args []string) {
@@ -23,107 +32,219 @@ func cmdLogin(args []string) {
instance := fs.String("instance", DefaultInstance, "Tinqs Studio instance URL")
fs.Parse(args)
reader := bufio.NewReader(os.Stdin)
base := strings.TrimRight(*instance, "/")
fmt.Printf("Logging in to %s\n\n", *instance)
// Check if this is a first-ever login on this machine
existing, _ := loadConfig()
isFirstLogin := existing == nil
// Username
fmt.Print("Username: ")
username, _ := reader.ReadString('\n')
username = strings.TrimSpace(username)
if username == "" {
fatal("username cannot be empty")
}
// Password (hidden)
fmt.Print("Password: ")
pwBytes, err := term.ReadPassword(int(syscall.Stdin))
fmt.Println() // newline after hidden input
// 1. Start local server on random port
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
fatal("failed to read password: %v", err)
}
password := string(pwBytes)
if password == "" {
fatal("password cannot be empty")
fatal("cannot start local server: %v", err)
}
port := listener.Addr().(*net.TCPAddr).Port
redirectURI := fmt.Sprintf("http://127.0.0.1:%d", port)
// Create API token via basic auth
tokenName := fmt.Sprintf("tstudio-%s", hostname())
body := fmt.Sprintf(`{"name":%q,"scopes":["all"]}`, tokenName)
url := strings.TrimRight(*instance, "/") + "/api/v1/users/" + username + "/tokens"
req, err := http.NewRequest("POST", url, strings.NewReader(body))
// 2. Generate PKCE code verifier + challenge (RFC 7636)
codeVerifier, err := generateCodeVerifier(codeVerifierLength)
if err != nil {
fatal("failed to create request: %v", err)
fatal("cannot generate code verifier: %v", err)
}
req.Header.Set("Content-Type", "application/json")
codeChallenge := generateCodeChallenge(codeVerifier)
// 3. Generate CSRF state
stateBytes := make([]byte, 16)
rand.Read(stateBytes)
state := base64.RawURLEncoding.EncodeToString(stateBytes)
// 4. Build authorization URL
authorizeURL := fmt.Sprintf("%s/login/oauth/authorize?client_id=%s&redirect_uri=%s&response_type=code&state=%s&scope=%s&code_challenge=%s&code_challenge_method=S256",
base,
url.QueryEscape(OAuthClientID),
url.QueryEscape(redirectURI),
url.QueryEscape(state),
url.QueryEscape(oauthScopes),
url.QueryEscape(codeChallenge),
)
// 5. Open browser
fmt.Printf("Opening %s ...\n", base)
fmt.Println("Waiting for you to authorize in the browser...\n")
fmt.Printf("If the browser doesn't open, visit:\n%s\n\n", authorizeURL)
openBrowser(authorizeURL)
// 6. Wait for callback
var authCode string
var authErr error
srv := &http.Server{}
srv.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("state") != state {
authErr = fmt.Errorf("invalid state — possible CSRF attack")
w.Header().Set("Content-Type", "text/html")
fmt.Fprint(w, authPage("Error", "Invalid state parameter. Try again.", true))
go srv.Shutdown(context.Background())
return
}
if errMsg := r.URL.Query().Get("error"); errMsg != "" {
authErr = fmt.Errorf("authorization denied: %s", errMsg)
w.Header().Set("Content-Type", "text/html")
fmt.Fprint(w, authPage("Denied", "Authorization was denied. You can close this tab.", true))
go srv.Shutdown(context.Background())
return
}
authCode = r.URL.Query().Get("code")
if authCode == "" {
authErr = fmt.Errorf("no authorization code received")
w.Header().Set("Content-Type", "text/html")
fmt.Fprint(w, authPage("Error", "No authorization code received.", true))
go srv.Shutdown(context.Background())
return
}
w.Header().Set("Content-Type", "text/html")
fmt.Fprint(w, authPage("Logged In", "You can close this tab and return to the terminal.", false))
go srv.Shutdown(context.Background())
})
// Timeout
go func() {
time.Sleep(authTimeout)
if authCode == "" && authErr == nil {
authErr = fmt.Errorf("timed out waiting for authorization (5 minutes)")
srv.Shutdown(context.Background())
}
}()
srv.Serve(listener)
if authErr != nil {
fatal("%v", authErr)
}
// 7. Exchange code for token (with PKCE code_verifier)
fmt.Println("Exchanging token...")
tokenURL := base + "/login/oauth/access_token"
data := url.Values{
"client_id": {OAuthClientID},
"code": {authCode},
"grant_type": {"authorization_code"},
"redirect_uri": {redirectURI},
"code_verifier": {codeVerifier},
}
req, _ := http.NewRequest("POST", tokenURL, strings.NewReader(data.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", UserAgent)
req.SetBasicAuth(username, password)
resp, err := httpClient.Do(req)
if err != nil {
fatal("failed to connect to %s: %v", *instance, err)
fatal("token exchange failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized {
fatal("invalid username or password")
var tokenResp struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int64 `json:"expires_in"`
Error string `json:"error"`
}
if resp.StatusCode == http.StatusUnprocessableEntity {
// Token with this name may already exist — try with a suffix
tokenName = fmt.Sprintf("tstudio-%s-%d", hostname(), os.Getpid())
body = fmt.Sprintf(`{"name":%q,"scopes":["all"]}`, tokenName)
req, _ = http.NewRequest("POST", url, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", UserAgent)
req.SetBasicAuth(username, password)
resp, err = httpClient.Do(req)
if err != nil {
fatal("retry failed: %v", err)
}
defer resp.Body.Close()
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
fatal("cannot parse token response: %v", err)
}
if tokenResp.Error != "" {
fatal("token exchange failed: %s", tokenResp.Error)
}
if tokenResp.AccessToken == "" {
fatal("server returned empty access token")
}
var token GiteaToken
if err := json.NewDecoder(resp.Body).Decode(&token); err != nil {
fatal("failed to parse response: %v", err)
// 8. Verify token and get user info
userResp, err := apiGet(base, "/user", tokenResp.AccessToken)
if err != nil {
fatal("cannot verify token: %v", err)
}
if token.SHA1 == "" {
fatal("server returned empty token (HTTP %d)", resp.StatusCode)
var user GiteaUser
if err := decodeJSON(userResp, &user); err != nil {
fatal("cannot get user info: %v", err)
}
// 9. Save config
cfg := &Config{
Instance: *instance,
Username: username,
Token: token.SHA1,
TokenID: token.ID,
Instance: base,
Username: user.Login,
Token: tokenResp.AccessToken,
RefreshToken: tokenResp.RefreshToken,
FirstLogin: isFirstLogin,
}
if err := saveConfig(cfg); err != nil {
fatal("failed to save credentials: %v", err)
fatal("cannot save credentials: %v", err)
}
fmt.Printf("\nLogged in as %s on %s\n", username, *instance)
fmt.Printf("Token stored in %s\n", configPath())
fmt.Printf("\nLogged in as %s on %s\n", user.Login, base)
// Full machine setup: SSH key + git credentials
// 10. Full machine setup
fmt.Println("\nSetting up SSH key...")
if err := setupSSHKey(cfg); err != nil {
fmt.Fprintf(os.Stderr, "Warning: SSH key setup failed: %v\n", err)
fmt.Fprintf(os.Stderr, "You can still use HTTPS. Run 'tstudio setup-git' to retry.\n")
}
fmt.Println("\nConfiguring git credentials...")
if err := setupGitCredentials(cfg); err != nil {
fmt.Fprintf(os.Stderr, "Warning: could not configure git: %v\n", err)
fmt.Fprintf(os.Stderr, "Run 'tstudio setup-git' manually to fix.\n")
fmt.Fprintf(os.Stderr, "Warning: git credential setup failed: %v\n", err)
}
// Open welcome page in browser
welcomeURL := strings.TrimRight(*instance, "/") + "/cli/welcome"
fmt.Printf("\nOpening %s ...\n", welcomeURL)
openBrowser(welcomeURL)
// 11. First login → open getting started guide
if isFirstLogin {
guideURL := base + "/cli/getting-started"
fmt.Printf("\nOpening getting started guide: %s\n", guideURL)
openBrowser(guideURL)
}
fmt.Println("\nDone. You're all set.")
}
// refreshToken silently refreshes the access token using the refresh token.
func refreshToken(cfg *Config) error {
if cfg.RefreshToken == "" {
return fmt.Errorf("no refresh token — run 'tstudio login'")
}
tokenURL := cfg.Instance + "/login/oauth/access_token"
data := url.Values{
"client_id": {OAuthClientID},
"grant_type": {"refresh_token"},
"refresh_token": {cfg.RefreshToken},
}
req, _ := http.NewRequest("POST", tokenURL, strings.NewReader(data.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
var tokenResp struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
Error string `json:"error"`
}
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
return err
}
if tokenResp.Error != "" {
return fmt.Errorf("%s", tokenResp.Error)
}
cfg.Token = tokenResp.AccessToken
if tokenResp.RefreshToken != "" {
cfg.RefreshToken = tokenResp.RefreshToken
}
return saveConfig(cfg)
}
func openBrowser(url string) {
@@ -141,17 +262,18 @@ func openBrowser(url string) {
func cmdLogout(args []string) {
cfg, _ := loadConfig()
if cfg != nil && cfg.Token != "" && cfg.TokenID > 0 {
// Revoke the token on the server
path := fmt.Sprintf("/users/%s/tokens/%d", cfg.Username, cfg.TokenID)
resp, err := apiDelete(cfg.Instance, path, cfg.Token)
if err == nil {
resp.Body.Close()
}
if cfg != nil && cfg.Token != "" {
// Clear git credentials for this host
host := strings.TrimPrefix(cfg.Instance, "https://")
host = strings.TrimPrefix(host, "http://")
credInput := fmt.Sprintf("protocol=https\nhost=%s\nusername=%s\npassword=%s\n\n", host, cfg.Username, cfg.Token)
credCmd := exec.Command("git", "credential", "reject")
credCmd.Stdin = strings.NewReader(credInput)
credCmd.Run() // best-effort
}
if err := deleteConfig(); err != nil {
fatal("failed to remove credentials: %v", err)
fatal("cannot remove credentials: %v", err)
}
fmt.Println("Logged out. Credentials removed.")
}
@@ -163,3 +285,37 @@ func hostname() string {
}
return strings.ToLower(h)
}
// PKCE helpers (RFC 7636)
func generateCodeVerifier(length int) (string, error) {
b := make([]byte, length)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b)[:length], nil
}
func generateCodeChallenge(verifier string) string {
hash := sha256.Sum256([]byte(verifier))
return base64.RawURLEncoding.EncodeToString(hash[:])
}
// HTML pages shown in browser during auth flow
func authPage(title, message string, isError bool) string {
color := "#c9935a"
if isError {
color = "#e84855"
}
return fmt.Sprintf(`<!DOCTYPE html>
<html><head><title>tstudio — %s</title>
<style>
body{font-family:-apple-system,system-ui,sans-serif;background:#0a0a0f;color:#e8e4df;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}
.box{text-align:center;padding:48px}
h1{color:%s;font-size:2rem;margin-bottom:8px}
p{color:#9e9890;font-size:1.1rem}
</style></head><body>
<div class="box"><h1>%s</h1><p>%s</p></div>
</body></html>`, title, color, title, message)
}
+5
View File
@@ -80,6 +80,11 @@ func BuiltinApplications() map[string]*BuiltinOAuth2Application {
DisplayName: "tea",
RedirectURIs: []string{"http://127.0.0.1", "https://127.0.0.1"},
}
m["b8f4e9a1-7c3d-4f2e-a1d5-6e8b9c0f3a2d"] = &BuiltinOAuth2Application{
ConfigName: "tstudio-cli",
DisplayName: "Tinqs Studio CLI",
RedirectURIs: []string{"http://127.0.0.1", "https://127.0.0.1"},
}
return m
}
+1 -1
View File
@@ -107,7 +107,7 @@ var OAuth2 = struct {
JWTSigningAlgorithm: "RS256",
JWTSigningPrivateKeyFile: "jwt/private.pem",
MaxTokenLength: math.MaxInt16,
DefaultApplications: []string{"git-credential-oauth", "git-credential-manager", "tea"},
DefaultApplications: []string{"git-credential-oauth", "git-credential-manager", "tea", "tstudio-cli"},
}
func loadOAuth2From(rootCfg ConfigProvider) {
+7
View File
@@ -11,9 +11,16 @@ import (
)
const tplCLIWelcome templates.TplName = "tstudio/welcome"
const tplCLIGettingStarted templates.TplName = "tstudio/getting-started"
// CLIWelcome renders the post-login success page for tstudio CLI
func CLIWelcome(ctx *context.Context) {
ctx.Data["Title"] = "Authenticated with tstudio"
ctx.HTML(http.StatusOK, tplCLIWelcome)
}
// CLIGettingStarted renders the first-time setup guide (public, no auth required)
func CLIGettingStarted(ctx *context.Context) {
ctx.Data["Title"] = "Getting Started — Tinqs Studio CLI"
ctx.HTML(http.StatusOK, tplCLIGettingStarted)
}
+1
View File
@@ -518,6 +518,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
// tstudio CLI pages
m.Get("/cli/welcome", CLIWelcome)
m.Get("/cli/getting-started", CLIGettingStarted)
m.Post("/-/markup", reqSignIn, web.Bind(structs.MarkupOption{}), misc.Markup)
m.Post("/-/web-banner/dismiss", misc.WebBannerDismiss)
+144
View File
@@ -0,0 +1,144 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Getting Started — Tinqs Studio CLI</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, 'SF Pro Text', system-ui, sans-serif;
background: #0a0a0f;
color: #e8e4df;
line-height: 1.6;
}
.container { max-width: 720px; margin: 0 auto; padding: 60px 24px 80px; }
h1 { font-size: 2.4rem; font-weight: 300; margin-bottom: 8px; }
h1 span { color: #c9935a; }
.subtitle { color: #9e9890; font-size: 1.1rem; margin-bottom: 48px; }
h2 {
color: #c9935a;
font-size: 1.2rem;
font-weight: 600;
margin: 40px 0 16px;
padding-bottom: 8px;
border-bottom: 1px solid #1e1c24;
}
h2 .step {
display: inline-block;
background: #c9935a;
color: #0a0a0f;
width: 28px;
height: 28px;
border-radius: 50%;
text-align: center;
line-height: 28px;
font-size: 0.85rem;
margin-right: 8px;
}
p { color: #b8b3ad; margin-bottom: 12px; }
code {
background: #14131a;
border: 1px solid #1e1c24;
border-radius: 4px;
padding: 2px 8px;
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 0.9em;
color: #e0b87a;
}
pre {
background: #14131a;
border: 1px solid #1e1c24;
border-radius: 8px;
padding: 16px 20px;
margin: 12px 0 20px;
overflow-x: auto;
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 0.85rem;
line-height: 1.7;
color: #e8e4df;
}
pre .comment { color: #6b6660; }
.card {
background: #14131a;
border: 1px solid #1e1c24;
border-radius: 8px;
padding: 20px 24px;
margin: 12px 0;
}
.card h3 { color: #e0b87a; font-size: 1rem; margin-bottom: 8px; }
.card p { color: #9e9890; margin-bottom: 0; }
.done {
margin-top: 48px;
padding: 24px;
background: linear-gradient(135deg, #1a1510 0%, #14131a 100%);
border: 1px solid #c9935a33;
border-radius: 8px;
text-align: center;
}
.done h3 { color: #c9935a; margin-bottom: 8px; }
.done p { color: #9e9890; }
a { color: #c9935a; text-decoration: none; }
a:hover { text-decoration: underline; }
.check { color: #4ade80; }
</style>
</head>
<body>
<div class="container">
<h1>Welcome to <span>Tinqs Studio</span></h1>
<p class="subtitle">Your machine is connected. Here's how to work with the platform.</p>
<h2><span class="step">1</span> What just happened</h2>
<p>When you ran <code>tstudio login</code>, the CLI:</p>
<div class="card">
<p><span class="check">&#10003;</span> Authenticated via your browser (OAuth2)</p>
<p><span class="check">&#10003;</span> Generated an SSH key and registered it with your account</p>
<p><span class="check">&#10003;</span> Configured git so push/pull works over both SSH and HTTPS</p>
<p><span class="check">&#10003;</span> Installed itself to your PATH</p>
</div>
<p>Both <code>git clone git@ssh.tinqs.com:tinqs/repo.git</code> and HTTPS clones work now.</p>
<h2><span class="step">2</span> Clone a project</h2>
<pre>tstudio clone tinqs/studio</pre>
<p>Or use regular git — credentials are already configured:</p>
<pre>git clone git@ssh.tinqs.com:tinqs/studio.git</pre>
<h2><span class="step">3</span> Set up Cursor</h2>
<p>Cursor (and Claude Code, Windsurf, etc.) work automatically once your machine is connected. The agents use your machine's git credentials — no extra tokens needed.</p>
<p><strong>For a new project in Cursor:</strong></p>
<pre><span class="comment"># Clone the repo</span>
tstudio clone tinqs/your-project
<span class="comment"># Open in Cursor</span>
cursor tinqs/your-project</pre>
<p><strong>For an existing project:</strong> If you have repos from the old <code>git.arikigame.com</code>, fix the remotes first:</p>
<pre>tstudio migrate --dir ~/projects</pre>
<p><strong>Agent configuration:</strong> If the repo has a <code>.cursor/</code> directory, it contains agent rules that Cursor picks up automatically. The platform provides these for each project — you don't need to write your own.</p>
<h2><span class="step">4</span> Verify everything works</h2>
<pre>tstudio doctor</pre>
<p>This checks git, SSH, your auth token, and connectivity. All checks should pass.</p>
<h2><span class="step">5</span> Useful commands</h2>
<div class="card">
<h3>Day-to-day</h3>
<p><code>tstudio repos</code> — list your repositories</p>
<p><code>tstudio clone owner/repo</code> — clone with credentials</p>
<p><code>tstudio whoami</code> — show your identity</p>
</div>
<div class="card">
<h3>Management</h3>
<p><code>tstudio token create --name ci-bot</code> — create API tokens for agents</p>
<p><code>tstudio update</code> — update the CLI to latest version</p>
<p><code>tstudio migrate --dir path</code> — fix old remotes from arikigame.com</p>
</div>
<div class="done">
<h3>You're ready</h3>
<p>Clone a project, open it in your editor, and start building. The platform handles the rest.</p>
</div>
</div>
</body>
</html>