feat(oauth): one-click consent: all scopes pre-selected, list collapsed, Allow above the fold (#1953)

The read-only default forced every agent-first user to scroll a scope
list and hand-tick write rows before the flow could work. Founder call
2026-08-26: pre-check ALL scopes when the client requests none (Claude's
connector case), collapse the scope list into an expandable details fold
('Alla förvalda, visa och justera'), and keep the Allow button visible
without scrolling.

Why this is defensible: every write is STAGED for explicit approval
before anything touches the ledger, each scope row stays individually
untickable inside the fold, the warn line states the staging rule right
above the button, and the grant is revocable under Inställningar >
API-nycklar. A client that requests explicit scopes still gets exactly
that set (RFC 6749 3.3 least-privilege unchanged), and the tampered/empty
POST fallback stays read-only.

CONNECTORS.md gains the share link (connectorName/connectorUrl params)
plus the starter prompt to pair with it.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-26 15:06:46 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent 41424a1650
commit e7e4efbfbc
4 changed files with 99 additions and 42 deletions
@@ -162,11 +162,11 @@ describe('GET /api/mcp-oauth/authorize: CSP', () => {
})
it('renders both read and write rows when client passes only the legacy `mcp` scope marker', async () => {
// Claude's connector sends scope=mcp today. The consent UI must render
// every scope group so the user can opt into write/approval rows if they
// want, but each write/approve row MUST start unchecked. Affirmative
// opt-in is the access-control gate (GDPR Art. 25(2), ISO 27001:2022
// A.5.18 / A.8.2, SOC 2 CC6.3, ASVS V10.2.2 / V2.3.1).
// Claude's connector sends scope=mcp today. The consent UI renders every
// scope group with ALL rows pre-checked (one-click consent, founder
// decision 2026-08-26): the affirmative act is the Allow click on a page
// that shows the full set, every write is staged for approval before it
// touches the ledger, and each row stays individually untickable.
const request = new Request(
buildAuthorizeUrl({
response_type: 'code',
@@ -186,21 +186,21 @@ describe('GET /api/mcp-oauth/authorize: CSP', () => {
expect(html).toMatch(/value="invoices:write"/)
expect(html).toMatch(/value="pending_operations:approve"/)
// Write and approval scopes MUST render unchecked. Users have to make an
// affirmative, deliberate selection for each destructive permission.
// Every row starts checked: the deliberate act is the visible Allow
// click, and unticking stays available per row inside the details fold.
const writeRow = html.match(/<input[^>]*value="transactions:write"[^>]*>/)?.[0]
expect(writeRow).toBeDefined()
expect(writeRow!).not.toContain('checked')
expect(writeRow!).toContain('checked')
const approveRow = html.match(/<input[^>]*value="pending_operations:approve"[^>]*>/)?.[0]
expect(approveRow).toBeDefined()
expect(approveRow!).not.toContain('checked')
expect(approveRow!).toContain('checked')
const bookkeepingRow = html.match(/<input[^>]*value="bookkeeping:write"[^>]*>/)?.[0]
expect(bookkeepingRow).toBeDefined()
expect(bookkeepingRow!).not.toContain('checked')
expect(bookkeepingRow!).toContain('checked')
// The :read counterpart is pre-checked (safe default).
// The :read counterpart is pre-checked too.
const readRow = html.match(/<input[^>]*value="transactions:read"[^>]*>/)?.[0]
expect(readRow).toBeDefined()
expect(readRow!).toContain('checked')
@@ -435,7 +435,7 @@ describe('account with no company yet (issue #1814)', () => {
expect(supabase.from).not.toHaveBeenCalled()
})
it('pre-ticks companies:write so the agent can create the company, other writes stay unticked', async () => {
it('pre-ticks every scope for a companyless account (one-click consent covers the create flow)', async () => {
mocks.createClient.mockResolvedValue(buildSupabase({ id: 'user-1', email: 'ny@example.se' }))
const response = await GET(new Request(buildAuthorizeUrl(authorizeParams)))
@@ -446,7 +446,7 @@ describe('account with no company yet (issue #1814)', () => {
expect(companiesWrite!).toContain('checked')
const transactionsWrite = html.match(/<input[^>]*value="transactions:write"[^>]*>/)?.[0]
expect(transactionsWrite).toBeDefined()
expect(transactionsWrite!).not.toContain('checked')
expect(transactionsWrite!).toContain('checked')
})
it('POST still issues an authorization code', async () => {
+67 -28
View File
@@ -40,7 +40,8 @@ type ScopeParseResult =
*
* Returns:
* - { ok, scopes: undefined } when no scope param was supplied: the consent
* UI pre-checks DEFAULT_OAUTH_SCOPES (read-only, GDPR Art. 25(2)).
* UI pre-checks ALL_SCOPES (one-click consent; every write is staged for
* approval, and the empty-selection POST fallback stays read-only).
* - { ok, scopes: [...] } when at least one valid scope was requested.
* - { invalid_scope } when a scope param was supplied but every value was
* unknown: refusing the request is safer than silently dropping it back
@@ -275,23 +276,18 @@ export async function GET(request: Request) {
// - Client requested specific scopes → ceiling = that set, pre-checked =
// that set (RFC 6749 §3.3 strict least-privilege).
// - Client passed no scope (or only the legacy `mcp` marker, Claude's
// connector today) → ceiling = ALL_SCOPES so every read/write row
// renders; pre-checked = DEFAULT_OAUTH_SCOPES so only the read rows
// start ticked. The user has to actively tick :write to widen the
// grant. This preserves GDPR Art. 25(2) (defaults are minimal /
// read-only) while still letting the resource owner authorise write
// scopes per RFC 6749 §3.3 ("based on … the resource owner's
// instructions"), which is the whole point of the consent step.
// connector today) → ceiling = ALL_SCOPES and pre-checked = ALL_SCOPES:
// one-click consent (founder decision 2026-08-26; the read-only default
// killed the agent flow with an insufficient-scope dead-end mid-chat).
// The mitigations that make full-by-default defensible: every write is
// STAGED for explicit approval before anything touches the ledger, the
// full scope list stays on the page (collapsed but expandable) with
// every row untickable, the warn line states the staging rule above the
// button, and the grant is revocable under Inställningar › API-nycklar.
// RFC 6749 §3.3 lets the resource owner authorise the set presented;
// the consent is the click on a page that shows exactly that set.
const grantCeiling = new Set<ApiKeyScope>(parsed.scopes ?? ALL_SCOPES)
const preChecked = new Set<ApiKeyScope>(parsed.scopes ?? DEFAULT_OAUTH_SCOPES)
// An account with no company is connecting in order to create one
// (issue #1814): pre-tick the one write scope that gnubok_create_company
// needs, so the agent-driven setup does not dead-end on insufficient scope
// right after signup. Still a checkbox the user can untick, and still
// bounded by the client's ceiling.
if (!companyId && grantCeiling.has('companies:write')) {
preChecked.add('companies:write')
}
const preChecked = new Set<ApiKeyScope>(parsed.scopes ?? ALL_SCOPES)
const scopeCheckboxesHtml = renderScopeCheckboxes(preChecked, grantCeiling)
// Render consent page
@@ -423,11 +419,48 @@ export async function GET(request: Request) {
line-height: 1.55;
margin: -1rem 0 1.75rem;
}
.scopes-details {
border: 1px solid var(--border);
border-radius: 8px;
padding: 0 0.875rem;
background: var(--surface);
}
.scopes-details summary {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 0;
cursor: pointer;
list-style: none;
user-select: none;
}
.scopes-details summary::-webkit-details-marker { display: none; }
.scopes-details summary:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
border-radius: 6px;
}
.scopes-summary-hint {
flex: 1;
text-align: right;
font-size: 0.75rem;
color: var(--fg-faint);
}
.scopes-chevron {
width: 14px;
height: 14px;
color: var(--fg-faint);
transition: transform 150ms;
flex-shrink: 0;
}
.scopes-details[open] .scopes-chevron { transform: rotate(90deg); }
.scopes-details[open] summary { border-bottom: 1px solid var(--border); }
.scopes-details .scope-groups { padding-bottom: 0.5rem; }
.scopes-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 0.625rem;
padding: 0.625rem 0;
margin-bottom: 0.25rem;
border-bottom: 1px solid var(--border);
}
@@ -603,7 +636,7 @@ export async function GET(request: Request) {
<main class="card" role="main">
<div class="eyebrow">${appNameLower} · mcp</div>
<h1>Anslut MCP-klient</h1>
<p class="lede">En extern applikation begär åtkomst till ditt ${appNameLower}-konto. Välj vilka behörigheter du vill bevilja.</p>
<p class="lede">En extern applikation begär åtkomst till ditt ${appNameLower}-konto. Alla behörigheter är förvalda; varje skrivning kräver ändå ditt godkännande innan den bokförs.</p>
<div class="account">
${accountRowHtml}
@@ -614,16 +647,22 @@ export async function GET(request: Request) {
<input type="hidden" name="scope_binding" value="${escapeHtml(scopeBindingValue)}">
<input type="hidden" name="scope_binding_sig" value="${escapeHtml(scopeBindingSignature)}">
<div class="scopes-header">
<span class="scopes-title">Behörigheter</span>
<div class="scopes-controls">
<button type="button" id="select-read">Endast läs</button>
<button type="button" id="select-all">Alla</button>
<button type="button" id="select-none">Inga</button>
<details class="scopes-details">
<summary>
<span class="scopes-title">Behörigheter</span>
<span class="scopes-summary-hint">Alla förvalda &middot; visa och justera</span>
<svg class="scopes-chevron" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path d="M6 4l4 4-4 4" stroke-linecap="round" stroke-linejoin="round"/></svg>
</summary>
<div class="scopes-header">
<span class="scopes-title">Justera</span>
<div class="scopes-controls">
<button type="button" id="select-read">Endast läs</button>
<button type="button" id="select-all">Alla</button>
<button type="button" id="select-none">Inga</button>
</div>
</div>
</div>
<div class="scope-groups">${scopeCheckboxesHtml}</div>
<div class="scope-groups">${scopeCheckboxesHtml}</div>
</details>
<div class="warn">
<svg class="warn-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">