Skip to main content

didbot_serve/oauth/
authorize.rs

1//! `GET /oauth/authorize`, the authorization response, and the ceiling.
2//!
3//! **Nothing is decided here.** This module reads a pushed authorization
4//! request and the decision [`crate::oauth::par`] already minted for it. A
5//! policy edited between the push and this call does not reach back into
6//! that record — it applies to the next push, which is
7//! `plan/app-allowlist.md`'s "de-admission is a rule change, not session
8//! invalidation".
9//!
10//! # The page waits for the account
11//!
12//! A browser that loads the page before the account has answered gets a page
13//! that reloads itself every [`RELOAD_INTERVAL`]. Once the account approves a
14//! sign-in whose code a browser carries ([`browser_carries`]), the page
15//! hands the code to a browser signed in as the account
16//! (`crate::oauth::browser`), on a top-level navigation, once. Any other
17//! browser keeps getting the page until the request expires. A decline, a
18//! verdict that grants nothing, or an expiry sends the browser back to the
19//! client with `access_denied`. See [`answer`] for what each load gets.
20//!
21//! A browser signed in as the account can also confirm a sign-in the
22//! account's remembered approval covers, without asking the account
23//! ([`confirms_unasked`]). The route approves it as
24//! `bot.did.approveAuthorization` would before [`answer`] reads the record.
25//!
26//! # The ceiling is not policy
27//!
28//! [`ScopePolicy`] is the one narrowing mechanism left beside the tree, and
29//! calling it a policy overstates it. It is the **deployment's** statement of
30//! the most any agent here may ever hold, applied by intersection with what
31//! the client asked for. It is applied at PAR alongside the tree's denials,
32//! and again at every token issue and every write. It cannot grant: [`ConfiguredCeiling::new`] keeps only the atoms
33//! [`GrantAnyScope`]'s compiled-in ceiling already admits, so the widest a
34//! configured ceiling can be is the compiled-in one and every step from
35//! there subtracts. It is also blunt — one statement for every agent and
36//! every client, because it is keyed on neither.
37//!
38//! Retiring it in favour of a rule in the tree has a precondition:
39//! `didbot_policy::Subject::Grant` carries a `client_id` and the requested
40//! scopes, and no account. A per-account ceiling is not expressible as a
41//! policy until it does. See `plan/scope-policy.md`.
42
43use time::OffsetDateTime;
44
45use crate::oauth::decision::{DecisionRecord, DecisionState, DecisionStore, Remembered, Verdict};
46use crate::oauth::par::{ParStore, PushedRequest};
47use crate::oauth::scope::ScopeSet;
48
49/// The ceiling. PAR applies it to write the verdict an agent reads, and
50/// `POST /oauth/token` and every write apply it again to the login's whole
51/// request ([`crate::oauth::token::in_force`]). An approval covers that
52/// request, so what a login may do moves with the ceiling.
53pub trait ScopePolicy: Send + Sync {
54    /// The most this `did` may ever hold from `client_id`. A ceiling only
55    /// ever shrinks from [`GrantAnyScope`]'s; an empty [`ScopeSet`] refuses
56    /// every non-empty request. Both arguments are ignored by every
57    /// implementation here — see the module doc.
58    fn ceiling(&self, did: &str, client_id: &str) -> ScopeSet;
59}
60
61/// The compiled-in ceiling, and the widest one this binary has: every
62/// implementation of [`ScopePolicy`] is a subset of it.
63///
64/// It is written with wildcards rather than computed, so what it admits is
65/// legible: every collection, every method, and the base scope.
66/// `transition:*`, `account` and `identity` are outside it — the
67/// compiled-in denial `plan/scope-policy.md`'s hard-blocked list asks for.
68#[derive(Debug, Clone, Copy, Default)]
69pub struct GrantAnyScope;
70
71/// What [`GrantAnyScope`] admits.
72const EVERY_SCOPE: &str = "atproto repo:* rpc:* blob:*/*";
73
74impl ScopePolicy for GrantAnyScope {
75    fn ceiling(&self, _did: &str, _client_id: &str) -> ScopeSet {
76        ScopeSet::parse(EVERY_SCOPE).expect("a constant this crate's own parser accepts")
77    }
78}
79
80/// The ceiling an operator wrote down, applied to every agent and every
81/// client alike.
82///
83/// `plan/scope-policy.md`'s "from server configuration first": an
84/// enumeration of scope atoms, not a pattern, read out of
85/// `didbot_config::sections::OAuthSection::scope_ceiling`. Per-agent and
86/// per-client profiles are the operator's repository's to hold, and this
87/// ignores both arguments until that exists.
88///
89/// A seam, not a destination. `plan/policy-store.md`'s ceiling — read from
90/// policy records in the operator's repository, per agent and per client —
91/// implements this same [`ScopePolicy`] trait and replaces this
92/// implementation whole when it lands; nothing that calls a ceiling has to
93/// change for it. What it needs first is an account on
94/// `didbot_policy::Subject::Grant`, which is what a per-account rule would
95/// have to match on.
96///
97/// **It can only narrow.** [`ConfiguredCeiling::new`] intersects what it was
98/// given with [`GrantAnyScope`]'s own ceiling and keeps the overlap, so a
99/// configured atom outside the compiled-in ceiling — anything in
100/// `plan/scope-policy.md`'s hard-blocked set, or a `kind:` a later grammar
101/// adds — grants nothing however it is spelled in the file. That is what
102/// lets a scope live in a configuration file at all; see
103/// `didbot_config`'s own crate doc.
104#[derive(Debug, Clone)]
105pub struct ConfiguredCeiling(ScopeSet);
106
107/// What [`ConfiguredCeiling::new`] did not keep, and why — for the startup
108/// line that reports it, since the two reasons ask different things of an
109/// operator.
110#[derive(Debug, Default, Clone, PartialEq, Eq)]
111pub struct DroppedAtoms {
112    /// Atoms the compiled-in ceiling does not admit whole, and atoms that
113    /// did not parse. Rewriting them differently changes nothing: this build
114    /// grants none of them however they are spelled.
115    pub outside: Vec<String>,
116    /// Atoms past [`crate::oauth::scope::MAX_SCOPE_ATOMS`]. The ceiling is
117    /// the ones before them, so a shorter enumeration is a different
118    /// ceiling.
119    pub over_bound: Vec<String>,
120}
121
122impl DroppedAtoms {
123    /// Whether every configured atom was kept.
124    pub fn is_empty(&self) -> bool {
125        self.outside.is_empty() && self.over_bound.is_empty()
126    }
127}
128
129impl ConfiguredCeiling {
130    /// Builds a ceiling from the atoms an operator enumerated, dropping
131    /// every one the compiled-in ceiling does not already admit.
132    ///
133    /// An atom is kept only if the compiled-in ceiling admits it **whole**.
134    /// A partial admission is dropped rather than kept at its projection:
135    /// `transition:generic` overlaps `repo:* rpc:*`, and an operator who
136    /// wrote a hard-blocked atom should get nothing for it rather than the
137    /// slice of it that happens to fall inside. An atom that does not parse
138    /// is dropped for the same reason — this runs at startup, and a ceiling
139    /// that fails closed on the atoms it understood is the safe reading of a
140    /// file it only half understands. The dropped atoms are returned so the
141    /// caller can log them.
142    ///
143    /// **Bounded at [`crate::oauth::scope::MAX_SCOPE_ATOMS`].**
144    /// [`ScopeSet::new`] compares every pair of atoms it is handed, and that
145    /// constant is what bounds the comparison — which it does for
146    /// [`ScopeSet::parse`], applied per string, but each configured atom is
147    /// parsed on its own here and the results accumulate. Without this a
148    /// configuration file naming a few thousand atoms is a few million
149    /// comparisons at boot.
150    pub fn new<'a>(atoms: impl IntoIterator<Item = &'a str>) -> (Self, DroppedAtoms) {
151        let compiled = GrantAnyScope.ceiling("", "");
152        let mut kept: Vec<crate::oauth::scope::Scope> = Vec::new();
153        let mut dropped = DroppedAtoms::default();
154        for atom in atoms {
155            let Ok(parsed) = ScopeSet::parse(atom) else {
156                dropped.outside.push(atom.to_owned());
157                continue;
158            };
159            let (granted, cut) = parsed.narrow(&compiled);
160            if !cut.0.is_empty() || granted.0.is_empty() {
161                dropped.outside.push(atom.to_owned());
162                continue;
163            }
164            if kept.len() + granted.0.len() > crate::oauth::scope::MAX_SCOPE_ATOMS {
165                dropped.over_bound.push(atom.to_owned());
166                continue;
167            }
168            kept.extend(granted.0);
169        }
170        (Self(ScopeSet::new(kept)), dropped)
171    }
172
173    /// What this ceiling admits, for a caller that wants to log or show it.
174    pub fn scopes(&self) -> &ScopeSet {
175        &self.0
176    }
177}
178
179impl ScopePolicy for ConfiguredCeiling {
180    fn ceiling(&self, _did: &str, _client_id: &str) -> ScopeSet {
181        self.0.clone()
182    }
183}
184
185/// How often the page reloads itself while the account has not answered.
186///
187/// Each address may load `GET /oauth/authorize`
188/// [`crate::rate_limit::DEFAULT_LIMIT`] times a
189/// [`crate::rate_limit::WINDOW`]. Every five seconds is twelve loads a
190/// minute, so a second tab waiting on the same address still fits.
191pub const RELOAD_INTERVAL: std::time::Duration = std::time::Duration::from_secs(5);
192
193// A code is minted after its request was pushed, so while this holds the
194// request always expires first, and a redirect the page hands over never
195// carries a code that has expired.
196const _: () = assert!(
197    crate::oauth::par::REQUEST_URI_TTL.whole_seconds()
198        <= crate::oauth::token::AUTHORIZATION_CODE_TTL.whole_seconds()
199);
200
201/// Where an authorization response carries its parameters.
202#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
203pub enum ResponseMode {
204    /// In the redirect's query. A push that names no mode gets this.
205    #[default]
206    Query,
207    /// In the redirect's fragment, which a browser keeps to itself.
208    Fragment,
209}
210
211impl ResponseMode {
212    /// Every mode a push may name.
213    pub const ALL: [Self; 2] = [Self::Query, Self::Fragment];
214
215    /// The wire word for this mode.
216    pub fn as_str(self) -> &'static str {
217        match self {
218            Self::Query => "query",
219            Self::Fragment => "fragment",
220        }
221    }
222
223    /// The mode `raw` names, if it is one this server answers in.
224    pub fn named(raw: &str) -> Option<Self> {
225        Self::ALL.into_iter().find(|mode| mode.as_str() == raw)
226    }
227}
228
229/// The client's redirect, carrying an authorization response: `result` (a
230/// code, or an error), then `state` if the client sent one, then `iss`, in
231/// the query or the fragment as `mode` says.
232///
233/// `iss` is RFC 9207's: this server's issuer, which a client compares with
234/// the authorization server it pushed the request to. The metadata's
235/// `authorization_response_iss_parameter_supported` promises it, and a
236/// client that reads that refuses a response without it.
237///
238/// Parsed rather than concatenated: `state` is whatever the client sent.
239pub fn authorization_response(
240    redirect_uri: &str,
241    mode: ResponseMode,
242    result: &[(&str, &str)],
243    state: Option<&str>,
244    issuer: &str,
245) -> Result<url::Url, url::ParseError> {
246    let mut redirect = url::Url::parse(redirect_uri)?;
247    let pairs = result
248        .iter()
249        .copied()
250        .chain(state.map(|state| ("state", state)))
251        .chain([("iss", issuer)]);
252    match mode {
253        ResponseMode::Query => {
254            redirect.query_pairs_mut().extend_pairs(pairs);
255        }
256        ResponseMode::Fragment => {
257            let fragment = url::form_urlencoded::Serializer::new(String::new())
258                .extend_pairs(pairs)
259                .finish();
260            redirect.set_fragment(Some(&fragment));
261        }
262    }
263    Ok(redirect)
264}
265
266/// Whether a browser carries a sign-in's code to its client: every redirect
267/// but a loopback one taking its code in the query.
268///
269/// That one goes to a listener on the host that approved the sign-in, and
270/// what approved it fetches it there, as `didbot_agentd::loopback::deliver`
271/// does. An approval holds any other for the authorize page to hand to a
272/// browser signed in as the account.
273pub fn browser_carries(redirect_uri: &str, mode: ResponseMode) -> bool {
274    mode == ResponseMode::Fragment
275        || url::Url::parse(redirect_uri).map_or(true, |url| !didbot_http::is_loopback(&url))
276}
277
278/// Whether this load confirms the sign-in without asking the account, on
279/// the approval it `remembered`. See `plan/remembered-consent.md`.
280///
281/// Every condition must hold. The client is confidential, so only the holder
282/// of its key pushed this request, which is the spec's rule for silent
283/// sign-in. A browser carries its code, and this load is a top-level
284/// navigation by a browser signed in as the account, so the code can reach
285/// no other browser. The record is live and grants something. And every atom
286/// it grants is covered by the grant the account approved for this
287/// [`crate::oauth::client_metadata::ClientKey`].
288///
289/// The e-stop is the caller's to check.
290pub fn confirms_unasked(
291    pushed: &PushedRequest,
292    record: &DecisionRecord,
293    remembered: Option<&Remembered>,
294    loader: &Loader,
295    now: OffsetDateTime,
296) -> bool {
297    pushed.client_metadata.authentication.is_confidential()
298        && browser_carries(&pushed.redirect_uri, pushed.response_mode)
299        && loader.navigation
300        && loader.signed_in_as(&record.account)
301        && record.is_live(now)
302        && record.token.is_some()
303        && remembered.is_some_and(|remembered| {
304            now < remembered.expires_at
305                && record
306                    .granted()
307                    .0
308                    .iter()
309                    .all(|atom| remembered.granted.covers(atom))
310        })
311}
312
313/// The browser loading the page, as far as the page's answer turns on it.
314#[derive(Debug, Clone, Default)]
315pub struct Loader {
316    /// The accounts it is signed in as; see `crate::oauth::browser`.
317    pub accounts: Vec<String>,
318    /// Whether the load is a top-level navigation: `Sec-Fetch-Dest` absent,
319    /// where a browser does not send it, or `document`.
320    pub navigation: bool,
321}
322
323impl Loader {
324    /// Whether this browser is signed in as `account`.
325    pub fn signed_in_as(&self, account: &str) -> bool {
326        self.accounts.iter().any(|held| held == account)
327    }
328}
329
330/// A decision, as the page shows it.
331///
332/// Only a decision that grants something reaches this type, so `granted`
333/// and `cut` are read off the verdict once, where the refusal case is
334/// already answered.
335#[derive(Debug, Clone)]
336pub struct AuthorizePending {
337    /// The decision itself — client origin, requested scope, verdict, and
338    /// what the verdict cut.
339    pub record: DecisionRecord,
340    /// What approving this covers.
341    pub granted: ScopeSet,
342    /// What the ceiling took out of the request. Empty when it took
343    /// nothing.
344    pub cut: ScopeSet,
345    /// What did the narrowing, when `cut` is not empty.
346    pub rule: String,
347    /// Whether the browser loading the page is signed in as the account.
348    pub signed_in: bool,
349    /// Whether the approval's code waits here for a browser signed in as the
350    /// account.
351    pub held: bool,
352}
353
354impl AuthorizePending {
355    /// The page for `record`, or `None` for a verdict that grants nothing.
356    fn of(record: DecisionRecord, signed_in: bool, held: bool) -> Option<Self> {
357        let (granted, cut, rule) = match &record.verdict {
358            Verdict::Allow => (record.requested.clone(), ScopeSet::default(), String::new()),
359            Verdict::Narrow { granted, cut, rule } => (granted.clone(), cut.clone(), rule.clone()),
360            Verdict::Deny { .. } => return None,
361        };
362        Some(Self {
363            record,
364            granted,
365            cut,
366            rule,
367            signed_in,
368            held,
369        })
370    }
371
372    /// Whether the account has yet to answer.
373    pub fn waiting(&self) -> bool {
374        self.record.state == DecisionState::Pending
375    }
376
377    /// Whether the page reloads itself: the account has yet to answer, or a
378    /// code waits for a browser signed in as the account.
379    pub fn reloads(&self) -> bool {
380        self.waiting() || self.held
381    }
382}
383
384/// What one load of `GET /oauth/authorize` gets.
385#[derive(Debug, Clone)]
386pub enum AuthorizeAnswer {
387    /// The page: the account has not answered, or it approved and the code
388    /// went elsewhere or waits for another browser.
389    Page(AuthorizePending),
390    /// The client's redirect, carrying the code an approval held for a
391    /// browser signed in as the account.
392    Redirect {
393        /// Where the browser goes.
394        to: String,
395        /// The account it is signed in as.
396        account: String,
397    },
398    /// The sign-in ended without a code.
399    Denied(Denial),
400}
401
402/// A sign-in that ended without a code, and the pushed request whose
403/// redirect says so.
404#[derive(Debug, Clone)]
405pub struct Denial {
406    pushed: PushedRequest,
407    description: &'static str,
408}
409
410impl Denial {
411    /// The client's redirect, carrying `access_denied` with a description,
412    /// the client's `state`, and `issuer`.
413    pub fn redirect(&self, issuer: &str) -> Result<url::Url, url::ParseError> {
414        authorization_response(
415            &self.pushed.redirect_uri,
416            self.pushed.response_mode,
417            &[
418                ("error", "access_denied"),
419                ("error_description", self.description),
420            ],
421            self.pushed.state.as_deref(),
422            issuer,
423        )
424    }
425}
426
427/// Why `authorize` answered with an error rather than the page or a
428/// redirect.
429#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
430pub enum AuthorizeError {
431    /// `request_uri` names nothing this server holds: unknown, displaced,
432    /// or expired and forgotten.
433    #[error("request_uri is not live: unknown or expired")]
434    UnknownRequestUri,
435    /// The `client_id` on this request does not match the one the pushed
436    /// request was made under.
437    #[error("client_id does not match the pushed authorization request")]
438    ClientMismatch,
439}
440
441/// The `error_description` for a request the account declined.
442const DECLINED: &str = "the account declined this authorization request";
443/// The `error_description` for a request that expired before its code
444/// reached the client.
445const EXPIRED: &str = "this authorization request expired";
446/// The `error_description` for a request whose verdict grants nothing.
447const REFUSED: &str = "this server refused this authorization request";
448
449/// Answers one load of the page for `request_uri`, by `loader`, at `now`.
450///
451/// The page, while the account has not answered. Once it approves, the
452/// redirect the approval held for a browser goes to a top-level navigation
453/// by a browser signed in as the record's account, once
454/// ([`DecisionStore::collect`]). Any other load gets the page, approved and
455/// reloading, until the request expires, and then `access_denied`. An
456/// approval that held nothing gets the page, with no code on it: its code
457/// went to what approved it. A decline, a verdict that grants nothing, or a
458/// request that expired first is a [`Denial`]: the browser goes to the
459/// client with `access_denied` (RFC 6749 §4.1.2.1).
460///
461/// RFC 9126 §4 allows a reload of the same `request_uri`, so nothing here
462/// spends it. PKCE leaves a code useless to anything but the client that
463/// pushed the request.
464pub fn answer(
465    par_store: &dyn ParStore,
466    decisions: &dyn DecisionStore,
467    request_uri: &str,
468    client_id: &str,
469    loader: &Loader,
470    now: OffsetDateTime,
471) -> Result<AuthorizeAnswer, AuthorizeError> {
472    let pushed = par_store
473        .get(request_uri)
474        .ok_or(AuthorizeError::UnknownRequestUri)?;
475    if pushed.client_id != client_id {
476        return Err(AuthorizeError::ClientMismatch);
477    }
478    let record = decisions
479        .get(request_uri)
480        .ok_or(AuthorizeError::UnknownRequestUri)?;
481    let signed_in = loader.signed_in_as(&record.account);
482    let expired = now >= record.expires_at;
483    let held = decisions.holds(request_uri);
484    let page = |record, held| match AuthorizePending::of(record, signed_in, held) {
485        Some(page) => AuthorizeAnswer::Page(page),
486        None => AuthorizeAnswer::Denied(Denial {
487            pushed: pushed.clone(),
488            description: REFUSED,
489        }),
490    };
491    let description = match record.state {
492        DecisionState::Approved if held && expired => {
493            decisions.collect(request_uri);
494            EXPIRED
495        }
496        DecisionState::Approved if held && signed_in && loader.navigation => {
497            return Ok(match decisions.collect(request_uri) {
498                Some(to) => AuthorizeAnswer::Redirect {
499                    to,
500                    account: record.account,
501                },
502                // A load a moment ago took it.
503                None => page(record, false),
504            });
505        }
506        DecisionState::Approved => return Ok(page(record, held)),
507        DecisionState::Pending if !expired => return Ok(page(record, false)),
508        DecisionState::Declined => DECLINED,
509        DecisionState::Pending | DecisionState::Expired => EXPIRED,
510    };
511    Ok(AuthorizeAnswer::Denied(Denial {
512        pushed,
513        description,
514    }))
515}
516
517#[cfg(test)]
518mod tests {
519    use super::*;
520    use crate::oauth::client_metadata::{ApplicationType, ClientKey, ClientMetadataDocument};
521    use crate::oauth::consent::ConsentReference;
522    use crate::oauth::decision::{ClientSelf, MemoryDecisionStore};
523    use crate::oauth::par::MemoryParStore;
524
525    const CLIENT_ID: &str = "https://client.example/id.json";
526    const ISSUER: &str = "https://pds.example";
527
528    fn document(client_id: &str) -> ClientMetadataDocument {
529        ClientMetadataDocument {
530            client_id: client_id.to_owned(),
531            application_type: ApplicationType::Web,
532            client_name: None,
533            client_uri: None,
534            logo_uri: None,
535            redirect_uris: vec!["https://client.example/cb".to_owned()],
536            grant_types: vec!["authorization_code".to_owned()],
537            response_types: vec!["code".to_owned()],
538            authentication: crate::oauth::client_auth::ClientAuthentication::public(),
539            scope: None,
540            dpop_bound_access_tokens: true,
541        }
542    }
543
544    fn pushed(client_id: &str, scope: &str) -> PushedRequest {
545        let document = document(client_id);
546        PushedRequest {
547            client_id: client_id.to_owned(),
548            client_key: ClientKey::compute(&document),
549            client_metadata: document,
550            redirect_uri: "https://client.example/cb".to_owned(),
551            scope: ScopeSet::parse(scope).unwrap(),
552            state: Some("xyz".to_owned()),
553            response_mode: ResponseMode::Query,
554            code_challenge: "abc".to_owned(),
555            code_challenge_method: "S256".to_owned(),
556            login_hint: Some("did:web:agent.example".to_owned()),
557            did: "did:web:agent.example".to_owned(),
558            dpop_jkt: crate::oauth::dpop_seam::DpopThumbprint("a-key".to_owned()),
559            expires_at: OffsetDateTime::now_utc() + time::Duration::minutes(2),
560        }
561    }
562
563    /// The record PAR would have minted for `pushed`, with `verdict` as the
564    /// answer it reached.
565    fn record(request_uri: &str, verdict: Verdict, token: Option<&str>) -> DecisionRecord {
566        let now = OffsetDateTime::now_utc();
567        DecisionRecord {
568            request_uri: request_uri.to_owned(),
569            account: "did:web:agent.example".to_owned(),
570            client_origin: "https://client.example".to_owned(),
571            client_key: ClientKey("k".to_owned()),
572            first_time: true,
573            client_self: ClientSelf::default(),
574            requested: ScopeSet::parse("atproto").unwrap(),
575            verdict,
576            token: token.map(|t| ConsentReference(t.to_owned())),
577            created_at: now,
578            expires_at: now + time::Duration::minutes(2),
579            state: DecisionState::Pending,
580            sequence: 0,
581        }
582    }
583
584    /// A push and its decision, stored the way PAR stores them.
585    fn stored(verdict: Verdict) -> (MemoryParStore, MemoryDecisionStore, String) {
586        let par = MemoryParStore::new();
587        let uri = par.push(pushed(CLIENT_ID, "atproto"));
588        let decisions = MemoryDecisionStore::new();
589        let token = (!verdict.is_deny()).then_some("reference");
590        decisions.put(record(&uri, verdict, token)).unwrap();
591        (par, decisions, uri)
592    }
593
594    fn load(par: &MemoryParStore, decisions: &MemoryDecisionStore, uri: &str) -> AuthorizeAnswer {
595        load_by(par, decisions, uri, &Loader::default())
596    }
597
598    /// One load by `loader`, now.
599    fn load_by(
600        par: &MemoryParStore,
601        decisions: &MemoryDecisionStore,
602        uri: &str,
603        loader: &Loader,
604    ) -> AuthorizeAnswer {
605        answer(
606            par,
607            decisions,
608            uri,
609            CLIENT_ID,
610            loader,
611            OffsetDateTime::now_utc(),
612        )
613        .expect("the page answers")
614    }
615
616    /// A browser signed in as `accounts`, loading the page as `navigation`
617    /// says.
618    fn browser(accounts: &[&str], navigation: bool) -> Loader {
619        Loader {
620            accounts: accounts
621                .iter()
622                .map(|account| (*account).to_owned())
623                .collect(),
624            navigation,
625        }
626    }
627
628    /// Where the account's approval of a web sign-in sends a browser.
629    const HELD: &str =
630        "https://client.example/cb?code=c0de&state=xyz&iss=https%3A%2F%2Fpds.example";
631
632    /// The query of the redirect a denial sends the browser to.
633    fn sent_to(answer: AuthorizeAnswer) -> Vec<(String, String)> {
634        let AuthorizeAnswer::Denied(denial) = answer else {
635            panic!("expected a denial, got {answer:?}");
636        };
637        denial
638            .redirect(ISSUER)
639            .unwrap()
640            .query_pairs()
641            .map(|(key, value)| (key.into_owned(), value.into_owned()))
642            .collect()
643    }
644
645    /// A remembered approval confirms a sign-in only when every rule holds,
646    /// and each rule refuses on its own.
647    #[test]
648    fn a_remembered_approval_confirms_only_when_every_rule_holds() {
649        let now = OffsetDateTime::now_utc();
650        let account = "did:web:agent.example";
651        let mut confidential = pushed(CLIENT_ID, "atproto");
652        confidential
653            .client_metadata
654            .authentication
655            .token_endpoint_auth_method = crate::oauth::client_auth::PRIVATE_KEY_JWT.to_owned();
656        let pending = record("urn:r", Verdict::Allow, Some("reference"));
657        let remembered = Remembered {
658            client_id: CLIENT_ID.to_owned(),
659            granted: ScopeSet::parse("atproto repo:*").unwrap(),
660            expires_at: now + time::Duration::minutes(1),
661        };
662        let signed_in = browser(&[account], true);
663        let confirms = |pushed: &PushedRequest,
664                        record: &DecisionRecord,
665                        remembered: Option<&Remembered>,
666                        loader: &Loader| {
667            confirms_unasked(pushed, record, remembered, loader, now)
668        };
669        assert!(confirms(
670            &confidential,
671            &pending,
672            Some(&remembered),
673            &signed_in
674        ));
675
676        let public = pushed(CLIENT_ID, "atproto");
677        let mut loopback = confidential.clone();
678        loopback.redirect_uri = "http://127.0.0.1/cb".to_owned();
679        let mut answered = pending.clone();
680        answered.state = DecisionState::Approved;
681        let denied = record(
682            "urn:r",
683            Verdict::Deny {
684                reason: "no".to_owned(),
685                rule: "a-policy".to_owned(),
686            },
687            None,
688        );
689        let mut wider = pending.clone();
690        wider.requested = ScopeSet::parse("atproto blob:*/*").unwrap();
691        let lapsed = Remembered {
692            expires_at: now,
693            ..remembered.clone()
694        };
695        let refusals = [
696            (
697                "a public client",
698                confirms(&public, &pending, Some(&remembered), &signed_in),
699            ),
700            (
701                "a loopback sign-in",
702                confirms(&loopback, &pending, Some(&remembered), &signed_in),
703            ),
704            (
705                "a browser signed in as another account",
706                confirms(
707                    &confidential,
708                    &pending,
709                    Some(&remembered),
710                    &browser(&["did:web:other.example"], true),
711                ),
712            ),
713            (
714                "a load that is not a navigation",
715                confirms(
716                    &confidential,
717                    &pending,
718                    Some(&remembered),
719                    &browser(&[account], false),
720                ),
721            ),
722            (
723                "an answered record",
724                confirms(&confidential, &answered, Some(&remembered), &signed_in),
725            ),
726            (
727                "a verdict that grants nothing",
728                confirms(&confidential, &denied, Some(&remembered), &signed_in),
729            ),
730            (
731                "a wider grant",
732                confirms(&confidential, &wider, Some(&remembered), &signed_in),
733            ),
734            (
735                "no remembered approval",
736                confirms(&confidential, &pending, None, &signed_in),
737            ),
738            (
739                "a lapsed one",
740                confirms(&confidential, &pending, Some(&lapsed), &signed_in),
741            ),
742        ];
743        for (why, confirmed) in refusals {
744            assert!(!confirmed, "{why} confirmed");
745        }
746    }
747
748    /// RFC 9126 §4 allows a reload of the same `request_uri`, so the page
749    /// answers every load until the account does, and spends nothing.
750    #[test]
751    fn the_page_answers_every_reload_until_the_account_answers() {
752        let (par, decisions, uri) = stored(Verdict::Allow);
753        for _ in 0..3 {
754            let AuthorizeAnswer::Page(page) = load(&par, &decisions, &uri) else {
755                panic!("the account has not answered");
756            };
757            assert!(page.waiting());
758            assert_eq!(page.granted.to_string(), "atproto");
759        }
760    }
761
762    /// An approval that held nothing sent its code to what approved it, so
763    /// every load after it gets the page, which carries no code and no
764    /// longer reloads, whoever loads it.
765    #[test]
766    fn an_approval_that_held_nothing_gets_the_page_and_no_code() {
767        let (par, decisions, uri) = stored(Verdict::Allow);
768        decisions
769            .approve(&uri, None)
770            .expect("a live record approves");
771        for loader in [Loader::default(), browser(&["did:web:agent.example"], true)] {
772            let AuthorizeAnswer::Page(page) = load_by(&par, &decisions, &uri, &loader) else {
773                panic!("an approval that held nothing sends the browser nowhere");
774            };
775            assert!(!page.waiting() && !page.reloads());
776        }
777    }
778
779    /// A held code goes to a top-level navigation by a browser signed in as
780    /// the record's account, once. Another account's browser, a browser
781    /// signed in as nobody, and a load that is not a navigation each get the
782    /// page, approved and reloading.
783    #[test]
784    fn a_held_code_goes_once_to_a_navigation_signed_in_as_the_account() {
785        let (par, decisions, uri) = stored(Verdict::Allow);
786        decisions
787            .approve(&uri, Some(HELD.to_owned()))
788            .expect("a live record approves");
789        for (loader, signed_in) in [
790            (Loader::default(), false),
791            (browser(&["did:web:someone-else.example"], true), false),
792            (browser(&["did:web:agent.example"], false), true),
793        ] {
794            let AuthorizeAnswer::Page(page) = load_by(&par, &decisions, &uri, &loader) else {
795                panic!("{loader:?} is not the account's browser navigating");
796            };
797            assert!(!page.waiting() && page.held && page.reloads(), "{loader:?}");
798            assert_eq!(page.signed_in, signed_in, "{loader:?}");
799        }
800
801        let account = browser(
802            &["did:web:someone-else.example", "did:web:agent.example"],
803            true,
804        );
805        let AuthorizeAnswer::Redirect { to, account: named } =
806            load_by(&par, &decisions, &uri, &account)
807        else {
808            panic!("the account's browser collects the code");
809        };
810        assert_eq!(to, HELD);
811        assert_eq!(named, "did:web:agent.example");
812        let AuthorizeAnswer::Page(page) = load_by(&par, &decisions, &uri, &account) else {
813            panic!("the code went out once");
814        };
815        assert!(!page.held && !page.reloads());
816    }
817
818    /// A held code nobody collected is dropped when its request expires, and
819    /// the browser still waiting is sent back with `access_denied`.
820    #[test]
821    fn a_held_code_nobody_collected_ends_in_access_denied() {
822        let (par, decisions, uri) = stored(Verdict::Allow);
823        decisions
824            .approve(&uri, Some(HELD.to_owned()))
825            .expect("a live record approves");
826        let expiry = decisions.get(&uri).unwrap().expires_at;
827        let answered = answer(
828            &par,
829            &decisions,
830            &uri,
831            CLIENT_ID,
832            &Loader::default(),
833            expiry,
834        )
835        .expect("the page answers");
836        let pairs = sent_to(answered);
837        assert_eq!(pairs[0], ("error".to_owned(), "access_denied".to_owned()));
838        assert_eq!(pairs[1].1, EXPIRED);
839        assert!(
840            !decisions.holds(&uri),
841            "the code was dropped with the request"
842        );
843    }
844
845    /// A sign-in that ends without a code sends the browser to the client
846    /// with `access_denied`, its `state` and this server's `iss`.
847    #[test]
848    fn a_decline_an_expiry_and_a_denial_send_access_denied() {
849        let (par, decisions, declined) = stored(Verdict::Allow);
850        decisions.settle(&declined, DecisionState::Declined);
851
852        let expired = par.push(pushed(CLIENT_ID, "atproto"));
853        decisions
854            .put(record(&expired, Verdict::Allow, Some("expired")))
855            .unwrap();
856
857        let denied = par.push(pushed(CLIENT_ID, "atproto"));
858        let deny = Verdict::Deny {
859            reason: "nope".to_owned(),
860            rule: "a-rule".to_owned(),
861        };
862        decisions.put(record(&denied, deny, None)).unwrap();
863
864        let later = OffsetDateTime::now_utc() + time::Duration::minutes(3);
865        for (uri, now, description) in [
866            (&declined, OffsetDateTime::now_utc(), DECLINED),
867            (&expired, later, EXPIRED),
868            (&denied, OffsetDateTime::now_utc(), REFUSED),
869        ] {
870            let answered =
871                answer(&par, &decisions, uri, CLIENT_ID, &Loader::default(), now).unwrap();
872            let pairs = sent_to(answered);
873            let names: Vec<&str> = pairs.iter().map(|(name, _)| name.as_str()).collect();
874            assert_eq!(names, ["error", "error_description", "state", "iss"]);
875            assert_eq!(pairs[0].1, "access_denied");
876            assert_eq!(pairs[1].1, description);
877            assert_eq!(pairs[2].1, "xyz");
878            assert_eq!(pairs[3].1, ISSUER);
879        }
880    }
881
882    /// A request nothing was ever stored under is unknown, and nothing is
883    /// sent anywhere for it.
884    #[test]
885    fn a_request_nothing_was_stored_under_is_unknown() {
886        let (par, decisions, _) = stored(Verdict::Allow);
887        let never_existed = "urn:ietf:params:oauth:request_uri:0000000000000000";
888        assert_eq!(
889            answer(
890                &par,
891                &decisions,
892                never_existed,
893                CLIENT_ID,
894                &Loader::default(),
895                OffsetDateTime::now_utc()
896            )
897            .unwrap_err(),
898            AuthorizeError::UnknownRequestUri
899        );
900    }
901
902    #[test]
903    fn a_mismatched_client_id_is_refused() {
904        let (par, decisions, uri) = stored(Verdict::Allow);
905        assert_eq!(
906            answer(
907                &par,
908                &decisions,
909                &uri,
910                "https://someone-else.example/id.json",
911                &Loader::default(),
912                OffsetDateTime::now_utc()
913            )
914            .unwrap_err(),
915            AuthorizeError::ClientMismatch
916        );
917    }
918
919    /// `fragment` puts the whole response after `#` and leaves the
920    /// redirect's own query alone; `query` adds to that query.
921    #[test]
922    fn a_response_goes_in_the_query_or_the_fragment_the_client_named() {
923        let result = [("code", "c0de")];
924        let query = authorization_response(
925            "https://client.example/cb?from=app",
926            ResponseMode::Query,
927            &result,
928            Some("a b&c"),
929            ISSUER,
930        )
931        .unwrap();
932        assert_eq!(
933            query.as_str(),
934            "https://client.example/cb?from=app&code=c0de&state=a+b%26c&iss=https%3A%2F%2Fpds.example"
935        );
936
937        let fragment = authorization_response(
938            "https://client.example/cb?from=app",
939            ResponseMode::Fragment,
940            &result,
941            Some("a b&c"),
942            ISSUER,
943        )
944        .unwrap();
945        assert_eq!(
946            fragment.as_str(),
947            "https://client.example/cb?from=app#code=c0de&state=a+b%26c&iss=https%3A%2F%2Fpds.example"
948        );
949    }
950
951    /// A browser carries every sign-in's code but a loopback redirect's in
952    /// the query, which is fetched on the host that approved it.
953    #[test]
954    fn a_browser_carries_every_code_but_a_loopback_query() {
955        for (redirect_uri, mode, browser) in [
956            ("http://127.0.0.1:4000/cb", ResponseMode::Query, false),
957            ("http://[::1]:4000/cb", ResponseMode::Query, false),
958            ("http://127.0.0.1:4000/cb", ResponseMode::Fragment, true),
959            ("https://client.example/cb", ResponseMode::Query, true),
960            ("com.example.app:/cb", ResponseMode::Query, true),
961        ] {
962            assert_eq!(
963                browser_carries(redirect_uri, mode),
964                browser,
965                "{redirect_uri} {mode:?}"
966            );
967        }
968    }
969
970    /// A configured ceiling may only shrink from the compiled-in one. An
971    /// operator naming a hard-blocked capability gets nothing for it, which
972    /// is what lets a scope live in a configuration file at all.
973    #[test]
974    fn a_configured_ceiling_cannot_reach_past_the_compiled_in_one() {
975        let (ceiling, dropped) = ConfiguredCeiling::new([
976            "atproto",
977            "repo:app.bsky.feed.post",
978            "transition:generic",
979            "account:email",
980            "notascope:whatever",
981        ]);
982        let admitted = ceiling.ceiling("did:web:a", "https://c");
983        assert_eq!(admitted.to_string(), "atproto repo:app.bsky.feed.post");
984        assert_eq!(
985            dropped.outside,
986            vec!["transition:generic", "account:email", "notascope:whatever"]
987        );
988        assert!(dropped.over_bound.is_empty());
989    }
990
991    /// **The bound that did not hold.** `MAX_SCOPE_ATOMS` bounds
992    /// `ScopeSet::new`'s pairwise comparison, and `ScopeSet::parse` applies
993    /// it per string — but each configured atom is its own string, so what
994    /// accumulated here was unbounded. A ceiling naming more atoms than that
995    /// is the atoms up to the bound, and the rest are reported.
996    #[test]
997    fn a_configured_ceiling_is_bounded_and_says_what_the_bound_dropped() {
998        let atoms: Vec<String> = (0..crate::oauth::scope::MAX_SCOPE_ATOMS + 10)
999            .map(|n| format!("repo:com.example.c{n}"))
1000            .collect();
1001        let (ceiling, dropped) = ConfiguredCeiling::new(atoms.iter().map(String::as_str));
1002        assert_eq!(
1003            ceiling.scopes().0.len(),
1004            crate::oauth::scope::MAX_SCOPE_ATOMS
1005        );
1006        assert_eq!(dropped.over_bound.len(), 10);
1007        assert!(dropped.outside.is_empty());
1008        assert_eq!(
1009            dropped.over_bound[0],
1010            format!("repo:com.example.c{}", crate::oauth::scope::MAX_SCOPE_ATOMS)
1011        );
1012    }
1013
1014    #[test]
1015    fn a_configured_ceiling_with_nothing_in_it_grants_nothing() {
1016        let (ceiling, _) = ConfiguredCeiling::new(std::iter::empty());
1017        assert!(ceiling.scopes().0.is_empty());
1018        let request = ScopeSet::parse("atproto").unwrap();
1019        let (granted, cut) = request.narrow(&ceiling.ceiling("did:web:a", "https://c"));
1020        assert!(granted.0.is_empty());
1021        assert_eq!(cut, request);
1022    }
1023}