diff --git a/internal/server/handlers_oauth.go b/internal/server/handlers_oauth.go
index 2e4e2d14e..80f9e959f 100644
--- a/internal/server/handlers_oauth.go
+++ b/internal/server/handlers_oauth.go
@@ -1602,7 +1602,17 @@ body {
background: #2a2a2a; color: #e8e8e8; border-color: #555;
}
}
-h1 { font-size: 1.5em; margin: 0 0 .25em; }
+h1 { font-size: 1.5em; margin: 0 0 .25em; overflow-wrap: anywhere; }
+.resource-brand {
+ margin: 0 0 .75em; font-size: 1.05em; color: #444;
+ overflow-wrap: anywhere;
+}
+.resource-brand strong { font-size: 1.15em; color: #1a1a1a; }
+@media (prefers-color-scheme: dark) {
+ .resource-brand { color: #bbb; }
+ .resource-brand strong { color: #fff; }
+}
+.client-card .meta { min-width: 0; overflow-wrap: anywhere; }
.client-card {
display: flex; align-items: center; gap: 1em; margin: 1.5em 0;
padding: 1em; border: 1px solid #ddd; border-radius: 10px; background: #fafafa;
@@ -1656,6 +1666,11 @@ input[type=text], input[type=search] {
.picker { background: #1c1c1c; }
}
.hint { color: #555; font-size: .9em; margin: .75em 0; }
+.ws-summary {
+ margin: .1em 0 .5em 1.9em; color: #555; font-size: .9em;
+ overflow-wrap: anywhere;
+}
+@media (prefers-color-scheme: dark) { .ws-summary { color: #aaa; } }
.footer-disclosure {
margin: 1.5em 0; padding: .85em 1em;
background: #f5f5f5; border-radius: 8px; color: #555; font-size: .9em;
@@ -1690,6 +1705,7 @@ button.primary:hover:not(:disabled) { background: #1e54d4; }
+Pad · {{.ResourceHost}}
Authorize {{.ClientName}}
{{if .Surface}}Connecting Pad's {{.Surface}} tools.
{{end}}
Signed in as {{.Username}} {{if .UserEmail}} ({{.UserEmail}}){{end}}.
@@ -1698,7 +1714,8 @@ button.primary:hover:not(:disabled) { background: #1e54d4; }
{{if .ClientLogoURL}} {{end}}
{{.ClientName}}
- wants to access your Pad on your behalf.
+ is requesting access to your Pad workspaces on {{.ResourceHost}}.
+ The app chose this name; Pad has not verified it.{{if .RedirectHost}} After you decide, you return to {{.RedirectHost}} .{{end}}
@@ -1748,6 +1765,9 @@ button.primary:hover:not(:disabled) { background: #1e54d4; }
All my workspaces — includes workspaces you join or create later.
+ {{if .WorkspaceSummary}}
+ Your Pad workspaces now: {{range $i, $n := .WorkspaceSummary}}{{if $i}}, {{end}}{{$n}} {{end}}{{if .WorkspaceMore}} and {{.WorkspaceMore}} more{{end}}.
+ {{end}}
{{if .Workspaces}}
@@ -1878,18 +1898,38 @@ button.primary:hover:not(:disabled) { background: #1e54d4; }
// consentData is the template's data shape. Field names match the
// template's references; adding a new field requires updating both.
type consentData struct {
+ // ClientName is the name the app registered for itself (or its
+ // client_id), capped by consentClientName. It is the app's CLAIM, so
+ // the page labels it unverified and names Pad and the redirect host,
+ // which the app cannot choose (TASK-1071).
ClientName string
+ // ResourceHost names the Pad instance being authorized: the host of
+ // the configured public URL, else the request's Host. This is the
+ // "your Pad workspaces on " identification the RFC 8707
+ // relaxation leans on (see audienceForAuthorize).
+ ResourceHost string
+ // RedirectHost names the redirect_uri fosite validated against the
+ // client's registration: where the user's browser goes after deciding
+ // (see consentRedirectTarget).
+ RedirectHost string
// Surface names the Pad surface being connected when it is not /mcp
// (TASK-3321 U2b): "ChatGPT" when the request names the ChatGPT
// catalog's resource. Set by the server from the requested audience,
// never from the client's own name, so a user is not approving an
// unlabelled second door.
- Surface string
- ClientLogoURL string
- Username string
- UserEmail string
- Workspaces []consentWorkspaceRow // user's workspace memberships
- CanRead bool // tier radios — true iff client requested the corresponding pad:* scope
+ Surface string
+ ClientLogoURL string
+ Username string
+ UserEmail string
+ Workspaces []consentWorkspaceRow // user's workspace memberships
+ // WorkspaceSummary is the first consentWorkspaceSummaryMax workspace
+ // names, shown under "All my workspaces" so the default choice still
+ // names the user's actual Pad workspaces (the picker that lists them
+ // is hidden until "Only specific" is picked); WorkspaceMore counts the
+ // rest (TASK-1071).
+ WorkspaceSummary []string
+ WorkspaceMore int
+ CanRead bool // tier radios — true iff client requested the corresponding pad:* scope
CanWrite bool
CanAdmin bool
DefaultTier string // "read" / "write" / "admin" — initially-checked radio
@@ -2045,8 +2085,22 @@ func (s *Server) renderConsent(w http.ResponseWriter, r *http.Request, ar fosite
suggested = truncateBindableText(suggested, 120)
}
+ summary := make([]string, 0, consentWorkspaceSummaryMax)
+ for i, row := range rows {
+ if i == consentWorkspaceSummaryMax {
+ break
+ }
+ summary = append(summary, row.Name)
+ }
+
+ redirectHost := consentRedirectTarget(ar.GetRedirectURI())
+
data := consentData{
- ClientName: clientName,
+ ClientName: consentClientName(clientName),
+ ResourceHost: s.consentResourceHost(r),
+ RedirectHost: redirectHost,
+ WorkspaceSummary: summary,
+ WorkspaceMore: len(rows) - len(summary),
Surface: s.consentSurface(ar),
ClientLogoURL: logo,
Username: user.Name,
@@ -2207,6 +2261,66 @@ func (s *Server) consentSurface(ar fosite.AuthorizeRequester) string {
return ""
}
+// consentClientNameMax caps the client's self-chosen name on the consent
+// page, in runes. Registration does not limit it (RFC 7591 sets no bound),
+// and an unbounded name could push the Pad identification and the
+// workspace list below the fold (TASK-1071).
+const consentClientNameMax = 80
+
+// consentWorkspaceSummaryMax is how many workspace names the "All my
+// workspaces" line names before "and N more".
+const consentWorkspaceSummaryMax = 5
+
+// consentClientName returns name cut to consentClientNameMax runes, with an
+// ellipsis when it was cut, and whitespace runs collapsed so a name cannot
+// pad itself out with blank lines.
+func consentClientName(name string) string {
+ name = strings.Join(strings.Fields(name), " ")
+ r := []rune(name)
+ if len(r) <= consentClientNameMax {
+ return name
+ }
+ return string(r[:consentClientNameMax-1]) + "…"
+}
+
+// consentRedirectTarget names where the browser goes after the decision.
+// An https or http redirect is named by its host. Any other scheme hands the
+// browser to an app, so it is named with the scheme: "claude://oauth", not
+// "oauth", which reads as a website. A URI with no host (registration refuses
+// one today, but the page must not go quiet if that changes) is named by its
+// scheme and path.
+func consentRedirectTarget(u *url.URL) string {
+ if u == nil {
+ return ""
+ }
+ switch {
+ case u.Scheme == "http" || u.Scheme == "https":
+ return u.Host
+ case u.Host != "":
+ return u.Scheme + "://" + u.Host
+ case u.Scheme != "":
+ return consentClientName(u.Scheme + ":" + u.Opaque + u.Path)
+ }
+ return ""
+}
+
+// consentResourceHost names the Pad instance on the consent page: the host
+// of the configured public URL, else the host the request came in on (also
+// when the configured URL names an unspecified address like 0.0.0.0, which
+// is a bind address and not a name the user would recognize).
+func (s *Server) consentResourceHost(r *http.Request) string {
+ if s.baseURL != "" {
+ if u, err := url.Parse(s.baseURL); err == nil {
+ switch u.Hostname() {
+ case "", "0.0.0.0", "::":
+ default:
+ return u.Host
+ }
+ }
+ }
+ return r.Host
+}
+
// dedupeStrings returns values with each string once, in first-seen order.
func dedupeStrings(values []string) []string {
seen := make(map[string]bool, len(values))
diff --git a/internal/server/task1071_consent_identity_test.go b/internal/server/task1071_consent_identity_test.go
new file mode 100644
index 000000000..b42b08be6
--- /dev/null
+++ b/internal/server/task1071_consent_identity_test.go
@@ -0,0 +1,252 @@
+package server
+
+import (
+ "html"
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+
+ "github.com/PerpetualSoftware/pad/internal/models"
+)
+
+// TASK-1071: the consent screen is the trust anchor the RFC 8707 relaxation
+// leans on (audienceForAuthorize), so it must say plainly that the resource
+// is Pad, name which Pad, list the user's actual workspaces, and not let the
+// app's self-chosen name crowd that out or pass as verified.
+
+func registerNamedTestClient(t *testing.T, srv *Server, name, redirectURI string) string {
+ t.Helper()
+ rr := doRequest(srv, "POST", "/oauth/register", map[string]any{
+ "client_name": name,
+ "redirect_uris": []string{redirectURI},
+ })
+ if rr.Code != http.StatusCreated {
+ t.Fatalf("register %q: %d (body: %s)", name, rr.Code, rr.Body.String())
+ }
+ var resp map[string]any
+ parseJSON(t, rr, &resp)
+ id, _ := resp["client_id"].(string)
+ if id == "" {
+ t.Fatal("register: empty client_id")
+ }
+ return id
+}
+
+func renderConsentFor(t *testing.T, srv *Server, clientID, redirectURI, sessionToken string) string {
+ t.Helper()
+ q := url.Values{
+ "client_id": {clientID},
+ "response_type": {"code"},
+ "redirect_uri": {redirectURI},
+ "scope": {"pad:read"},
+ "resource": {testCanonicalAudience},
+ "audience": {testCanonicalAudience},
+ "code_challenge": {s256Challenge("code-verifier-abc123-must-be-43-to-128-chars-long")},
+ "code_challenge_method": {"S256"},
+ "state": {"task-1071-state"},
+ }
+ rr := doAuthedRequest(srv, "GET", "/oauth/authorize?"+q.Encode(), nil, sessionToken)
+ if rr.Code != http.StatusOK {
+ t.Fatalf("consent: %d (location: %s)", rr.Code, rr.Header().Get("Location"))
+ }
+ return rr.Body.String()
+}
+
+// bodyOf is the page after , so CSS and the never satisfy an
+// assertion about what the user reads.
+func bodyOf(t *testing.T, page string) string {
+ t.Helper()
+ i := strings.Index(page, " ")
+ if i < 0 {
+ t.Fatal("no in consent page")
+ }
+ return page[i:]
+}
+
+func TestConsentIdentifiesPadAndTheUsersWorkspaces(t *testing.T) {
+ t.Parallel()
+ srv, _ := oauthEnabledTestServer(t)
+ srv.SetBaseURL("https://pad.example.org")
+ user, session := loginTestUser(t, srv)
+ mustSeedWorkspaceWithRole(t, srv, user.ID, "Alpha Project", "alpha-project", "owner")
+ mustSeedWorkspaceWithRole(t, srv, user.ID, "Beta Notes", "beta-notes", "viewer")
+ other, err := srv.store.CreateUser(models.UserCreate{Email: "other-1071@example.com", Name: "Other", Password: "pw-test-12345"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ mustSeedWorkspaceWithRole(t, srv, other.ID, "Not Yours", "not-yours", "owner")
+
+ id := registerNamedTestClient(t, srv, "Cursor", "https://app.test/cb")
+ page := bodyOf(t, renderConsentFor(t, srv, id, "https://app.test/cb", session))
+
+ for _, want := range []string{
+ `Pad · pad.example.org`,
+ "is requesting access to your Pad workspaces on pad.example.org.",
+ "The app chose this name; Pad has not verified it.",
+ "you return to app.test ",
+ `value="alpha-project"`,
+ "Alpha Project",
+ `value="beta-notes"`,
+ "Beta Notes",
+ // Named under the default "All my workspaces" choice too, where the
+ // per-workspace picker is hidden.
+ "Your Pad workspaces now: Alpha Project , Beta Notes .",
+ } {
+ if !strings.Contains(page, want) {
+ t.Errorf("consent page must contain %q", want)
+ }
+ }
+ if strings.Contains(page, "not-yours") || strings.Contains(page, "Not Yours") {
+ t.Error("consent page lists a workspace the user is not a member of")
+ }
+ // Pad is named before the app is.
+ if p, c := strings.Index(page, "Pad "), strings.Index(page, "Authorize Cursor"); p < 0 || c < 0 || p > c {
+ t.Errorf("Pad must be identified above the app's name (pad at %d, app at %d)", p, c)
+ }
+}
+
+func TestConsentWorkspaceSummaryNamesFiveThenCounts(t *testing.T) {
+ t.Parallel()
+ srv, _ := oauthEnabledTestServer(t)
+ user, session := loginTestUser(t, srv)
+ for _, n := range []string{"A1", "A2", "A3", "A4", "A5", "A6", "A7"} {
+ mustSeedWorkspaceWithRole(t, srv, user.ID, "WS "+n, "ws-"+strings.ToLower(n), "editor")
+ }
+ id := registerNamedTestClient(t, srv, "Cursor", "https://app.test/cb")
+ page := bodyOf(t, renderConsentFor(t, srv, id, "https://app.test/cb", session))
+ want := "Your Pad workspaces now: WS A1 , WS A2 , WS A3 , WS A4 , WS A5 and 2 more."
+ if !strings.Contains(page, want) {
+ t.Errorf("summary must name five and count the rest; want %q", want)
+ }
+}
+
+func TestConsentResourceHostFallsBackToTheRequestHost(t *testing.T) {
+ t.Parallel()
+ for _, base := range []string{"", "http://0.0.0.0:7777"} {
+ srv, _ := oauthEnabledTestServer(t)
+ srv.SetBaseURL(base)
+ _, session := loginTestUser(t, srv)
+ id := registerNamedTestClient(t, srv, "Cursor", "https://app.test/cb")
+ page := bodyOf(t, renderConsentFor(t, srv, id, "https://app.test/cb", session))
+ // httptest requests arrive with Host "example.com".
+ if !strings.Contains(page, "your Pad workspaces on example.com.") {
+ t.Errorf("base %q: consent page must name the request host", base)
+ }
+ }
+}
+
+// The four client-name/logo edge cases the task names.
+func TestConsentClientNameEdgeCases(t *testing.T) {
+ t.Parallel()
+
+ t.Run("a long name is cut, and Pad's identification still renders", func(t *testing.T) {
+ t.Parallel()
+ srv, _ := oauthEnabledTestServer(t)
+ _, session := loginTestUser(t, srv)
+ long := strings.Repeat("Very Long Client Name ", 50)
+ id := registerNamedTestClient(t, srv, long, "https://app.test/cb")
+ page := bodyOf(t, renderConsentFor(t, srv, id, "https://app.test/cb", session))
+ if strings.Contains(page, strings.TrimSpace(long)) {
+ t.Error("the full 1100-character name must not render")
+ }
+ if !strings.Contains(page, "…") {
+ t.Error("a cut name must show it was cut")
+ }
+ if !strings.Contains(page, "your Pad workspaces on") {
+ t.Error("Pad identification must survive a long client name")
+ }
+ })
+
+ t.Run("a name claiming to be Pad is still labelled the app's own claim", func(t *testing.T) {
+ t.Parallel()
+ srv, _ := oauthEnabledTestServer(t)
+ _, session := loginTestUser(t, srv)
+ id := registerNamedTestClient(t, srv, "Pad (getpad.dev) official", "https://evil.test/cb")
+ page := bodyOf(t, renderConsentFor(t, srv, id, "https://evil.test/cb", session))
+ for _, want := range []string{
+ "The app chose this name; Pad has not verified it.",
+ "you return to evil.test ",
+ } {
+ if !strings.Contains(page, want) {
+ t.Errorf("must contain %q", want)
+ }
+ }
+ })
+
+ t.Run("markup in the name is escaped, never rendered", func(t *testing.T) {
+ t.Parallel()
+ srv, _ := oauthEnabledTestServer(t)
+ _, session := loginTestUser(t, srv)
+ evil := ` `
+ id := registerNamedTestClient(t, srv, evil, "https://app.test/cb")
+ page := bodyOf(t, renderConsentFor(t, srv, id, "https://app.test/cb", session))
+ if strings.Contains(page, "")) {
+ t.Error("the escaped name must render")
+ }
+ })
+
+ t.Run("no logo renders no img, and the card still identifies Pad", func(t *testing.T) {
+ t.Parallel()
+ srv, _ := oauthEnabledTestServer(t)
+ _, session := loginTestUser(t, srv)
+ id := registerNamedTestClient(t, srv, "Cursor", "https://app.test/cb")
+ page := bodyOf(t, renderConsentFor(t, srv, id, "https://app.test/cb", session))
+ if strings.Contains(page, ` claude://oauth") {
+ t.Error("a custom-scheme return must be named with its scheme")
+ }
+}