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:
@@ -1261,3 +1261,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-08-26] Webhook event catalogue lives in lib/webhooks/public-events.ts (grouped, with docs prose) and the fan-out handler set, the v1 create enum (so the OpenAPI spec and skills/accounted-api), and the docs page all derive from it: the enum and the docs had drifted to 24 of the 28 events the handler delivered, so the four reconciliation.* events were rejected at subscribe time. No API_V1_VERSION bump: the changelog already lists them as additive.
|
||||
[2026-08-26] Removed the phantom V1_ENDPOINT_SCOPES entries GET /api/v1/openapi.yaml, GET /api/v1/companies/:companyId and GET /api/v1/companies/:companyId/events instead of building the routes: no route file, registry entry, docs, skill or test referenced them, and the new scope-registry-parity test needs the map to describe only what exists. A company-detail GET can be added later with its entry in the same PR.
|
||||
[2026-08-26] gnubok_connect_bank / gnubok_connect_skatteverket moved from catalogVisibility 'search' to the default catalog: Claude.ai can only invoke tools present in tools/list, so search-only tools are discover-only there and the onboarding skill's steps 3-4 dead-ended on client-side tool-not-found (verified via event_log: the server never received the calls). Search-only visibility remains fine for tools an agent reads about before asking the user, but anything a skill instructs the agent to CALL must be in the default catalog.
|
||||
[2026-08-26] OAuth consent pre-checks ALL scopes (one-click, list collapsed in details): founder call after the read-only default dead-ended agent flows; defensible because every write is staged for approval, rows stay untickable, grant revocable.
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 · 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">
|
||||
|
||||
@@ -4,7 +4,24 @@ This plugin bundles exactly one connector: the Accounted MCP server, the same se
|
||||
|
||||
| Connector | Server | Auth | What it reaches |
|
||||
|-----------|--------|------|-----------------|
|
||||
| Accounted | `https://app.accounted.se/api/extensions/ext/mcp-server/mcp?tool_namespace=accounted` | OAuth 2.1 (PKCE). Read-only scopes by default; write scopes are ticked explicitly on the consent screen. | The user's own companies in Accounted: ledger, transactions, invoices, VAT, payroll, reconciliation, year-end. |
|
||||
| Accounted | `https://app.accounted.se/api/extensions/ext/mcp-server/mcp?tool_namespace=accounted` | OAuth 2.1 (PKCE). One-click consent: all scopes pre-selected (adjustable in a fold); every write still stages for explicit approval before it touches the ledger. | The user's own companies in Accounted: ledger, transactions, invoices, VAT, payroll, reconciliation, year-end. |
|
||||
|
||||
## Share link and starter prompt
|
||||
|
||||
One-click add on claude.ai (opens the Add custom connector dialog prefilled; the params are `connectorName`/`connectorUrl`):
|
||||
|
||||
```
|
||||
https://claude.ai/customize/connectors?modal=add-custom-connector&connectorName=Accounted&connectorUrl=https%3A%2F%2Fapp.accounted.se%2Fapi%2Fextensions%2Fext%2Fmcp-server%2Fmcp%3Ftool_namespace%3Daccounted
|
||||
```
|
||||
|
||||
Pair the link with a starter prompt the user pastes as their first message. The short form works because the server-side onboarding skill carries the whole flow; the long form pre-answers the three opening questions and saves a round-trip:
|
||||
|
||||
> Sätt upp mitt företag i Accounted.
|
||||
|
||||
> Sätt upp mitt företag i Accounted.
|
||||
> Organisationsnummer: `<orgnr>`
|
||||
> Tidigare bokföringssystem: `<t.ex. Fortnox, eller inget>`
|
||||
> Bank: `<t.ex. Swedbank>`
|
||||
|
||||
## How the connection works
|
||||
|
||||
|
||||
Reference in New Issue
Block a user