From fe5691a02141fef00388ee824c479583365511e5 Mon Sep 17 00:00:00 2001 From: hjaiswal12 Date: Tue, 30 Jun 2026 14:02:19 +0530 Subject: [PATCH] RFE-9481: Add copy-to-clipboard on OAuth token display page Add copy buttons for the API token, oc login command, and curl example after the existing Display Token step. Copy text is exposed via escaped data attributes and copied only on explicit user click. Co-authored-by: Cursor --- pkg/server/tokenrequest/tokenrequest.go | 86 ++++++++- pkg/server/tokenrequest/tokenrequest_test.go | 182 +++++++++++++++++++ 2 files changed, 263 insertions(+), 5 deletions(-) create mode 100644 pkg/server/tokenrequest/tokenrequest_test.go diff --git a/pkg/server/tokenrequest/tokenrequest.go b/pkg/server/tokenrequest/tokenrequest.go index 7ed86387d..e45e92cff 100644 --- a/pkg/server/tokenrequest/tokenrequest.go +++ b/pkg/server/tokenrequest/tokenrequest.go @@ -8,6 +8,7 @@ import ( "net/http" "net/url" "path" + "strings" "github.com/openshift/osincli" @@ -144,9 +145,23 @@ func (t *tokenRequest) displayTokenPost(osinOAuthClient *osincli.Client, w http. } data.AccessToken = accessData.AccessToken + data.OcLoginCommand = formatOcLoginCommand(data.AccessToken, data.PublicMasterURL) + data.CurlCommand = formatCurlCommand(data.AccessToken, data.PublicMasterURL) renderToken(w, data) } +func formatOcLoginCommand(accessToken, publicMasterURL string) string { + return fmt.Sprintf("oc login --token=%s --server=%s", accessToken, publicMasterURL) +} + +func formatCurlCommand(accessToken, publicMasterURL string) string { + return fmt.Sprintf( + `curl -H "Authorization: Bearer %s" "%s/apis/user.openshift.io/v1/users/~"`, + accessToken, + strings.TrimRight(publicMasterURL, "/"), + ) +} + func displayTokenStart(osinOAuthClient *osincli.Client, w http.ResponseWriter, req *http.Request, data *sharedData) (*osincli.AuthorizeData, bool) { w.Header().Set("Content-Type", "text/html; charset=UTF-8") @@ -179,6 +194,8 @@ type tokenData struct { sharedData AccessToken string + OcLoginCommand string + CurlCommand string PublicMasterURL string LogoutURL string } @@ -216,27 +233,86 @@ const cssStyle = ` pre { padding-left: 1em; border-radius: 5px; color: #003d6e; background-color: #EAEDF0; padding: 1.5em 0 1.5em 4.5em; white-space: normal; text-indent: -2em; } a { color: #00f; text-decoration: none; } a:hover { text-decoration: underline; } - button { background: none; border: none; color: #00f; text-decoration: none; font: inherit; padding: 0; } - button:hover { text-decoration: underline; cursor: pointer; } + button, .copy-button { background: none; border: none; color: #00f; text-decoration: none; font: inherit; padding: 0; } + button:hover, .copy-button:hover { text-decoration: underline; cursor: pointer; } + .copy-heading { display: inline; } + .copy-heading .copy-button { font-size: 0.85em; margin-left: 0.75em; } @media (min-width: 768px) { .nowrap { white-space: nowrap; } } ` +const copyToClipboardScript = ` + +` + var tokenTemplate = template.Must(template.New("tokenTemplate").Parse( cssStyle + ` {{ if .Error }} {{ .Error }} {{ else }} -

Your API token is

+

Your API token is

{{.AccessToken}} -

Log in with this token

+

Log in with this token

oc login --token={{.AccessToken}} --server={{.PublicMasterURL}}
-

Use this token directly against the API

+

Use this token directly against the API

curl -H "Authorization: Bearer {{.AccessToken}}" "{{.PublicMasterURL}}/apis/user.openshift.io/v1/users/~"
+ ` + copyToClipboardScript + ` {{ end }}

diff --git a/pkg/server/tokenrequest/tokenrequest_test.go b/pkg/server/tokenrequest/tokenrequest_test.go new file mode 100644 index 000000000..3584ab6fc --- /dev/null +++ b/pkg/server/tokenrequest/tokenrequest_test.go @@ -0,0 +1,182 @@ +package tokenrequest + +import ( + "bytes" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/openshift/oauth-server/pkg/server/csrf" +) + +func TestFormatOcLoginCommand(t *testing.T) { + token := "sha256~abc123" + server := "https://api.example.com:6443" + + got := formatOcLoginCommand(token, server) + want := "oc login --token=sha256~abc123 --server=https://api.example.com:6443" + if got != want { + t.Fatalf("formatOcLoginCommand() = %q, want %q", got, want) + } +} + +func TestFormatCurlCommand(t *testing.T) { + token := "sha256~abc123" + server := "https://api.example.com:6443/" + + got := formatCurlCommand(token, server) + want := `curl -H "Authorization: Bearer sha256~abc123" "https://api.example.com:6443/apis/user.openshift.io/v1/users/~"` + if got != want { + t.Fatalf("formatCurlCommand() = %q, want %q", got, want) + } +} + +func TestRenderTokenIncludesCopyToClipboardControls(t *testing.T) { + data := tokenData{ + sharedData: sharedData{ + RequestURL: "/oauth/token/request", + }, + AccessToken: "sha256~token-value", + OcLoginCommand: formatOcLoginCommand("sha256~token-value", "https://api.example.com:6443"), + CurlCommand: formatCurlCommand("sha256~token-value", "https://api.example.com:6443"), + PublicMasterURL: "https://api.example.com:6443", + } + + var buf bytes.Buffer + renderToken(&buf, data) + body := buf.String() + + expectContains(t, body, []string{ + "Your API token is", + "Log in with this token", + "Use this token directly against the API", + `class="copy-button"`, + `aria-label="Copy to clipboard"`, + `data-copy-text="sha256~token-value"`, + `data-copy-text="oc login --token=sha256~token-value --server=https://api.example.com:6443"`, + `data-copy-text="curl -H "Authorization: Bearer sha256~token-value" "https://api.example.com:6443/apis/user.openshift.io/v1/users/~""`, + "navigator.clipboard", + "document.execCommand('copy')", + `Request another token`, + }) + + if strings.Count(body, `class="copy-button"`) != 3 { + t.Fatalf("expected 3 copy buttons, got %d in:\n%s", strings.Count(body, `class="copy-button"`), body) + } +} + +func TestRenderTokenEscapesSpecialCharactersInCopyAttributes(t *testing.T) { + token := `sha256~test"onclick='alert(1)'` + server := "https://api.example.com:6443" + + data := tokenData{ + sharedData: sharedData{ + RequestURL: "/oauth/token/request", + }, + AccessToken: token, + OcLoginCommand: formatOcLoginCommand(token, server), + CurlCommand: formatCurlCommand(token, server), + PublicMasterURL: server, + } + + var buf bytes.Buffer + renderToken(&buf, data) + body := buf.String() + + if strings.Contains(body, `data-copy-text="sha256~test"onclick`) { + t.Fatalf("token copy attribute was not HTML-escaped:\n%s", body) + } + if strings.Contains(body, "") { + t.Fatalf("unexpected unescaped script content in output:\n%s", body) + } + expectContains(t, body, []string{ + `data-copy-text="sha256~test"onclick='alert(1)'"`, + }) +} + +func TestRenderTokenErrorStateOmitsCopyControls(t *testing.T) { + data := tokenData{ + sharedData: sharedData{ + Error: "Error checking token", + RequestURL: "/oauth/token/request", + }, + } + + var buf bytes.Buffer + renderToken(&buf, data) + body := buf.String() + + expectContains(t, body, []string{ + "Error checking token", + `Request another token`, + }) + if strings.Contains(body, `class="copy-button"`) { + t.Fatalf("error state should not render copy buttons:\n%s", body) + } +} + +func TestRenderFormDisplayTokenStepUnchanged(t *testing.T) { + data := formData{ + sharedData: sharedData{ + RequestURL: "/oauth/token/request", + }, + Action: "https://oauth.example.com/oauth/token/display", + Code: "auth-code", + CSRF: "csrf-token", + } + + var buf bytes.Buffer + renderForm(&buf, data) + body := buf.String() + + expectContains(t, body, []string{ + `action="https://oauth.example.com/oauth/token/display"`, + `name="code" value="auth-code"`, + `name="csrf" value="csrf-token"`, + "Display Token", + }) + if strings.Contains(body, `class="copy-button"`) { + t.Fatalf("display token form should not include copy buttons:\n%s", body) + } +} + +func TestDisplayTokenPostRejectsInvalidCSRF(t *testing.T) { + handler := &tokenRequest{ + csrf: &csrf.FakeCSRF{Token: "expected-csrf"}, + } + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/oauth/token/display", strings.NewReader("csrf=wrong")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + handler.displayTokenPost(nil, rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected status %d, got %d: %s", http.StatusBadRequest, rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), "Could not check CSRF token") { + t.Fatalf("unexpected body: %s", rec.Body.String()) + } +} + +func TestDisplayTokenRejectsUnsupportedMethod(t *testing.T) { + handler := &tokenRequest{} + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodDelete, "/oauth/token/display", nil) + handler.displayToken(nil, rec, req) + + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected status %d, got %d: %s", http.StatusMethodNotAllowed, rec.Code, rec.Body.String()) + } +} + +func expectContains(t *testing.T, body string, expected []string) { + t.Helper() + for _, fragment := range expected { + if !strings.Contains(body, fragment) { + t.Fatalf("expected body to contain %q, got:\n%s", fragment, body) + } + } +}