diff --git a/internal/cli/app_test.go b/internal/cli/app_test.go
index 42ab12f..63cd19f 100644
--- a/internal/cli/app_test.go
+++ b/internal/cli/app_test.go
@@ -328,7 +328,7 @@ func TestEnsureAppConfigStateMigratesPreviousDefaults(t *testing.T) {
}
func TestWaitForOAuthCallbackAdvertisesLocalhost(t *testing.T) {
- server, err := waitForOAuthCallback("expected-state", 2_000_000_000)
+ server, err := waitForOAuthCallback("expected-state", 2*time.Second)
if err != nil {
t.Fatal(err)
}
@@ -336,11 +336,11 @@ func TestWaitForOAuthCallbackAdvertisesLocalhost(t *testing.T) {
if !strings.Contains(server.RedirectURI, "http://localhost:") {
t.Fatalf("expected localhost redirect URI, got %s", server.RedirectURI)
}
- resp, err := http.Get(server.RedirectURI + "?code=test-code&state=expected-state")
- if err != nil {
- t.Fatal(err)
- }
- resp.Body.Close()
+ response := make(chan callbackHTTPResult, 1)
+ go func() {
+ resp, requestErr := http.Get(server.RedirectURI + "?code=test-code&state=expected-state")
+ response <- callbackHTTPResult{response: resp, err: requestErr}
+ }()
payload, err := server.Wait()
if err != nil {
t.Fatal(err)
@@ -348,6 +348,26 @@ func TestWaitForOAuthCallbackAdvertisesLocalhost(t *testing.T) {
if payload.Code != "test-code" {
t.Fatalf("unexpected code: %s", payload.Code)
}
+ if err := server.CompleteSuccess(oauthSuccessPageData{AuthenticatedAt: "2026-07-22 14:30 UTC", ExpiresAt: "2026-07-22 16:30 UTC", CLIVersion: "dev"}); err != nil {
+ t.Fatal(err)
+ }
+ result := <-response
+ if result.err != nil {
+ t.Fatal(result.err)
+ }
+ resp := result.response
+ for header, want := range map[string]string{
+ "Cache-Control": "no-store",
+ "Content-Security-Policy": "default-src 'none'",
+ "Content-Type": "text/html; charset=utf-8",
+ "Referrer-Policy": "no-referrer",
+ "X-Content-Type-Options": "nosniff",
+ } {
+ if got := resp.Header.Get(header); !strings.Contains(got, want) {
+ t.Fatalf("expected %s header to contain %q, got %q", header, want, got)
+ }
+ }
+ resp.Body.Close()
}
func TestExchangeTokenLogsSanitizedInvalidShape(t *testing.T) {
@@ -743,6 +763,11 @@ func TestWaitForOAuthCallbackMismatchAndTimeout(t *testing.T) {
}
}
+type callbackHTTPResult struct {
+ response *http.Response
+ err error
+}
+
func TestWaitForOAuthCallbackAcceptsIPv4WhenRedirectUsesLocalhost(t *testing.T) {
server, err := waitForOAuthCallback("expected-state", time.Second)
if err != nil {
@@ -759,11 +784,11 @@ func TestWaitForOAuthCallbackAcceptsIPv4WhenRedirectUsesLocalhost(t *testing.T)
if err != nil {
t.Fatal(err)
}
- resp, err := http.Get("http://127.0.0.1:" + parsed.Port() + "/oauth/callback?code=test-code&state=expected-state")
- if err != nil {
- t.Fatal(err)
- }
- resp.Body.Close()
+ response := make(chan callbackHTTPResult, 1)
+ go func() {
+ resp, requestErr := http.Get("http://127.0.0.1:" + parsed.Port() + "/oauth/callback?code=test-code&state=expected-state")
+ response <- callbackHTTPResult{response: resp, err: requestErr}
+ }()
payload, err := server.Wait()
if err != nil {
t.Fatal(err)
@@ -771,6 +796,88 @@ func TestWaitForOAuthCallbackAcceptsIPv4WhenRedirectUsesLocalhost(t *testing.T)
if payload.Code != "test-code" || payload.State != "expected-state" {
t.Fatalf("unexpected callback payload: %+v", payload)
}
+ select {
+ case result := <-response:
+ if result.response != nil {
+ result.response.Body.Close()
+ }
+ t.Fatal("success response was written before authentication completed")
+ case <-time.After(25 * time.Millisecond):
+ }
+ if err := server.CompleteSuccess(oauthSuccessPageData{AuthenticatedAt: "2026-07-22 14:30 UTC", ExpiresAt: "2026-07-22 16:30 UTC", CLIVersion: "dev"}); err != nil {
+ t.Fatal(err)
+ }
+ result := <-response
+ if result.err != nil {
+ t.Fatal(result.err)
+ }
+ defer result.response.Body.Close()
+ if result.response.StatusCode != http.StatusOK {
+ t.Fatalf("expected success status, got %d", result.response.StatusCode)
+ }
+ for name, want := range map[string]string{
+ "Cache-Control": "no-store",
+ "Referrer-Policy": "no-referrer",
+ "X-Content-Type-Options": "nosniff",
+ "Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src data:; script-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
+ } {
+ if got := result.response.Header.Get(name); got != want {
+ t.Fatalf("expected %s=%q, got %q", name, want, got)
+ }
+ }
+ body, err := io.ReadAll(result.response.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, forbidden := range []string{"test-code", "expected-state", "access-token", "refresh-token"} {
+ if strings.Contains(string(body), forbidden) {
+ t.Fatalf("callback response leaked %q", forbidden)
+ }
+ }
+}
+
+func TestOAuthCallbackFailureAndDuplicateDoNotBlock(t *testing.T) {
+ server, err := waitForOAuthCallback("expected-state", time.Second)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer server.Close()
+ first := make(chan callbackHTTPResult, 1)
+ go func() {
+ resp, requestErr := http.Get(server.RedirectURI + "?code=first-code&state=expected-state")
+ first <- callbackHTTPResult{response: resp, err: requestErr}
+ }()
+ if _, err := server.Wait(); err != nil {
+ t.Fatal(err)
+ }
+
+ duplicate, err := http.Get(server.RedirectURI + "?code=second-code&state=expected-state")
+ if err != nil {
+ t.Fatal(err)
+ }
+ duplicate.Body.Close()
+ if duplicate.StatusCode != http.StatusConflict {
+ t.Fatalf("expected duplicate callback conflict, got %d", duplicate.StatusCode)
+ }
+
+ if err := server.Complete(errors.New("token exchange failed")); err != nil {
+ t.Fatal(err)
+ }
+ result := <-first
+ if result.err != nil {
+ t.Fatal(result.err)
+ }
+ defer result.response.Body.Close()
+ if result.response.StatusCode != http.StatusInternalServerError {
+ t.Fatalf("expected failure status, got %d", result.response.StatusCode)
+ }
+ body, err := io.ReadAll(result.response.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if strings.Contains(string(body), "token exchange failed") || strings.Contains(string(body), "Authentication successful") {
+ t.Fatal("failure response exposed internal error details or success UI")
+ }
}
func TestExchangeAuthorizationCodeFailureAndScopeArray(t *testing.T) {
diff --git a/internal/cli/auth.go b/internal/cli/auth.go
index 46bae71..acebb59 100644
--- a/internal/cli/auth.go
+++ b/internal/cli/auth.go
@@ -18,6 +18,7 @@ import (
"runtime"
"strconv"
"strings"
+ "sync"
"time"
)
@@ -53,7 +54,9 @@ func (a *App) login(noBrowser bool, region string, progress progressEmitter) (ma
}
timeout = time.Duration(parsed) * time.Millisecond
}
- callback, err := waitForOAuthCallback(state, timeout)
+ callback, err := waitForOAuthCallbackWithPage(state, timeout, callbackPageConfig{
+ Region: loginRegion,
+ })
if err != nil {
return nil, err
}
@@ -79,16 +82,23 @@ func (a *App) login(noBrowser bool, region string, progress progressEmitter) (ma
if err != nil {
return nil, err
}
+ completeWithError := func(authErr error) (map[string]any, error) {
+ _ = callback.Complete(authErr)
+ return nil, authErr
+ }
progress.emit("oauth:received", "Authorization code received; exchanging for token", nil)
token, err := a.exchangeAuthorizationCode(config.TokenURL, config.ClientID, payload.Code, pair.CodeVerifier, callback.RedirectURI)
if err != nil {
- return nil, err
+ return completeWithError(err)
}
if err := saveSession(a.env, token); err != nil {
- return nil, err
+ return completeWithError(err)
}
- progress.emit("oauth:complete", "Session stored", nil)
if err := a.resetSessionRuntimeState(loginRegion); err != nil {
+ return completeWithError(err)
+ }
+ progress.emit("oauth:complete", "Session stored", nil)
+ if err := callback.CompleteSuccess(oauthSuccessDataForSession(token)); err != nil {
return nil, err
}
return map[string]any{"action": "login", "expiresAt": token.ExpiresAt, "region": loginRegion, "scope": token.Scope, "status": "authenticated"}, nil
@@ -270,12 +280,22 @@ func browserOpenCommand(goos, target string) (string, []string) {
}
}
+type callbackResult struct {
+ Err error
+ PageData oauthSuccessPageData
+}
+
type callbackServer struct {
- RedirectURI string
- wait chan callbackPayload
- errs chan error
- server *http.Server
- listeners []net.Listener
+ RedirectURI string
+ wait chan callbackPayload
+ errs chan error
+ results chan callbackResult
+ responseWritten chan error
+ server *http.Server
+ listeners []net.Listener
+ deadline time.Time
+ callbackOnce sync.Once
+ completeOnce sync.Once
}
type callbackPayload struct {
@@ -284,8 +304,14 @@ type callbackPayload struct {
}
func waitForOAuthCallback(expectedState string, timeout time.Duration) (*callbackServer, error) {
+ return waitForOAuthCallbackWithPage(expectedState, timeout, callbackPageConfig{})
+}
+
+func waitForOAuthCallbackWithPage(expectedState string, timeout time.Duration, page callbackPageConfig) (*callbackServer, error) {
wait := make(chan callbackPayload, 1)
errs := make(chan error, 1)
+ results := make(chan callbackResult, 1)
+ responseWritten := make(chan error, 1)
mux := http.NewServeMux()
srv := &http.Server{
Handler: mux,
@@ -303,29 +329,72 @@ func waitForOAuthCallback(expectedState string, timeout time.Duration) (*callbac
listeners = append(listeners, ln6)
}
cs := &callbackServer{
- RedirectURI: fmt.Sprintf("http://localhost:%d/oauth/callback", port),
- wait: wait,
- errs: errs,
- server: srv,
- listeners: listeners,
+ RedirectURI: fmt.Sprintf("http://localhost:%d/oauth/callback", port),
+ wait: wait,
+ errs: errs,
+ results: results,
+ responseWritten: responseWritten,
+ server: srv,
+ listeners: listeners,
+ deadline: time.Now().Add(timeout),
}
mux.HandleFunc("/oauth/callback", func(w http.ResponseWriter, r *http.Request) {
code := r.URL.Query().Get("code")
state := r.URL.Query().Get("state")
oauthErr := r.URL.Query().Get("error")
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Header().Set("Cache-Control", "no-store")
+ w.Header().Set("Referrer-Policy", "no-referrer")
+ w.Header().Set("X-Content-Type-Options", "nosniff")
+ w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src data:; script-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'")
switch {
case oauthErr != "":
- http.Error(w, "Agora CLI login failed. Return to the terminal for details.", http.StatusBadRequest)
- errs <- fmt.Errorf("OAuth authorization failed: %s", oauthErr)
+ w.WriteHeader(http.StatusBadRequest)
+ _, _ = io.WriteString(w, renderOAuthCallbackErrorPage(page, "Agora CLI login failed", "Return to the terminal for details."))
+ cs.reportError(fmt.Errorf("OAuth authorization failed: %s", oauthErr))
case code == "" || state == "":
- http.Error(w, "Agora CLI login callback was missing required fields.", http.StatusBadRequest)
+ w.WriteHeader(http.StatusBadRequest)
+ _, _ = io.WriteString(w, renderOAuthCallbackErrorPage(page, "Missing callback fields", "The OAuth callback did not include the required code and state fields."))
+ cs.reportError(errors.New("OAuth callback did not include the required code and state fields."))
case state != expectedState:
- http.Error(w, "Agora CLI login state mismatch.", http.StatusBadRequest)
- errs <- errors.New("OAuth state mismatch.")
+ w.WriteHeader(http.StatusBadRequest)
+ _, _ = io.WriteString(w, renderOAuthCallbackErrorPage(page, "Login state mismatch", "Return to the terminal and restart login to protect this session."))
+ cs.reportError(errors.New("OAuth state mismatch."))
default:
- w.WriteHeader(http.StatusOK)
- _, _ = io.WriteString(w, `
Agora CLI Login CompleteAgora CLI login complete
You can close this browser window and return to your terminal.
`)
- wait <- callbackPayload{Code: code, State: state}
+ accepted := false
+ cs.callbackOnce.Do(func() {
+ accepted = true
+ wait <- callbackPayload{Code: code, State: state}
+ })
+ if !accepted {
+ w.WriteHeader(http.StatusConflict)
+ _, _ = io.WriteString(w, renderOAuthCallbackErrorPage(page, "Login callback already received", "Return to the terminal for details."))
+ return
+ }
+
+ remaining := time.Until(cs.deadline)
+ if remaining <= 0 {
+ w.WriteHeader(http.StatusGatewayTimeout)
+ _, _ = io.WriteString(w, renderOAuthCallbackErrorPage(page, "Login timed out", "Return to the terminal and restart login."))
+ cs.responseWritten <- errors.New("OAuth callback timed out before authentication completed.")
+ return
+ }
+ select {
+ case result := <-results:
+ var writeErr error
+ if result.Err != nil {
+ w.WriteHeader(http.StatusInternalServerError)
+ _, writeErr = io.WriteString(w, renderOAuthCallbackErrorPage(page, "Agora CLI login failed", "Return to the terminal for details."))
+ } else {
+ w.WriteHeader(http.StatusOK)
+ _, writeErr = io.WriteString(w, renderOAuthCallbackSuccessPage(page, result.PageData))
+ }
+ cs.responseWritten <- writeErr
+ case <-time.After(remaining):
+ w.WriteHeader(http.StatusGatewayTimeout)
+ _, _ = io.WriteString(w, renderOAuthCallbackErrorPage(page, "Login timed out", "Return to the terminal and restart login."))
+ cs.responseWritten <- errors.New("OAuth callback timed out before authentication completed.")
+ }
}
})
for _, listener := range listeners {
@@ -335,7 +404,7 @@ func waitForOAuthCallback(expectedState string, timeout time.Duration) (*callbac
}
go func() {
<-time.After(timeout)
- errs <- errors.New("Timed out waiting for the OAuth callback. Re-run with --no-browser to copy the URL manually, or check that your browser completed the login flow.")
+ cs.reportError(errors.New("Timed out waiting for the OAuth callback. Re-run with --no-browser to copy the URL manually, or check that your browser completed the login flow."))
}()
return cs, nil
}
@@ -349,6 +418,52 @@ func (c *callbackServer) Wait() (callbackPayload, error) {
}
}
+func (c *callbackServer) reportError(err error) {
+ select {
+ case c.errs <- err:
+ default:
+ }
+}
+
+// Complete releases the pending browser request only after the login flow has
+// exchanged the authorization code, stored the session, and reset runtime state.
+func (c *callbackServer) Complete(err error) error {
+ return c.complete(callbackResult{Err: err})
+}
+
+// CompleteSuccess releases the browser only after the session has been stored,
+// and supplies the non-sensitive runtime values rendered into the success page.
+func (c *callbackServer) CompleteSuccess(data oauthSuccessPageData) error {
+ return c.complete(callbackResult{PageData: data})
+}
+
+func (c *callbackServer) complete(result callbackResult) error {
+ completed := false
+ c.completeOnce.Do(func() {
+ completed = true
+ c.results <- result
+ })
+ if !completed {
+ return errors.New("OAuth callback was already completed.")
+ }
+
+ select {
+ case writeErr := <-c.responseWritten:
+ return writeErr
+ default:
+ }
+ remaining := time.Until(c.deadline)
+ if remaining <= 0 {
+ return errors.New("Timed out writing the OAuth callback response.")
+ }
+ select {
+ case writeErr := <-c.responseWritten:
+ return writeErr
+ case <-time.After(remaining):
+ return errors.New("Timed out writing the OAuth callback response.")
+ }
+}
+
func (c *callbackServer) Close() error {
var firstErr error
if err := c.server.Close(); err != nil {
diff --git a/internal/cli/env_help.go b/internal/cli/env_help.go
index 723bb89..da8aefd 100644
--- a/internal/cli/env_help.go
+++ b/internal/cli/env_help.go
@@ -33,7 +33,7 @@ func agoraEnvCatalog() []agoraEnvVar {
// Interaction
{Name: "AGORA_NO_INPUT", Category: "interaction", Description: "When set, accept default for confirmation prompts (alias of --yes). Never starts a new interactive OAuth flow in JSON/CI/non-TTY contexts.", Default: "0", Effect: "0 | 1 | true | yes | y"},
{Name: "AGORA_BROWSER_AUTO_OPEN", Category: "interaction", Description: "When 0, never auto-open a browser for OAuth login (forces --no-browser semantics).", Default: "1", Effect: "0 | 1"},
- {Name: "AGORA_LOGIN_TIMEOUT_MS", Category: "interaction", Description: "How long to wait for the OAuth callback before giving up.", Default: "300000", Effect: "milliseconds"},
+ {Name: "AGORA_LOGIN_TIMEOUT_MS", Category: "interaction", Description: "How long to wait for the OAuth callback before giving up.", Default: "120000", Effect: "milliseconds"},
{Name: "AGORA_ALLOW_UPGRADE_IN_CI", Category: "interaction", Description: "When 1, allow installer-managed `agora upgrade` to mutate the binary in CI environments.", Default: "0", Effect: "0 | 1 | true | yes | y"},
// Storage / paths
{Name: "AGORA_HOME", Category: "storage", Description: "Override the directory the CLI uses for config, session, context, cache, and logs.", Effect: "absolute path"},
diff --git a/internal/cli/integration_auth_test.go b/internal/cli/integration_auth_test.go
index 38c6af1..d18abc9 100644
--- a/internal/cli/integration_auth_test.go
+++ b/internal/cli/integration_auth_test.go
@@ -6,16 +6,68 @@ package cli
import (
"context"
"encoding/json"
+ "io"
"net/http"
+ "os"
+ "path/filepath"
"strings"
"testing"
"time"
)
+type loginBrowserResult struct {
+ status int
+ body string
+ err error
+}
+
+func runLoginAndCaptureBrowser(t *testing.T, configHome string, oauth *fakeOAuthServer) (cliResult, loginBrowserResult) {
+ t.Helper()
+ browser := loginBrowserResult{}
+ followed := false
+ result := runCLI(t, []string{"login", "--region", "global"}, cliRunOptions{env: map[string]string{
+ "XDG_CONFIG_HOME": configHome,
+ "AGORA_OAUTH_BASE_URL": oauth.baseURL,
+ "AGORA_OAUTH_CLIENT_ID": "test-public-client",
+ "AGORA_OAUTH_SCOPE": "basic_info,console",
+ "AGORA_BROWSER_AUTO_OPEN": "0",
+ "AGORA_LOGIN_TIMEOUT_MS": "2000",
+ "AGORA_LOG_LEVEL": "error",
+ "AGORA_DEBUG": "0",
+ }, onStderr: func(stderr string) bool {
+ if followed {
+ return false
+ }
+ u := parseAuthURL(stderr)
+ if u == "" {
+ return false
+ }
+ followed = true
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ req, _ := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ browser.err = err
+ return false
+ }
+ defer resp.Body.Close()
+ body, err := io.ReadAll(resp.Body)
+ browser.status = resp.StatusCode
+ browser.body = string(body)
+ browser.err = err
+ return true
+ }})
+ return result, browser
+}
+
func TestCLILoginAndWhoAmI(t *testing.T) {
configHome := t.TempDir()
oauth := newFakeOAuthServer()
defer oauth.server.Close()
+ browserBody := ""
+ browserStatus := 0
+ followed := false
result := runCLI(t, []string{"login", "--region", "cn"}, cliRunOptions{env: map[string]string{
"XDG_CONFIG_HOME": configHome,
@@ -27,15 +79,25 @@ func TestCLILoginAndWhoAmI(t *testing.T) {
"AGORA_LOG_LEVEL": "error",
"AGORA_DEBUG": "0",
}, onStderr: func(stderr string) bool {
+ if followed {
+ return false
+ }
u := parseAuthURL(stderr)
if u == "" {
return false
}
+ followed = true
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
resp, err := http.DefaultClient.Do(req)
if err == nil {
+ body, readErr := io.ReadAll(resp.Body)
+ if readErr != nil {
+ return false
+ }
+ browserBody = string(body)
+ browserStatus = resp.StatusCode
resp.Body.Close()
}
return err == nil
@@ -43,6 +105,9 @@ func TestCLILoginAndWhoAmI(t *testing.T) {
if result.exitCode != 0 {
t.Fatalf("expected exit 0, got %d stderr=%s", result.exitCode, result.stderr)
}
+ if browserStatus != http.StatusOK || !strings.Contains(browserBody, "认证成功") || !strings.Contains(browserBody, `aria-label="声网"`) {
+ t.Fatalf("expected localized CN success page, got status=%d body=%s", browserStatus, browserBody)
+ }
status := runCLI(t, []string{"whoami", "--json"}, cliRunOptions{env: map[string]string{
"XDG_CONFIG_HOME": configHome,
@@ -84,3 +149,58 @@ func TestCLIAuthStatusExitCode(t *testing.T) {
t.Fatalf("expected structured unauthenticated status error, got exit=%d stdout=%s stderr=%s", result.exitCode, result.stdout, result.stderr)
}
}
+
+func TestCLILoginTokenExchangeFailureRendersErrorPage(t *testing.T) {
+ configHome := t.TempDir()
+ oauth := newFakeOAuthServer()
+ oauth.tokenStatus = http.StatusBadRequest
+ oauth.tokenBody = `{"error":"invalid_grant","secret":"must-not-reach-browser"}`
+ defer oauth.server.Close()
+
+ result, browser := runLoginAndCaptureBrowser(t, configHome, oauth)
+ if result.exitCode == 0 {
+ t.Fatalf("expected login failure, got stdout=%s stderr=%s", result.stdout, result.stderr)
+ }
+ if browser.err != nil {
+ t.Fatal(browser.err)
+ }
+ if browser.status != http.StatusInternalServerError {
+ t.Fatalf("expected browser status 500, got %d", browser.status)
+ }
+ for _, forbidden := range []string{"Authentication successful", "must-not-reach-browser", "invalid_grant"} {
+ if strings.Contains(browser.body, forbidden) {
+ t.Fatalf("failure page contained %q", forbidden)
+ }
+ }
+ saved, err := loadSession(map[string]string{"XDG_CONFIG_HOME": configHome})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if saved != nil {
+ t.Fatal("token-exchange failure should not save a session")
+ }
+}
+
+func TestCLILoginSessionWriteFailureRendersErrorPage(t *testing.T) {
+ configHome := t.TempDir()
+ sessionPath := filepath.Join(configHome, "agora-cli", "session.json")
+ if err := os.MkdirAll(sessionPath, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ oauth := newFakeOAuthServer()
+ defer oauth.server.Close()
+
+ result, browser := runLoginAndCaptureBrowser(t, configHome, oauth)
+ if result.exitCode == 0 {
+ t.Fatalf("expected login failure, got stdout=%s stderr=%s", result.stdout, result.stderr)
+ }
+ if browser.err != nil {
+ t.Fatal(browser.err)
+ }
+ if browser.status != http.StatusInternalServerError {
+ t.Fatalf("expected browser status 500, got %d", browser.status)
+ }
+ if strings.Contains(browser.body, "Authentication successful") || strings.Contains(browser.body, "access-token-value") {
+ t.Fatal("session-write failure rendered success or exposed credentials")
+ }
+}
diff --git a/internal/cli/integration_test.go b/internal/cli/integration_test.go
index 10e9fc3..458ff53 100644
--- a/internal/cli/integration_test.go
+++ b/internal/cli/integration_test.go
@@ -355,6 +355,8 @@ type fakeOAuthServer struct {
authorizeRedirectURIs []string
authorizeRawQueries []string
tokenRequests []string
+ tokenStatus int
+ tokenBody string
}
func newFakeOAuthServer() *fakeOAuthServer {
@@ -376,6 +378,11 @@ func newFakeOAuthServer() *fakeOAuthServer {
_ = r.Body.Close()
oauth.tokenRequests = append(oauth.tokenRequests, string(body))
w.Header().Set("content-type", "application/json")
+ if oauth.tokenStatus != 0 {
+ w.WriteHeader(oauth.tokenStatus)
+ _, _ = io.WriteString(w, oauth.tokenBody)
+ return
+ }
values := string(body)
if strings.Contains(values, "grant_type=authorization_code") {
_, _ = io.WriteString(w, `{"access_token":"access-token-value","token_type":"Bearer","expires_in":7199,"refresh_token":"refresh-token-value","scope":"basic_info,console"}`)
diff --git a/internal/cli/oauth_pages.go b/internal/cli/oauth_pages.go
new file mode 100644
index 0000000..12ff755
--- /dev/null
+++ b/internal/cli/oauth_pages.go
@@ -0,0 +1,206 @@
+package cli
+
+import (
+ "bytes"
+ "html"
+ "html/template"
+ "strings"
+ "time"
+)
+
+// callbackPageConfig carries the login region used to brand the browser callback page.
+type callbackPageConfig struct {
+ Region string
+}
+
+// oauthSuccessPageData contains the small, non-sensitive runtime snapshot
+// rendered after the token is stored. It must never contain credentials,
+// authorization codes, OAuth state, or local file contents.
+type oauthSuccessPageData struct {
+ AuthenticatedAt string
+ ExpiresAt string
+ CLIVersion string
+}
+
+var (
+ oauthSuccessPageGlobalTemplate = template.Must(template.New("oauth-success-global").Parse(oauthSuccessPageHTML))
+ oauthSuccessPageCNTemplate = template.Must(template.New("oauth-success-cn").Parse(oauthSuccessPageCNHTML))
+)
+
+// loginPageContent contains the region-specific copy rendered on the OAuth callback page.
+type loginPageContent struct {
+ Language string
+ PageClass string
+ Title string
+ Message string
+ ActionLabel string
+ PrimaryAction string
+ Safety string
+}
+
+// renderOAuthCallbackSuccessPage renders the browser page shown after a successful OAuth callback.
+func renderOAuthCallbackSuccessPage(config callbackPageConfig, data oauthSuccessPageData) string {
+ pageTemplate := oauthSuccessPageGlobalTemplate
+ if normalizeContextRegion(config.Region) == regionCN {
+ pageTemplate = oauthSuccessPageCNTemplate
+ }
+ var rendered bytes.Buffer
+ if err := pageTemplate.Execute(&rendered, data); err != nil {
+ return ""
+ }
+ return rendered.String()
+}
+
+func oauthSuccessDataForSession(token session) oauthSuccessPageData {
+ cliVersion := strings.TrimSpace(version)
+ if cliVersion == "" {
+ cliVersion = "dev"
+ }
+ return oauthSuccessPageData{
+ AuthenticatedAt: formatOAuthPageTimestamp(token.ObtainedAt),
+ ExpiresAt: formatOAuthPageTimestamp(token.ExpiresAt),
+ CLIVersion: cliVersion,
+ }
+}
+
+func formatOAuthPageTimestamp(value string) string {
+ parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(value))
+ if err != nil {
+ return "—"
+ }
+ return parsed.UTC().Format("2006-01-02 15:04 UTC")
+}
+
+// renderOAuthCallbackErrorPage renders a branded browser page for OAuth callback errors.
+func renderOAuthCallbackErrorPage(config callbackPageConfig, title, message string) string {
+ content := loginPageContentForRegion(config.Region)
+ return renderOAuthCallbackPage(content, true, title, message)
+}
+
+// loginPageContentForRegion returns the final login-page copy for the active control-plane region.
+func loginPageContentForRegion(region string) loginPageContent {
+ if normalizeContextRegion(region) == regionCN {
+ return loginPageContent{
+ Language: "zh-CN",
+ PageClass: "shengwang-page",
+ Title: "你已成功登录声网 CLI",
+ Message: "此浏览器步骤已完成。CLI 现在可以将此账号作为当前本地配置继续使用。",
+ ActionLabel: "回到终端后确认当前登录状态。",
+ PrimaryAction: "agora auth status",
+ Safety: "你可以关闭此页面,回到终端继续操作。",
+ }
+ }
+
+ return loginPageContent{
+ Language: "en",
+ PageClass: "agora-page",
+ Title: "You are now authenticated with Agora CLI",
+ Message: "This browser step completed successfully. The CLI can now use this account as your active local configuration.",
+ ActionLabel: "Return to your terminal and confirm the active account.",
+ PrimaryAction: "agora auth status",
+ Safety: "You can close this window and return to your terminal.",
+ }
+}
+
+// renderOAuthCallbackPage builds the complete OAuth callback HTML document.
+func renderOAuthCallbackPage(content loginPageContent, isError bool, errorTitle, errorMessage string) string {
+ title := content.Title
+ message := content.Message
+ if isError {
+ title = valueOrDefault(errorTitle, "Login could not be completed")
+ message = valueOrDefault(errorMessage, "Return to your terminal for details.")
+ }
+
+ var b strings.Builder
+ b.WriteString(`Agora CLI Login`)
+ b.WriteString(brandLogoHTML(content.PageClass))
+ b.WriteString(`CLI
`)
+ b.WriteString(escapeText(title))
+ b.WriteString(`
`)
+ b.WriteString(escapeText(message))
+ b.WriteString(`
`)
+ if !isError {
+ b.WriteString(``)
+ b.WriteString(escapeText(content.ActionLabel))
+ b.WriteString(`
`)
+ b.WriteString(escapeText(content.PrimaryAction))
+ b.WriteString(` `)
+ }
+ b.WriteString(``)
+ b.WriteString(escapeText(content.Safety))
+ b.WriteString(`
`)
+
+ return b.String()
+}
+
+// loginPageCSS returns shared page CSS plus the theme for the active login region.
+func loginPageCSS(pageClass string) string {
+ var b strings.Builder
+ b.WriteString(`
+*{box-sizing:border-box}
+body{margin:0;min-height:100vh;font-family:ui-sans-serif,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;-webkit-font-smoothing:antialiased}
+.page{min-height:100vh;display:grid;place-items:center;padding:64px 24px;color:var(--ink);background:var(--bg)}
+.card{width:min(760px,100%);padding:40px;border:1px solid var(--line);border-radius:20px;background:var(--panel);box-shadow:var(--shadow)}
+.brand{display:inline-flex;align-items:center;gap:10px;color:var(--ink);font-size:22px;font-weight:800;letter-spacing:-.01em}
+.brand-logo{display:inline-flex;align-items:center;flex:0 0 auto}
+.brand-logo svg{display:block;width:100%;height:100%}
+.brand-logo img{display:block;width:100%;height:auto}
+.brand-logo-agora{width:82px;max-height:32px}
+.hero{margin-top:36px}
+h1{margin:0;max-width:700px;font-size:clamp(26px,3vw,31px);line-height:1.22;letter-spacing:-.03em}
+.message{max-width:680px;margin:18px 0 0;color:var(--muted);font-size:16px;line-height:1.65}
+.next{display:grid;grid-template-columns:1fr auto;gap:18px;align-items:center;margin-top:28px;padding:14px 16px;border:1px solid var(--next-line);border-radius:12px;background:var(--next-bg)}
+.next p{margin:0;color:var(--next-text);font-size:14px;font-weight:600}
+code{padding:5px 9px;border:1px solid var(--code-line);border-radius:8px;background:var(--code-bg);color:var(--primary);font:700 14px ui-monospace,SFMono-Regular,Menlo,monospace;white-space:nowrap}
+.safety{margin:20px 0 0;color:var(--muted);font-size:13px;line-height:1.6}
+.is-error .next{display:none}
+@media (max-width:900px){
+ .page{padding:32px 18px}
+ .card{padding:28px}
+ .next{grid-template-columns:1fr}
+ code{white-space:normal;word-break:break-word}
+}
+`)
+
+ if pageClass == "shengwang-page" {
+ b.WriteString(`
+.shengwang-page{--ink:#152033;--muted:#617083;--primary:#0b9dfd;--brand:#0b9dfd;--line:#dce7f5;--panel:#fff;--shadow:0 20px 55px rgba(21,45,85,.1);--next-line:#dce7f5;--next-bg:#f7fbff;--next-text:#46576c;--code-line:#cfe0f8;--code-bg:#eef6ff;--bg:linear-gradient(180deg,#f6faff 0%,#fff 58%,#f9fbfd 100%)}
+.brand-logo-cn{width:58px;height:auto;color:var(--brand)}
+.brand-logo-cn svg{width:58px;height:auto}
+`)
+ return b.String()
+ }
+
+ b.WriteString(`
+.agora-page{--ink:#172033;--muted:#647084;--primary:#09976f;--line:#dfe5ee;--panel:#fff;--shadow:0 20px 55px rgba(22,33,55,.1);--next-line:#dfe5ee;--next-bg:#f8fafc;--next-text:#4c596c;--code-line:#d8e2ee;--code-bg:#f3f6fa;--bg:linear-gradient(180deg,#f8fafc 0%,#fff 58%,#f6f8fb 100%)}
+`)
+ return b.String()
+}
+
+// brandLogoHTML returns the official region-specific brand mark used in the callback page.
+func brandLogoHTML(pageClass string) string {
+ if pageClass == "shengwang-page" {
+ return ``
+ }
+
+ return `Agora`
+}
+
+// escapeText escapes user-visible text before it is inserted into HTML.
+func escapeText(value string) string {
+ return html.EscapeString(value)
+}
+
+// escapeAttr escapes attribute values before they are inserted into HTML.
+func escapeAttr(value string) string {
+ return html.EscapeString(value)
+}
diff --git a/internal/cli/oauth_pages_test.go b/internal/cli/oauth_pages_test.go
new file mode 100644
index 0000000..264a605
--- /dev/null
+++ b/internal/cli/oauth_pages_test.go
@@ -0,0 +1,135 @@
+package cli
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestEmbeddedOAuthSuccessPageIsSelfContained(t *testing.T) {
+ for name, html := range map[string]string{
+ "global": oauthSuccessPageHTML,
+ "cn": oauthSuccessPageCNHTML,
+ } {
+ if html == "" {
+ t.Fatalf("embedded %s OAuth success page is empty", name)
+ }
+ if !strings.Contains(html, "Code generated by agora-cli-login-redirect") {
+ t.Fatalf("embedded %s page is missing its generated-file notice", name)
+ }
+ for _, forbidden := range []string{"