tangled
alpha
login
or
join now
baileytownsend.dev
/
pds-gatekeeper
89
fork
atom
Microservice to bring 2FA to self hosted PDSes
89
fork
atom
overview
issues
1
pulls
3
pipelines
Added oauth check
baileytownsend.dev
1 day ago
74d84fc6
dac7bffc
+89
-48
6 changed files
expand all
collapse all
unified
split
migrations
20260228000000_admin_sessions.sql
src
admin
middleware.rs
oauth.rs
routes.rs
session.rs
main.rs
+9
-7
migrations/20260228000000_admin_sessions.sql
···
1
1
-
CREATE TABLE admin_sessions (
2
2
-
session_id VARCHAR(36) PRIMARY KEY,
3
3
-
did VARCHAR NOT NULL,
4
4
-
handle VARCHAR NOT NULL,
5
5
-
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
6
6
-
expires_at TIMESTAMP NOT NULL
1
1
+
CREATE TABLE admin_sessions
2
2
+
(
3
3
+
session_id VARCHAR(36) PRIMARY KEY,
4
4
+
did VARCHAR NOT NULL,
5
5
+
handle VARCHAR NOT NULL,
6
6
+
oauth_session_id VARCHAR NOT NULL,
7
7
+
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
8
8
+
expires_at TIMESTAMP NOT NULL
7
9
);
8
8
-
CREATE INDEX idx_admin_sessions_expires_at ON admin_sessions(expires_at);
10
10
+
CREATE INDEX idx_admin_sessions_expires_at ON admin_sessions (expires_at);
+25
-14
src/admin/middleware.rs
···
6
6
};
7
7
use axum_extra::extract::cookie::SignedCookieJar;
8
8
9
9
-
use crate::AppState;
10
10
-
11
9
use super::rbac::RbacConfig;
12
10
use super::session;
11
11
+
use crate::AppState;
12
12
+
use jacquard_common::types::did::Did;
13
13
14
14
/// Admin session data injected into request extensions.
15
15
#[derive(Debug, Clone)]
···
36
36
impl AdminPermissions {
37
37
pub fn compute(rbac: &RbacConfig, did: &str) -> Self {
38
38
Self {
39
39
-
can_view_accounts: rbac
40
40
-
.can_access_endpoint(did, "com.atproto.admin.getAccountInfo")
39
39
+
can_view_accounts: rbac.can_access_endpoint(did, "com.atproto.admin.getAccountInfo")
41
40
|| rbac.can_access_endpoint(did, "com.atproto.admin.getAccountInfos"),
42
41
can_manage_takedowns: rbac
43
42
.can_access_endpoint(did, "com.atproto.admin.updateSubjectStatus"),
44
44
-
can_delete_account: rbac
45
45
-
.can_access_endpoint(did, "com.atproto.admin.deleteAccount"),
43
43
+
can_delete_account: rbac.can_access_endpoint(did, "com.atproto.admin.deleteAccount"),
46
44
can_reset_password: rbac
47
45
.can_access_endpoint(did, "com.atproto.admin.updateAccountPassword"),
48
48
-
can_create_account: rbac
49
49
-
.can_access_endpoint(did, "com.atproto.server.createAccount"),
50
50
-
can_manage_invites: rbac
51
51
-
.can_access_endpoint(did, "com.atproto.admin.getInviteCodes"),
52
52
-
can_create_invite: rbac
53
53
-
.can_access_endpoint(did, "com.atproto.server.createInviteCode"),
46
46
+
can_create_account: rbac.can_access_endpoint(did, "com.atproto.server.createAccount"),
47
47
+
can_manage_invites: rbac.can_access_endpoint(did, "com.atproto.admin.getInviteCodes"),
48
48
+
can_create_invite: rbac.can_access_endpoint(did, "com.atproto.server.createInviteCode"),
54
49
can_send_email: rbac.can_access_endpoint(did, "com.atproto.admin.sendEmail"),
55
55
-
can_request_crawl: rbac
56
56
-
.can_access_endpoint(did, "com.atproto.sync.requestCrawl"),
50
50
+
can_request_crawl: rbac.can_access_endpoint(did, "com.atproto.sync.requestCrawl"),
57
51
}
58
52
}
59
53
}
···
91
85
// Verify the DID is still a valid member
92
86
if !rbac.is_member(&session_row.did) {
93
87
return Redirect::to("/admin/login").into_response();
88
88
+
}
89
89
+
90
90
+
let oauth_client = if let Some(client) = &state.admin_oauth_client {
91
91
+
client
92
92
+
} else {
93
93
+
return Redirect::to("/admin/login").into_response();
94
94
+
};
95
95
+
96
96
+
let did: Did = session_row.did.clone().into();
97
97
+
let oauth_session_id = session_row.oauth_session_id.clone();
98
98
+
match oauth_client.restore(&did, oauth_session_id.as_str()).await {
99
99
+
Ok(_) => {}
100
100
+
Err(e) => {
101
101
+
tracing::error!("Failed to restore admin session: {}", e);
102
102
+
let error_msg = e.to_string();
103
103
+
return Redirect::to(&format!("/admin/login?error={}", error_msg)).into_response();
104
104
+
}
94
105
}
95
106
96
107
let roles = rbac.get_member_roles(&session_row.did);
+43
-14
src/admin/oauth.rs
···
7
7
response::{Html, IntoResponse, Redirect, Response},
8
8
};
9
9
use axum_extra::extract::cookie::{Cookie, SameSite, SignedCookieJar};
10
10
+
use jacquard_common::CowStr;
10
11
use jacquard_identity::JacquardResolver;
12
12
+
use jacquard_identity::resolver::IdentityResolver;
11
13
use jacquard_oauth::session::ClientSessionData;
12
14
use jacquard_oauth::{
13
15
atproto::{AtprotoClientMetadata, GrantType},
···
197
199
};
198
200
199
201
// Extract DID and handle from the OAuth session
200
200
-
let (did, handle) = oauth_session.session_info().await;
202
202
+
let (did, session_id) = oauth_session.session_info().await;
203
203
+
let session_id_string = session_id.to_string();
204
204
+
log::info!("Authenticated as DID {}", did);
205
205
+
let handle = match state.resolver.resolve_did_doc(&did).await {
206
206
+
Ok(did_doc) => match did_doc.parse() {
207
207
+
Ok(parsed_did_doc) => {
208
208
+
let handles = parsed_did_doc.handles();
209
209
+
if handles.len() > 0 {
210
210
+
handles[0].to_string()
211
211
+
} else {
212
212
+
"Not found".to_string()
213
213
+
}
214
214
+
}
215
215
+
Err(err) => {
216
216
+
tracing::error!("Failed to parse DID document for {}: {}", did, err);
217
217
+
"Not found".to_string()
218
218
+
}
219
219
+
},
220
220
+
Err(err) => {
221
221
+
tracing::error!("Failed to resolve DID document for {}: {}", did, err);
222
222
+
//just default to the did
223
223
+
"Not found".to_string()
224
224
+
}
225
225
+
};
201
226
let did_str = did.to_string();
202
202
-
let handle_str = handle.to_string();
203
203
-
log::info!("Authenticated as DID {} ({})", did_str, handle_str);
227
227
+
204
228
// Check if this DID is a member in the RBAC config
205
229
if !rbac.is_member(&did_str) {
206
230
tracing::warn!("Access denied for DID {} (not in RBAC config)", did_str);
···
209
233
"Access Denied",
210
234
&format!(
211
235
"Your identity ({}) is not authorized to access the admin portal. Contact your PDS administrator.",
212
212
-
handle_str
236
236
+
did_str
213
237
),
214
238
);
215
239
}
216
240
217
241
// Create admin session
218
242
let ttl_hours = state.app_config.admin_session_ttl_hours;
219
219
-
let session_id =
220
220
-
match session::create_session(&state.pds_gatekeeper_pool, &did_str, &handle_str, ttl_hours)
221
221
-
.await
222
222
-
{
223
223
-
Ok(id) => id,
224
224
-
Err(e) => {
225
225
-
tracing::error!("Failed to create admin session: {}", e);
226
226
-
return Redirect::to("/admin/login?error=Session+creation+failed").into_response();
227
227
-
}
228
228
-
};
243
243
+
let session_id = match session::create_session(
244
244
+
&state.pds_gatekeeper_pool,
245
245
+
&did_str,
246
246
+
&handle,
247
247
+
&session_id_string,
248
248
+
ttl_hours,
249
249
+
)
250
250
+
.await
251
251
+
{
252
252
+
Ok(id) => id,
253
253
+
Err(e) => {
254
254
+
tracing::error!("Failed to create admin session: {}", e);
255
255
+
return Redirect::to("/admin/login?error=Session+creation+failed").into_response();
256
256
+
}
257
257
+
};
229
258
230
259
// Set signed cookie
231
260
let mut cookie = Cookie::new("__gatekeeper_admin_session", session_id);
+2
-3
src/admin/routes.rs
···
9
9
};
10
10
use axum_extra::extract::cookie::{Cookie, SignedCookieJar};
11
11
use jacquard::client::BasicClient;
12
12
+
use jacquard_api::com_atproto::server::create_app_password::app_password_state::members::password;
12
13
use jacquard_common::types::handle::Handle;
13
14
use jacquard_identity::resolver::IdentityResolver;
14
15
use serde::Deserialize;
15
15
-
use tracing::log;
16
16
17
17
// ─── Query parameter types ───────────────────────────────────────────────────
18
18
···
461
461
);
462
462
}
463
463
};
464
464
-
let client = BasicClient::unauthenticated();
465
464
466
465
tracing::debug!("Resolving handle: {}", handle);
467
467
-
look_up_did = match client.resolve_handle(&handle).await {
466
466
+
look_up_did = match state.resolver.resolve_handle(&handle).await {
468
467
Ok(did) => did.to_string(),
469
468
Err(e) => {
470
469
tracing::warn!("Failed to resolve handle '{}': {}", handle, e);
+6
-6
src/admin/session.rs
···
8
8
pub session_id: String,
9
9
pub did: String,
10
10
pub handle: String,
11
11
+
pub oauth_session_id: String,
11
12
pub created_at: String,
12
13
pub expires_at: String,
13
14
}
···
17
18
pool: &SqlitePool,
18
19
did: &str,
19
20
handle: &str,
21
21
+
oauth_session_id: &str,
20
22
ttl_hours: u64,
21
23
) -> Result<String> {
22
24
let session_id = Uuid::new_v4().to_string();
···
25
27
let expires_at = (now + chrono::Duration::hours(ttl_hours as i64)).to_rfc3339();
26
28
27
29
sqlx::query(
28
28
-
"INSERT INTO admin_sessions (session_id, did, handle, created_at, expires_at) VALUES (?, ?, ?, ?, ?)",
30
30
+
"INSERT INTO admin_sessions (session_id, did, handle, oauth_session_id, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)",
29
31
)
30
32
.bind(&session_id)
31
33
.bind(did)
32
34
.bind(handle)
35
35
+
.bind(oauth_session_id)
33
36
.bind(&created_at)
34
37
.bind(&expires_at)
35
38
.execute(pool)
···
39
42
}
40
43
41
44
/// Looks up a session by ID. Returns None if the session does not exist or has expired.
42
42
-
pub async fn get_session(
43
43
-
pool: &SqlitePool,
44
44
-
session_id: &str,
45
45
-
) -> Result<Option<AdminSessionRow>> {
45
45
+
pub async fn get_session(pool: &SqlitePool, session_id: &str) -> Result<Option<AdminSessionRow>> {
46
46
let now = Utc::now().to_rfc3339();
47
47
48
48
let row = sqlx::query_as::<_, AdminSessionRow>(
49
49
-
"SELECT session_id, did, handle, created_at, expires_at FROM admin_sessions WHERE session_id = ? AND expires_at > ?",
49
49
+
"SELECT session_id, did, handle, oauth_session_id, created_at, expires_at FROM admin_sessions WHERE session_id = ? AND expires_at > ?",
50
50
)
51
51
.bind(session_id)
52
52
.bind(&now)
+4
-4
src/main.rs
···
19
19
use handlebars::Handlebars;
20
20
use hyper_util::{client::legacy::connect::HttpConnector, rt::TokioExecutor};
21
21
use jacquard_common::types::did::Did;
22
22
-
use jacquard_identity::{PublicResolver, resolver::PlcSource};
22
22
+
use jacquard_identity::{JacquardResolver, PublicResolver, resolver::PlcSource};
23
23
use rand::Rng;
24
24
use rust_embed::RustEmbed;
25
25
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode};
···
250
250
let account_db_url = format!("{pds_root}/account.sqlite");
251
251
252
252
let account_options = SqliteConnectOptions::new()
253
253
-
.journal_mode(SqliteJournalMode::Wal)
253
253
+
// .journal_mode(SqliteJournalMode::Wal)
254
254
.filename(account_db_url)
255
255
.busy_timeout(Duration::from_secs(5));
256
256
···
303
303
let plc_source_url =
304
304
env::var("PDS_DID_PLC_URL").unwrap_or_else(|_| "https://plc.directory".to_string());
305
305
let plc_source = PlcSource::PlcDirectory {
306
306
-
base: plc_source_url.parse().unwrap(),
306
306
+
base: plc_source_url.parse()?,
307
307
};
308
308
let mut resolver = PublicResolver::default();
309
309
-
resolver = resolver.with_plc_source(plc_source.clone());
309
309
+
resolver = resolver.with_plc_source(plc_source.clone()).with_cache();
310
310
311
311
let app_config = AppConfig::new();
312
312