From eeecb48c8d8cbc2cc0323a369a143016a352e73f Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Tue, 1 Sep 2026 13:13:54 -0400 Subject: [PATCH] test(oauth): prove the agent-driven flow and the consent seam over the real router crates/didbot-serve/tests/oauth_agent_flow.rs exercises plan/oauth.md's "agent completes the flow itself" and "confirm after the page" items against the real HTTP router rather than each module's own unit-level fakes: it pushes a login_hint-bearing request, drives GET /oauth/authorize through tower::ServiceExt::oneshot, and reads the consent reference off the response body the way a headless browser would -- no browser, no network. A stand-in for the harness's PreToolUse rewrite then drives oauth::token::confirm_and_issue_code through to a real POST /oauth/token exchange, and separately proves each of the three consent checks (replay, identity mismatch, policy refusal) issues no code. plan/oauth.md is updated to point at this coverage; neither checklist item is ticked, since the actual headless-browser client and the PreToolUse rewrite remain out of this crate's declared scope. Co-Authored-By: Claude Opus 5 (1M context) Change-Id: I030ae0bc2281ff8c29c2bf0277dc18b2edcdadb0 --- crates/didbot-serve/tests/oauth_agent_flow.rs | 468 ++++++++++++++++++ plan/oauth.md | 20 +- 2 files changed, 487 insertions(+), 1 deletion(-) create mode 100644 crates/didbot-serve/tests/oauth_agent_flow.rs diff --git a/crates/didbot-serve/tests/oauth_agent_flow.rs b/crates/didbot-serve/tests/oauth_agent_flow.rs new file mode 100644 index 00000000..dd29f1a7 --- /dev/null +++ b/crates/didbot-serve/tests/oauth_agent_flow.rs @@ -0,0 +1,468 @@ +//! `plan/oauth.md`'s "the agent completes the flow itself" and "confirm +//! after the page, through an identity-aware call", exercised end to end. +//! +//! `crate::oauth::authorize`, `crate::oauth::consent` and +//! `crate::oauth::token` each already carry their own unit tests against +//! pure functions and fakes. What none of them prove is that the real HTTP +//! router -- the thing an agent's headless browser actually talks to -- +//! resolves a `login_hint`, renders the placeholder consent page, and lets a +//! reference read out of that page's body be redeemed for a working access +//! token. That is this file's job, and it is deliberately not a browser +//! test: `tower::ServiceExt::oneshot` hands requests to the router directly +//! (the same substitution `didbot/tests/end_to_end.rs` makes, for the same +//! reason), and the "headless browser" is nothing more than reading +//! `data-consent-reference` out of the response body, because that is all a +//! real one would need to do -- this page carries no script and asks for no +//! interaction beyond what `login_hint` already settled at PAR time. +//! +//! The "identity-aware call" itself -- the `PreToolUse`-stamped tool call a +//! harness would drive `oauth::consent::confirm` through -- is not built by +//! this crate; see `oauth::consent`'s own module doc for exactly why. This +//! file stands in for it the same way that doc describes: calling +//! `oauth::token::confirm_and_issue_code` directly with a `stamped_did` +//! the test supplies, never anything the HTTP layer read out of a request. +//! That is the property the seam exists to guarantee -- a caller cannot name +//! its own acting identity -- and every failure case below confirms the +//! corresponding check refuses *before* a code, and therefore a token, is +//! ever issued. `code_store.issue` sits after every `?` in +//! `confirm_and_issue_code`'s body (see `oauth::token`), the same +//! structural guarantee `didbot-claim::orchestrate::claim` uses for its own +//! writer: there is no path from a failed check to an issued code. + +use std::sync::Arc; + +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine; +use sha2::{Digest, Sha256}; +use tower::ServiceExt; + +use didbot_attest::{AttestationClaim, SharedSecretBackend}; +use didbot_dns::LoopbackDns; +use didbot_identity::Zone; +use didbot_pds::{MemoryAccountStore, ProvisionRequest, Provisioner, Registry}; +use didbot_serve::oauth::authorize::ScopePolicy; +use didbot_serve::oauth::client_metadata::{ + ClientKey, ClientMetadataDocument, MemoryClientMetadataCache, +}; +use didbot_serve::oauth::consent::{ + ConsentError, ConsentPolicy, ConsentReference, MemoryConsentStore, +}; +use didbot_serve::oauth::dpop_seam::{DpopError, DpopThumbprint, DpopVerifier}; +use didbot_serve::oauth::par::{AppAdmission, MemoryParStore, ParStore, PushedRequest}; +use didbot_serve::oauth::scope::ScopeSet; +use didbot_serve::oauth::token::{ + confirm_and_issue_code, MemoryAuthorizationCodeStore, OAuthTokenStore, +}; +use didbot_serve::oauth::OAuthState; +use didbot_serve::rate_limit::RateLimiter; +use didbot_serve::{app_with_auth, AuthState, BroadcastSink, Firehose, HealthState}; + +const SECRET: &[u8] = b"oauth-agent-flow-test-secret"; +const NODE: &str = "test-node"; +const ZONE: &str = "agents.localhost"; +const CLIENT_ID: &str = "https://client.example/id.json"; +const REDIRECT_URI: &str = "https://client.example/cb"; + +/// A [`ScopePolicy`] that grants exactly what it is asked for -- the +/// permissive stand-in `plan/scope-policy.md` will eventually replace. +struct AllowRequestedScope; +impl ScopePolicy for AllowRequestedScope { + fn ceiling(&self, _did: &str, _client_id: &str) -> ScopeSet { + ScopeSet::parse("atproto transition:generic").unwrap() + } +} + +/// A [`ConsentPolicy`] that always allows -- `plan/app-allowlist.md` and +/// `plan/scope-policy.md`'s own second check are a different epic. +struct AllowAllConsent; +impl ConsentPolicy for AllowAllConsent { + fn allow(&self, _did: &str, _client_id: &str) -> Result<(), String> { + Ok(()) + } +} + +/// A [`ConsentPolicy`] that always refuses, to exercise check 3 in +/// isolation from checks 1 and 2. +struct RefuseConsent; +impl ConsentPolicy for RefuseConsent { + fn allow(&self, _did: &str, _client_id: &str) -> Result<(), String> { + Err("policy says no".to_owned()) + } +} + +/// Admits every client. This file pushes requests directly into +/// [`MemoryParStore`] rather than through `POST /oauth/par`, so this hook is +/// never actually consulted -- `plan/app-allowlist.md` is a different epic +/// and fetching client metadata over the network is not this file's concern +/// -- but every builder needs one wired in regardless. +struct AllowAllAdmission; +impl AppAdmission for AllowAllAdmission { + fn check( + &self, + _client_id: &str, + _key: &ClientKey, + _document: &ClientMetadataDocument, + ) -> Result<(), String> { + Ok(()) + } +} + +/// A [`DpopVerifier`] fixed to one thumbprint regardless of what it is +/// handed. RFC 9449 verification is `oauth::dpop`'s own, already-tested +/// concern; this file is about the consent seam and the token endpoint +/// behind it, not about re-proving DPoP. +struct FixedDpop; +impl DpopVerifier for FixedDpop { + fn verify(&self, _proof: &str, _method: &str, _uri: &str) -> Result { + Ok(DpopThumbprint("fixed-thumbprint".to_owned())) + } +} + +/// A router with a real account already provisioned, plus direct handles to +/// the stores its `OAuthState` was built with -- so a test can seed a +/// pushed request and reach the consent seam without a real +/// `POST /oauth/par` round trip or a real `PreToolUse` rewrite, neither of +/// which exists in this repository (see this file's own module doc). +struct Fixture { + app: axum::Router, + par_store: Arc, + consent_store: Arc, + consent_policy: Arc, + code_store: Arc, + agent_did: String, +} + +fn build(consent_policy: Arc) -> Fixture { + let zone = Zone::new(ZONE).expect("valid zone"); + let attest = SharedSecretBackend::new(SECRET.to_vec(), [NODE]); + let claim: AttestationClaim = attest + .produce_claim(NODE, "nonce-1", time::OffsetDateTime::now_utc()) + .expect("the backend accepts its own claim"); + let provisioner = Provisioner::new( + "did:web:owner.example", + zone, + format!("http://{ZONE}"), + attest, + LoopbackDns::new(), + MemoryAccountStore::new(), + ); + let provisioned = provisioner + .provision(ProvisionRequest::new("agent-one", None, claim)) + .expect("provisioning a fresh agent succeeds"); + let agent_did = provisioned.account.did.as_str().to_owned(); + let registry: Arc = Arc::new(provisioner); + + let par_store = Arc::new(MemoryParStore::new()); + let consent_store = Arc::new(MemoryConsentStore::new()); + let code_store = Arc::new(MemoryAuthorizationCodeStore::new()); + let oauth = OAuthState { + http: reqwest::Client::new(), + client_cache: Arc::new(MemoryClientMetadataCache::new()), + par_store: par_store.clone(), + admission: Arc::new(AllowAllAdmission), + scope_policy: Arc::new(AllowRequestedScope), + consent_store: consent_store.clone(), + consent_policy: consent_policy.clone(), + code_store: code_store.clone(), + tokens: Arc::new(OAuthTokenStore::new()), + dpop: Arc::new(FixedDpop), + authorize_rate_limiter: Arc::new(RateLimiter::default()), + }; + let auth = AuthState { + oauth, + ..AuthState::default() + }; + let app = app_with_auth( + registry, + BroadcastSink::default(), + Firehose::default(), + auth, + HealthState::new(), + ); + + Fixture { + app, + par_store, + consent_store, + consent_policy, + code_store, + agent_did, + } +} + +fn client_document() -> ClientMetadataDocument { + ClientMetadataDocument { + client_id: CLIENT_ID.to_owned(), + client_name: None, + client_uri: None, + logo_uri: None, + redirect_uris: vec![REDIRECT_URI.to_owned()], + grant_types: vec!["authorization_code".to_owned()], + response_types: vec!["code".to_owned()], + token_endpoint_auth_method: "none".to_owned(), + scope: None, + dpop_bound_access_tokens: true, + } +} + +fn code_verifier_and_challenge() -> (String, String) { + let verifier = "a-fixed-code-verifier-at-least-43-characters-long".to_owned(); + let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes())); + (verifier, challenge) +} + +/// Pushes a pending request naming `fixture.agent_did` as `login_hint` -- +/// the normal atproto OAuth case, where the client already knows which +/// account it wants to sign in as and `authorize` needs no interactive +/// form to resolve one. +fn push(fixture: &Fixture, code_challenge: &str) -> String { + fixture.par_store.push(PushedRequest { + client_id: CLIENT_ID.to_owned(), + client_key: ClientKey::compute(&client_document()), + client_metadata: client_document(), + redirect_uri: REDIRECT_URI.to_owned(), + scope: ScopeSet::parse("atproto transition:generic").unwrap(), + state: None, + code_challenge: code_challenge.to_owned(), + code_challenge_method: "S256".to_owned(), + login_hint: Some(fixture.agent_did.clone()), + expires_at: time::OffsetDateTime::now_utc() + time::Duration::minutes(2), + }) +} + +async fn get(app: &axum::Router, path: &str) -> (StatusCode, String) { + let response = app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri(path) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + (status, String::from_utf8(body.to_vec()).unwrap()) +} + +/// The "headless browser" for this test: it does not render anything, it +/// reads the one attribute a real agent-driven one would need out of the +/// page `GET /oauth/authorize` serves. +fn read_consent_reference(page: &str) -> String { + let marker = "data-consent-reference=\""; + let start = page + .find(marker) + .expect("the placeholder page carries a consent reference") + + marker.len(); + let end = page[start..].find('"').expect("the attribute is closed"); + page[start..start + end].to_owned() +} + +/// Minimal `application/x-www-form-urlencoded` encoding for this file's own +/// fixed set of ASCII field values -- not a general-purpose encoder, just +/// enough to keep this test crate from taking on a new dependency for one +/// call site. +fn form_encode(value: &str) -> String { + let mut out = String::new(); + for byte in value.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(byte as char) + } + _ => out.push_str(&format!("%{byte:02X}")), + } + } + out +} + +fn form_body(fields: &[(&str, &str)]) -> String { + fields + .iter() + .map(|(key, value)| format!("{}={}", form_encode(key), form_encode(value))) + .collect::>() + .join("&") +} + +async fn post_token(app: &axum::Router, form: &[(&str, &str)]) -> (StatusCode, serde_json::Value) { + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/oauth/token") + .header("content-type", "application/x-www-form-urlencoded") + .header("dpop", "any-proof-string") + .body(Body::from(form_body(form))) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let json = serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null); + (status, json) +} + +/// An agent's headless browser drives `GET /oauth/authorize`, reads the +/// reference off the page, and a stand-in for the stamped confirming call +/// redeems it for tokens that work at `POST /oauth/token` -- `plan/oauth.md`'s +/// exit criterion, minus the third-party client and the real harness. +#[tokio::test] +async fn the_agent_completes_the_flow_and_the_stamped_confirmation_issues_a_working_token() { + let fixture = build(Arc::new(AllowAllConsent)); + let (verifier, challenge) = code_verifier_and_challenge(); + let request_uri = push(&fixture, &challenge); + + let (status, page) = get( + &fixture.app, + &format!("/oauth/authorize?client_id={CLIENT_ID}&request_uri={request_uri}"), + ) + .await; + assert_eq!(status, StatusCode::OK); + let reference = read_consent_reference(&page); + + let code = confirm_and_issue_code( + fixture.consent_store.as_ref(), + fixture.consent_policy.as_ref(), + fixture.code_store.as_ref(), + &ConsentReference(reference), + &fixture.agent_did, + ) + .expect("a live reference, matching identity and an allow-all policy confirms"); + + let (status, json) = post_token( + &fixture.app, + &[ + ("grant_type", "authorization_code"), + ("code", &code), + ("redirect_uri", REDIRECT_URI), + ("code_verifier", &verifier), + ("client_id", CLIENT_ID), + ], + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(json["sub"], fixture.agent_did); + assert!(json["access_token"].as_str().is_some()); +} + +/// Check 1: a reference already spent is refused on a second attempt, and +/// no second code is ever handed back for it to be redeemed with. +#[tokio::test] +async fn a_replayed_reference_issues_no_second_code() { + let fixture = build(Arc::new(AllowAllConsent)); + let (_, challenge) = code_verifier_and_challenge(); + let request_uri = push(&fixture, &challenge); + let (_, page) = get( + &fixture.app, + &format!("/oauth/authorize?client_id={CLIENT_ID}&request_uri={request_uri}"), + ) + .await; + let reference = ConsentReference(read_consent_reference(&page)); + + assert!(confirm_and_issue_code( + fixture.consent_store.as_ref(), + fixture.consent_policy.as_ref(), + fixture.code_store.as_ref(), + &reference, + &fixture.agent_did, + ) + .is_ok()); + + let replay = confirm_and_issue_code( + fixture.consent_store.as_ref(), + fixture.consent_policy.as_ref(), + fixture.code_store.as_ref(), + &reference, + &fixture.agent_did, + ); + assert!(matches!(replay, Err(ConsentError::NotLive(_)))); +} + +/// Check 2: nothing about the wire shape lets a caller name its own acting +/// identity. The page and the reference it carries say nothing about which +/// DID confirms it -- only the stamp does -- so a stamp naming any DID +/// other than the one `login_hint` resolved to is refused, no matter how +/// plausible-looking that other DID is. +#[tokio::test] +async fn a_stamped_identity_that_the_request_did_not_name_issues_no_code() { + let fixture = build(Arc::new(AllowAllConsent)); + let (_, challenge) = code_verifier_and_challenge(); + let request_uri = push(&fixture, &challenge); + let (_, page) = get( + &fixture.app, + &format!("/oauth/authorize?client_id={CLIENT_ID}&request_uri={request_uri}"), + ) + .await; + let reference = ConsentReference(read_consent_reference(&page)); + + let result = confirm_and_issue_code( + fixture.consent_store.as_ref(), + fixture.consent_policy.as_ref(), + fixture.code_store.as_ref(), + &reference, + "did:web:some-other-agent.example", + ); + assert!(matches!(result, Err(ConsentError::IdentityMismatch))); +} + +/// Check 3: even a live reference with a correctly stamped identity is +/// refused when policy says no, and issues no code. +#[tokio::test] +async fn a_policy_refusal_issues_no_code_even_with_a_live_matching_reference() { + let fixture = build(Arc::new(RefuseConsent)); + let (_, challenge) = code_verifier_and_challenge(); + let request_uri = push(&fixture, &challenge); + let (_, page) = get( + &fixture.app, + &format!("/oauth/authorize?client_id={CLIENT_ID}&request_uri={request_uri}"), + ) + .await; + let reference = ConsentReference(read_consent_reference(&page)); + + let result = confirm_and_issue_code( + fixture.consent_store.as_ref(), + fixture.consent_policy.as_ref(), + fixture.code_store.as_ref(), + &reference, + &fixture.agent_did, + ); + assert!(matches!(result, Err(ConsentError::PolicyRefused(_)))); +} + +/// An unresolvable `login_hint` -- no account on this deployment answers to +/// it -- refuses at `authorize` itself, before any reference is minted at +/// all. There is nothing for a headless browser to read off this page. +#[tokio::test] +async fn an_unresolvable_login_hint_refuses_before_any_reference_is_minted() { + let fixture = build(Arc::new(AllowAllConsent)); + let (_, challenge) = code_verifier_and_challenge(); + let request_uri = fixture.par_store.push(PushedRequest { + client_id: CLIENT_ID.to_owned(), + client_key: ClientKey::compute(&client_document()), + client_metadata: client_document(), + redirect_uri: REDIRECT_URI.to_owned(), + scope: ScopeSet::parse("atproto transition:generic").unwrap(), + state: None, + code_challenge: challenge, + code_challenge_method: "S256".to_owned(), + login_hint: Some("did:web:nobody-this-deployment-hosts.example".to_owned()), + expires_at: time::OffsetDateTime::now_utc() + time::Duration::minutes(2), + }); + + let (status, page) = get( + &fixture.app, + &format!("/oauth/authorize?client_id={CLIENT_ID}&request_uri={request_uri}"), + ) + .await; + assert_ne!(status, StatusCode::OK); + assert!(!page.contains("data-consent-reference")); +} diff --git a/plan/oauth.md b/plan/oauth.md index 22f54ff0..f1ec7038 100644 --- a/plan/oauth.md +++ b/plan/oauth.md @@ -24,6 +24,19 @@ Without them an authorization server can only approve everything. - [ ] **The agent completes the flow itself.** The profile has no device grant and no client credentials grant: every flow assumes a browser. An agent drives a headless one, types its handle, and reads our page in the popup. + + This crate's side of that is built: `login_hint` is accepted at PAR and + resolved to an account at `authorize` (`crate::routes::resolve_identifier`), + and `GET /oauth/authorize` renders the placeholder page a headless + browser reads the consent reference off of. Proved end to end, with no + browser and no network, by `crates/didbot-serve/tests/oauth_agent_flow.rs`: + it pushes a request naming an already-provisioned agent as `login_hint`, + drives the real router's `GET /oauth/authorize` through + `tower::ServiceExt::oneshot`, and reads `data-consent-reference` out of + the response body exactly as a real headless client would. Still open, + and out of this crate's scope (`crates: [didbot-serve, didbot-pds]`): an + actual headless-browser client and whatever client-side UI an agent + types its handle into — neither belongs to the authorization server. - [ ] **Confirm after the page, through an identity-aware call.** The agent holds no key. It passes the page's one-time reference back through a tool call, and the `PreToolUse` rewrite stamps the acting DID. Three @@ -33,7 +46,12 @@ Without them an authorization server can only approve everything. The seam these three checks attach to is built: `oauth::consent::confirm` and `oauth::token::confirm_and_issue_code`, which adds authorization-code - issuance on top. Still open: the tool call itself and the + issuance on top — each check has its own unit test in `oauth::consent`, + and `oauth_agent_flow.rs` (above) drives all three to a refusal over the + real router too (replay, identity mismatch, policy refusal), confirming + no code is ever issued on any of them, and that a live, correctly + confirmed reference redeems for a real access token at `POST + /oauth/token`. Still open: the tool call itself and the `PreToolUse` rewrite that stamps it, which are the harness's to build, not this crate's — see `oauth::consent`'s module doc for exactly where a future caller plugs in the stamped DID. -- 2.51.2