1use 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
28const PATH: &str = "/xrpc/com.atproto.server.getServiceAuth";
30
31pub const MAX_LIFETIME: Duration = Duration::hours(1);
33
34pub const MAX_UNBOUND_LIFETIME: Duration = Duration::minutes(1);
36
37const MAX_AUD_BYTES: usize = 2048;
39
40const 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
62pub(crate) fn is_protected(lxm: &str) -> bool {
64 PROTECTED_METHODS
65 .iter()
66 .any(|protected| protected.eq_ignore_ascii_case(lxm))
67}
68
69pub(crate) struct Caller {
71 pub(crate) did: String,
73 pub(crate) client_id: Option<ClientId>,
76}
77
78pub(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
109pub(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#[derive(Debug, Deserialize)]
123pub(crate) struct ServiceAuthQuery {
124 aud: String,
125 exp: Option<i64>,
126 lxm: Option<String>,
127}
128
129pub(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
196fn 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
223fn bad_expiration(message: String) -> ApiError {
225 ApiError::new(StatusCode::BAD_REQUEST, "BadExpiration", message)
226}
227
228fn 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
237fn 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}