Skip to main content

didbot_serve/routes/
oauth.rs

1use super::*;
2use crate::landing;
3use didbot_brand::page::{escape, Landing, Tone};
4
5use crate::oauth::error::OAuthError;
6use crate::refusal::Severity;
7
8/// An OAuth refusal on its way out: what the client is sent, and how the
9/// journal says it. See `crate::refusal`.
10pub(crate) struct Refused {
11    error: OAuthError,
12    severity: Severity,
13    /// The reason, when it is more than the description the client is
14    /// sent. A client metadata failure is one sentence on the wire and the
15    /// whole cause here.
16    reason: Option<String>,
17}
18
19impl Refused {
20    pub(crate) fn info(error: OAuthError) -> Self {
21        Self {
22            error,
23            severity: Severity::Info,
24            reason: None,
25        }
26    }
27
28    pub(crate) fn warn(error: OAuthError) -> Self {
29        Self {
30            error,
31            severity: Severity::Warn,
32            reason: None,
33        }
34    }
35
36    fn because(mut self, reason: impl std::fmt::Display) -> Self {
37        self.reason = Some(reason.to_string());
38        self
39    }
40
41    /// Writes the refusal and answers with it.
42    pub(crate) fn answer(
43        self,
44        endpoint: &'static str,
45        client_id: Option<&str>,
46        account: Option<&str>,
47    ) -> Response {
48        self.write(endpoint, client_id, account);
49        self.error.into_response()
50    }
51
52    /// Writes the refusal, for an endpoint that answers a browser with a
53    /// page instead.
54    ///
55    /// `use_dpop_nonce` is the first answer every DPoP client is given, and
56    /// it retries at once, so it is not written.
57    pub(crate) fn write(
58        &self,
59        endpoint: &'static str,
60        client_id: Option<&str>,
61        account: Option<&str>,
62    ) {
63        use crate::oauth::error::OAuthErrorCode;
64        if self.error.error() != OAuthErrorCode::UseDpopNonce.as_str() {
65            crate::refusal::refused(crate::refusal::Refusal {
66                endpoint,
67                client_id,
68                account,
69                error: self.error.error(),
70                reason: self
71                    .reason
72                    .as_deref()
73                    .unwrap_or_else(|| self.error.description()),
74                severity: self.severity,
75            });
76        }
77    }
78}
79
80/// `GET /.well-known/oauth-authorization-server`
81///
82/// See `crate::oauth::discovery` for why the issuer this reports is read out
83/// of this deployment's own zone service document rather than computed here.
84pub(crate) async fn oauth_server_metadata(State(state): State<AppState>) -> Response {
85    use crate::oauth::error::{OAuthError, OAuthErrorCode};
86    match crate::oauth::discovery::server_metadata(state.registry.as_ref()) {
87        Ok(metadata) => Json(metadata).into_response(),
88        Err(err) => OAuthError::new(OAuthErrorCode::ServerError, err.to_string()).into_response(),
89    }
90}
91
92/// `GET /.well-known/oauth-protected-resource`
93///
94/// RFC 9728 plus the atproto profile; see `crate::oauth::discovery` for why
95/// this reports the same origin `oauth_server_metadata`'s `issuer` does.
96pub(crate) async fn oauth_protected_resource_metadata(State(state): State<AppState>) -> Response {
97    use crate::oauth::error::{OAuthError, OAuthErrorCode};
98    match crate::oauth::discovery::protected_resource_metadata(state.registry.as_ref()) {
99        Ok(metadata) => Json(metadata).into_response(),
100        Err(err) => OAuthError::new(OAuthErrorCode::ServerError, err.to_string()).into_response(),
101    }
102}
103
104/// The wire fields `POST /oauth/par` accepts, form-encoded per RFC 9126.
105#[derive(serde::Deserialize)]
106pub(crate) struct OAuthParForm {
107    client_id: String,
108    response_type: Option<String>,
109    response_mode: Option<String>,
110    redirect_uri: Option<String>,
111    scope: Option<String>,
112    state: Option<String>,
113    code_challenge: Option<String>,
114    code_challenge_method: Option<String>,
115    login_hint: Option<String>,
116    client_assertion_type: Option<String>,
117    client_assertion: Option<String>,
118    dpop_jkt: Option<String>,
119}
120
121/// `POST /oauth/par`
122///
123/// The one place a sign-in is judged, and the earliest point a client is
124/// known; see `crate::oauth::par`'s doc for the two inputs that reach the
125/// verdict, and `crate::oauth::decision` for the record they are written
126/// into. `login_hint` is resolved through this layer's own
127/// [`Registry`] — the layer that holds one — and a hint naming no account on
128/// this deployment gets the same `201` a real one does; see
129/// `crate::oauth::par::push_for_unresolved_hint` for what it does not get.
130///
131/// Unauthenticated, and bounded per caller address by
132/// [`crate::oauth::OAuthState::par_rate_limiter`] before any of that
133/// happens. Its DPoP proof is checked next; see [`checked_proof`].
134pub(crate) async fn oauth_par(
135    State(state): State<AppState>,
136    peer: Peer,
137    headers: HeaderMap,
138    axum::Form(form): axum::Form<OAuthParForm>,
139) -> Response {
140    use crate::oauth::par::{push_authorization_request, DecisionSeams, ParRequest};
141
142    let client_id = form.client_id.clone();
143    let refuse = |refused: Refused| refused.answer("par", Some(&client_id), None);
144
145    // Before the client document is fetched: the fetch goes to a host this
146    // caller chose, and nothing here has authenticated the caller.
147    let key = caller_key(&headers, peer.0, state.trust_forwarded_headers);
148    if !state.oauth.par_rate_limiter.check(&key) {
149        return refuse(Refused::info(too_many_oauth_requests("par")));
150    }
151
152    // Checked and not accepted: a push may carry no credential at all, and
153    // an entry in the replay window costs one. Its key is what the code this
154    // push leads to is bound to.
155    let proof = match checked_proof(&state, &headers, "/oauth/par") {
156        Ok(proof) => proof,
157        Err(refused) => return refuse(Refused::info(refused)),
158    };
159
160    let request = ParRequest {
161        client_id: form.client_id,
162        response_type: form.response_type,
163        response_mode: form.response_mode,
164        redirect_uri: form.redirect_uri,
165        scope: form.scope,
166        state: form.state,
167        code_challenge: form.code_challenge,
168        code_challenge_method: form.code_challenge_method,
169        login_hint: form.login_hint,
170        credentials: crate::oauth::client_auth::ClientCredentials {
171            assertion_type: form.client_assertion_type,
172            assertion: form.client_assertion,
173        },
174        dpop_jkt: form.dpop_jkt,
175        proof_key: proof.thumbprint,
176    };
177    // Empty before the zone's document exists, as `deployment_did` is, and
178    // then no client assertion names it.
179    let issuer = crate::oauth::discovery::issuer_for(state.registry.as_ref()).unwrap_or_default();
180    let pds_did = deployment_did(&state);
181    let pds_hostname = state.registry.zone().to_owned();
182    let seams = DecisionSeams {
183        scope_policy: state.oauth.scope_policy.as_ref(),
184        consent_store: state.oauth.consent_store.as_ref(),
185        decisions: state.oauth.decision_store.as_ref(),
186        gate: state.oauth.grant_gate.as_ref(),
187        evaluation_log: state.oauth.evaluation_log.as_ref(),
188        pds_did: &pds_did,
189        pds_hostname: &pds_hostname,
190        loopback_clients: crate::oauth::client_metadata::LoopbackClients::for_zone(&pds_hostname),
191        client_assertions: state.oauth.client_assertions.as_ref(),
192        issuer: &issuer,
193        permission_sets: state.oauth.permission_sets.as_ref(),
194    };
195    match push_authorization_request(
196        &state.oauth.http,
197        state.oauth.client_cache.as_ref(),
198        state.oauth.par_store.as_ref(),
199        request,
200        &|hint| resolve_identifier(&state, hint),
201        &seams,
202    )
203    .await
204    {
205        Ok((request_uri, ttl)) => (
206            StatusCode::CREATED,
207            Json(json!({ "request_uri": request_uri, "expires_in": ttl.whole_seconds() })),
208        )
209            .into_response(),
210        Err(err) => refuse(par_refusal(err)),
211    }
212}
213
214/// What a refused push answers, and how loudly it is written.
215///
216/// `warn` is a client whose sign-ins will all fail until its own
217/// deployment changes, and a server too full to take a push. The rest is a
218/// caller's mistake or this server's policy, at `info`.
219fn par_refusal(err: crate::oauth::par::ParError) -> Refused {
220    use crate::oauth::error::OAuthErrorCode;
221    use crate::oauth::par::ParError;
222    let code = match err {
223        ParError::MissingParameter(_)
224        | ParError::BadScope(_)
225        | ParError::UnsupportedCodeChallengeMethod(_)
226        | ParError::MalformedCodeChallenge
227        | ParError::TooLong { .. }
228        | ParError::ClientRefused
229        | ParError::UnsupportedResponseMode(_)
230        | ParError::RedirectUriNotRegistered(_) => OAuthErrorCode::InvalidRequest,
231        ParError::UnsupportedResponseType => OAuthErrorCode::UnsupportedResponseType,
232        ParError::ClientId(_) | ParError::ClientMetadata(_) => OAuthErrorCode::InvalidClient,
233        ParError::ClientAuthentication(ref refused) => refused.code(),
234        ParError::DpopKeyMismatch => OAuthErrorCode::InvalidDpopProof,
235        ParError::TooManyPending(_) => OAuthErrorCode::AccessDenied,
236    };
237    let error = OAuthError::new(code, err.to_string());
238    match err {
239        // The client is sent one sentence; the cause is the operator's.
240        ParError::ClientMetadata(cause) => Refused::warn(error).because(cause),
241        ParError::ClientAuthentication(_)
242        | ParError::RedirectUriNotRegistered(_)
243        | ParError::TooManyPending(_) => Refused::warn(error),
244        _ => Refused::info(error),
245    }
246}
247
248/// This deployment's own DID, as a policy subject names it: empty before
249/// the zone's document exists.
250pub(crate) fn deployment_did(state: &AppState) -> String {
251    state
252        .registry
253        .did_document(state.registry.zone())
254        .map(|document| document.id.clone())
255        .unwrap_or_default()
256}
257
258/// `GET /oauth/authorize`'s query parameters.
259#[derive(serde::Deserialize)]
260pub(crate) struct OAuthAuthorizeQuery {
261    client_id: String,
262    request_uri: String,
263}
264
265/// The 429 the unauthenticated OAuth endpoints answer with; see
266/// [`OAuthError::rate_limited`].
267pub(crate) fn too_many_oauth_requests(endpoint: &str) -> OAuthError {
268    OAuthError::rate_limited(endpoint)
269}
270
271pub(crate) fn authorize_error(err: crate::oauth::authorize::AuthorizeError) -> OAuthError {
272    use crate::oauth::authorize::AuthorizeError;
273    use crate::oauth::error::OAuthErrorCode;
274    let code = match err {
275        AuthorizeError::UnknownRequestUri | AuthorizeError::ClientMismatch => {
276            OAuthErrorCode::InvalidGrant
277        }
278    };
279    OAuthError::new(code, err.to_string())
280}
281
282/// The page [`oauth_authorize`] answers when `err` stops it before it
283/// finds a sign-in to show.
284pub(crate) fn authorize_error_page(err: crate::oauth::authorize::AuthorizeError) -> Response {
285    use crate::oauth::authorize::AuthorizeError;
286    let what = match err {
287        AuthorizeError::UnknownRequestUri => "this sign-in request expired or is unknown",
288        AuthorizeError::ClientMismatch => "this link does not match its sign-in request",
289    };
290    landing::refused(
291        StatusCode::BAD_REQUEST,
292        landing::AUTHORIZE,
293        "invalid_grant",
294        what,
295        "",
296        "Start the sign-in again from the app.",
297    )
298}
299
300/// The page `GET /oauth/authorize` serves for one decision.
301///
302/// A person's view of exactly what `bot.did.getAuthorization` hands an
303/// agent: the origin the client served its metadata from, its content key,
304/// what it asked for, what this server would grant, and what it cut. The
305/// client's own name, home page and logo are not here and must not be — see
306/// `crate::oauth::decision`'s doc and `plan/app-allowlist.md`.
307///
308/// The heading is a placeholder, per this project's copywriting rule. The
309/// rest is plain labels over the attributes, which are the load-bearing part.
310///
311/// The page names the decision's state and its own `authorize_url`. While
312/// the account has not answered, or its approval's code waits for a browser
313/// signed in as the account, it reloads itself every
314/// [`crate::oauth::authorize::RELOAD_INTERVAL`], with no script, so the
315/// load after the answer sees it. It carries no code; see
316/// [`oauth_authorize`]. The URL is in the text because a harness finds a
317/// sign-in by the `/oauth/authorize?` URLs in a tool's output: a browser tool
318/// that reads this page reports the `request_uri`.
319///
320/// `data-browser` says whether this browser is signed in as the account.
321/// While a code waits for one that is, a browser that is not is told where
322/// to sign in, `sign_in_url`.
323///
324/// The page lists the scopes requested, then the scopes the scope ceiling
325/// allows. Approving covers that second list, and the ceiling is asked again
326/// at every use (`data-approves`, `data-ceiling-checked`).
327///
328/// There is no form. Approving is `bot.did.approveAuthorization`, which
329/// takes the account's own agent token as `Credential::AccountSelf` — a
330/// credential an HTML form cannot carry, so a form here could only post to
331/// something that would refuse it. A browser-driven client approving on the
332/// agent's behalf reads the one-time reference from
333/// `bot.did.getAuthorization`, which takes the same credential the approval
334/// itself takes: this route takes no credential at all, so anything that can
335/// follow the `request_uri` can read what this page says, and the reference
336/// is not on it.
337///
338/// Every element carries its value in an attribute for such a client and in
339/// text for a person, which is why the account is rendered both ways rather
340/// than sitting in markup a reader cannot see.
341pub(crate) fn authorize_page(
342    pending: &crate::oauth::authorize::AuthorizePending,
343    authorize_url: &str,
344    sign_in_url: &str,
345) -> Response {
346    let record = &pending.record;
347    let items = |scopes: &crate::oauth::scope::ScopeSet| -> String {
348        let items: String = scopes
349            .0
350            .iter()
351            .map(|atom| {
352                let atom = escape(&atom.to_string());
353                format!("<li data-scope=\"{atom}\">{atom}</li>")
354            })
355            .collect();
356        format!("<ul>{items}</ul>")
357    };
358    // Named for the check that produced it. The scope ceiling is the only
359    // thing that decides this list; a token policy can still refuse the
360    // exchange, and a write policy can still refuse a write a listed scope
361    // covers, so a list labelled "policies" would promise their answers too.
362    let only = if pending.cut.0.is_empty() {
363        ""
364    } else {
365        "only "
366    };
367    let allowed = format!(
368        "The scope ceiling allows {only}these scopes:{}",
369        items(&pending.granted)
370    );
371    let account = escape(&record.account);
372    let browser = match (pending.signed_in, pending.held) {
373        (true, _) => {
374            format!("<p data-browser=\"signed-in\">This browser is signed in as {account}.</p>")
375        }
376        (false, false) => format!(
377            "<p data-browser=\"not-signed-in\">This browser is not signed in as {account}.</p>"
378        ),
379        (false, true) => format!(
380            "<p class=\"next\" data-browser=\"not-signed-in\" data-sign-in=\"{url}\">This \
381             browser is not signed in as {account}. Sign it in at <a href=\"{url}\">{url}</a>.</p>",
382            url = escape(sign_in_url)
383        ),
384    };
385    let (status, tone) = match (pending.waiting(), pending.held) {
386        (true, _) => ("waiting for the account", Tone::Waiting),
387        (false, true) => ("approved: waiting for its browser", Tone::Waiting),
388        (false, false) => ("approved", Tone::Done),
389    };
390    let body = format!(
391        "<p data-state=\"{state}\">State: {state}</p>\
392         <p data-client-origin=\"{origin}\" data-client-key=\"{key}\" \
393            data-client-first-time=\"{first}\">{origin}{unverified}</p>\
394         <p data-account=\"{account}\">{account}</p>\
395         {browser}\
396         <div data-requested=\"{requested}\">This application has requested these scopes:\
397            {requested_items}</div>\
398         <div data-verdict=\"{verdict}\" data-granted=\"{granted}\" data-cut=\"{cut}\" \
399            data-rule=\"{rule}\" data-approves=\"granted\" \
400            data-ceiling-checked=\"each-use\">\
401            {allowed}</div>\
402         <p class=\"hint\" data-authorize-url=\"{url}\">Authorize URL: {url}</p>",
403        state = record.state.as_str(),
404        url = escape(authorize_url),
405        origin = escape(&record.client_origin),
406        key = escape(&record.client_key.0),
407        first = record.first_time,
408        unverified = if didbot_pds::policy::is_local_origin(&record.client_origin) {
409            ": an unverified local app"
410        } else {
411            ""
412        },
413        requested = escape(&record.requested.to_string()),
414        requested_items = items(&record.requested),
415        verdict = record.verdict.kind(),
416        granted = escape(&pending.granted.to_string()),
417        cut = escape(&pending.cut.to_string()),
418        rule = escape(&pending.rule),
419    );
420    landing::page(
421        StatusCode::OK,
422        &Landing {
423            placeholder: landing::AUTHORIZE,
424            tone,
425            status,
426            body: &body,
427            reload: pending
428                .reloads()
429                .then_some(crate::oauth::authorize::RELOAD_INTERVAL.as_secs()),
430        },
431    )
432}
433
434/// `GET /oauth/authorize`
435///
436/// Answers every load of a pushed request this server holds, which RFC 9126
437/// §4 allows, with what [`crate::oauth::authorize::answer`] decides: the
438/// page while the account has not answered, and after it approved.
439///
440/// A code an approval held for a browser goes, as `303 See Other` to the
441/// client's redirect, to a top-level navigation by a browser whose
442/// [`crate::oauth::browser::COOKIE`] names a session signed in as the
443/// account, once. `Sec-Fetch-Dest` tells a navigation apart where a browser
444/// sends it. Any other browser gets the page, approved and reloading, until
445/// the request expires, which is logged with the account. A decline, a
446/// verdict that grants nothing, or an expiry answers `303 See Other` to the
447/// client's redirect with `access_denied`.
448///
449/// A load that meets [`crate::oauth::authorize::confirms_unasked`] first
450/// approves the sign-in on the account's remembered approval, so that
451/// browser collects the code without the account being asked.
452///
453/// Nothing is decided here: see `crate::oauth::decision` for why the verdict
454/// is reached at PAR. Every answer carries `Cache-Control: no-store`, since
455/// the page changes when the account answers, and the landing page's
456/// content security policy.
457pub(crate) async fn oauth_authorize(
458    State(state): State<AppState>,
459    peer: Peer,
460    query: Result<Query<OAuthAuthorizeQuery>, QueryRejection>,
461    headers: HeaderMap,
462) -> Response {
463    landing_headers(authorize_answer(&state, peer, query, &headers))
464}
465
466/// `response`, uncached and under the landing page's content security
467/// policy, as every answer from the two sign-in routes is, a redirect too.
468fn landing_headers(mut response: Response) -> Response {
469    let answered = response.headers_mut();
470    answered.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
471    answered.insert(
472        header::CONTENT_SECURITY_POLICY,
473        HeaderValue::from_static(didbot_brand::page::content_security_policy()),
474    );
475    response
476}
477
478/// Everything [`oauth_authorize`] answers, before the header every answer
479/// carries.
480fn authorize_answer(
481    state: &AppState,
482    peer: Peer,
483    query: Result<Query<OAuthAuthorizeQuery>, QueryRejection>,
484    headers: &HeaderMap,
485) -> Response {
486    use crate::oauth::authorize::{answer, AuthorizeAnswer, Loader};
487    use crate::oauth::error::OAuthErrorCode;
488
489    let Query(query) = match query {
490        Ok(query) => query,
491        Err(rejection) => {
492            let refused = ApiError::bad_request(rejection.body_text());
493            crate::refusal::refused(crate::refusal::Refusal {
494                endpoint: "authorize",
495                client_id: None,
496                account: None,
497                error: refused.error(),
498                reason: refused.message(),
499                severity: Severity::Info,
500            });
501            return landing::refused(
502                StatusCode::BAD_REQUEST,
503                landing::AUTHORIZE,
504                "invalid_request",
505                "this sign-in link is incomplete",
506                "",
507                "Start the sign-in again from the app.",
508            );
509        }
510    };
511    // Every refusal is written to the journal and answered with a page.
512    let refuse = |refused: Refused, page: Response| {
513        refused.write("authorize", Some(&query.client_id), None);
514        page
515    };
516
517    let key = caller_key(headers, peer.0, state.trust_forwarded_headers);
518    if !state.oauth.authorize_rate_limiter.check(&key) {
519        return refuse(
520            Refused::info(too_many_oauth_requests("authorize")),
521            landing::too_many(landing::AUTHORIZE),
522        );
523    }
524
525    let browser = crate::oauth::browser::presented(headers)
526        .and_then(|cookie| state.oauth.browser_sessions.browser(&cookie));
527    let loader = Loader {
528        accounts: browser
529            .as_ref()
530            .map(|browser| browser.accounts.clone())
531            .unwrap_or_default(),
532        navigation: headers
533            .get("sec-fetch-dest")
534            .is_none_or(|dest| dest == "document"),
535    };
536    confirm_remembered(state, &query.request_uri, &query.client_id, &loader);
537    let answered = match answer(
538        state.oauth.par_store.as_ref(),
539        state.oauth.decision_store.as_ref(),
540        &query.request_uri,
541        &query.client_id,
542        &loader,
543        time::OffsetDateTime::now_utc(),
544    ) {
545        Ok(answered) => answered,
546        Err(err) => {
547            return refuse(
548                Refused::info(authorize_error(err.clone())),
549                authorize_error_page(err),
550            )
551        }
552    };
553    // Read out of this deployment's own zone service document, never a
554    // header; see `oauth::discovery::issuer_for`.
555    let issuer = crate::oauth::discovery::issuer_for(state.registry.as_ref());
556    let response = match (answered, issuer) {
557        (AuthorizeAnswer::Redirect { to, account }, _) => {
558            info!(
559                account,
560                "a browser signed in as the account collected its code"
561            );
562            see_other(&to, landing::AUTHORIZE)
563        }
564        (AuthorizeAnswer::Denied(denial), Ok(issuer)) => match denial.redirect(&issuer) {
565            Ok(to) => see_other(to.as_str(), landing::AUTHORIZE),
566            Err(err) => {
567                let why = format!("the client's redirect is unusable: {err}");
568                refuse(
569                    Refused::warn(OAuthError::new(OAuthErrorCode::ServerError, why.clone())),
570                    landing::server_error(landing::AUTHORIZE, &why),
571                )
572            }
573        },
574        (AuthorizeAnswer::Page(pending), Ok(issuer)) => {
575            if pending.held {
576                info!(
577                    account = pending.record.account,
578                    signed_in = pending.signed_in,
579                    "an approved sign-in's code waits for a browser signed in as the account"
580                );
581            }
582            let query = url::form_urlencoded::Serializer::new(String::new())
583                .append_pair("client_id", &query.client_id)
584                .append_pair("request_uri", &query.request_uri)
585                .finish();
586            let authorize_url = format!("{issuer}/oauth/authorize?{query}");
587            let sign_in_url = format!("{issuer}{}", crate::oauth::browser::PATH);
588            authorize_page(&pending, &authorize_url, &sign_in_url)
589        }
590        (_, Err(err)) => landing::server_error(landing::AUTHORIZE, &err.to_string()),
591    };
592    // A browser that presented an id its session has since replaced is
593    // given the current one, whatever the page answered.
594    match browser.filter(|browser| browser.replaced) {
595        Some(browser) => with_browser_cookie(response, &browser, landing::AUTHORIZE),
596        None => response,
597    }
598}
599
600/// `GET /oauth/browser`'s query parameters.
601#[derive(serde::Deserialize)]
602pub(crate) struct OAuthBrowserQuery {
603    /// A ticket from `bot.did.createBrowserSignIn`.
604    ticket: Option<String>,
605}
606
607/// `GET /oauth/browser`
608///
609/// With a `ticket`, spends it and signs this browser in as the account it
610/// was minted for, then answers `303 See Other` to this page with
611/// [`crate::oauth::browser::COOKIE`] set. Without one, lists the accounts
612/// this browser is signed in as. See `crate::oauth::browser`.
613///
614/// Served on the issuer's host alone (`crate::oauth::discovery::issuer_for`),
615/// so no cookie is set on an agent's own host. A ticket is refused, and left
616/// as it was, when `Sec-Fetch-Site` says the link was followed from another
617/// site, so a link planted on another page cannot sign a browser in, and
618/// while the e-stop is engaged. The request span carries the path alone, so
619/// a ticket is never logged.
620///
621/// Every answer carries `Cache-Control: no-store` and the landing page's
622/// content security policy.
623pub(crate) async fn oauth_browser(
624    State(state): State<AppState>,
625    uri: axum::http::Uri,
626    query: Result<Query<OAuthBrowserQuery>, QueryRejection>,
627    headers: HeaderMap,
628) -> Response {
629    landing_headers(browser_answer(&state, &uri, query, &headers))
630}
631
632/// Everything [`oauth_browser`] answers, before the headers every answer
633/// carries.
634fn browser_answer(
635    state: &AppState,
636    uri: &axum::http::Uri,
637    query: Result<Query<OAuthBrowserQuery>, QueryRejection>,
638    headers: &HeaderMap,
639) -> Response {
640    use crate::oauth::browser::PATH;
641
642    const NEW_LINK: &str = "Ask the account for a new sign-in link.";
643    let Query(query) = match query {
644        Ok(query) => query,
645        Err(_) => {
646            return landing::refused(
647                StatusCode::BAD_REQUEST,
648                landing::BROWSER,
649                "InvalidRequest",
650                "this sign-in link is malformed",
651                "",
652                NEW_LINK,
653            )
654        }
655    };
656    let issuer = match crate::oauth::discovery::issuer_for(state.registry.as_ref()) {
657        Ok(issuer) => issuer,
658        Err(err) => return landing::server_error(landing::BROWSER, &err.to_string()),
659    };
660    if !on_issuer_host(&issuer, uri, headers) {
661        let here = escape(&format!("{issuer}{PATH}"));
662        return landing::refused(
663            StatusCode::FORBIDDEN,
664            landing::BROWSER,
665            "WrongHost",
666            "this is not this server's sign-in address",
667            &format!(
668                "<p data-refused=\"host\">This page is served only at \
669                 <a href=\"{here}\">{here}</a>.</p>"
670            ),
671            "Open that address instead.",
672        );
673    }
674
675    let sessions = &state.oauth.browser_sessions;
676    let presented = crate::oauth::browser::presented(headers);
677    let Some(ticket) = query.ticket else {
678        return browser_list(presented.and_then(|cookie| sessions.browser(&cookie)));
679    };
680    if !followed_from_here(headers) {
681        info!("refused a browser sign-in link followed from another site");
682        return landing::refused(
683            StatusCode::FORBIDDEN,
684            landing::BROWSER,
685            "CrossSite",
686            "a sign-in link followed from another site is refused",
687            "<p data-refused=\"cross-site\">The link still works, once.</p>",
688            "Paste it into this browser's address bar.",
689        );
690    }
691    if let Err(halted) = state.estop.check_issue(EstopRefusal::Token) {
692        info!(%halted, "refused a browser sign-in while the e-stop is engaged");
693        return landing::refused(
694            StatusCode::SERVICE_UNAVAILABLE,
695            landing::BROWSER,
696            "Halted",
697            "this server has stopped signing anything in",
698            "",
699            "Try again once this server's operator releases the stop.",
700        );
701    }
702    let Some(signed_in) = sessions.redeem(&ticket, presented.as_deref()) else {
703        return landing::refused(
704            StatusCode::BAD_REQUEST,
705            landing::BROWSER,
706            "TicketNotLive",
707            "this sign-in link is not live",
708            "<p data-ticket=\"not-live\">It is unknown, used or expired.</p>",
709            NEW_LINK,
710        );
711    };
712    info!(account = signed_in.account, "a browser signed in");
713    with_browser_cookie(
714        see_other(PATH, landing::BROWSER),
715        &signed_in.browser,
716        landing::BROWSER,
717    )
718}
719
720/// Whether a request was made to the issuer's own host, as its `Host` or its
721/// HTTP/2 authority names it.
722fn on_issuer_host(issuer: &str, uri: &axum::http::Uri, headers: &HeaderMap) -> bool {
723    let Some(authority) = request_authority(uri, headers) else {
724        return false;
725    };
726    let host = host_without_port(authority);
727    url::Url::parse(issuer)
728        .ok()
729        .and_then(|issuer| issuer.host_str().map(str::to_owned))
730        .is_some_and(|expected| expected.eq_ignore_ascii_case(host))
731}
732
733/// Whether `Sec-Fetch-Site` lets a sign-in link through: absent, `none` for
734/// a link a person or a tool opened directly, or `same-origin`.
735fn followed_from_here(headers: &HeaderMap) -> bool {
736    headers
737        .get("sec-fetch-site")
738        .is_none_or(|site| matches!(site.to_str(), Ok("none" | "same-origin")))
739}
740
741/// The accounts a browser is signed in as, and the cookie again when it
742/// presented an id its session has since replaced.
743fn browser_list(browser: Option<crate::oauth::browser::Browser>) -> Response {
744    let accounts = browser
745        .as_ref()
746        .map(|browser| browser.accounts.as_slice())
747        .unwrap_or_default();
748    let (status, tone, body) = if accounts.is_empty() {
749        (
750            "not signed in",
751            Tone::Waiting,
752            "<p data-accounts=\"0\">This browser is signed in as no account here.</p>\
753             <p class=\"next\">Open a sign-in link from an account to sign it in.</p>"
754                .to_owned(),
755        )
756    } else {
757        let items: String = accounts
758            .iter()
759            .map(|account| {
760                let account = escape(account);
761                format!("<li data-account=\"{account}\">{account}</li>")
762            })
763            .collect();
764        (
765            "signed in",
766            Tone::Done,
767            format!(
768                "<p data-accounts=\"{}\">This browser is signed in as:</p><ul>{items}</ul>\
769                 <p class=\"next\">Go back to the sign-in that sent you here.</p>",
770                accounts.len()
771            ),
772        )
773    };
774    let page = landing::page(
775        StatusCode::OK,
776        &Landing {
777            placeholder: landing::BROWSER,
778            tone,
779            status,
780            body: &body,
781            reload: None,
782        },
783    );
784    match browser.filter(|browser| browser.replaced) {
785        Some(browser) => with_browser_cookie(page, &browser, landing::BROWSER),
786        None => page,
787    }
788}
789
790/// `response`, setting [`crate::oauth::browser::COOKIE`] to `browser`'s
791/// current session id. `placeholder` heads the page if it cannot.
792fn with_browser_cookie(
793    mut response: Response,
794    browser: &crate::oauth::browser::Browser,
795    placeholder: &str,
796) -> Response {
797    match HeaderValue::try_from(crate::oauth::browser::cookie(browser)) {
798        Ok(cookie) => {
799            response.headers_mut().append(header::SET_COOKIE, cookie);
800            response
801        }
802        Err(_) => landing::server_error(placeholder, "a browser session id is not a header value"),
803    }
804}
805
806/// `303 See Other` to `location`, which a browser follows with a `GET`
807/// whatever method brought it here. `placeholder` heads the page if it
808/// cannot.
809fn see_other(location: &str, placeholder: &str) -> Response {
810    match HeaderValue::from_str(location) {
811        Ok(location) => (StatusCode::SEE_OTHER, [(header::LOCATION, location)]).into_response(),
812        Err(_) => landing::server_error(placeholder, "the client's redirect is not a header value"),
813    }
814}
815
816/// The wire fields `POST /oauth/token` accepts, form-encoded per RFC 6749
817/// §4.
818#[derive(serde::Deserialize)]
819pub(crate) struct OAuthTokenForm {
820    grant_type: String,
821    code: Option<String>,
822    redirect_uri: Option<String>,
823    code_verifier: Option<String>,
824    client_id: Option<String>,
825    refresh_token: Option<String>,
826    client_assertion_type: Option<String>,
827    client_assertion: Option<String>,
828}
829
830/// Marks every answer from `POST /oauth/par` and `POST /oauth/token`
831/// uncacheable, success and refusal alike.
832///
833/// RFC 6749 §5.1 has an authorization server send `Cache-Control: no-store`
834/// and `Pragma: no-cache` on any response containing tokens, credentials or
835/// other sensitive information. The reference provider sends both on every
836/// answer from these endpoints, as this does.
837pub(crate) async fn never_cached(
838    request: axum::extract::Request,
839    next: axum::middleware::Next,
840) -> Response {
841    let mut response = next.run(request).await;
842    let headers = response.headers_mut();
843    headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
844    headers.insert(header::PRAGMA, HeaderValue::from_static("no-cache"));
845    response
846}
847
848/// The DPoP proof a request to this authorization server carries, checked
849/// against the absolute URL of `path` on this deployment.
850///
851/// A client answers a nonce challenge by sending the same body again with a
852/// new proof, so this runs before anything the body names is fetched or
853/// spent: the client's document, its assertion, a code, a refresh token. The
854/// reference provider checks in the same order. The proof's `jti` is read
855/// here and not spent; see `crate::oauth::dpop`.
856fn checked_proof(
857    state: &AppState,
858    headers: &HeaderMap,
859    path: &str,
860) -> Result<crate::oauth::dpop_seam::VerifiedProof, crate::oauth::error::OAuthError> {
861    use crate::oauth::dpop_seam::DpopError;
862    use crate::oauth::error::{OAuthError, OAuthErrorCode};
863
864    let proof = match crate::oauth::dpop::presented_proof(headers) {
865        Ok(Some(proof)) => proof,
866        Ok(None) => {
867            return Err(OAuthError::new(
868                OAuthErrorCode::InvalidDpopProof,
869                "this request carries no DPoP proof",
870            ))
871        }
872        Err(err) => {
873            return Err(OAuthError::new(
874                OAuthErrorCode::InvalidDpopProof,
875                err.to_string(),
876            ))
877        }
878    };
879    // The absolute URL a conformant client's proof signs, read out of this
880    // deployment's own zone service document and never out of `Host` or an
881    // `X-Forwarded-*` header; see `oauth::discovery::request_htu`.
882    let htu = crate::oauth::discovery::request_htu(state.registry.as_ref(), path)
883        .map_err(|err| OAuthError::new(OAuthErrorCode::ServerError, err.to_string()))?;
884    // RFC 9449 §8's `use_dpop_nonce` for a proof that carries no nonce this
885    // server accepts, and §5's `invalid_dpop_proof` for any other refusal.
886    state
887        .oauth
888        .dpop
889        .verify(proof, "POST", &htu, None)
890        .map_err(|err| match err {
891            DpopError::UseNonce { nonce, message } => {
892                OAuthError::new(OAuthErrorCode::UseDpopNonce, message).with_dpop_nonce(nonce)
893            }
894            err => OAuthError::new(OAuthErrorCode::InvalidDpopProof, err.to_string()),
895        })
896}
897
898pub(crate) fn oauth_token_response(tokens: crate::oauth::token::OAuthTokens) -> Response {
899    let expires_in = (tokens.access_expires_at - time::OffsetDateTime::now_utc())
900        .whole_seconds()
901        .max(0);
902    Json(json!({
903        // `DPoP`, not `Bearer`: this deployment's tokens slot into
904        // `crate::auth`'s existing credential taxonomy rather than a scheme
905        // of this module's own invention; see `crate::oauth::token`'s doc.
906        "token_type": "DPoP",
907        "access_token": tokens.access_token,
908        "expires_in": expires_in,
909        "refresh_token": tokens.refresh_token,
910        "scope": tokens.scope.to_string(),
911        "sub": tokens.did,
912    }))
913    .into_response()
914}
915
916/// What a refused exchange or refresh answers, and how loudly it is
917/// written.
918///
919/// `warn` is a presentation that looks like a stolen credential (a refresh
920/// token used twice, a proof under another key), a client whose
921/// authentication no longer matches its metadata, and a grant this server
922/// could not keep. The rest is ordinary expiry, a caller's mistake, or this
923/// server's policy, at `info`.
924pub(crate) fn token_refusal(err: crate::oauth::token::TokenError) -> Refused {
925    use crate::oauth::error::OAuthErrorCode;
926    use crate::oauth::token::TokenError;
927    let code = match err {
928        TokenError::UnsupportedGrantType(_) => OAuthErrorCode::UnsupportedGrantType,
929        TokenError::MissingParameter(_) => OAuthErrorCode::InvalidRequest,
930        TokenError::InvalidCode
931        | TokenError::InvalidRefreshToken
932        | TokenError::RefreshReused
933        | TokenError::DpopKeyMismatch
934        | TokenError::RedirectUriMismatch
935        | TokenError::PkceFailed
936        | TokenError::ClientMismatch
937        | TokenError::ClientAuthenticationChanged
938        | TokenError::ClientRefused => OAuthErrorCode::InvalidGrant,
939        TokenError::ClientAssertionRequired => OAuthErrorCode::InvalidClient,
940        TokenError::Dpop(_) | TokenError::CodeKeyMismatch => OAuthErrorCode::InvalidDpopProof,
941        TokenError::NotKept => OAuthErrorCode::ServerError,
942    };
943    let error = OAuthError::new(code, err.to_string());
944    match err {
945        TokenError::RefreshReused
946        | TokenError::DpopKeyMismatch
947        | TokenError::CodeKeyMismatch
948        | TokenError::ClientAuthenticationChanged
949        | TokenError::ClientAssertionRequired
950        | TokenError::NotKept => Refused::warn(error),
951        _ => Refused::info(error),
952    }
953}
954
955/// How long `bot.did.listPendingAuthorizations` will hold a request open.
956///
957/// The daemon polls with `wait=25`, one poll per account it holds a token
958/// for, and this is the ceiling it is clamped to. Long enough that a poll
959/// loop is cheap and short enough to stay well inside any intermediary's own
960/// idle timeout.
961pub const MAX_AUTHORIZATION_WAIT: u64 = 30;
962
963/// `GET /xrpc/bot.did.listPendingAuthorizations`'s query parameters.
964#[derive(serde::Deserialize)]
965pub(crate) struct ListPendingAuthorizationsQuery {
966    /// Where the caller got to last time. Opaque on the wire, and safe to
967    /// keep across a restart: a cursor this server did not mint is read as
968    /// the beginning rather than as a place it has not reached. See
969    /// `crate::oauth::decision::Cursor`.
970    cursor: Option<String>,
971    /// How many seconds to hold the request open for if there is nothing
972    /// newer, clamped to [`MAX_AUTHORIZATION_WAIT`].
973    wait: Option<u64>,
974}
975
976/// `GET /xrpc/bot.did.getAuthorization`'s query parameters.
977#[derive(serde::Deserialize)]
978#[serde(rename_all = "camelCase")]
979pub(crate) struct GetAuthorizationQuery {
980    /// The pushed request whose decision to read.
981    request_uri: String,
982}
983
984/// `GET /xrpc/bot.did.listPendingAuthorizations`
985///
986/// `Credential::AccountSelf`, used as identity rather than as a comparison:
987/// this route names no account, and the decisions it lists are the ones
988/// addressed to the account the presented agent token authenticates as. An
989/// agent cannot ask for anybody else's, because there is nowhere in the
990/// request to ask.
991///
992/// Long-polls. It answers at once when this account has a live decision
993/// newer than `cursor`, and otherwise holds the request open for up to
994/// `wait` seconds waiting for one — which is what lets the daemon learn of a
995/// decision without polling in a tight loop. Records that were approved,
996/// declined, or whose pushed request expired are not returned; the cursor
997/// still advances past them, so a caller that keeps sending back what it was
998/// given never re-reads a decision it has already seen.
999pub(crate) async fn list_pending_authorizations(
1000    State(state): State<AppState>,
1001    headers: HeaderMap,
1002    query: Result<Query<ListPendingAuthorizationsQuery>, QueryRejection>,
1003) -> Response {
1004    let Query(query) = match query {
1005        Ok(query) => query,
1006        Err(rejection) => return ApiError::bad_request(rejection.body_text()).into_response(),
1007    };
1008    let did = match auth::require_agent_token(state.registry.as_ref(), &headers) {
1009        Ok(did) => did,
1010        Err(err) => return err.into_response(),
1011    };
1012    let cursor = query.cursor.as_deref();
1013    let wait = std::time::Duration::from_secs(query.wait.unwrap_or(0).min(MAX_AUTHORIZATION_WAIT));
1014
1015    let deadline = tokio::time::Instant::now() + wait;
1016    let arrivals = state.oauth.decision_store.arrivals();
1017    let shutdown = state.oauth.shutdown.clone();
1018    loop {
1019        // Subscribe before reading, so a record that arrives between the
1020        // read and the wait wakes this rather than being missed until the
1021        // deadline. A stop that came first resolves `stopping` at once: an
1022        // HTTP/2 client can still send a poll after the stop began, and one
1023        // parked then holds the listener closed for all of `drain_secs`.
1024        let notified = arrivals.notified();
1025        let stopping = shutdown.clone().wait();
1026        let (pending, next) = state.oauth.decision_store.pending_for(&did, cursor);
1027        if !pending.is_empty() || tokio::time::Instant::now() >= deadline {
1028            return pending_authorizations(&pending, &next);
1029        }
1030        let waited = tokio::select! {
1031            () = notified => true,
1032            () = stopping => false,
1033            () = tokio::time::sleep_until(deadline) => false,
1034        };
1035        if !waited {
1036            let (pending, next) = state.oauth.decision_store.pending_for(&did, cursor);
1037            return pending_authorizations(&pending, &next);
1038        }
1039    }
1040}
1041
1042/// The one answer shape `bot.did.listPendingAuthorizations` has, built in
1043/// one place so the wait's three exits cannot drift apart.
1044pub(crate) fn pending_authorizations(
1045    pending: &[crate::oauth::decision::DecisionRecord],
1046    next: &str,
1047) -> Response {
1048    let pending: Vec<serde_json::Value> = pending
1049        .iter()
1050        .map(crate::oauth::decision::DecisionRecord::to_json)
1051        .collect();
1052    Json(json!({ "cursor": next, "pending": pending })).into_response()
1053}
1054
1055/// `GET /xrpc/bot.did.getAuthorization`
1056///
1057/// One decision by the pushed request it decides. `Credential::AccountSelf`:
1058/// answered only when the decision is addressed to the account the presented
1059/// token authenticates as, and answered as "not found" otherwise, so this is
1060/// not a way to learn that somebody else has a sign-in in flight.
1061pub(crate) async fn get_authorization(
1062    State(state): State<AppState>,
1063    headers: HeaderMap,
1064    query: Result<Query<GetAuthorizationQuery>, QueryRejection>,
1065) -> Response {
1066    let Query(query) = match query {
1067        Ok(query) => query,
1068        Err(rejection) => return ApiError::bad_request(rejection.body_text()).into_response(),
1069    };
1070    let did = match auth::require_agent_token(state.registry.as_ref(), &headers) {
1071        Ok(did) => did,
1072        Err(err) => return err.into_response(),
1073    };
1074    match state.oauth.decision_store.get(&query.request_uri) {
1075        Some(record) if record.account == did => Json(record.to_json()).into_response(),
1076        _ => ApiError::new(
1077            StatusCode::NOT_FOUND,
1078            "AuthorizationNotFound",
1079            "no authorization request of yours answers to that request_uri",
1080        )
1081        .into_response(),
1082    }
1083}
1084
1085/// Reads one decision by its approval token, for the account the presented
1086/// credential authenticates as.
1087///
1088/// The account check is here rather than in a request field: an agent
1089/// approving cannot name the account it is approving for, so naming somebody
1090/// else's is not a thing the wire allows. A token that names a decision
1091/// addressed to another account is answered exactly as an unknown token is,
1092/// so a caller holding a stolen token learns nothing from the refusal.
1093pub(crate) fn decision_by_token(
1094    state: &AppState,
1095    did: &str,
1096    token: &str,
1097) -> Result<crate::oauth::decision::DecisionRecord, ApiError> {
1098    let unknown =
1099        || ApiError::bad_request("this approval token is unknown, already used, or expired");
1100    let record = state
1101        .oauth
1102        .decision_store
1103        .by_token(token)
1104        .ok_or_else(unknown)?;
1105    if record.account != did {
1106        return Err(unknown());
1107    }
1108    if !record.is_live(time::OffsetDateTime::now_utc()) {
1109        return Err(unknown());
1110    }
1111    Ok(record)
1112}
1113
1114/// `POST /xrpc/bot.did.approveAuthorization`
1115///
1116/// The one way an authorization is approved. `Credential::AccountSelf`: the
1117/// agent presents its own token, and the decision it approves must be the
1118/// one addressed to that account — `plan/oauth.md`'s "the account is
1119/// checked server-side, not by the daemon", enforced by the credential
1120/// rather than by a DID in the body a caller could write anything into.
1121///
1122/// Answers with the client's redirect, built here from the pending
1123/// consent's own `redirect_uri` and `state` and the pushed request's
1124/// `response_mode` — which is the whole of what an approving caller is not
1125/// allowed to see — and this server's issuer. A loopback redirect carrying
1126/// its code in the query is the caller's to deliver, because this server
1127/// cannot reach the host the client listens on. A browser carries any other
1128/// ([`crate::oauth::authorize::browser_carries`]): the approval holds it for
1129/// `GET /oauth/authorize` to hand to a browser signed in as the account.
1130///
1131/// Answers with the scope the decision record granted, which is what the
1132/// code carries: a token is issued at that set as the ceiling grants it at
1133/// exchange, and judged the same way at every use after.
1134///
1135/// The approval is remembered for the account and the client's
1136/// [`crate::oauth::client_metadata::ClientKey`], at this grant; see
1137/// [`crate::oauth::decision::DecisionStore::remember`].
1138///
1139/// Nothing is evaluated here. The record PAR wrote is read, and a policy
1140/// loaded since that push does not reach back into it — see
1141/// `crate::oauth::par`.
1142pub(crate) async fn approve_authorization(
1143    State(state): State<AppState>,
1144    headers: HeaderMap,
1145    XrpcBody(body): XrpcBody,
1146) -> Response {
1147    if let Err(halted) = state.estop.check_issue(EstopRefusal::Token) {
1148        return ApiError::from(halted).into_response();
1149    }
1150    let request: crate::wire::ApproveAuthorizationRequest = match parse_body(&body) {
1151        Ok(request) => request,
1152        Err(err) => return err.into_response(),
1153    };
1154    let did = match auth::require_agent_token(state.registry.as_ref(), &headers) {
1155        Ok(did) => did,
1156        Err(err) => return err.into_response(),
1157    };
1158    let record = match decision_by_token(&state, &did, &request.token) {
1159        Ok(record) => record,
1160        Err(err) => return err.into_response(),
1161    };
1162    if let crate::oauth::decision::Verdict::Deny { reason, rule } = &record.verdict {
1163        info!(account = did, rule, "approval refused: the decision denies");
1164        return ApiError::bad_request(format!(
1165            "this authorization request was refused by `{rule}`: {reason}"
1166        ))
1167        .into_response();
1168    }
1169    let issued = match issue_approval(&state, &did, &record, request.token) {
1170        Ok(issued) => issued,
1171        Err(err) => return err.into_response(),
1172    };
1173    state.oauth.decision_store.remember(
1174        &did,
1175        &record.client_key,
1176        &issued.client_id,
1177        issued.granted.clone(),
1178        time::OffsetDateTime::now_utc(),
1179    );
1180    if issued.browser {
1181        info!(
1182            account = did,
1183            "approved; a browser signed in as the account collects the code"
1184        );
1185    } else {
1186        info!(account = did, "approved; the caller delivers the code");
1187    }
1188
1189    Json(json!({
1190        "requestUri": record.request_uri,
1191        "granted": issued.granted.0.iter().map(ToString::to_string).collect::<Vec<_>>(),
1192        "redirect": issued.redirect.as_str(),
1193    }))
1194    .into_response()
1195}
1196
1197/// What [`issue_approval`] issued.
1198struct Issued {
1199    /// The client the approval is for.
1200    client_id: String,
1201    /// What the code carries.
1202    granted: crate::oauth::scope::ScopeSet,
1203    /// The client's redirect, carrying the code.
1204    redirect: url::Url,
1205    /// Whether a browser carries the code, so the record holds `redirect`
1206    /// for a browser signed in as the account.
1207    browser: bool,
1208}
1209
1210/// Approves `record` for `did` by spending `token`: issues the code, builds
1211/// the client's redirect, and settles the record, holding the redirect when
1212/// a browser carries it.
1213///
1214/// What `bot.did.approveAuthorization` does once it knows the caller, and
1215/// what `GET /oauth/authorize` does for a sign-in the account's remembered
1216/// approval confirms. The e-stop is the caller's to check.
1217fn issue_approval(
1218    state: &AppState,
1219    did: &str,
1220    record: &crate::oauth::decision::DecisionRecord,
1221    token: String,
1222) -> Result<Issued, ApiError> {
1223    // Read before anything is spent, so a server that cannot name itself
1224    // refuses the approval rather than using the decision up.
1225    let issuer = crate::oauth::discovery::issuer_for(state.registry.as_ref()).map_err(|err| {
1226        ApiError::new(
1227            StatusCode::INTERNAL_SERVER_ERROR,
1228            "ServerError",
1229            err.to_string(),
1230        )
1231    })?;
1232
1233    let Some(pushed) = state.oauth.par_store.get(&record.request_uri) else {
1234        return Err(ApiError::bad_request(
1235            "this authorization request is not live",
1236        ));
1237    };
1238
1239    // Spending the reference is what makes an approval single-use: a second
1240    // call finds nothing to confirm.
1241    let reference = crate::oauth::consent::ConsentReference(token);
1242    let pending =
1243        crate::oauth::consent::confirm(state.oauth.consent_store.as_ref(), &reference, did)
1244            .map_err(|err| {
1245                info!(account = did, error = %err, "consent refused");
1246                ApiError::bad_request(err.to_string())
1247            })?;
1248
1249    let granted = record.granted();
1250    let redirect_uri = pending.redirect_uri.clone();
1251    let state_param = pending.state.clone();
1252    let code = state.oauth.code_store.issue(pending.into());
1253    // `redeem` takes a code out of the store, so a code that cannot reach
1254    // its client is never exchanged.
1255    let redirect = match crate::oauth::authorize::authorization_response(
1256        &redirect_uri,
1257        pushed.response_mode,
1258        &[("code", &code)],
1259        state_param.as_deref(),
1260        &issuer,
1261    ) {
1262        Ok(redirect) => redirect,
1263        Err(err) => {
1264            state.oauth.code_store.redeem(&code);
1265            return Err(ApiError::bad_request(format!(
1266                "the client's redirect is unusable: {err}"
1267            )));
1268        }
1269    };
1270    let browser =
1271        crate::oauth::authorize::browser_carries(&pushed.redirect_uri, pushed.response_mode);
1272    // Settled together with the redirect it holds for a browser, so no load
1273    // of the page finds this approval without it. A decline or an expiry
1274    // that reached the record first leaves nothing to settle.
1275    if state
1276        .oauth
1277        .decision_store
1278        .approve(&record.request_uri, browser.then(|| redirect.to_string()))
1279        .is_none()
1280    {
1281        state.oauth.code_store.redeem(&code);
1282        return Err(ApiError::bad_request(
1283            "this authorization request has already been answered",
1284        ));
1285    }
1286    Ok(Issued {
1287        client_id: pushed.client_id,
1288        granted,
1289        redirect,
1290        browser,
1291    })
1292}
1293
1294/// Approves the sign-in `request_uri` names without asking the account, when
1295/// this load meets every rule of
1296/// [`crate::oauth::authorize::confirms_unasked`] and the e-stop is clear.
1297/// [`crate::oauth::authorize::answer`] then hands this browser the code.
1298///
1299/// The remembered approval is left as it was, so a confirmation never
1300/// renews it.
1301fn confirm_remembered(
1302    state: &AppState,
1303    request_uri: &str,
1304    client_id: &str,
1305    loader: &crate::oauth::authorize::Loader,
1306) {
1307    let now = time::OffsetDateTime::now_utc();
1308    let (Some(pushed), Some(record)) = (
1309        state.oauth.par_store.get(request_uri),
1310        state.oauth.decision_store.get(request_uri),
1311    ) else {
1312        return;
1313    };
1314    if pushed.client_id != client_id {
1315        return;
1316    }
1317    let remembered =
1318        state
1319            .oauth
1320            .decision_store
1321            .remembered(&record.account, &record.client_key, now);
1322    if !crate::oauth::authorize::confirms_unasked(
1323        &pushed,
1324        &record,
1325        remembered.as_ref(),
1326        loader,
1327        now,
1328    ) {
1329        return;
1330    }
1331    // Logged and counted by the e-stop itself. The page waits, as it does
1332    // for any sign-in the account has not answered.
1333    if state.estop.check_issue(EstopRefusal::Token).is_err() {
1334        return;
1335    }
1336    let Some(token) = record.token.clone() else {
1337        return;
1338    };
1339    match issue_approval(state, &record.account, &record, token.0) {
1340        Ok(_) => info!(
1341            account = record.account,
1342            client = pushed.client_id,
1343            "confirmed a sign-in on the account's remembered approval"
1344        ),
1345        Err(err) => info!(
1346            account = record.account,
1347            client = pushed.client_id,
1348            error = err.message(),
1349            "a remembered approval did not confirm a sign-in"
1350        ),
1351    }
1352}
1353
1354/// `POST /xrpc/bot.did.createBrowserSignIn`
1355///
1356/// Mints a ticket that signs a browser in as the account, and answers with
1357/// the link that spends it, on the issuer's host, and when it stops being
1358/// live. See `crate::oauth::browser`.
1359///
1360/// `Credential::AccountSelf`, used as identity: the ticket is the account's
1361/// the presented agent token authenticates as, and it replaces any ticket
1362/// that account already had. Refused while the e-stop is engaged, with
1363/// nothing stored. The ticket is never logged.
1364pub(crate) async fn create_browser_sign_in(
1365    State(state): State<AppState>,
1366    headers: HeaderMap,
1367) -> Response {
1368    if let Err(halted) = state.estop.check_issue(EstopRefusal::Token) {
1369        return ApiError::from(halted).into_response();
1370    }
1371    let did = match auth::require_agent_token(state.registry.as_ref(), &headers) {
1372        Ok(did) => did,
1373        Err(err) => return err.into_response(),
1374    };
1375    let issuer = match crate::oauth::discovery::issuer_for(state.registry.as_ref()) {
1376        Ok(issuer) => issuer,
1377        Err(err) => {
1378            return ApiError::new(
1379                StatusCode::INTERNAL_SERVER_ERROR,
1380                "ServerError",
1381                err.to_string(),
1382            )
1383            .into_response()
1384        }
1385    };
1386    let minted = state.oauth.browser_sessions.mint(&did);
1387    info!(account = did, "minted a browser sign-in link");
1388    Json(json!({
1389        "url": format!("{issuer}{}?ticket={}", crate::oauth::browser::PATH, minted.ticket),
1390        "expiresAt": didbot_schema::format::datetime::render(minted.expires_at),
1391    }))
1392    .into_response()
1393}
1394
1395/// `POST /xrpc/bot.did.declineAuthorization`
1396///
1397/// An agent's "no", recorded rather than left to look like an expiry.
1398/// `Credential::AccountSelf`, the same posture as [`approve_authorization`].
1399/// The approval token is spent either way, so a declined request cannot be
1400/// approved afterwards.
1401pub(crate) async fn decline_authorization(
1402    State(state): State<AppState>,
1403    headers: HeaderMap,
1404    XrpcBody(body): XrpcBody,
1405) -> Response {
1406    let request: crate::wire::DeclineAuthorizationRequest = match parse_body(&body) {
1407        Ok(request) => request,
1408        Err(err) => return err.into_response(),
1409    };
1410    let did = match auth::require_agent_token(state.registry.as_ref(), &headers) {
1411        Ok(did) => did,
1412        Err(err) => return err.into_response(),
1413    };
1414    let record = match decision_by_token(&state, &did, &request.token) {
1415        Ok(record) => record,
1416        Err(err) => return err.into_response(),
1417    };
1418    if state
1419        .oauth
1420        .decision_store
1421        .settle(
1422            &record.request_uri,
1423            crate::oauth::decision::DecisionState::Declined,
1424        )
1425        .is_none()
1426    {
1427        return ApiError::bad_request("this authorization request has already been answered")
1428            .into_response();
1429    }
1430    // Spend the reference too, so nothing can approve what was declined.
1431    state
1432        .oauth
1433        .consent_store
1434        .consume(&crate::oauth::consent::ConsentReference(request.token));
1435    info!(
1436        account = did,
1437        reason = request.reason.as_deref().unwrap_or(""),
1438        "authorization declined"
1439    );
1440    Json(json!({ "requestUri": record.request_uri })).into_response()
1441}
1442
1443/// The confidential client a `POST /oauth/token` request authenticated as,
1444/// or `None` when it carries no client assertion.
1445///
1446/// A request with no assertion fetches nothing. Whether its code or refresh
1447/// token needed one is the grant's to say; see `crate::oauth::token`. One
1448/// with an assertion is checked against the key set its client publishes
1449/// now, read with its metadata document.
1450async fn authenticated_client(
1451    state: &AppState,
1452    client_id: Option<&str>,
1453    credentials: &crate::oauth::client_auth::ClientCredentials,
1454) -> Result<Option<crate::oauth::client_auth::AuthenticatedClient>, Refused> {
1455    use crate::oauth::error::OAuthErrorCode;
1456
1457    if credentials.is_empty() {
1458        return Ok(None);
1459    }
1460    let Some(client_id) = client_id else {
1461        return Err(Refused::info(OAuthError::new(
1462            OAuthErrorCode::InvalidRequest,
1463            "a client_assertion needs the client_id it authenticates",
1464        )));
1465    };
1466    // The one spelling PAR takes, checked before anything is fetched, as PAR
1467    // checks it.
1468    if let Err(error) = didbot_pds::policy::ClientId::parse(client_id) {
1469        return Err(Refused::info(OAuthError::new(
1470            OAuthErrorCode::InvalidClient,
1471            error.to_string(),
1472        )));
1473    }
1474    let issuer = crate::oauth::discovery::issuer_for(state.registry.as_ref()).map_err(|err| {
1475        Refused::warn(OAuthError::new(
1476            OAuthErrorCode::ServerError,
1477            err.to_string(),
1478        ))
1479    })?;
1480    let (document, _) = crate::oauth::client_metadata::resolve_client(
1481        &state.oauth.http,
1482        state.oauth.client_cache.as_ref(),
1483        client_id,
1484        crate::oauth::client_metadata::LoopbackClients::for_zone(state.registry.zone()),
1485    )
1486    .await
1487    .map_err(|error| {
1488        // The caller gets the one sentence PAR answers with, which names
1489        // nothing the fetch found (see `ParError::ClientMetadata`). The
1490        // journal gets the cause.
1491        Refused::warn(OAuthError::new(
1492            OAuthErrorCode::InvalidClient,
1493            crate::oauth::par::ParError::ClientMetadata(error.clone()).to_string(),
1494        ))
1495        .because(error)
1496    })?;
1497    let key = state
1498        .oauth
1499        .client_assertions
1500        .authenticate(&document, credentials, &issuer)
1501        .map_err(|error| Refused::warn(OAuthError::new(error.code(), error.to_string())))?;
1502    Ok(
1503        key.map(|key| crate::oauth::client_auth::AuthenticatedClient {
1504            client_id: client_id.to_owned(),
1505            key,
1506        }),
1507    )
1508}
1509
1510/// `POST /oauth/token`
1511///
1512/// `authorization_code` exchange and `refresh_token` rotation, both DPoP
1513/// bound. Every exchange and every refresh runs through
1514/// `crate::oauth::dpop_seam::DpopVerifier`, which [`crate::oauth::OAuthState::default`]
1515/// wires to the real RFC 9449 verifier — see `crate::oauth::dpop`. The proof
1516/// is checked before the client or the grant; see [`checked_proof`].
1517///
1518/// A confidential client authenticates each one with a client assertion,
1519/// checked by [`authenticated_client`] before either grant runs.
1520///
1521/// Both are judged by policy before a token is issued; see
1522/// `crate::oauth::token::GateAdmission`.
1523pub(crate) async fn oauth_token(
1524    State(state): State<AppState>,
1525    peer: Peer,
1526    headers: HeaderMap,
1527    axum::Form(form): axum::Form<OAuthTokenForm>,
1528) -> Response {
1529    use crate::oauth::error::OAuthErrorCode;
1530
1531    let client_id = form.client_id.clone();
1532    let refuse = |refused: Refused| refused.answer("token", client_id.as_deref(), None);
1533
1534    if let Err(halted) = state.estop.check_issue(EstopRefusal::Token) {
1535        let refused = ApiError::from(halted);
1536        crate::refusal::refused(crate::refusal::Refusal {
1537            endpoint: "token",
1538            client_id: client_id.as_deref(),
1539            account: None,
1540            error: refused.error(),
1541            reason: refused.message(),
1542            severity: Severity::Info,
1543        });
1544        return refused.into_response();
1545    }
1546
1547    // Before the DPoP proof is verified: the proof is checked before the
1548    // credential in the body, so a caller with a fresh key spends an ECDSA
1549    // check per call while holding no credential at all.
1550    let key = caller_key(&headers, peer.0, state.trust_forwarded_headers);
1551    if !state.oauth.token_rate_limiter.check(&key) {
1552        return refuse(Refused::info(too_many_oauth_requests("token")));
1553    }
1554
1555    let proof = match checked_proof(&state, &headers, "/oauth/token") {
1556        Ok(proof) => proof,
1557        Err(refused) => return refuse(Refused::info(refused)),
1558    };
1559
1560    let pds_did = deployment_did(&state);
1561    let pds_hostname = state.registry.zone().to_owned();
1562    let admission = crate::oauth::token::GateAdmission {
1563        gate: state.oauth.grant_gate.as_ref(),
1564        registry: state.registry.as_ref(),
1565        pds_did: &pds_did,
1566        pds_hostname: &pds_hostname,
1567    };
1568
1569    let credentials = crate::oauth::client_auth::ClientCredentials {
1570        assertion_type: form.client_assertion_type,
1571        assertion: form.client_assertion,
1572    };
1573    let client = match authenticated_client(&state, form.client_id.as_deref(), &credentials).await {
1574        Ok(client) => client,
1575        Err(refused) => return refuse(refused),
1576    };
1577
1578    match form.grant_type.as_str() {
1579        "authorization_code" => {
1580            let (Some(code), Some(redirect_uri), Some(code_verifier), Some(client_id)) = (
1581                form.code,
1582                form.redirect_uri,
1583                form.code_verifier,
1584                form.client_id,
1585            ) else {
1586                return refuse(Refused::info(OAuthError::new(
1587                    OAuthErrorCode::InvalidRequest,
1588                    "authorization_code grant needs code, redirect_uri, code_verifier, and client_id",
1589                )));
1590            };
1591            match state.oauth.tokens.exchange_code(
1592                state.oauth.code_store.as_ref(),
1593                state.oauth.dpop.as_ref(),
1594                &admission,
1595                state.oauth.scope_policy.as_ref(),
1596                &code,
1597                &redirect_uri,
1598                &code_verifier,
1599                &client_id,
1600                client.as_ref(),
1601                &proof,
1602            ) {
1603                Ok(tokens) => oauth_token_response(tokens),
1604                Err(err) => refuse(token_refusal(err)),
1605            }
1606        }
1607        "refresh_token" => {
1608            let Some(refresh_token) = form.refresh_token else {
1609                return refuse(Refused::info(OAuthError::new(
1610                    OAuthErrorCode::InvalidRequest,
1611                    "refresh_token grant needs refresh_token",
1612                )));
1613            };
1614            match state.oauth.tokens.refresh(
1615                state.oauth.dpop.as_ref(),
1616                &admission,
1617                state.oauth.scope_policy.as_ref(),
1618                &refresh_token,
1619                client.as_ref(),
1620                &proof,
1621            ) {
1622                Ok(tokens) => oauth_token_response(tokens),
1623                Err(err) => refuse(token_refusal(err)),
1624            }
1625        }
1626        other => refuse(Refused::info(OAuthError::new(
1627            OAuthErrorCode::UnsupportedGrantType,
1628            format!("grant_type `{other}` is not supported"),
1629        ))),
1630    }
1631}