id: browser-binding title: A web sign-in's code reaches only the agent's own browser status: open crates: [didbot-serve, didbot-agentd, didbot-config, didbot-lexicon] dependsOn: [oauth] exitCriterion: > A stranger's web sign-in, started in the stranger's own browser and approved by the account, never sends that browser a code, and the account's own browser still completes a web sign-in. A test over the real router shows both. #
browser-binding #
With #1070,
GET /oauth/authorize sends whichever browser loads it on to the app with a
code, once the account approves. Nothing ties that browser to the account, so
a stranger who plants the authorize URL of a sign-in they started is signed in
to the app as the agent if the model approves it
(issue 166).
This epic is a proposal. It weighs three options, recommends one and specifies it. The decisions it needs are the owner's, listed at the end.
The attack #
- A stranger starts a sign-in to a web app with the agent's handle, in the stranger's own browser. The app pushes the request here and sends that browser to the authorize page, which reloads every five seconds until the account answers.
- The stranger posts the page's URL where the agent reads: a post, an issue, a page the agent browses.
- The plugin reports any
/oauth/authorize?URL in any tool's output, on any host (AUTHORIZE_URL_REin didbot-claude'splugin/bin/hook.py). So #1068 counts it as seen and shows the sign-in to the model. - The model approves. The stranger's browser gets the
303with the code on its next reload, and the app signs that browser in as the agent.
Before #1070 the approval was spent and no browser got the code. Now the model's judgment is the only check. A loopback sign-in is not exposed: the daemon delivers its code to a listener on the agent's host.
What the spec and the reference expect #
The spec's "Authorization Interface":
- "When a client redirects to the Authorization Server's authorization URL
(the declared
authorization_endpoint), the server first needs to authenticate the user." - The method is the server's to choose: "secure cookies, email, various two-factor authentication, passkeys". "Servers may also support multiple concurrent auth sessions with users."
- "If a
login_hintwas supplied, the Authorization Server should only allow the user to authenticate with that account."
Its summary of the flow: "The Authorization Server uses the request_uri to
look up the earlier Authorization Request parameters, authenticates the user
(which might include sign-in or account selection), and prompts the user with
the Authorization Interface."
The reference provider,
packages/oauth/oauth-provider
at 9c76c342:
DeviceManagerkeeps a browser in two cookies,dev-idandses-id:HttpOnly,Secure,Path=/, andSameSite=Laxby default. It replacesses-idon every sign-in, every five minutes, and when the browser's address or user agent changes.RequestManager.getbinds a request to the first browser that loads it and refuses any other: "This request was initiated from another device".setAuthorizedissues a code only to that browser.OAuthProvider.authorizelists the accounts signed in on that browser (listDeviceAccounts). The/consentroute takes the approval from that browser, signed in as the account.- The authorize page answers only a top-level navigation
(
validateFetchMode(req, ['navigate'])andvalidateFetchDest(req, ['document'])). The routes the page calls also checkSec-Fetch-Site,Origin,Referer, and a CSRF token sent as both a cookie and a header. - A browser's sign-in counts for seven days (
AUTHENTICATION_MAX_AGE).
indigo's OAuth package is "currently focused on clients" and has no authorization server.
In both, a code goes only to a browser signed in as the account. The
reference's first-browser rule is not what protects it, because a stranger's
browser loads the stranger's sign-in first. What protects it is that the
approval is made in that browser, signed in. Here the account approves
elsewhere, over bot.did.approveAuthorization, so the browser that collects
the code has to be signed in on its own. This design follows the spec, and
takes the cookie flags and the navigation check from the reference.
Option one: do nothing #
A browser-carried code goes to whichever browser loads the page after the approval, as #1070 has it.
- Leaves open: #166. #1068 shows that the agent saw a URL, not that it
started the sign-in, and a planted page can ask the model to approve. The
stranger gets a login as the agent at the approved scopes, for 14 days
(
REFRESH_TTL). - Costs: nothing.
- Breaks: nothing.
- Stores: nothing.
Option two: sign the agent's browser in #
The account signs its own browser in. The daemon has the server mint a one-time ticket, the agent's browser opens it, and the server sets a session cookie for the account. The authorize page releases a browser-carried code only to a browser whose session holds the account the sign-in names.
- Costs: a new credential, which the owner must approve. One
bot.didmethod, one page, one store, one config key and onedidbot-oauthverb. The daemon and the plugin carry the ticket, and #1070's page checks the cookie. The agent signs its browser in once per lifetime. - Breaks: a sign-in whose code a browser carries completes only in a browser signed in as the account. A browser tool that keeps no cookies cannot complete one.
- Stores: tickets and browser sessions, in memory. See the state machine.
Option three: hand the code to the agent's browser through the daemon #
The page never releases a code. bot.did.approveAuthorization already
answers with the redirect. The daemon hands it to the context that approved,
and that context's browser tool opens it.
- Costs: no new credential. The model opens each web sign-in's redirect after approving it. The daemon keeps each approved redirect for its context until the request expires.
- Breaks: the page stops sending the browser on. This departs from the spec's flow, where the authorization server redirects the browser. The server never has a signed-in browser, which remembered approvals for web apps need (#1076).
- Stores: approved redirects, in the daemon's memory. The server's held redirect goes.
- Weakness: the redirect signs any browser in to the app as the agent, because a web app's backend exchanges whatever code reaches its callback. The planted text that won the approval can ask the model to post it. The plugin could write the redirect into the browser tool's input so the model never holds it, but then every web sign-in depends on that rewrite.
Not enough: show the model who loaded the page #
The server could record the address and user agent of each browser that loads the page, and show them to the model before it approves.
- The model does not know its own browser's public address or user agent.
- The stranger chooses their user agent and can load the page from a nearby address.
- It puts strangers' addresses into the record and the transcript.
- The model's judgment stays the only check.
Recommendation #
Option two.
- It is the spec's model: the authorization server authenticates the browser with a secure cookie, and several accounts may be signed in at once.
- Its check does not depend on the model. A browser that never redeemed a ticket never gets a code, however the model was talked into approving.
- A web sign-in completes as #1070 has it, with no extra step per sign-in and no code in the model's context.
- It is what #1076's option three needs for remembered approvals of confidential web apps.
If the owner refuses a new credential, option three with the plugin's rewrite is the fallback. Option one leaves #166 open for as long as #1070 releases codes to browsers.
The credential #
A browser session for an account. It is not a Credential variant, as the
operator's dashboard session is not (auth.rs's module doc). No route reads
it but GET /oauth/authorize and GET /oauth/browser.
- Proves: this browser redeemed a one-time ticket that the account asked for with its own agent token, within the lifetime, and the account has not signed another browser in since.
- Does not prove: that the browser is on the agent's host, that the model drives it, or who started any sign-in.
- Allows: collecting a browser-carried code for a sign-in addressed to the account, after the account approved it. It cannot approve, read or write.
- Lifetime:
[oauth] browser_session_ttl_secs, counted from the sign-in. Default 43 200 (12 hours, as the operator's session). Reasonable range 300 to 86 400. Use does not extend it. A ticket lives 60 seconds, is spent on first use, and is never logged. - Cookie:
__Host-didbot_browser=<session id>; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=<lifetime>, with noDomain.__Host-: a browser takes the cookie only from the issuer's host, withPath=/and noDomain. So a page on an agent's host, a subdomain of the issuer's, cannot set one the issuer is handed, which is the origin problem in agent-sites. The prefix forcesPath=/.HttpOnly: no page script reads it.SameSite=Lax: the app's cross-site redirect to the authorize page carries it, and a cross-site frame or subresource request does not.Strictwould drop it on the app's redirect.Secure: this server always serves TLS.
- Host: the issuer's host alone (
oauth::discovery::issuer_for;pds.did.boton the live deployment).GET /oauth/browserrefuses any other host. - Several accounts in one browser: one cookie names one session, and the session holds a set of accounts, each with its own deadline. An account is in one browser at most: signing it in elsewhere takes it out of the old one. The session gets a new id whenever an account joins. The old id keeps working for a 10-second grace, so two sign-ins in flight in one browser both land.
- Ended by: the deadline; the account signing in to another browser; the account's deletion or erasure; the e-stop's Revoke; a restart. Signing in again in the same browser replaces the session id, which ends a copied cookie once its grace passes.
- E-stop: a Pause refuses minting and signing in (
Estop::check_issue) and leaves sessions alone.POST /oauth/tokenalready refuses an exchange under a Pause. A Revoke ends every session and ticket, and a release restores none. - CSRF: neither page takes a
POST. The sign-in link is aGETwhose ticket is the proof. It is refused whenSec-Fetch-Siteiscross-siteorsame-site, so a link planted on another page cannot sign a browser in. The authorize page releases a code only to a top-level navigation (Sec-Fetch-Dest: document, where the browser sends it). A cross-site navigation that carries the cookie only sends the agent's own browser to a redirect the client registered, which is the ordinary flow. - The same origin as blobs and the dashboard: the cookie rides every
request to the issuer's host,
getBlobincluded. An uploaded HTML blob runs no script there (default-src 'none'; sandbox, incrate::blobs), so nothing on the origin can use the cookie. Both pages here have no script and answer withdefault-src 'none'; frame-ancestors 'none'.
How a ticket reaches the browser #
- The daemon tells each context its sign-in page,
<issuer>/oauth/browser, along with its identity. - When a browser tool the plugin recognises is about to open that page, the
plugin's
PreToolUsereport says so. The daemon has the server mint a ticket as that context's account (bot.did.createBrowserSignIn) and answers with the ticket's URL. The plugin writes it into the tool's input (updatedInput). Neither keeps it. - The browser redeems the ticket. The server answers
303to/oauth/browser, which lists the accounts this browser holds. The model reads that page and never the ticket. - Claude Code's hook reference says
updatedInput"replaces a tool's arguments before it runs", and that MCP tools "appear as regular tools in tool events". cred-delivery records that arguments an earlier design rewrote were not echoed to the model. Neither was measured on a browser tool, so the plugin work starts by measuring both there. - The wire: two optional fields on the socket protocol, one on a report and one on its answer, as #1068 added within version 2. This is the first socket answer that carries a secret on purpose.
- Where the harness cannot rewrite the input, the plugin shows the model the
link instead. On a host with no daemon,
didbot-oauth browserprints it, since that mode already hands the process the account's token. A shown link is spent within seconds, but a model talked into posting it gives the stranger a session until the account signs in again.
How it fits #1068 and the loopback path #
Three checks, and only the last is outside the model:
- #1068 shows the model only the sign-ins its context saw.
- The model approves or declines.
- A browser-carried code goes only to a browser signed in as the account.
oauth::authorize::browser_collects still decides who carries a code. A
loopback redirect with its code in the query is fetched by the daemon on the
agent's host (didbot_agentd::loopback::deliver). No browser takes part, the
page never redirects for it, and a browser session changes nothing for it.
Every other redirect waits in the decision store for a browser signed in as
the record's account, until the request expires.
State machine #
It lives in memory, in a BrowserSessions store on OAuthState beside the
decision store. A restart ends every session, as it ends every operator
session.
| Stored fact | Type | Where |
|---|---|---|
| A ticket | Ticket { account: String, expires_at: OffsetDateTime }, keyed by the ticket: 32 random bytes, base64url |
BrowserSessions |
| The account's live ticket | HashMap<String, String>, from the account's DID to its one live ticket |
BrowserSessions |
| A browser session | BrowserSession { accounts: HashMap<String, OffsetDateTime> }, from each account's DID to its deadline, keyed by the session id: 32 random bytes, base64url |
BrowserSessions |
| The account's browser | HashMap<String, String>, from the account's DID to the one session holding it |
BrowserSessions |
| A replaced session id | HashMap<String, (String, OffsetDateTime)>, from the old id to the current id and the end of its 10-second grace |
BrowserSessions |
| The cookie | __Host-didbot_browser=<session id>, with the flags above |
the browser |
| The lifetime | browser_session_ttl_secs: Option<u64> |
[oauth] in the config file |
| The ticket's lifetime | BROWSER_TICKET_TTL, 60 seconds |
a constant, as REQUEST_URI_TTL is |
| The held redirect | unchanged from #1070 | the decision store |
Each map holds at most one entry per account, or one per sign-in inside its grace, so the store is bounded by the number of accounts.
Tickets:
| Trigger | When | Effect |
|---|---|---|
bot.did.createBrowserSignIn, with the account's agent token |
the e-stop is clear | the account's previous ticket is removed; a new one is stored for 60 seconds; answers {url, expiresAt} |
| the same | a Pause or a Revoke stands | 503 Halted; nothing is stored |
GET /oauth/browser?ticket=… |
the ticket is live; the host is the issuer's; Sec-Fetch-Site is absent, none or same-origin; the e-stop is clear |
the ticket is removed; the account signs in to this browser (below); 303 to /oauth/browser |
| the same | the ticket is unknown, used or expired | a page saying the link is not live; nothing changes |
| the same | another host, a Sec-Fetch-Site of cross-site or same-site, or the e-stop is engaged |
refused; the ticket is left as it was |
| any write to the store | a ticket's expires_at has passed |
the ticket is removed |
Sessions:
| Trigger | When | Effect |
|---|---|---|
| a ticket is redeemed | the browser presents a live session, or a replaced id inside its grace | the account joins that session with a deadline of now plus the lifetime, and leaves any other session; the session gets a new id; the old id is kept for its grace; the cookie is set to the new id |
| the same | the browser presents no live session | a new session holds the account; the cookie is set |
| a request presents a replaced id | inside its grace | it is served as the current session, and the cookie is reset to the current id |
| any read or write | an account's deadline has passed | the account leaves its session; a session with no account left is removed |
| any read or write | a replaced id's grace has passed | the replaced id is removed |
| the account is deleted or erased | always | it leaves its session and its ticket is removed (Registry::set_account_revoke_hook) |
| the e-stop's Revoke is thrown | always | every session, replaced id and ticket is removed; a release restores none (Estop::set_revoke_hook) |
| a Pause is thrown, or the account is frozen or locked | always | nothing changes, as a Pause, a freeze and a lock leave every credential |
| a code is released | always | nothing changes: use does not move a deadline |
Loads of GET /oauth/authorize, changing #1070's oauth::authorize::answer:
| The record | This browser | Answer |
|---|---|---|
| pending | any | the page, reloading; data-browser says whether this browser holds the account |
| approved, a redirect held | its session holds the account, and Sec-Fetch-Dest is absent or document |
303 to the redirect; the redirect is removed |
| approved, a redirect held | any other | the page, approved, saying this browser is not signed in as the account and where to sign it in; it reloads until the request expires; logged at info with the account |
| approved, nothing held | any | the page, approved, with no code |
| declined, expired or refused | any | 303 with access_denied, as #1070 has it |
The daemon and the plugin:
| Trigger | Effect |
|---|---|
| the daemon tells a context its identity | the answer names the sign-in page |
a PreToolUse report says a recognised browser tool is about to open the sign-in page |
the daemon mints a ticket as that context's account and answers with its URL; the plugin writes it into the tool's input; neither keeps it |
| minting fails, or no daemon answers | the input is left alone, and the page says the browser is not signed in |
| a tool's output carries the cookie's value | the plugin removes it (updatedToolOutput) |
What it leaves open #
- A client the model approves gets its code. That is consent, and which clients may be approved is app-allowlist's.
- A browser tool that shows request headers can show the model the cookie where the plugin misses it. A model talked into posting it gives the stranger the session until the account signs in again or the deadline passes.
- Anything running as the agent host's user reaches the socket and can have a ticket minted. That is the ceiling agentd states: "The design defends a credential from leaving the machine, and not from being misused on it."
- Contexts that share a browser share what it collects.
What not to build #
- A cookie on an agent's host, or one with a
Domain. - A code for the first browser that loads the page. The stranger's loads first.
- Signing a browser in by having the account approve a code the browser shows. A stranger can plant that code as easily as an authorize URL.
- A form or a
POSTon either page. - A deadline that use extends.
Tests #
Over the real router, with a cookie jar standing in for a browser:
- The planted sign-in. #166's four steps: a web client's push naming the
account, a browser with no cookie on its page, and the account's approval.
Every load until expiry answers the page, never a
303carryingcode, and the load after expiry answersaccess_denied. - The account's browser. The account mints a ticket and a browser redeems
it. That browser's next load after the approval gets the
303withcode,stateandiss, once. jacquard-oauth's web client (oauth_standard_client.rs'sa_web_client_signs_in_by_handle) signs its browser in first and still completes. - Its own account only. A browser signed in as another account gets no code.
- The ticket. Used twice, used after 60 seconds, used on an agent's host,
used with
Sec-Fetch-Site: cross-site, or replaced by a newer one: each is refused and sets no cookie. - The cookie. Exactly
__Host-didbot_browser=…; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=…with noDomain, and a new id each time an account joins. The old id works only inside its grace. - Several accounts, one browser each. Two accounts in one browser each collect their own code. Signing one in to a second browser takes it out of the first, and the other stays.
- Every end holds. Past the deadline, after the account is deleted or
erased, and after a Revoke, the browser collects nothing, and a release
restores nothing. Under a Pause, minting and signing in answer
503 Halted, a signed-in browser still collects, andPOST /oauth/tokenrefuses the exchange. - Use does not extend. A browser that collects often still loses the account at its deadline.
- Only a top-level navigation collects. A load with the cookie and
Sec-Fetch-Dest: emptyoriframegets no code. - The loopback path. A loopback sign-in with its code in the query is delivered by the daemon, and the page never redirects for it, with or without a cookie.
- Nothing else reads the cookie. A repository write, the four
bot.didsign-in routes and the dashboard's API answer the cookie alone as they answer no credential.security_headers.rs'sa_blob_is_served_sandboxedstays, and both pages answer withdefault-src 'none'. - The lifetime.
[oauth] browser_session_ttl_secsreaches the store.
In didbot-agentd: a PreToolUse report about the sign-in page is answered
with a ticket minted as that report's context's account and never another's,
and a report about anything else mints nothing.
In didbot-claude's plugin/bin/hook.py: only a recognised browser tool's
navigation to the exact sign-in page is rewritten, the ticket never reaches
additionalContext, and the cookie's value is removed from tool output.
Decisions for the owner #
Work #
If the owner picks option two:
Done #
Nothing closed yet.