Skip to main content

didbot_serve/
service_auth.rs

1//! `com.atproto.server.getServiceAuth`, and the rules a proxied call shares
2//! with it; see [`crate::proxy`].
3//!
4//! Both sign a service-auth token as an account, for another service to
5//! accept as that account calling it: the XRPC spec's "Inter-Service
6//! Authentication". The limits are the reference PDS's
7//! (`packages/pds/src/api/com/atproto/server/getServiceAuth.ts`): how far out
8//! `exp` may be, the shorter bound on a token bound to no method, and the
9//! methods that get no token at all.
10
11use axum::extract::rejection::QueryRejection;
12use axum::extract::{Query, State};
13use axum::http::{HeaderMap, StatusCode};
14use axum::response::{IntoResponse, Response};
15use axum::Json;
16use didbot_pds::policy::ClientId;
17use didbot_pds::service_auth::{ServiceAuth, DEFAULT_LIFETIME};
18use didbot_pds::EstopRefusal;
19use serde::Deserialize;
20use serde_json::json;
21use time::{Duration, OffsetDateTime};
22use tracing::info;
23
24use crate::auth;
25use crate::error::ApiError;
26use crate::routes::AppState;
27
28/// Where `getServiceAuth` is served, which a DPoP proof binds to.
29const PATH: &str = "/xrpc/com.atproto.server.getServiceAuth";
30
31/// The furthest out a requested `exp` may be.
32pub const MAX_LIFETIME: Duration = Duration::hours(1);
33
34/// The furthest out a requested `exp` may be on a token bound to no method.
35pub const MAX_UNBOUND_LIFETIME: Duration = Duration::minutes(1);
36
37/// The longest `aud` the lexicon admits, in bytes.
38const MAX_AUD_BYTES: usize = 2048;
39
40/// Methods that manage an account, which get no service-auth token and are
41/// not proxied: the reference PDS's `PROTECTED_METHODS`. Matched without
42/// regard to case, as it matches them.
43const PROTECTED_METHODS: &[&str] = &[
44    "com.atproto.admin.sendEmail",
45    "com.atproto.identity.requestPlcOperationSignature",
46    "com.atproto.identity.signPlcOperation",
47    "com.atproto.identity.updateHandle",
48    "com.atproto.server.activateAccount",
49    "com.atproto.server.confirmEmail",
50    "com.atproto.server.createAppPassword",
51    "com.atproto.server.deactivateAccount",
52    "com.atproto.server.getAccountInviteCodes",
53    "com.atproto.server.getSession",
54    "com.atproto.server.listAppPasswords",
55    "com.atproto.server.requestAccountDelete",
56    "com.atproto.server.requestEmailConfirmation",
57    "com.atproto.server.requestEmailUpdate",
58    "com.atproto.server.revokeAppPassword",
59    "com.atproto.server.updateEmail",
60];
61
62/// Whether `lxm` is one of [`PROTECTED_METHODS`].
63pub(crate) fn is_protected(lxm: &str) -> bool {
64    PROTECTED_METHODS
65        .iter()
66        .any(|protected| protected.eq_ignore_ascii_case(lxm))
67}
68
69/// Who a call at another service speaks for.
70pub(crate) struct Caller {
71    /// The account the token will be signed as.
72    pub(crate) did: String,
73    /// The application whose OAuth token was presented. `None` for the
74    /// account's own agent token.
75    pub(crate) client_id: Option<ClientId>,
76}
77
78/// Resolves the credential on a call at another service, and checks that
79/// it may call `lxm` at `aud`.
80///
81/// The credential is the one the write routes take: the account's own
82/// agent token, or a DPoP-bound OAuth token whose scope permits the call;
83/// see [`auth::Writer::authorize_rpc`]. `http_method` and `path` are this
84/// request's own, which a DPoP proof binds to.
85pub(crate) fn authorize(
86    state: &AppState,
87    headers: &HeaderMap,
88    http_method: &str,
89    path: &str,
90    lxm: Option<&str>,
91    aud: &str,
92) -> Result<Caller, ApiError> {
93    let writer = auth::require_agent_token_or_dpop(
94        state.registry.as_ref(),
95        &state.oauth,
96        headers,
97        http_method,
98        path,
99    )?;
100    let authorized = writer
101        .authorize_rpc(lxm, aud)
102        .map_err(|missing| auth::insufficient_scope(writer.did(), &missing))?;
103    Ok(Caller {
104        did: authorized.did.to_owned(),
105        client_id: authorized.client_id.cloned(),
106    })
107}
108
109/// Signs `token` as `did`, through the registry that holds its key.
110pub(crate) fn sign(
111    state: &AppState,
112    did: &str,
113    token: ServiceAuth<'_>,
114) -> Result<String, ApiError> {
115    state
116        .registry
117        .service_auth(did, token)
118        .map_err(|err| ApiError::from(&err))
119}
120
121/// The query `getServiceAuth` takes.
122#[derive(Debug, Deserialize)]
123pub(crate) struct ServiceAuthQuery {
124    aud: String,
125    exp: Option<i64>,
126    lxm: Option<String>,
127}
128
129/// `GET /xrpc/com.atproto.server.getServiceAuth`
130///
131/// A service-auth token signed as the caller's account, for `aud`. It
132/// issues a credential, so the e-stop's issuance gate refuses it under
133/// either setting.
134pub(crate) async fn get_service_auth(
135    State(state): State<AppState>,
136    headers: HeaderMap,
137    query: Result<Query<ServiceAuthQuery>, QueryRejection>,
138) -> Response {
139    match issue(&state, &headers, query) {
140        Ok(token) => Json(json!({ "token": token })).into_response(),
141        Err(err) => err.into_response(),
142    }
143}
144
145fn issue(
146    state: &AppState,
147    headers: &HeaderMap,
148    query: Result<Query<ServiceAuthQuery>, QueryRejection>,
149) -> Result<String, ApiError> {
150    state.estop.check_issue(EstopRefusal::Token)?;
151    let Query(query) = query.map_err(|rejection| ApiError::bad_request(rejection.body_text()))?;
152    if query.aud.len() > MAX_AUD_BYTES {
153        return Err(ApiError::bad_request(format!(
154            "aud may not exceed {MAX_AUD_BYTES} bytes"
155        )));
156    }
157    let lxm = query.lxm.as_deref();
158    if let Some(lxm) = lxm {
159        didbot_lexicon::nsid_syntax::validate(lxm)
160            .map_err(|err| ApiError::bad_request(format!("lxm `{lxm}` is not an NSID: {err}")))?;
161    }
162    let caller = authorize(state, headers, "GET", PATH, lxm, &query.aud)?;
163    if !is_audience(&query.aud) {
164        return Err(ApiError::bad_request(
165            "aud must be an atproto DID, or a DID and a service fragment",
166        ));
167    }
168    let now = OffsetDateTime::now_utc();
169    let exp = expiry(query.exp, now, lxm.is_some())?;
170    if let Some(lxm) = lxm.filter(|lxm| is_protected(lxm)) {
171        return Err(ApiError::bad_request(format!(
172            "`{lxm}` manages an account, and no service-auth token is minted for it"
173        )));
174    }
175    let token = sign(
176        state,
177        &caller.did,
178        ServiceAuth {
179            aud: &query.aud,
180            lxm,
181            iat: now,
182            exp,
183        },
184    )?;
185    info!(
186        did = %caller.did,
187        client = caller.client_id.as_ref().map(ClientId::as_str),
188        aud = %query.aud,
189        lxm = lxm.unwrap_or("-"),
190        lifetime = (exp - now).whole_seconds(),
191        "minted a service-auth token"
192    );
193    Ok(token)
194}
195
196/// When a token for `exp` stops being good: `exp` itself when it is in
197/// bounds, or [`DEFAULT_LIFETIME`] from `now` when the caller named none.
198fn expiry(exp: Option<i64>, now: OffsetDateTime, bound: bool) -> Result<OffsetDateTime, ApiError> {
199    let Some(exp) = exp else {
200        return Ok(now + DEFAULT_LIFETIME);
201    };
202    let exp = OffsetDateTime::from_unix_timestamp(exp)
203        .map_err(|_| bad_expiration(format!("{exp} is not a time")))?;
204    let ahead = exp - now;
205    if ahead.is_negative() {
206        return Err(bad_expiration("the expiration is in the past".to_owned()));
207    }
208    if ahead > MAX_LIFETIME {
209        return Err(bad_expiration(format!(
210            "the expiration may be at most {} seconds away",
211            MAX_LIFETIME.whole_seconds()
212        )));
213    }
214    if !bound && ahead > MAX_UNBOUND_LIFETIME {
215        return Err(bad_expiration(format!(
216            "a token bound to no method may expire at most {} seconds away",
217            MAX_UNBOUND_LIFETIME.whole_seconds()
218        )));
219    }
220    Ok(exp)
221}
222
223/// `BadExpiration`, the one error the lexicon declares.
224fn bad_expiration(message: String) -> ApiError {
225    ApiError::new(StatusCode::BAD_REQUEST, "BadExpiration", message)
226}
227
228/// Whether `aud` is an atproto DID, or one with a service fragment.
229fn is_audience(aud: &str) -> bool {
230    let (did, fragment) = match aud.split_once('#') {
231        Some((did, fragment)) => (did, Some(fragment)),
232        None => (aud, None),
233    };
234    didbot_identity::document_url(did).is_ok() && fragment.is_none_or(is_fragment)
235}
236
237/// Whether `fragment` is a non-empty RFC 3986 fragment, the syntax a DID
238/// URL's service id takes.
239fn is_fragment(fragment: &str) -> bool {
240    let bytes = fragment.as_bytes();
241    let mut at = 0;
242    while at < bytes.len() {
243        match bytes[at] {
244            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' => at += 1,
245            b'-' | b'.' | b'_' | b'~' | b'!' | b'$' | b'&' | b'\'' | b'(' | b')' | b'*' => at += 1,
246            b'+' | b',' | b';' | b'=' | b':' | b'@' | b'/' | b'?' => at += 1,
247            b'%' if bytes
248                .get(at + 1..at + 3)
249                .is_some_and(|hex| hex.iter().all(u8::is_ascii_hexdigit)) =>
250            {
251                at += 3;
252            }
253            _ => return false,
254        }
255    }
256    !bytes.is_empty()
257}