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 Complete

Agora 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(`

`) + 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 `` +} + +// 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{"`, + ExpiresAt: `2026-07-22 16:30 UTC`, + CLIVersion: `dev`, + }) + + for _, forbidden := range []string{"