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