From cf30affd6fc0ee2af7269430e4d594b004565b82 Mon Sep 17 00:00:00 2001 From: sunshinexcode <24xinhui@163.com> Date: Thu, 9 Jul 2026 13:07:13 +0800 Subject: [PATCH 1/4] feat(auth): add branded OAuth callback pages --- internal/cli/app_test.go | 11 +++ internal/cli/auth.go | 24 ++++- internal/cli/oauth_pages.go | 162 +++++++++++++++++++++++++++++++ internal/cli/oauth_pages_test.go | 92 ++++++++++++++++++ 4 files changed, 284 insertions(+), 5 deletions(-) create mode 100644 internal/cli/oauth_pages.go create mode 100644 internal/cli/oauth_pages_test.go diff --git a/internal/cli/app_test.go b/internal/cli/app_test.go index 42ab12f..c006912 100644 --- a/internal/cli/app_test.go +++ b/internal/cli/app_test.go @@ -340,6 +340,17 @@ func TestWaitForOAuthCallbackAdvertisesLocalhost(t *testing.T) { if err != nil { t.Fatal(err) } + 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() payload, err := server.Wait() if err != nil { diff --git a/internal/cli/auth.go b/internal/cli/auth.go index 46bae71..49925a2 100644 --- a/internal/cli/auth.go +++ b/internal/cli/auth.go @@ -53,7 +53,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 } @@ -284,6 +286,10 @@ 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) mux := http.NewServeMux() @@ -313,18 +319,26 @@ func waitForOAuthCallback(expectedState string, timeout time.Duration) (*callbac 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'; img-src https://cdn.prod.website-files.com; style-src 'unsafe-inline'; 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) + w.WriteHeader(http.StatusBadRequest) + _, _ = io.WriteString(w, renderOAuthCallbackErrorPage(page, "Agora CLI login failed", "Return to the terminal for details.")) errs <- 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.")) case state != expectedState: - http.Error(w, "Agora CLI login state mismatch.", http.StatusBadRequest) + w.WriteHeader(http.StatusBadRequest) + _, _ = io.WriteString(w, renderOAuthCallbackErrorPage(page, "Login state mismatch", "Return to the terminal and restart login to protect this session.")) errs <- 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.

`) + _, _ = io.WriteString(w, renderOAuthCallbackSuccessPage(page)) wait <- callbackPayload{Code: code, State: state} } }) diff --git a/internal/cli/oauth_pages.go b/internal/cli/oauth_pages.go new file mode 100644 index 0000000..794865b --- /dev/null +++ b/internal/cli/oauth_pages.go @@ -0,0 +1,162 @@ +package cli + +import ( + "html" + "strings" +) + +// callbackPageConfig carries the login region used to brand the browser callback page. +type callbackPageConfig struct { + Region string +} + +// 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) string { + content := loginPageContentForRegion(config.Region) + return renderOAuthCallbackPage(content, false, "", "") +} + +// 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..c2c9e76 --- /dev/null +++ b/internal/cli/oauth_pages_test.go @@ -0,0 +1,92 @@ +package cli + +import ( + "strings" + "testing" +) + +func TestOAuthCallbackSuccessPageUsesFinalGlobalDesign(t *testing.T) { + html := renderOAuthCallbackSuccessPage(callbackPageConfig{Region: regionGlobal}) + + for _, want := range []string{ + ``, + "agora-page", + "Agora%20Logo%20Crisp.webp", + "You are now authenticated with Agora CLI", + "This browser step completed successfully. The CLI can now use this account as your active local configuration.", + "agora auth status", + "You can close this window and return to your terminal.", + } { + if !strings.Contains(html, want) { + t.Fatalf("expected global login page to contain %q", want) + } + } + + for _, forbidden := range []string{"agora whoami", "global-minimal", "global-deck", "shengwang", "声网", "brand-logo-cn"} { + if strings.Contains(html, forbidden) { + t.Fatalf("global login page should not contain legacy variant content %q", forbidden) + } + } +} + +func TestOAuthCallbackSuccessPageDefaultsToGlobalBranding(t *testing.T) { + html := renderOAuthCallbackSuccessPage(callbackPageConfig{Region: "test"}) + + for _, want := range []string{ + "agora-page", + "Agora%20Logo%20Crisp.webp", + "You are now authenticated with Agora CLI", + } { + if !strings.Contains(html, want) { + t.Fatalf("expected fallback login page to contain %q", want) + } + } + + for _, forbidden := range []string{"shengwang", "声网", "brand-logo-cn"} { + if strings.Contains(html, forbidden) { + t.Fatalf("fallback login page should not contain China branding %q", forbidden) + } + } +} + +func TestOAuthCallbackSuccessPageUsesFinalChinaDesign(t *testing.T) { + html := renderOAuthCallbackSuccessPage(callbackPageConfig{Region: regionCN}) + + for _, want := range []string{ + ``, + "shengwang-page", + "brand-logo-cn", + "你已成功登录声网 CLI", + "此浏览器步骤已完成。CLI 现在可以将此账号作为当前本地配置继续使用。", + "agora auth status", + "你可以关闭此页面,回到终端继续操作。", + } { + if !strings.Contains(html, want) { + t.Fatalf("expected China login page to contain %q", want) + } + } + + for _, forbidden := range []string{"agora whoami", "cn-console", "cn-silk"} { + if strings.Contains(html, forbidden) { + t.Fatalf("China login page should not contain legacy variant content %q", forbidden) + } + } +} + +func TestOAuthCallbackErrorPageKeepsRegionBranding(t *testing.T) { + html := renderOAuthCallbackErrorPage(callbackPageConfig{Region: regionCN}, "Login state mismatch", "Return to the terminal and restart login.") + + for _, want := range []string{ + "shengwang-page is-error", + "Login state mismatch", + "Return to the terminal and restart login.", + } { + if !strings.Contains(html, want) { + t.Fatalf("expected error login page to contain %q", want) + } + } + + if strings.Contains(html, "agora auth status") { + t.Fatal("error login page should not show success action command") + } +} From 62d6e3b2acee1465fcf56df78b1f3d249657f5cb Mon Sep 17 00:00:00 2001 From: digitallysavvy Date: Wed, 22 Jul 2026 20:50:01 -0400 Subject: [PATCH 2/4] chore(auth): add generated OAuth success assets Commit the self-contained global and CN success pages. Embed both pages with go:embed and record their design revision and hashes. --- internal/cli/oauthui.go | 12 ++++++++++++ internal/cli/oauthui/oauth-success-cn.html | 9 +++++++++ internal/cli/oauthui/oauth-success.html | 9 +++++++++ internal/cli/oauthui/source.json | 14 ++++++++++++++ 4 files changed, 44 insertions(+) create mode 100644 internal/cli/oauthui.go create mode 100644 internal/cli/oauthui/oauth-success-cn.html create mode 100644 internal/cli/oauthui/oauth-success.html create mode 100644 internal/cli/oauthui/source.json diff --git a/internal/cli/oauthui.go b/internal/cli/oauthui.go new file mode 100644 index 0000000..ea6b6f6 --- /dev/null +++ b/internal/cli/oauthui.go @@ -0,0 +1,12 @@ +package cli + +import _ "embed" + +// oauthSuccessPageHTML is generated by agora-cli-login-redirect and committed +// so normal CLI builds never require Node.js, pnpm, or network access. +// +//go:embed oauthui/oauth-success.html +var oauthSuccessPageHTML string + +//go:embed oauthui/oauth-success-cn.html +var oauthSuccessPageCNHTML string diff --git a/internal/cli/oauthui/oauth-success-cn.html b/internal/cli/oauthui/oauth-success-cn.html new file mode 100644 index 0000000..017b231 --- /dev/null +++ b/internal/cli/oauthui/oauth-success-cn.html @@ -0,0 +1,9 @@ + +声网 CLI — 已认证
SSO
OAuth / 已完成

认证成功

你已成功登录声网。请返回终端继续操作。

认证时间
{{.AuthenticatedAt}}
有效期至
{{.ExpiresAt}}
CLI 版本
{{.CLIVersion}}
agora auth status✓ 已就绪

diff --git a/internal/cli/oauthui/oauth-success.html b/internal/cli/oauthui/oauth-success.html new file mode 100644 index 0000000..d8a81c6 --- /dev/null +++ b/internal/cli/oauthui/oauth-success.html @@ -0,0 +1,9 @@ + +Agora CLI — Authenticated
SSO
OAuth / Complete

Authentication successful

You're now signed in to Agora. Return to your terminal to continue where you left off.

Authenticated
{{.AuthenticatedAt}}
Expires
{{.ExpiresAt}}
CLI version
{{.CLIVersion}}
agora auth status✓ ready

diff --git a/internal/cli/oauthui/source.json b/internal/cli/oauthui/source.json new file mode 100644 index 0000000..f589607 --- /dev/null +++ b/internal/cli/oauthui/source.json @@ -0,0 +1,14 @@ +{ + "repository": "AgoraIO/agora-cli-login-redirect", + "commit": "7ad4e1d1a44767c6bce02a4eb77a48223c0d66f4", + "artifact": "oauth-success.html", + "sha256": "bd47c3d7f687767d0f8b7172706d7b9e06b78d5a1472ca6f57cafc8cc8d4e61a", + "artifacts": { + "oauth-success.html": { + "sha256": "bd47c3d7f687767d0f8b7172706d7b9e06b78d5a1472ca6f57cafc8cc8d4e61a" + }, + "oauth-success-cn.html": { + "sha256": "3f9a22263a44780b1845945fdacc825c419f938311cbbaa82b3b99deb5c47fcb" + } + } +} From 5ba2b8d1602e64514fd84f6dd73c6402aed7d6ea Mon Sep 17 00:00:00 2001 From: digitallysavvy Date: Wed, 22 Jul 2026 20:50:13 -0400 Subject: [PATCH 3/4] feat(auth): render success after session persistence Hold the browser response until token exchange, session persistence, and runtime reset finish. Render regional embedded templates with safe session details, reject duplicate callbacks, and cover callback failure paths. --- internal/cli/app_test.go | 124 +++++++++++++++++++--- internal/cli/auth.go | 141 ++++++++++++++++++++++---- internal/cli/integration_auth_test.go | 120 ++++++++++++++++++++++ internal/cli/integration_test.go | 7 ++ internal/cli/oauth_pages.go | 52 +++++++++- internal/cli/oauth_pages_test.go | 83 +++++++++++---- 6 files changed, 469 insertions(+), 58 deletions(-) diff --git a/internal/cli/app_test.go b/internal/cli/app_test.go index c006912..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,10 +336,26 @@ 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") + 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) } + 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'", @@ -352,13 +368,6 @@ func TestWaitForOAuthCallbackAdvertisesLocalhost(t *testing.T) { } } resp.Body.Close() - payload, err := server.Wait() - if err != nil { - t.Fatal(err) - } - if payload.Code != "test-code" { - t.Fatalf("unexpected code: %s", payload.Code) - } } func TestExchangeTokenLogsSanitizedInvalidShape(t *testing.T) { @@ -754,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 { @@ -770,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) @@ -782,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 49925a2..acebb59 100644 --- a/internal/cli/auth.go +++ b/internal/cli/auth.go @@ -18,6 +18,7 @@ import ( "runtime" "strconv" "strings" + "sync" "time" ) @@ -81,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 @@ -272,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 { @@ -292,6 +310,8 @@ func waitForOAuthCallback(expectedState string, timeout time.Duration) (*callbac 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, @@ -309,11 +329,14 @@ func waitForOAuthCallbackWithPage(expectedState string, timeout time.Duration, p 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") @@ -323,23 +346,55 @@ func waitForOAuthCallbackWithPage(expectedState string, timeout time.Duration, p 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'; img-src https://cdn.prod.website-files.com; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'") + 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 != "": w.WriteHeader(http.StatusBadRequest) _, _ = io.WriteString(w, renderOAuthCallbackErrorPage(page, "Agora CLI login failed", "Return to the terminal for details.")) - errs <- fmt.Errorf("OAuth authorization failed: %s", oauthErr) + cs.reportError(fmt.Errorf("OAuth authorization failed: %s", oauthErr)) case code == "" || state == "": 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: w.WriteHeader(http.StatusBadRequest) _, _ = io.WriteString(w, renderOAuthCallbackErrorPage(page, "Login state mismatch", "Return to the terminal and restart login to protect this session.")) - errs <- errors.New("OAuth state mismatch.") + cs.reportError(errors.New("OAuth state mismatch.")) default: - w.WriteHeader(http.StatusOK) - _, _ = io.WriteString(w, renderOAuthCallbackSuccessPage(page)) - 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 { @@ -349,7 +404,7 @@ func waitForOAuthCallbackWithPage(expectedState string, timeout time.Duration, p } 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 } @@ -363,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/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 index 794865b..12ff755 100644 --- a/internal/cli/oauth_pages.go +++ b/internal/cli/oauth_pages.go @@ -1,8 +1,11 @@ package cli import ( + "bytes" "html" + "html/template" "strings" + "time" ) // callbackPageConfig carries the login region used to brand the browser callback page. @@ -10,6 +13,20 @@ 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 @@ -22,9 +39,36 @@ type loginPageContent struct { } // renderOAuthCallbackSuccessPage renders the browser page shown after a successful OAuth callback. -func renderOAuthCallbackSuccessPage(config callbackPageConfig) string { - content := loginPageContentForRegion(config.Region) - return renderOAuthCallbackPage(content, false, "", "") +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. @@ -148,7 +192,7 @@ func brandLogoHTML(pageClass string) string { return `` } - return `` + return `` } // escapeText escapes user-visible text before it is inserted into HTML. diff --git a/internal/cli/oauth_pages_test.go b/internal/cli/oauth_pages_test.go index c2c9e76..264a605 100644 --- a/internal/cli/oauth_pages_test.go +++ b/internal/cli/oauth_pages_test.go @@ -5,24 +5,48 @@ import ( "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{"`, - "agora-page", - "Agora%20Logo%20Crisp.webp", - "You are now authenticated with Agora CLI", - "This browser step completed successfully. The CLI can now use this account as your active local configuration.", + ``, - "shengwang-page", - "brand-logo-cn", - "你已成功登录声网 CLI", - "此浏览器步骤已完成。CLI 现在可以将此账号作为当前本地配置继续使用。", + ``, + ExpiresAt: `2026-07-22 16:30 UTC`, + CLIVersion: `dev`, + }) + + for _, forbidden := range []string{"