Microservice to bring 2FA to self hosted PDSes
PDS Gatekeeper Authentication Middleware#
This document describes the authentication middleware system for pds-gatekeeper, which provides flexible authorization rules based on DIDs, handles, and OAuth scopes.
Overview#
The auth middleware validates incoming requests by:
- Extracting the DID and scopes from a JWT Bearer token
- Resolving the DID to a handle using jacquard-identity
- Validating against configured authorization rules
- Returning appropriate HTTP errors (401/403) on failure
Quick Start#
use axum::middleware::from_fn_with_state;
use crate::auth::{auth_middleware, handle_ends_with, scope_equals, with_rules, AuthRules};
let app = Router::new()
// Simple: require handle from specific domain
.route("/xrpc/community.blacksky.feed.get",
get(handler).layer(from_fn_with_state(
handle_ends_with(".blacksky.team", &state),
auth_middleware
)))
// Simple: require specific OAuth scope
.route("/xrpc/com.atproto.repo.createRecord",
post(handler).layer(from_fn_with_state(
scope_equals("repo:app.bsky.feed.post", &state),
auth_middleware
)))
.with_state(state);
ATProto OAuth Scopes Reference#
| Scope | Description |
|---|---|
atproto |
Base scope, required for all OAuth clients |
transition:generic |
Full repository access (equivalent to app passwords) |
repo:<collection> |
Access to specific collection (e.g., repo:app.bsky.feed.post) |
identity:handle |
Permits handle changes |
identity:* |
Full DID document control |
account:email |
Read email addresses |
account:repo?action=manage |
Import repository data |
blob:*/* |
Upload any blob type |
blob?accept=image/* |
Upload only images |
See Marvin's Guide to OAuth Scopes for complete details.
Helper Functions#
Identity Helpers#
| Function | Description |
|---|---|
handle_ends_with(suffix, state) |
Handle must end with suffix |
handle_ends_with_any(suffixes, state) |
Handle must end with any suffix (OR) |
did_equals(did, state) |
DID must match exactly |
did_equals_any(dids, state) |
DID must match any value (OR) |
Scope Helpers#
| Function | Description |
|---|---|
scope_equals(scope, state) |
Must have specific scope |
scope_any(scopes, state) |
Must have any of the scopes (OR) |
scope_all(scopes, state) |
Must have all scopes (AND) |
Combined Helpers (Identity + Scope)#
| Function | Description |
|---|---|
handle_ends_with_and_scope(suffix, scope, state) |
Handle suffix AND scope |
handle_ends_with_and_scopes(suffix, scopes, state) |
Handle suffix AND all scopes |
did_with_scope(did, scope, state) |
DID match AND scope |
did_with_scopes(did, scopes, state) |
DID match AND all scopes |
Custom Rules#
For complex authorization logic, use with_rules():
with_rules(AuthRules::Any(vec![
AuthRules::DidEquals("did:plc:rnpkyqnmsw4ipey6eotbdnnf".into()),
AuthRules::All(vec![
AuthRules::HandleEndsWith(".mod.team".into()),
AuthRules::ScopeEquals("account:email".into()),
]),
]), &state)
Realistic PDS Endpoint Examples#
Admin Endpoints#
Based on com.atproto.admin.* endpoints from the ATProto PDS:
// com.atproto.admin.deleteAccount
// Admin-only: specific DID with full access scope
.route("/xrpc/com.atproto.admin.deleteAccount",
post(delete_account).layer(from_fn_with_state(
did_with_scope("did:plc:rnpkyqnmsw4ipey6eotbdnnf", "transition:generic", &state),
auth_middleware
)))
// com.atproto.admin.getAccountInfo
// Either admin DID OR (moderator handle + account scope)
.route("/xrpc/com.atproto.admin.getAccountInfo",
get(get_account_info).layer(from_fn_with_state(
with_rules(AuthRules::Any(vec![
AuthRules::DidEquals("did:plc:rnpkyqnmsw4ipey6eotbdnnf".into()),
AuthRules::All(vec![
AuthRules::HandleEndsWith(".mod.team".into()),
AuthRules::ScopeEquals("account:email".into()),
]),
]), &state),
auth_middleware
)))
// com.atproto.admin.updateAccountEmail
// Admin DID with account management scope
.route("/xrpc/com.atproto.admin.updateAccountEmail",
post(update_email).layer(from_fn_with_state(
did_with_scopes(
"did:plc:rnpkyqnmsw4ipey6eotbdnnf",
["account:email", "account:repo?action=manage"],
&state
),
auth_middleware
)))
// com.atproto.admin.updateAccountHandle
// Admin with identity control
.route("/xrpc/com.atproto.admin.updateAccountHandle",
post(update_handle).layer(from_fn_with_state(
did_with_scope("did:plc:rnpkyqnmsw4ipey6eotbdnnf", "identity:*", &state),
auth_middleware
)))
Repository Endpoints#
// com.atproto.repo.createRecord
// Scoped write access to specific collection
.route("/xrpc/com.atproto.repo.createRecord",
post(create_record).layer(from_fn_with_state(
scope_equals("repo:app.bsky.feed.post", &state),
auth_middleware
)))
// com.atproto.repo.putRecord
// Either specific collection scope OR full access
.route("/xrpc/com.atproto.repo.putRecord",
post(put_record).layer(from_fn_with_state(
scope_any(["repo:app.bsky.feed.post", "transition:generic"], &state),
auth_middleware
)))
// com.atproto.repo.uploadBlob
// Blob upload with media type restriction (scope-based)
.route("/xrpc/com.atproto.repo.uploadBlob",
post(upload_blob).layer(from_fn_with_state(
scope_any(["blob:*/*", "blob?accept=image/*", "transition:generic"], &state),
auth_middleware
)))
Community/Custom Endpoints#
// Community feed generator - restricted to team members with full access
.route("/xrpc/community.blacksky.feed.generator",
post(generator).layer(from_fn_with_state(
handle_ends_with_and_scope(".blacksky.team", "transition:generic", &state),
auth_middleware
)))
// Multi-community endpoint
.route("/xrpc/community.shared.moderation.report",
post(report).layer(from_fn_with_state(
with_rules(AuthRules::All(vec![
AuthRules::HandleEndsWithAny(vec![
".blacksky.team".into(),
".bsky.team".into(),
".mod.social".into(),
]),
AuthRules::ScopeEquals("atproto".into()),
]), &state),
auth_middleware
)))
// VIP access - specific DIDs only
.route("/xrpc/community.blacksky.vip.access",
get(vip_handler).layer(from_fn_with_state(
did_equals_any([
"did:plc:rnpkyqnmsw4ipey6eotbdnnf",
"did:plc:abc123def456ghi789jklmno",
"did:plc:xyz987uvw654rst321qponml",
], &state),
auth_middleware
)))
Building Complex Authorization Rules#
The AuthRules enum supports arbitrary nesting:
// Complex: Admin OR (Team member with write scope) OR (Moderator with read-only)
let rules = AuthRules::Any(vec![
// Admin bypass
AuthRules::DidEquals("did:plc:rnpkyqnmsw4ipey6eotbdnnf".into()),
// Team member with write access
AuthRules::All(vec![
AuthRules::HandleEndsWith(".blacksky.team".into()),
AuthRules::ScopeEquals("transition:generic".into()),
]),
// Moderator with limited scope
AuthRules::All(vec![
AuthRules::HandleEndsWith(".mod.team".into()),
AuthRules::ScopeEqualsAny(vec![
"account:email".into(),
"atproto".into(),
]),
]),
]);
Error Responses#
| Status | Error Code | Description |
|---|---|---|
401 |
AuthRequired |
No Authorization header provided |
401 |
InvalidToken |
JWT validation failed (expired, invalid signature, malformed) |
403 |
AccessDenied |
Valid authentication but authorization rules rejected |
500 |
ResolutionError |
Failed to resolve DID to handle |
Response format:
{
"error": "AccessDenied",
"message": "Access denied by authorization rules"
}
JWT Token Format#
The middleware expects JWT tokens with these claims:
{
"sub": "did:plc:rnpkyqnmsw4ipey6eotbdnnf",
"scope": "atproto transition:generic repo:app.bsky.feed.post",
"iat": 1704067200,
"exp": 1704153600
}
sub(required): The user's DIDscope(optional): Space-separated OAuth scopes per RFC 6749
Handle Resolution#
DIDs are resolved to handles using the jacquard-identity PublicResolver:
- Check the
HandleCachefor a cached result - If miss, resolve the DID document via PLC directory
- Extract handle from
alsoKnownAsfield (format:at://handle.example.com) - Cache the result (1 hour TTL default)
This allows rules like HandleEndsWith(".blacksky.team") to work even though the JWT only contains the DID.