Microservice to bring 2FA to self hosted PDSes
1use super::session;
2use crate::AppState;
3use crate::admin::store::SqlAuthStore;
4use axum::{
5 extract::{Query, State},
6 http::StatusCode,
7 response::{Html, IntoResponse, Redirect, Response},
8};
9use axum_extra::extract::cookie::{Cookie, SameSite, SignedCookieJar};
10use jacquard_common::CowStr;
11use jacquard_identity::JacquardResolver;
12use jacquard_identity::resolver::IdentityResolver;
13use jacquard_oauth::session::ClientSessionData;
14use jacquard_oauth::{
15 atproto::{AtprotoClientMetadata, GrantType},
16 client::OAuthClient,
17 session::ClientData,
18 types::{AuthorizeOptions, CallbackParams},
19};
20use serde::Deserialize;
21use sqlx::SqlitePool;
22use tracing::log;
23
24/// Type alias for the concrete OAuthClient we use.
25pub type AdminOAuthClient = OAuthClient<JacquardResolver, SqlAuthStore>;
26
27/// Initialize the OAuth client for admin portal authentication.
28pub fn init_oauth_client(
29 pds_hostname: &str,
30 pool: SqlitePool,
31) -> Result<AdminOAuthClient, anyhow::Error> {
32 // Build client metadata
33 let client_id = format!("https://{}/admin/client-metadata.json", pds_hostname)
34 .parse()
35 .map_err(|_: url::ParseError| anyhow::anyhow!("Invalid client_id URL"))?;
36 let redirect_uri = format!("https://{}/admin/oauth/callback", pds_hostname)
37 .parse()
38 .map_err(|_: url::ParseError| anyhow::anyhow!("Invalid redirect_uri URL"))?;
39 let client_uri = format!("https://{}/admin/", pds_hostname)
40 .parse()
41 .map_err(|_: url::ParseError| anyhow::anyhow!("Invalid client_uri URL"))?;
42
43 let config = AtprotoClientMetadata::new(
44 client_id,
45 Some(client_uri),
46 vec![redirect_uri],
47 vec![GrantType::AuthorizationCode],
48 vec![jacquard_oauth::scopes::Scope::parse("atproto").expect("valid scope")],
49 None,
50 );
51
52 let client_data = ClientData::new(None, config);
53 let store = SqlAuthStore::new(pool);
54 let client = OAuthClient::new(store, client_data);
55
56 Ok(client)
57}
58
59/// GET /admin/client-metadata.json — Serves the OAuth client metadata.
60pub async fn client_metadata_json(State(state): State<AppState>) -> Response {
61 let pds_hostname = &state.app_config.pds_hostname;
62 let client_id = format!("https://{}/admin/client-metadata.json", pds_hostname);
63 let redirect_uri = format!("https://{}/admin/oauth/callback", pds_hostname);
64 let client_uri = format!("https://{}/admin/", pds_hostname);
65
66 let metadata = serde_json::json!({
67 "client_id": client_id,
68 "client_uri": client_uri,
69 "redirect_uris": [redirect_uri],
70 "grant_types": ["authorization_code"],
71 "response_types": ["code"],
72 "scope": "atproto",
73 "token_endpoint_auth_method": "none",
74 "application_type": "web",
75 "dpop_bound_access_tokens": true,
76
77 });
78
79 (
80 StatusCode::OK,
81 [(axum::http::header::CONTENT_TYPE, "application/json")],
82 serde_json::to_string_pretty(&metadata).unwrap_or_default(),
83 )
84 .into_response()
85}
86
87/// GET /admin/login — Renders the login page.
88pub async fn get_login(
89 State(state): State<AppState>,
90 Query(params): Query<LoginQueryParams>,
91) -> Response {
92 let mut data = serde_json::json!({
93 "pds_hostname": state.app_config.pds_hostname,
94 });
95
96 if let Some(error) = params.error {
97 data["error"] = serde_json::Value::String(error);
98 }
99
100 use axum_template::TemplateEngine;
101 match state.template_engine.render("admin/login.hbs", data) {
102 Ok(html) => Html(html).into_response(),
103 Err(e) => {
104 tracing::error!("Failed to render login template: {}", e);
105 StatusCode::INTERNAL_SERVER_ERROR.into_response()
106 }
107 }
108}
109
110#[derive(Debug, Deserialize)]
111pub struct LoginQueryParams {
112 pub error: Option<String>,
113}
114
115#[derive(Debug, Deserialize)]
116pub struct LoginForm {
117 pub handle: String,
118}
119
120/// POST /admin/login — Initiates the OAuth flow.
121pub async fn post_login(
122 State(state): State<AppState>,
123 axum::extract::Form(form): axum::extract::Form<LoginForm>,
124) -> Response {
125 let oauth_client: &AdminOAuthClient = match &state.admin_oauth_client {
126 Some(client) => client,
127 None => return StatusCode::NOT_FOUND.into_response(),
128 };
129
130 let pds_hostname = &state.app_config.pds_hostname;
131 let redirect_uri: url::Url = match format!("https://{}/admin/oauth/callback", pds_hostname)
132 .parse()
133 {
134 Ok(u) => u,
135 Err(_) => {
136 return Redirect::to("/admin/login?error=Invalid+server+configuration").into_response();
137 }
138 };
139
140 let options = AuthorizeOptions {
141 redirect_uri: Some(redirect_uri),
142 scopes: vec![
143 jacquard_oauth::scopes::Scope::parse("atproto").expect("valid scope"),
144 jacquard_oauth::scopes::Scope::parse("transition:generic").expect("valid scope"),
145 ],
146 prompt: None,
147 state: None,
148 };
149
150 match oauth_client.start_auth(&form.handle, options).await {
151 Ok(auth_url) => Redirect::to(&auth_url).into_response(),
152 Err(e) => {
153 tracing::error!("OAuth start_auth failed: {}", e);
154 let msg = format!("Login failed: {}", e);
155 let error_msg = urlencoding::encode(&msg);
156 Redirect::to(&format!("/admin/login?error={}", error_msg)).into_response()
157 }
158 }
159}
160
161#[derive(Debug, Deserialize)]
162pub struct OAuthCallbackParams {
163 pub code: String,
164 pub state: Option<String>,
165 pub iss: Option<String>,
166}
167
168/// GET /admin/oauth/callback — Handles the OAuth callback.
169pub async fn oauth_callback(
170 State(state): State<AppState>,
171 Query(params): Query<OAuthCallbackParams>,
172 jar: SignedCookieJar,
173) -> Response {
174 let oauth_client: &AdminOAuthClient = match &state.admin_oauth_client {
175 Some(client) => client,
176 None => return StatusCode::NOT_FOUND.into_response(),
177 };
178
179 let rbac = match &state.admin_rbac_config {
180 Some(rbac) => rbac,
181 None => return StatusCode::NOT_FOUND.into_response(),
182 };
183
184 let callback_params = CallbackParams {
185 code: params.code.as_str().into(),
186 state: params.state.as_deref().map(Into::into),
187 iss: params.iss.as_deref().map(Into::into),
188 };
189
190 // Exchange authorization code for session
191 let oauth_session = match oauth_client.callback(callback_params).await {
192 Ok(session) => session,
193 Err(e) => {
194 tracing::error!("OAuth callback failed: {}", e);
195 let msg = format!("Authentication failed: {}", e);
196 let error_msg = urlencoding::encode(&msg);
197 return Redirect::to(&format!("/admin/login?error={}", error_msg)).into_response();
198 }
199 };
200
201 // Extract DID and handle from the OAuth session
202 let (did, session_id) = oauth_session.session_info().await;
203 let session_id_string = session_id.to_string();
204 log::info!("Authenticated as DID {}", did);
205 let handle = match state.resolver.resolve_did_doc(&did).await {
206 Ok(did_doc) => match did_doc.parse() {
207 Ok(parsed_did_doc) => {
208 let handles = parsed_did_doc.handles();
209 if handles.len() > 0 {
210 handles[0].to_string()
211 } else {
212 "Not found".to_string()
213 }
214 }
215 Err(err) => {
216 tracing::error!("Failed to parse DID document for {}: {}", did, err);
217 "Not found".to_string()
218 }
219 },
220 Err(err) => {
221 tracing::error!("Failed to resolve DID document for {}: {}", did, err);
222 //just default to the did
223 "Not found".to_string()
224 }
225 };
226 let did_str = did.to_string();
227
228 // Check if this DID is a member in the RBAC config
229 if !rbac.is_member(&did_str) {
230 tracing::warn!("Access denied for DID {} (not in RBAC config)", did_str);
231 return render_error(
232 &state,
233 "Access Denied",
234 &format!(
235 "Your identity ({}) is not authorized to access the admin portal. Contact your PDS administrator.",
236 did_str
237 ),
238 );
239 }
240
241 // Create admin session
242 let ttl_hours = state.app_config.admin_session_ttl_hours;
243 let session_id = match session::create_session(
244 &state.pds_gatekeeper_pool,
245 &did_str,
246 &handle,
247 &session_id_string,
248 ttl_hours,
249 )
250 .await
251 {
252 Ok(id) => id,
253 Err(e) => {
254 tracing::error!("Failed to create admin session: {}", e);
255 return Redirect::to("/admin/login?error=Session+creation+failed").into_response();
256 }
257 };
258
259 // Set signed cookie
260 let mut cookie = Cookie::new("__gatekeeper_admin_session", session_id);
261 cookie.set_http_only(true);
262 cookie.set_secure(true);
263 cookie.set_same_site(SameSite::Lax);
264 cookie.set_path("/admin/");
265
266 let updated_jar = jar.add(cookie);
267
268 (updated_jar, Redirect::to("/admin/dashboard")).into_response()
269}
270
271fn render_error(state: &AppState, title: &str, message: &str) -> Response {
272 let data = serde_json::json!({
273 "error_title": title,
274 "error_message": message,
275 "pds_hostname": state.app_config.pds_hostname,
276 });
277
278 use axum_template::TemplateEngine;
279 match state.template_engine.render("admin/error.hbs", data) {
280 Ok(html) => Html(html).into_response(),
281 Err(_) => (StatusCode::FORBIDDEN, format!("{}: {}", title, message)).into_response(),
282 }
283}