fix: oauth consolidation, include-scope improvements

This commit is contained in:
lewis
2026-01-25 13:07:32 +00:00
committed by Tangled
parent 2d10dc0983
commit 8af0cfe0af
44 changed files with 2659 additions and 1013 deletions
@@ -1,77 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT token, request_uri, provider as \"provider: SsoProviderType\",\n provider_user_id, provider_username, provider_email, created_at, expires_at\n FROM sso_pending_registration\n WHERE token = $1 AND expires_at > NOW()\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "request_uri",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "provider: SsoProviderType",
"type_info": {
"Custom": {
"name": "sso_provider_type",
"kind": {
"Enum": [
"github",
"discord",
"google",
"gitlab",
"oidc"
]
}
}
}
},
{
"ordinal": 3,
"name": "provider_user_id",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "provider_username",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "provider_email",
"type_info": "Text"
},
{
"ordinal": 6,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 7,
"name": "expires_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
false,
false,
true,
true,
false,
false
]
},
"hash": "06eb7c6e1983b6121526ba63612236391290c2e63d37d2bb1cd89ea822950a82"
}
@@ -1,77 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n DELETE FROM sso_pending_registration\n WHERE token = $1 AND expires_at > NOW()\n RETURNING token, request_uri, provider as \"provider: SsoProviderType\",\n provider_user_id, provider_username, provider_email, created_at, expires_at\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "request_uri",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "provider: SsoProviderType",
"type_info": {
"Custom": {
"name": "sso_provider_type",
"kind": {
"Enum": [
"github",
"discord",
"google",
"gitlab",
"oidc"
]
}
}
}
},
{
"ordinal": 3,
"name": "provider_user_id",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "provider_username",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "provider_email",
"type_info": "Text"
},
{
"ordinal": 6,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 7,
"name": "expires_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
false,
false,
true,
true,
false,
false
]
},
"hash": "5031b96c65078d6c54954ce6e57ff9cbba4c48dd8a7546882ab5647114ffab4a"
}
@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT email_verified FROM users WHERE email = $1 OR handle = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "email_verified",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "6258398accee69e0c5f455a3c0ecc273b3da6ef5bb4d8660adafe63d8e3cd2d4"
}
@@ -1,31 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO external_identities (did, provider, provider_user_id, provider_username, provider_email)\n VALUES ($1, $2, $3, $4, $5)\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
{
"Custom": {
"name": "sso_provider_type",
"kind": {
"Enum": [
"github",
"discord",
"google",
"gitlab",
"oidc"
]
}
}
},
"Text",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "a4dc8fb22bd094d414c55b9da20b610f7b122b485ab0fd0d0646d68ae8e64fe6"
}
@@ -1,32 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO sso_pending_registration (token, request_uri, provider, provider_user_id, provider_username, provider_email)\n VALUES ($1, $2, $3, $4, $5, $6)\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
{
"Custom": {
"name": "sso_provider_type",
"kind": {
"Enum": [
"github",
"discord",
"google",
"gitlab",
"oidc"
]
}
}
},
"Text",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "dec3a21a8e60cc8d2c5dad727750bc88f5535dedae244f7b6e4afa95769b8f1a"
}
Generated
+2
View File
@@ -6156,11 +6156,13 @@ version = "0.1.0"
dependencies = [
"axum",
"futures",
"hickory-resolver",
"reqwest",
"serde",
"serde_json",
"tokio",
"tracing",
"urlencoding",
]
[[package]]
+7
View File
@@ -543,6 +543,13 @@ impl From<crate::auth::extractor::AuthError> for ApiError {
crate::auth::extractor::AuthError::AccountDeactivated => Self::AccountDeactivated,
crate::auth::extractor::AuthError::AccountTakedown => Self::AccountTakedown,
crate::auth::extractor::AuthError::AdminRequired => Self::AdminRequired,
crate::auth::extractor::AuthError::OAuthExpiredToken(msg) => {
Self::OAuthExpiredToken(Some(msg))
}
crate::auth::extractor::AuthError::UseDpopNonce(_)
| crate::auth::extractor::AuthError::InvalidDpopProof(_) => {
Self::AuthenticationFailed(None)
}
}
}
}
@@ -1,7 +1,7 @@
use super::did::verify_did_web;
use crate::api::error::ApiError;
use crate::api::repo::record::utils::create_signed_commit;
use crate::auth::{ServiceTokenVerifier, is_service_token};
use crate::auth::{ServiceTokenVerifier, extract_auth_token_from_header, is_service_token};
use crate::plc::{PlcClient, create_genesis_operation, signing_key_to_did_key};
use crate::state::{AppState, RateLimitKind};
use crate::types::{Did, Handle, Nsid, PlainPassword, Rkey};
@@ -96,9 +96,9 @@ pub async fn create_account(
.into_response();
}
let migration_auth = if let Some(extracted) = crate::auth::extract_auth_token_from_header(
headers.get("Authorization").and_then(|h| h.to_str().ok()),
) {
let migration_auth = if let Some(extracted) =
extract_auth_token_from_header(headers.get("Authorization").and_then(|h| h.to_str().ok()))
{
let token = extracted.token;
if is_service_token(&token) {
let verifier = ServiceTokenVerifier::new();
+12 -3
View File
@@ -267,9 +267,18 @@ async fn proxy_handler(
}
}
Err(e) => {
warn!("Token validation failed: {:?}", e);
if matches!(e, crate::auth::TokenValidationError::OAuthTokenExpired) {
return ApiError::from(e).into_response();
info!(error = ?e, "Proxy token validation failed, returning error to client");
if matches!(
e,
crate::auth::TokenValidationError::OAuthTokenExpired
| crate::auth::TokenValidationError::TokenExpired
) {
let mut response = ApiError::from(e).into_response();
let nonce = crate::oauth::verify::generate_dpop_nonce();
if let Ok(nonce_val) = nonce.parse() {
response.headers_mut().insert("DPoP-Nonce", nonce_val);
}
return response;
}
}
}
+17 -80
View File
@@ -1,5 +1,5 @@
use crate::api::error::ApiError;
use crate::auth::{BearerAuthAllowDeactivated, ServiceTokenVerifier, is_service_token};
use crate::auth::{BearerAuthAllowDeactivated, BlobAuth, BlobAuthResult};
use crate::delegation::DelegationActionType;
use crate::state::AppState;
use crate::types::{CidLink, Did};
@@ -44,88 +44,25 @@ fn detect_mime_type(data: &[u8], client_hint: &str) -> String {
pub async fn upload_blob(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
auth: BlobAuth,
body: Body,
) -> Response {
let extracted = match crate::auth::extract_auth_token_from_header(
headers.get("Authorization").and_then(|h| h.to_str().ok()),
) {
Some(t) => t,
None => return ApiError::AuthenticationRequired.into_response(),
};
let token = extracted.token;
let is_service_auth = is_service_token(&token);
let (did, _is_migration, controller_did): (Did, bool, Option<Did>) = if is_service_auth {
debug!("Verifying service token for blob upload");
let verifier = ServiceTokenVerifier::new();
match verifier
.verify_service_token(&token, Some("com.atproto.repo.uploadBlob"))
.await
{
Ok(claims) => {
debug!("Service token verified for DID: {}", claims.iss);
let did: Did = match claims.iss.parse() {
Ok(d) => d,
Err(_) => {
return ApiError::InvalidDid("Invalid DID format".into()).into_response();
}
};
(did, false, None)
}
Err(e) => {
error!("Service token verification failed: {:?}", e);
return ApiError::AuthenticationFailed(Some(format!(
"Service token verification failed: {}",
e
)))
.into_response();
}
}
} else {
let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok());
let http_uri = format!(
"https://{}/xrpc/com.atproto.repo.uploadBlob",
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())
);
match crate::auth::validate_token_with_dpop(
state.user_repo.as_ref(),
state.oauth_repo.as_ref(),
&token,
extracted.is_dpop,
dpop_proof,
"POST",
&http_uri,
true,
false,
)
.await
{
Ok(user) => {
let mime_type_for_check = headers
.get("content-type")
.and_then(|h| h.to_str().ok())
.unwrap_or("application/octet-stream");
if let Err(e) = crate::auth::scope_check::check_blob_scope(
user.is_oauth,
user.scope.as_deref(),
mime_type_for_check,
) {
return e;
}
let deactivated = state
.user_repo
.get_status_by_did(&user.did)
.await
.ok()
.flatten()
.and_then(|s| s.deactivated_at);
let ctrl_did = user.controller_did.clone();
(user.did, deactivated.is_some(), ctrl_did)
}
Err(_) => {
return ApiError::AuthenticationFailed(None).into_response();
let (did, controller_did): (Did, Option<Did>) = match auth.0 {
BlobAuthResult::Service { did } => (did, None),
BlobAuthResult::User(auth_user) => {
let mime_type_for_check = headers
.get("content-type")
.and_then(|h| h.to_str().ok())
.unwrap_or("application/octet-stream");
if let Err(e) = crate::auth::scope_check::check_blob_scope(
auth_user.is_oauth,
auth_user.scope.as_deref(),
mime_type_for_check,
) {
return e;
}
let ctrl_did = auth_user.controller_did.clone();
(auth_user.did, ctrl_did)
}
};
@@ -1,6 +1,7 @@
use crate::api::error::ApiError;
use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log};
use crate::api::repo::record::write::{CommitInfo, prepare_repo_write};
use crate::auth::BearerAuth;
use crate::delegation::DelegationActionType;
use crate::repo::tracking::TrackingBlockStore;
use crate::state::AppState;
@@ -8,7 +9,7 @@ use crate::types::{AtIdentifier, AtUri, Nsid, Rkey};
use axum::{
Json,
extract::State,
http::{HeaderMap, StatusCode},
http::StatusCode,
response::{IntoResponse, Response},
};
use cid::Cid;
@@ -39,19 +40,10 @@ pub struct DeleteRecordOutput {
pub async fn delete_record(
State(state): State<AppState>,
headers: HeaderMap,
axum::extract::OriginalUri(uri): axum::extract::OriginalUri,
auth: BearerAuth,
Json(input): Json<DeleteRecordInput>,
) -> Response {
let auth = match prepare_repo_write(
&state,
&headers,
&input.repo,
"POST",
&crate::util::build_full_url(&uri.to_string()),
)
.await
{
let auth = match prepare_repo_write(&state, auth.0, &input.repo).await {
Ok(res) => res,
Err(err_res) => return err_res,
};
@@ -3,6 +3,7 @@ use crate::api::error::ApiError;
use crate::api::repo::record::utils::{
CommitParams, RecordOp, commit_and_log, extract_backlinks, extract_blob_cids,
};
use crate::auth::{AuthenticatedUser, BearerAuth};
use crate::delegation::DelegationActionType;
use crate::repo::tracking::TrackingBlockStore;
use crate::state::AppState;
@@ -10,7 +11,7 @@ use crate::types::{AtIdentifier, AtUri, Did, Nsid, Rkey};
use axum::{
Json,
extract::State,
http::{HeaderMap, StatusCode},
http::StatusCode,
response::{IntoResponse, Response},
};
use cid::Cid;
@@ -33,32 +34,9 @@ pub struct RepoWriteAuth {
pub async fn prepare_repo_write(
state: &AppState,
headers: &HeaderMap,
auth_user: AuthenticatedUser,
repo: &AtIdentifier,
http_method: &str,
http_uri: &str,
) -> Result<RepoWriteAuth, Response> {
let extracted = crate::auth::extract_auth_token_from_header(
headers.get("Authorization").and_then(|h| h.to_str().ok()),
)
.ok_or_else(|| ApiError::AuthenticationRequired.into_response())?;
let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok());
let auth_user = crate::auth::validate_token_with_dpop(
state.user_repo.as_ref(),
state.oauth_repo.as_ref(),
&extracted.token,
extracted.is_dpop,
dpop_proof,
http_method,
http_uri,
false,
false,
)
.await
.map_err(|e| {
tracing::warn!(error = ?e, is_dpop = extracted.is_dpop, "Token validation failed in prepare_repo_write");
ApiError::from(e).into_response()
})?;
if repo.as_str() != auth_user.did.as_str() {
return Err(
ApiError::InvalidRepo("Repo does not match authenticated user".into()).into_response(),
@@ -146,19 +124,10 @@ pub struct CreateRecordOutput {
}
pub async fn create_record(
State(state): State<AppState>,
headers: HeaderMap,
axum::extract::OriginalUri(uri): axum::extract::OriginalUri,
auth: BearerAuth,
Json(input): Json<CreateRecordInput>,
) -> Response {
let auth = match prepare_repo_write(
&state,
&headers,
&input.repo,
"POST",
&crate::util::build_full_url(&uri.to_string()),
)
.await
{
let auth = match prepare_repo_write(&state, auth.0, &input.repo).await {
Ok(res) => res,
Err(err_res) => return err_res,
};
@@ -445,19 +414,10 @@ pub struct PutRecordOutput {
}
pub async fn put_record(
State(state): State<AppState>,
headers: HeaderMap,
axum::extract::OriginalUri(uri): axum::extract::OriginalUri,
auth: BearerAuth,
Json(input): Json<PutRecordInput>,
) -> Response {
let auth = match prepare_repo_write(
&state,
&headers,
&input.repo,
"POST",
&crate::util::build_full_url(&uri.to_string()),
)
.await
{
let auth = match prepare_repo_write(&state, auth.0, &input.repo).await {
Ok(res) => res,
Err(err_res) => return err_res,
};
@@ -40,35 +40,9 @@ pub struct CheckAccountStatusOutput {
pub async fn check_account_status(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
auth: crate::auth::BearerAuthAllowDeactivated,
) -> Response {
let extracted = match crate::auth::extract_auth_token_from_header(
headers.get("Authorization").and_then(|h| h.to_str().ok()),
) {
Some(t) => t,
None => return ApiError::AuthenticationRequired.into_response(),
};
let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok());
let http_uri = format!(
"https://{}/xrpc/com.atproto.server.checkAccountStatus",
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())
);
let did = match crate::auth::validate_token_with_dpop(
state.user_repo.as_ref(),
state.oauth_repo.as_ref(),
&extracted.token,
extracted.is_dpop,
dpop_proof,
"GET",
&http_uri,
true,
false,
)
.await
{
Ok(user) => user.did,
Err(e) => return ApiError::from(e).into_response(),
};
let did = auth.0.did;
let user_id = match state.user_repo.get_id_by_did(&did).await {
Ok(Some(id)) => id,
_ => {
@@ -331,42 +305,10 @@ async fn assert_valid_did_document_for_service(
pub async fn activate_account(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
auth: crate::auth::BearerAuthAllowDeactivated,
) -> Response {
info!("[MIGRATION] activateAccount called");
let extracted = match crate::auth::extract_auth_token_from_header(
headers.get("Authorization").and_then(|h| h.to_str().ok()),
) {
Some(t) => t,
None => {
info!("[MIGRATION] activateAccount: No auth token");
return ApiError::AuthenticationRequired.into_response();
}
};
let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok());
let http_uri = format!(
"https://{}/xrpc/com.atproto.server.activateAccount",
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())
);
let auth_user = match crate::auth::validate_token_with_dpop(
state.user_repo.as_ref(),
state.oauth_repo.as_ref(),
&extracted.token,
extracted.is_dpop,
dpop_proof,
"POST",
&http_uri,
true,
false,
)
.await
{
Ok(user) => user,
Err(e) => {
info!("[MIGRATION] activateAccount: Auth failed: {:?}", e);
return ApiError::from(e).into_response();
}
};
let auth_user = auth.0;
info!(
"[MIGRATION] activateAccount: Authenticated user did={}",
auth_user.did
@@ -528,36 +470,10 @@ pub struct DeactivateAccountInput {
pub async fn deactivate_account(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
auth: crate::auth::BearerAuth,
Json(input): Json<DeactivateAccountInput>,
) -> Response {
let extracted = match crate::auth::extract_auth_token_from_header(
headers.get("Authorization").and_then(|h| h.to_str().ok()),
) {
Some(t) => t,
None => return ApiError::AuthenticationRequired.into_response(),
};
let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok());
let http_uri = format!(
"https://{}/xrpc/com.atproto.server.deactivateAccount",
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())
);
let auth_user = match crate::auth::validate_token_with_dpop(
state.user_repo.as_ref(),
state.oauth_repo.as_ref(),
&extracted.token,
extracted.is_dpop,
dpop_proof,
"POST",
&http_uri,
false,
false,
)
.await
{
Ok(user) => user,
Err(e) => return ApiError::from(e).into_response(),
};
let auth_user = auth.0;
if let Err(e) = crate::auth::scope_check::check_account_scope(
auth_user.is_oauth,
@@ -607,47 +523,20 @@ pub async fn deactivate_account(
pub async fn request_account_delete(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
auth: crate::auth::BearerAuthAllowDeactivated,
) -> Response {
let extracted = match crate::auth::extract_auth_token_from_header(
headers.get("Authorization").and_then(|h| h.to_str().ok()),
) {
Some(t) => t,
None => return ApiError::AuthenticationRequired.into_response(),
};
let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok());
let http_uri = format!(
"https://{}/xrpc/com.atproto.server.requestAccountDelete",
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())
);
let validated = match crate::auth::validate_token_with_dpop(
state.user_repo.as_ref(),
state.oauth_repo.as_ref(),
&extracted.token,
extracted.is_dpop,
dpop_proof,
"POST",
&http_uri,
true,
false,
)
.await
{
Ok(user) => user,
Err(e) => return ApiError::from(e).into_response(),
};
let did = validated.did.clone();
let did = &auth.0.did;
if !crate::api::server::reauth::check_legacy_session_mfa(&*state.session_repo, &did).await {
if !crate::api::server::reauth::check_legacy_session_mfa(&*state.session_repo, did).await {
return crate::api::server::reauth::legacy_mfa_required_response(
&*state.user_repo,
&*state.session_repo,
&did,
did,
)
.await;
}
let user_id = match state.user_repo.get_id_by_did(&did).await {
let user_id = match state.user_repo.get_id_by_did(did).await {
Ok(Some(id)) => id,
_ => {
return ApiError::InternalError(None).into_response();
@@ -657,7 +546,7 @@ pub async fn request_account_delete(
let expires_at = Utc::now() + Duration::minutes(15);
if let Err(e) = state
.infra_repo
.create_deletion_request(&confirmation_token, &did, expires_at)
.create_deletion_request(&confirmation_token, did, expires_at)
.await
{
error!("DB error creating deletion token: {:?}", e);
@@ -1,4 +1,5 @@
use crate::api::ApiError;
use crate::auth::BearerAuth;
use crate::state::AppState;
use axum::{
Json,
@@ -35,36 +36,10 @@ pub struct UpdateDidDocumentOutput {
pub async fn update_did_document(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
auth: BearerAuth,
Json(input): Json<UpdateDidDocumentInput>,
) -> Response {
let extracted = match crate::auth::extract_auth_token_from_header(
headers.get("Authorization").and_then(|h| h.to_str().ok()),
) {
Some(t) => t,
None => return ApiError::AuthenticationRequired.into_response(),
};
let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok());
let http_uri = format!(
"https://{}/xrpc/_account.updateDidDocument",
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())
);
let auth_user = match crate::auth::validate_token_with_dpop(
state.user_repo.as_ref(),
state.oauth_repo.as_ref(),
&extracted.token,
extracted.is_dpop,
dpop_proof,
"POST",
&http_uri,
true,
false,
)
.await
{
Ok(user) => user,
Err(e) => return ApiError::from(e).into_response(),
};
let auth_user = auth.0;
if !auth_user.did.starts_with("did:web:") {
return ApiError::InvalidRequest(
@@ -166,37 +141,8 @@ pub async fn update_did_document(
.into_response()
}
pub async fn get_did_document(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
) -> Response {
let extracted = match crate::auth::extract_auth_token_from_header(
headers.get("Authorization").and_then(|h| h.to_str().ok()),
) {
Some(t) => t,
None => return ApiError::AuthenticationRequired.into_response(),
};
let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok());
let http_uri = format!(
"https://{}/xrpc/_account.getDidDocument",
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())
);
let auth_user = match crate::auth::validate_token_with_dpop(
state.user_repo.as_ref(),
state.oauth_repo.as_ref(),
&extracted.token,
extracted.is_dpop,
dpop_proof,
"GET",
&http_uri,
true,
false,
)
.await
{
Ok(user) => user,
Err(e) => return ApiError::from(e).into_response(),
};
pub async fn get_did_document(State(state): State<AppState>, auth: BearerAuth) -> Response {
let auth_user = auth.0;
if !auth_user.did.starts_with("did:web:") {
return ApiError::InvalidRequest(
+5 -22
View File
@@ -1,10 +1,9 @@
use crate::api::error::ApiError;
use crate::auth::{BearerAuth, extract_auth_token_from_header, validate_token_with_dpop};
use crate::auth::{BearerAuth, OptionalBearerAuth};
use crate::state::AppState;
use axum::{
Json,
extract::State,
http::HeaderMap,
response::{IntoResponse, Response},
};
use cid::Cid;
@@ -22,27 +21,11 @@ pub struct CheckSignupQueueOutput {
pub estimated_time_ms: Option<i64>,
}
pub async fn check_signup_queue(State(state): State<AppState>, headers: HeaderMap) -> Response {
if let Some(extracted) =
extract_auth_token_from_header(headers.get("Authorization").and_then(|h| h.to_str().ok()))
pub async fn check_signup_queue(auth: OptionalBearerAuth) -> Response {
if let Some(user) = auth.0
&& user.is_oauth
{
let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok());
if let Ok(user) = validate_token_with_dpop(
state.user_repo.as_ref(),
state.oauth_repo.as_ref(),
&extracted.token,
extracted.is_dpop,
dpop_proof,
"GET",
"/",
false,
false,
)
.await
&& user.is_oauth
{
return ApiError::Forbidden.into_response();
}
return ApiError::Forbidden.into_response();
}
Json(CheckSignupQueueOutput {
activated: true,
@@ -0,0 +1,547 @@
mod common;
mod helpers;
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use chrono::Utc;
use common::{base_url, client, create_account_and_login, pds_endpoint};
use helpers::verify_new_account;
use reqwest::StatusCode;
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn generate_pkce() -> (String, String) {
let verifier_bytes: [u8; 32] = rand::random();
let code_verifier = URL_SAFE_NO_PAD.encode(verifier_bytes);
let mut hasher = Sha256::new();
hasher.update(code_verifier.as_bytes());
let code_challenge = URL_SAFE_NO_PAD.encode(hasher.finalize());
(code_verifier, code_challenge)
}
async fn setup_mock_client_metadata(redirect_uri: &str, dpop_bound: bool) -> MockServer {
let mock_server = MockServer::start().await;
let metadata = json!({
"client_id": mock_server.uri(),
"client_name": "Auth Extractor Test Client",
"redirect_uris": [redirect_uri],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none",
"dpop_bound_access_tokens": dpop_bound
});
Mock::given(method("GET"))
.and(path("/"))
.respond_with(ResponseTemplate::new(200).set_body_json(metadata))
.mount(&mock_server)
.await;
mock_server
}
async fn get_oauth_session(
http_client: &reqwest::Client,
url: &str,
dpop_bound: bool,
) -> (String, String, String, String) {
let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8];
let handle = format!("ae{}", suffix);
let password = "AuthExtract123!";
let create_res = http_client
.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
.json(&json!({
"handle": handle,
"email": format!("{}@example.com", handle),
"password": password
}))
.send()
.await
.unwrap();
assert_eq!(create_res.status(), StatusCode::OK);
let account: Value = create_res.json().await.unwrap();
let did = account["did"].as_str().unwrap().to_string();
verify_new_account(http_client, &did).await;
let redirect_uri = "https://example.com/auth-callback";
let mock_client = setup_mock_client_metadata(redirect_uri, dpop_bound).await;
let client_id = mock_client.uri();
let (code_verifier, code_challenge) = generate_pkce();
let par_body: Value = http_client
.post(format!("{}/oauth/par", url))
.form(&[
("response_type", "code"),
("client_id", &client_id),
("redirect_uri", redirect_uri),
("code_challenge", &code_challenge),
("code_challenge_method", "S256"),
])
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let request_uri = par_body["request_uri"].as_str().unwrap();
let auth_res = http_client
.post(format!("{}/oauth/authorize", url))
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.json(&json!({
"request_uri": request_uri,
"username": &handle,
"password": password,
"remember_device": false
}))
.send()
.await
.unwrap();
let auth_body: Value = auth_res.json().await.unwrap();
let mut location = auth_body["redirect_uri"].as_str().unwrap().to_string();
if location.contains("/oauth/consent") {
let consent_res = http_client
.post(format!("{}/oauth/authorize/consent", url))
.header("Content-Type", "application/json")
.json(&json!({
"request_uri": request_uri,
"approved_scopes": ["atproto"],
"remember": false
}))
.send()
.await
.unwrap();
let consent_body: Value = consent_res.json().await.unwrap();
location = consent_body["redirect_uri"].as_str().unwrap().to_string();
}
let code = location
.split("code=")
.nth(1)
.unwrap()
.split('&')
.next()
.unwrap();
let token_body: Value = http_client
.post(format!("{}/oauth/token", url))
.form(&[
("grant_type", "authorization_code"),
("code", code),
("redirect_uri", redirect_uri),
("code_verifier", &code_verifier),
("client_id", &client_id),
])
.send()
.await
.unwrap()
.json()
.await
.unwrap();
(
token_body["access_token"].as_str().unwrap().to_string(),
token_body["refresh_token"].as_str().unwrap().to_string(),
client_id,
did,
)
}
#[tokio::test]
async fn test_oauth_token_works_with_bearer_auth() {
let url = base_url().await;
let http_client = client();
let (access_token, _, _, did) = get_oauth_session(&http_client, url, false).await;
let res = http_client
.get(format!("{}/xrpc/com.atproto.server.getSession", url))
.bearer_auth(&access_token)
.send()
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK, "OAuth token should work with BearerAuth extractor");
let body: Value = res.json().await.unwrap();
assert_eq!(body["did"].as_str().unwrap(), did);
}
#[tokio::test]
async fn test_session_token_still_works() {
let url = base_url().await;
let http_client = client();
let (jwt, did) = create_account_and_login(&http_client).await;
let res = http_client
.get(format!("{}/xrpc/com.atproto.server.getSession", url))
.bearer_auth(&jwt)
.send()
.await
.unwrap();
assert_eq!(res.status(), StatusCode::OK, "Session token should still work");
let body: Value = res.json().await.unwrap();
assert_eq!(body["did"].as_str().unwrap(), did);
}
#[tokio::test]
async fn test_oauth_admin_extractor_allows_oauth_tokens() {
let url = base_url().await;
let http_client = client();
let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8];
let handle = format!("adm{}", suffix);
let password = "AdminOAuth123!";
let create_res = http_client
.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
.json(&json!({
"handle": handle,
"email": format!("{}@example.com", handle),
"password": password
}))
.send()
.await
.unwrap();
assert_eq!(create_res.status(), StatusCode::OK);
let account: Value = create_res.json().await.unwrap();
let did = account["did"].as_str().unwrap().to_string();
verify_new_account(&http_client, &did).await;
let pool = common::get_test_db_pool().await;
sqlx::query!("UPDATE users SET is_admin = TRUE WHERE did = $1", &did)
.execute(pool)
.await
.expect("Failed to mark user as admin");
let redirect_uri = "https://example.com/admin-callback";
let mock_client = setup_mock_client_metadata(redirect_uri, false).await;
let client_id = mock_client.uri();
let (code_verifier, code_challenge) = generate_pkce();
let par_body: Value = http_client
.post(format!("{}/oauth/par", url))
.form(&[
("response_type", "code"),
("client_id", &client_id),
("redirect_uri", redirect_uri),
("code_challenge", &code_challenge),
("code_challenge_method", "S256"),
])
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let request_uri = par_body["request_uri"].as_str().unwrap();
let auth_res = http_client
.post(format!("{}/oauth/authorize", url))
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.json(&json!({
"request_uri": request_uri,
"username": &handle,
"password": password,
"remember_device": false
}))
.send()
.await
.unwrap();
let auth_body: Value = auth_res.json().await.unwrap();
let mut location = auth_body["redirect_uri"].as_str().unwrap().to_string();
if location.contains("/oauth/consent") {
let consent_res = http_client
.post(format!("{}/oauth/authorize/consent", url))
.header("Content-Type", "application/json")
.json(&json!({
"request_uri": request_uri,
"approved_scopes": ["atproto"],
"remember": false
}))
.send()
.await
.unwrap();
let consent_body: Value = consent_res.json().await.unwrap();
location = consent_body["redirect_uri"].as_str().unwrap().to_string();
}
let code = location.split("code=").nth(1).unwrap().split('&').next().unwrap();
let token_body: Value = http_client
.post(format!("{}/oauth/token", url))
.form(&[
("grant_type", "authorization_code"),
("code", code),
("redirect_uri", redirect_uri),
("code_verifier", &code_verifier),
("client_id", &client_id),
])
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let access_token = token_body["access_token"].as_str().unwrap();
let res = http_client
.get(format!("{}/xrpc/com.atproto.admin.getAccountInfos?dids={}", url, did))
.bearer_auth(access_token)
.send()
.await
.unwrap();
assert_eq!(
res.status(),
StatusCode::OK,
"OAuth token for admin user should work with admin endpoint"
);
}
#[tokio::test]
async fn test_expired_oauth_token_returns_proper_error() {
let url = base_url().await;
let http_client = client();
let now = Utc::now().timestamp();
let header = json!({"alg": "HS256", "typ": "at+jwt"});
let payload = json!({
"iss": url,
"sub": "did:plc:test123",
"aud": url,
"iat": now - 7200,
"exp": now - 3600,
"jti": "expired-token",
"sid": "expired-session",
"scope": "atproto",
"client_id": "https://example.com"
});
let fake_token = format!(
"{}.{}.{}",
URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap()),
URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap()),
URL_SAFE_NO_PAD.encode([1u8; 32])
);
let res = http_client
.get(format!("{}/xrpc/com.atproto.server.getSession", url))
.bearer_auth(&fake_token)
.send()
.await
.unwrap();
assert_eq!(
res.status(),
StatusCode::UNAUTHORIZED,
"Expired token should be rejected"
);
}
#[tokio::test]
async fn test_dpop_nonce_error_has_proper_headers() {
let url = base_url().await;
let pds_url = pds_endpoint();
let http_client = client();
let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8];
let handle = format!("dpop{}", suffix);
let create_res = http_client
.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
.json(&json!({
"handle": handle,
"email": format!("{}@test.com", handle),
"password": "DpopTest123!"
}))
.send()
.await
.unwrap();
assert_eq!(create_res.status(), StatusCode::OK);
let account: Value = create_res.json().await.unwrap();
let did = account["did"].as_str().unwrap();
verify_new_account(&http_client, did).await;
let redirect_uri = "https://example.com/dpop-callback";
let mock_server = MockServer::start().await;
let client_id = mock_server.uri();
let metadata = json!({
"client_id": &client_id,
"client_name": "DPoP Test Client",
"redirect_uris": [redirect_uri],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none",
"dpop_bound_access_tokens": true
});
Mock::given(method("GET"))
.and(path("/"))
.respond_with(ResponseTemplate::new(200).set_body_json(metadata))
.mount(&mock_server)
.await;
let (code_verifier, code_challenge) = generate_pkce();
let par_body: Value = http_client
.post(format!("{}/oauth/par", url))
.form(&[
("response_type", "code"),
("client_id", &client_id),
("redirect_uri", redirect_uri),
("code_challenge", &code_challenge),
("code_challenge_method", "S256"),
])
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let request_uri = par_body["request_uri"].as_str().unwrap();
let auth_res = http_client
.post(format!("{}/oauth/authorize", url))
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.json(&json!({
"request_uri": request_uri,
"username": &handle,
"password": "DpopTest123!",
"remember_device": false
}))
.send()
.await
.unwrap();
let auth_body: Value = auth_res.json().await.unwrap();
let mut location = auth_body["redirect_uri"].as_str().unwrap().to_string();
if location.contains("/oauth/consent") {
let consent_res = http_client
.post(format!("{}/oauth/authorize/consent", url))
.header("Content-Type", "application/json")
.json(&json!({
"request_uri": request_uri,
"approved_scopes": ["atproto"],
"remember": false
}))
.send()
.await
.unwrap();
let consent_body: Value = consent_res.json().await.unwrap();
location = consent_body["redirect_uri"].as_str().unwrap().to_string();
}
let code = location.split("code=").nth(1).unwrap().split('&').next().unwrap();
let token_endpoint = format!("{}/oauth/token", pds_url);
let (_, dpop_proof) = generate_dpop_proof("POST", &token_endpoint, None);
let token_res = http_client
.post(format!("{}/oauth/token", url))
.header("DPoP", &dpop_proof)
.form(&[
("grant_type", "authorization_code"),
("code", code),
("redirect_uri", redirect_uri),
("code_verifier", &code_verifier),
("client_id", &client_id),
])
.send()
.await
.unwrap();
let token_status = token_res.status();
let token_nonce = token_res.headers().get("dpop-nonce").map(|h| h.to_str().unwrap().to_string());
let token_body: Value = token_res.json().await.unwrap();
let access_token = if token_status == StatusCode::OK {
token_body["access_token"].as_str().unwrap().to_string()
} else if token_body.get("error").and_then(|e| e.as_str()) == Some("use_dpop_nonce") {
let nonce = token_nonce.expect("Token endpoint should return DPoP-Nonce on use_dpop_nonce error");
let (_, dpop_proof_with_nonce) = generate_dpop_proof("POST", &token_endpoint, Some(&nonce));
let retry_res = http_client
.post(format!("{}/oauth/token", url))
.header("DPoP", &dpop_proof_with_nonce)
.form(&[
("grant_type", "authorization_code"),
("code", code),
("redirect_uri", redirect_uri),
("code_verifier", &code_verifier),
("client_id", &client_id),
])
.send()
.await
.unwrap();
let retry_body: Value = retry_res.json().await.unwrap();
retry_body["access_token"].as_str().expect("Should get access_token after nonce retry").to_string()
} else {
panic!("Token exchange failed unexpectedly: {:?}", token_body);
};
let res = http_client
.get(format!("{}/xrpc/com.atproto.server.getSession", url))
.header("Authorization", format!("DPoP {}", access_token))
.send()
.await
.unwrap();
assert_eq!(res.status(), StatusCode::UNAUTHORIZED, "DPoP token without proof should fail");
let www_auth = res.headers().get("www-authenticate").map(|h| h.to_str().unwrap());
assert!(www_auth.is_some(), "Should have WWW-Authenticate header");
assert!(
www_auth.unwrap().contains("use_dpop_nonce"),
"WWW-Authenticate should indicate dpop nonce required"
);
let nonce = res.headers().get("dpop-nonce").map(|h| h.to_str().unwrap());
assert!(nonce.is_some(), "Should return DPoP-Nonce header");
let body: Value = res.json().await.unwrap();
assert_eq!(body["error"].as_str().unwrap(), "use_dpop_nonce");
}
fn generate_dpop_proof(method: &str, uri: &str, nonce: Option<&str>) -> (Value, String) {
use p256::ecdsa::{SigningKey, signature::Signer};
use p256::elliptic_curve::rand_core::OsRng;
let signing_key = SigningKey::random(&mut OsRng);
let verifying_key = signing_key.verifying_key();
let point = verifying_key.to_encoded_point(false);
let x = URL_SAFE_NO_PAD.encode(point.x().unwrap());
let y = URL_SAFE_NO_PAD.encode(point.y().unwrap());
let jwk = json!({
"kty": "EC",
"crv": "P-256",
"x": x,
"y": y
});
let header = {
let h = json!({
"typ": "dpop+jwt",
"alg": "ES256",
"jwk": jwk.clone()
});
h
};
let mut payload = json!({
"jti": uuid::Uuid::new_v4().to_string(),
"htm": method,
"htu": uri,
"iat": Utc::now().timestamp()
});
if let Some(n) = nonce {
payload["nonce"] = json!(n);
}
let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap());
let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap());
let signing_input = format!("{}.{}", header_b64, payload_b64);
let signature: p256::ecdsa::Signature = signing_key.sign(signing_input.as_bytes());
let sig_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes());
let proof = format!("{}.{}", signing_input, sig_b64);
(jwk, proof)
}
+437 -143
View File
@@ -1,16 +1,18 @@
use axum::{
extract::FromRequestParts,
http::{header::AUTHORIZATION, request::Parts},
http::{StatusCode, header::AUTHORIZATION, request::Parts},
response::{IntoResponse, Response},
};
use tracing::{debug, error, info};
use super::{
AuthenticatedUser, TokenValidationError, validate_bearer_token_allow_takendown,
validate_bearer_token_cached, validate_bearer_token_cached_allow_deactivated,
validate_token_with_dpop,
AccountStatus, AuthenticatedUser, ServiceTokenClaims, ServiceTokenVerifier, is_service_token,
validate_bearer_token, validate_bearer_token_allow_deactivated,
validate_bearer_token_allow_takendown,
};
use crate::api::error::ApiError;
use crate::state::AppState;
use crate::types::Did;
use crate::util::build_full_url;
pub struct BearerAuth(pub AuthenticatedUser);
@@ -24,11 +26,38 @@ pub enum AuthError {
AccountDeactivated,
AccountTakedown,
AdminRequired,
OAuthExpiredToken(String),
UseDpopNonce(String),
InvalidDpopProof(String),
}
impl IntoResponse for AuthError {
fn into_response(self) -> Response {
ApiError::from(self).into_response()
match self {
Self::UseDpopNonce(nonce) => (
StatusCode::UNAUTHORIZED,
[
("DPoP-Nonce", nonce.as_str()),
("WWW-Authenticate", "DPoP error=\"use_dpop_nonce\""),
],
axum::Json(serde_json::json!({
"error": "use_dpop_nonce",
"message": "DPoP nonce required"
})),
)
.into_response(),
Self::OAuthExpiredToken(msg) => ApiError::OAuthExpiredToken(Some(msg)).into_response(),
Self::InvalidDpopProof(msg) => (
StatusCode::UNAUTHORIZED,
[("WWW-Authenticate", "DPoP error=\"invalid_dpop_proof\"")],
axum::Json(serde_json::json!({
"error": "invalid_dpop_proof",
"message": msg
})),
)
.into_response(),
other => ApiError::from(other).into_response(),
}
}
}
@@ -107,6 +136,68 @@ pub fn extract_auth_token_from_header(auth_header: Option<&str>) -> Option<Extra
None
}
#[derive(Default)]
struct StatusCheckFlags {
allow_deactivated: bool,
allow_takendown: bool,
}
async fn verify_oauth_token_and_build_user(
state: &AppState,
token: &str,
dpop_proof: Option<&str>,
method: &str,
uri: &str,
flags: StatusCheckFlags,
) -> Result<AuthenticatedUser, AuthError> {
match crate::oauth::verify::verify_oauth_access_token(
state.oauth_repo.as_ref(),
token,
dpop_proof,
method,
uri,
)
.await
{
Ok(result) => {
let user_info = state
.user_repo
.get_user_info_by_did(&result.did)
.await
.ok()
.flatten()
.ok_or(AuthError::AuthenticationFailed)?;
let status = AccountStatus::from_db_fields(
user_info.takedown_ref.as_deref(),
user_info.deactivated_at,
);
if !flags.allow_deactivated && status.is_deactivated() {
return Err(AuthError::AccountDeactivated);
}
if !flags.allow_takendown && status.is_takendown() {
return Err(AuthError::AccountTakedown);
}
Ok(AuthenticatedUser {
did: result.did,
key_bytes: user_info.key_bytes.and_then(|kb| {
crate::config::decrypt_key(&kb, user_info.encryption_version).ok()
}),
is_oauth: true,
is_admin: user_info.is_admin,
status,
scope: result.scope,
controller_did: None,
})
}
Err(crate::oauth::OAuthError::ExpiredToken(msg)) => Err(AuthError::OAuthExpiredToken(msg)),
Err(crate::oauth::OAuthError::UseDpopNonce(nonce)) => Err(AuthError::UseDpopNonce(nonce)),
Err(crate::oauth::OAuthError::InvalidDpopProof(msg)) => {
Err(AuthError::InvalidDpopProof(msg))
}
Err(_) => Err(AuthError::AuthenticationFailed),
}
}
impl FromRequestParts<AppState> for BearerAuth {
type Rejection = AuthError;
@@ -124,45 +215,44 @@ impl FromRequestParts<AppState> for BearerAuth {
let extracted =
extract_auth_token_from_header(Some(auth_header)).ok_or(AuthError::InvalidFormat)?;
if extracted.is_dpop {
let dpop_proof = parts.headers.get("dpop").and_then(|h| h.to_str().ok());
let method = parts.method.as_str();
let uri = build_full_url(&parts.uri.to_string());
let dpop_proof = parts.headers.get("DPoP").and_then(|h| h.to_str().ok());
let method = parts.method.as_str();
let uri = build_full_url(&parts.uri.to_string());
match validate_token_with_dpop(
state.user_repo.as_ref(),
state.oauth_repo.as_ref(),
&extracted.token,
true,
dpop_proof,
method,
&uri,
false,
false,
)
.await
{
Ok(user) => Ok(BearerAuth(user)),
Err(TokenValidationError::AccountDeactivated) => Err(AuthError::AccountDeactivated),
Err(TokenValidationError::AccountTakedown) => Err(AuthError::AccountTakedown),
Err(TokenValidationError::TokenExpired) => Err(AuthError::TokenExpired),
Err(_) => Err(AuthError::AuthenticationFailed),
match validate_bearer_token(state.user_repo.as_ref(), &extracted.token).await {
Ok(user) if !user.is_oauth => {
return if user.status.is_deactivated() {
Err(AuthError::AccountDeactivated)
} else if user.status.is_takendown() {
Err(AuthError::AccountTakedown)
} else {
Ok(BearerAuth(user))
};
}
} else {
match validate_bearer_token_cached(
state.user_repo.as_ref(),
state.cache.as_ref(),
&extracted.token,
)
.await
{
Ok(user) => Ok(BearerAuth(user)),
Err(TokenValidationError::AccountDeactivated) => Err(AuthError::AccountDeactivated),
Err(TokenValidationError::AccountTakedown) => Err(AuthError::AccountTakedown),
Err(TokenValidationError::TokenExpired) => Err(AuthError::TokenExpired),
Err(_) => Err(AuthError::AuthenticationFailed),
Ok(_) => {}
Err(super::TokenValidationError::AccountDeactivated) => {
return Err(AuthError::AccountDeactivated);
}
Err(super::TokenValidationError::AccountTakedown) => {
return Err(AuthError::AccountTakedown);
}
Err(super::TokenValidationError::TokenExpired) => {
info!("JWT access token expired in BearerAuth, returning ExpiredToken");
return Err(AuthError::TokenExpired);
}
Err(_) => {}
}
verify_oauth_token_and_build_user(
state,
&extracted.token,
dpop_proof,
method,
&uri,
StatusCheckFlags::default(),
)
.await
.map(BearerAuth)
}
}
@@ -185,43 +275,43 @@ impl FromRequestParts<AppState> for BearerAuthAllowDeactivated {
let extracted =
extract_auth_token_from_header(Some(auth_header)).ok_or(AuthError::InvalidFormat)?;
if extracted.is_dpop {
let dpop_proof = parts.headers.get("dpop").and_then(|h| h.to_str().ok());
let method = parts.method.as_str();
let uri = build_full_url(&parts.uri.to_string());
let dpop_proof = parts.headers.get("DPoP").and_then(|h| h.to_str().ok());
let method = parts.method.as_str();
let uri = build_full_url(&parts.uri.to_string());
match validate_token_with_dpop(
state.user_repo.as_ref(),
state.oauth_repo.as_ref(),
&extracted.token,
true,
dpop_proof,
method,
&uri,
true,
false,
)
match validate_bearer_token_allow_deactivated(state.user_repo.as_ref(), &extracted.token)
.await
{
Ok(user) => Ok(BearerAuthAllowDeactivated(user)),
Err(TokenValidationError::AccountTakedown) => Err(AuthError::AccountTakedown),
Err(TokenValidationError::TokenExpired) => Err(AuthError::TokenExpired),
Err(_) => Err(AuthError::AuthenticationFailed),
{
Ok(user) if !user.is_oauth => {
return if user.status.is_takendown() {
Err(AuthError::AccountTakedown)
} else {
Ok(BearerAuthAllowDeactivated(user))
};
}
} else {
match validate_bearer_token_cached_allow_deactivated(
state.user_repo.as_ref(),
state.cache.as_ref(),
&extracted.token,
)
.await
{
Ok(user) => Ok(BearerAuthAllowDeactivated(user)),
Err(TokenValidationError::AccountTakedown) => Err(AuthError::AccountTakedown),
Err(TokenValidationError::TokenExpired) => Err(AuthError::TokenExpired),
Err(_) => Err(AuthError::AuthenticationFailed),
Ok(_) => {}
Err(super::TokenValidationError::AccountTakedown) => {
return Err(AuthError::AccountTakedown);
}
Err(super::TokenValidationError::TokenExpired) => {
return Err(AuthError::TokenExpired);
}
Err(_) => {}
}
verify_oauth_token_and_build_user(
state,
&extracted.token,
dpop_proof,
method,
&uri,
StatusCheckFlags {
allow_deactivated: true,
allow_takendown: false,
},
)
.await
.map(BearerAuthAllowDeactivated)
}
}
@@ -244,39 +334,43 @@ impl FromRequestParts<AppState> for BearerAuthAllowTakendown {
let extracted =
extract_auth_token_from_header(Some(auth_header)).ok_or(AuthError::InvalidFormat)?;
if extracted.is_dpop {
let dpop_proof = parts.headers.get("dpop").and_then(|h| h.to_str().ok());
let method = parts.method.as_str();
let uri = build_full_url(&parts.uri.to_string());
let dpop_proof = parts.headers.get("DPoP").and_then(|h| h.to_str().ok());
let method = parts.method.as_str();
let uri = build_full_url(&parts.uri.to_string());
match validate_token_with_dpop(
state.user_repo.as_ref(),
state.oauth_repo.as_ref(),
&extracted.token,
true,
dpop_proof,
method,
&uri,
false,
true,
)
match validate_bearer_token_allow_takendown(state.user_repo.as_ref(), &extracted.token)
.await
{
Ok(user) => Ok(BearerAuthAllowTakendown(user)),
Err(TokenValidationError::AccountDeactivated) => Err(AuthError::AccountDeactivated),
Err(TokenValidationError::TokenExpired) => Err(AuthError::TokenExpired),
Err(_) => Err(AuthError::AuthenticationFailed),
{
Ok(user) if !user.is_oauth => {
return if user.status.is_deactivated() {
Err(AuthError::AccountDeactivated)
} else {
Ok(BearerAuthAllowTakendown(user))
};
}
} else {
match validate_bearer_token_allow_takendown(state.user_repo.as_ref(), &extracted.token)
.await
{
Ok(user) => Ok(BearerAuthAllowTakendown(user)),
Err(TokenValidationError::AccountDeactivated) => Err(AuthError::AccountDeactivated),
Err(TokenValidationError::TokenExpired) => Err(AuthError::TokenExpired),
Err(_) => Err(AuthError::AuthenticationFailed),
Ok(_) => {}
Err(super::TokenValidationError::AccountDeactivated) => {
return Err(AuthError::AccountDeactivated);
}
Err(super::TokenValidationError::TokenExpired) => {
return Err(AuthError::TokenExpired);
}
Err(_) => {}
}
verify_oauth_token_and_build_user(
state,
&extracted.token,
dpop_proof,
method,
&uri,
StatusCheckFlags {
allow_deactivated: false,
allow_takendown: true,
},
)
.await
.map(BearerAuthAllowTakendown)
}
}
@@ -299,57 +393,45 @@ impl FromRequestParts<AppState> for BearerAuthAdmin {
let extracted =
extract_auth_token_from_header(Some(auth_header)).ok_or(AuthError::InvalidFormat)?;
let user = if extracted.is_dpop {
let dpop_proof = parts.headers.get("dpop").and_then(|h| h.to_str().ok());
let method = parts.method.as_str();
let uri = build_full_url(&parts.uri.to_string());
let dpop_proof = parts.headers.get("DPoP").and_then(|h| h.to_str().ok());
let method = parts.method.as_str();
let uri = build_full_url(&parts.uri.to_string());
match validate_token_with_dpop(
state.user_repo.as_ref(),
state.oauth_repo.as_ref(),
&extracted.token,
true,
dpop_proof,
method,
&uri,
false,
false,
)
.await
{
Ok(user) => user,
Err(TokenValidationError::AccountDeactivated) => {
match validate_bearer_token(state.user_repo.as_ref(), &extracted.token).await {
Ok(user) if !user.is_oauth => {
if user.status.is_deactivated() {
return Err(AuthError::AccountDeactivated);
}
Err(TokenValidationError::AccountTakedown) => {
if user.status.is_takendown() {
return Err(AuthError::AccountTakedown);
}
Err(TokenValidationError::TokenExpired) => {
return Err(AuthError::TokenExpired);
if !user.is_admin {
return Err(AuthError::AdminRequired);
}
Err(_) => return Err(AuthError::AuthenticationFailed),
return Ok(BearerAuthAdmin(user));
}
} else {
match validate_bearer_token_cached(
state.user_repo.as_ref(),
state.cache.as_ref(),
&extracted.token,
)
.await
{
Ok(user) => user,
Err(TokenValidationError::AccountDeactivated) => {
return Err(AuthError::AccountDeactivated);
}
Err(TokenValidationError::AccountTakedown) => {
return Err(AuthError::AccountTakedown);
}
Err(TokenValidationError::TokenExpired) => {
return Err(AuthError::TokenExpired);
}
Err(_) => return Err(AuthError::AuthenticationFailed),
Ok(_) => {}
Err(super::TokenValidationError::AccountDeactivated) => {
return Err(AuthError::AccountDeactivated);
}
};
Err(super::TokenValidationError::AccountTakedown) => {
return Err(AuthError::AccountTakedown);
}
Err(super::TokenValidationError::TokenExpired) => {
return Err(AuthError::TokenExpired);
}
Err(_) => {}
}
let user = verify_oauth_token_and_build_user(
state,
&extracted.token,
dpop_proof,
method,
&uri,
StatusCheckFlags::default(),
)
.await?;
if !user.is_admin {
return Err(AuthError::AdminRequired);
@@ -358,6 +440,218 @@ impl FromRequestParts<AppState> for BearerAuthAdmin {
}
}
pub struct OptionalBearerAuth(pub Option<AuthenticatedUser>);
impl FromRequestParts<AppState> for OptionalBearerAuth {
type Rejection = AuthError;
async fn from_request_parts(
parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
let auth_header = match parts.headers.get(AUTHORIZATION) {
Some(h) => match h.to_str() {
Ok(s) => s,
Err(_) => return Ok(OptionalBearerAuth(None)),
},
None => return Ok(OptionalBearerAuth(None)),
};
let extracted = match extract_auth_token_from_header(Some(auth_header)) {
Some(e) => e,
None => return Ok(OptionalBearerAuth(None)),
};
let dpop_proof = parts.headers.get("DPoP").and_then(|h| h.to_str().ok());
let method = parts.method.as_str();
let uri = build_full_url(&parts.uri.to_string());
if let Ok(user) = validate_bearer_token(state.user_repo.as_ref(), &extracted.token).await
&& !user.is_oauth
{
return if user.status.is_deactivated() || user.status.is_takendown() {
Ok(OptionalBearerAuth(None))
} else {
Ok(OptionalBearerAuth(Some(user)))
};
}
Ok(OptionalBearerAuth(
verify_oauth_token_and_build_user(
state,
&extracted.token,
dpop_proof,
method,
&uri,
StatusCheckFlags::default(),
)
.await
.ok(),
))
}
}
pub struct ServiceAuth {
pub claims: ServiceTokenClaims,
pub did: Did,
}
impl FromRequestParts<AppState> for ServiceAuth {
type Rejection = AuthError;
async fn from_request_parts(
parts: &mut Parts,
_state: &AppState,
) -> Result<Self, Self::Rejection> {
let auth_header = parts
.headers
.get(AUTHORIZATION)
.ok_or(AuthError::MissingToken)?
.to_str()
.map_err(|_| AuthError::InvalidFormat)?;
let extracted =
extract_auth_token_from_header(Some(auth_header)).ok_or(AuthError::InvalidFormat)?;
if !is_service_token(&extracted.token) {
return Err(AuthError::InvalidFormat);
}
let verifier = ServiceTokenVerifier::new();
let claims = verifier
.verify_service_token(&extracted.token, None)
.await
.map_err(|e| {
error!("Service token verification failed: {:?}", e);
AuthError::AuthenticationFailed
})?;
let did: Did = claims
.iss
.parse()
.map_err(|_| AuthError::AuthenticationFailed)?;
debug!("Service token verified for DID: {}", did);
Ok(ServiceAuth { claims, did })
}
}
pub struct OptionalServiceAuth(pub Option<ServiceTokenClaims>);
impl FromRequestParts<AppState> for OptionalServiceAuth {
type Rejection = std::convert::Infallible;
async fn from_request_parts(
parts: &mut Parts,
_state: &AppState,
) -> Result<Self, Self::Rejection> {
let auth_header = match parts.headers.get(AUTHORIZATION) {
Some(h) => match h.to_str() {
Ok(s) => s,
Err(_) => return Ok(OptionalServiceAuth(None)),
},
None => return Ok(OptionalServiceAuth(None)),
};
let extracted = match extract_auth_token_from_header(Some(auth_header)) {
Some(e) => e,
None => return Ok(OptionalServiceAuth(None)),
};
if !is_service_token(&extracted.token) {
return Ok(OptionalServiceAuth(None));
}
let verifier = ServiceTokenVerifier::new();
match verifier.verify_service_token(&extracted.token, None).await {
Ok(claims) => {
debug!("Service token verified for DID: {}", claims.iss);
Ok(OptionalServiceAuth(Some(claims)))
}
Err(e) => {
debug!("Service token verification failed (optional): {:?}", e);
Ok(OptionalServiceAuth(None))
}
}
}
}
pub enum BlobAuthResult {
Service { did: Did },
User(AuthenticatedUser),
}
pub struct BlobAuth(pub BlobAuthResult);
impl FromRequestParts<AppState> for BlobAuth {
type Rejection = AuthError;
async fn from_request_parts(
parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
let auth_header = parts
.headers
.get(AUTHORIZATION)
.ok_or(AuthError::MissingToken)?
.to_str()
.map_err(|_| AuthError::InvalidFormat)?;
let extracted =
extract_auth_token_from_header(Some(auth_header)).ok_or(AuthError::InvalidFormat)?;
if is_service_token(&extracted.token) {
debug!("Verifying service token for blob upload");
let verifier = ServiceTokenVerifier::new();
let claims = verifier
.verify_service_token(&extracted.token, Some("com.atproto.repo.uploadBlob"))
.await
.map_err(|e| {
error!("Service token verification failed: {:?}", e);
AuthError::AuthenticationFailed
})?;
let did: Did = claims
.iss
.parse()
.map_err(|_| AuthError::AuthenticationFailed)?;
debug!("Service token verified for DID: {}", did);
return Ok(BlobAuth(BlobAuthResult::Service { did }));
}
let dpop_proof = parts.headers.get("DPoP").and_then(|h| h.to_str().ok());
let uri = build_full_url("/xrpc/com.atproto.repo.uploadBlob");
if let Ok(user) =
validate_bearer_token_allow_deactivated(state.user_repo.as_ref(), &extracted.token)
.await
&& !user.is_oauth
{
return if user.status.is_takendown() {
Err(AuthError::AccountTakedown)
} else {
Ok(BlobAuth(BlobAuthResult::User(user)))
};
}
verify_oauth_token_and_build_user(
state,
&extracted.token,
dpop_proof,
"POST",
&uri,
StatusCheckFlags {
allow_deactivated: true,
allow_takendown: false,
},
)
.await
.map(|user| BlobAuth(BlobAuthResult::User(user)))
}
}
#[cfg(test)]
mod tests {
use super::*;
+2 -1
View File
@@ -16,7 +16,8 @@ pub mod verification_token;
pub mod webauthn;
pub use extractor::{
AuthError, BearerAuth, BearerAuthAdmin, BearerAuthAllowDeactivated, ExtractedToken,
AuthError, BearerAuth, BearerAuthAdmin, BearerAuthAllowDeactivated, BlobAuth, BlobAuthResult,
ExtractedToken, OptionalBearerAuth, OptionalServiceAuth, ServiceAuth,
extract_auth_token_from_header, extract_bearer_token_from_header,
};
pub use service::{ServiceTokenClaims, ServiceTokenVerifier, is_service_token};
+11 -2
View File
@@ -528,7 +528,11 @@ pub fn app(state: AppState) -> Router {
));
let xrpc_service = ServiceBuilder::new()
.layer(XrpcProxyLayer::new(state.clone()))
.service(xrpc_router.with_state(state.clone()));
.service(
xrpc_router
.layer(middleware::from_fn(oauth::verify::dpop_nonce_middleware))
.with_state(state.clone()),
);
let oauth_router = Router::new()
.route("/jwks", get(oauth::endpoints::oauth_jwks))
@@ -568,6 +572,10 @@ pub fn app(state: AppState) -> Router {
"/register/complete",
post(oauth::endpoints::register_complete),
)
.route(
"/establish-session",
post(oauth::endpoints::establish_session),
)
.route("/authorize/consent", get(oauth::endpoints::consent_get))
.route("/authorize/consent", post(oauth::endpoints::consent_post))
.route(
@@ -605,7 +613,8 @@ pub fn app(state: AppState) -> Router {
.route(
"/sso/check-handle-available",
get(sso::endpoints::check_handle_available),
);
)
.layer(middleware::from_fn(oauth::verify::dpop_nonce_middleware));
let well_known_router = Router::new()
.route("/did.json", get(api::identity::well_known_did))
@@ -2,6 +2,7 @@ use crate::comms::{channel_display_name, comms_repo::enqueue_2fa_code};
use crate::oauth::{
AuthFlowState, ClientMetadataCache, Code, DeviceData, DeviceId, OAuthError, SessionId,
db::should_show_consent,
scopes::expand_include_scopes,
};
use crate::state::{AppState, RateLimitKind};
use crate::types::{Did, Handle, PlainPassword};
@@ -1106,6 +1107,46 @@ pub async fn authorize_select(
.oauth_repo
.upsert_account_device(&did, &select_device_typed)
.await;
let requested_scope_str = request_data
.parameters
.scope
.as_deref()
.unwrap_or("atproto");
let requested_scopes: Vec<String> = requested_scope_str
.split_whitespace()
.map(|s| s.to_string())
.collect();
let client_id_typed = ClientId::from(request_data.parameters.client_id.clone());
let needs_consent = should_show_consent(
state.oauth_repo.as_ref(),
&did,
&client_id_typed,
&requested_scopes,
)
.await
.unwrap_or(true);
if needs_consent {
if state
.oauth_repo
.set_authorization_did(&select_request_id, &did, Some(&select_device_typed))
.await
.is_err()
{
return json_error(
StatusCode::INTERNAL_SERVER_ERROR,
"server_error",
"An error occurred. Please try again.",
);
}
let consent_url = format!(
"/app/oauth/consent?request_uri={}",
url_encode(&form.request_uri)
);
return Json(serde_json::json!({"redirect_uri": consent_url})).into_response();
}
let code = Code::generate();
let select_code = AuthorizationCode::from(code.0.clone());
if state
@@ -1475,7 +1516,8 @@ pub async fn consent_get(
requested_scope_str.to_string()
};
let requested_scopes: Vec<&str> = effective_scope_str.split_whitespace().collect();
let expanded_scope_str = expand_include_scopes(&effective_scope_str).await;
let requested_scopes: Vec<&str> = expanded_scope_str.split_whitespace().collect();
let consent_client_id = ClientId::from(request_data.parameters.client_id.clone());
let preferences = state
.oauth_repo
@@ -2407,39 +2449,37 @@ pub async fn passkey_start(
}
let delegation_from_param = match &form.delegated_did {
Some(delegated_did_str) => {
match delegated_did_str.parse::<tranquil_types::Did>() {
Ok(delegated_did) if delegated_did != user.did => {
match state
.delegation_repo
.get_delegation(&delegated_did, &user.did)
.await
{
Ok(Some(_)) => Some(delegated_did),
Ok(None) => None,
Err(e) => {
tracing::warn!(
error = %e,
delegated_did = %delegated_did,
controller_did = %user.did,
"Failed to verify delegation relationship"
);
None
}
Some(delegated_did_str) => match delegated_did_str.parse::<tranquil_types::Did>() {
Ok(delegated_did) if delegated_did != user.did => {
match state
.delegation_repo
.get_delegation(&delegated_did, &user.did)
.await
{
Ok(Some(_)) => Some(delegated_did),
Ok(None) => None,
Err(e) => {
tracing::warn!(
error = %e,
delegated_did = %delegated_did,
controller_did = %user.did,
"Failed to verify delegation relationship"
);
None
}
}
_ => None,
}
}
_ => None,
},
None => None,
};
let is_delegation_flow = delegation_from_param.is_some()
|| request_data.did.as_ref().map_or(false, |existing_did| {
|| request_data.did.as_ref().is_some_and(|existing_did| {
existing_did
.parse::<tranquil_types::Did>()
.ok()
.map_or(false, |parsed| parsed != user.did)
.is_some_and(|parsed| parsed != user.did)
});
if let Some(delegated_did) = delegation_from_param {
@@ -3601,3 +3641,79 @@ pub async fn register_complete(
);
Json(serde_json::json!({"redirect_uri": redirect_url})).into_response()
}
pub async fn establish_session(
State(state): State<AppState>,
headers: HeaderMap,
auth: crate::auth::BearerAuth,
) -> Response {
let did = &auth.0.did;
let existing_device = extract_device_cookie(&headers);
let (device_id, new_cookie) = match existing_device {
Some(id) => {
let device_typed = DeviceIdType::from(id.clone());
let _ = state
.oauth_repo
.upsert_account_device(did, &device_typed)
.await;
(id, None)
}
None => {
let new_id = DeviceId::generate();
let device_data = DeviceData {
session_id: SessionId::generate().0,
user_agent: extract_user_agent(&headers),
ip_address: extract_client_ip(&headers),
last_seen_at: Utc::now(),
};
let device_typed = DeviceIdType::from(new_id.0.clone());
if let Err(e) = state.oauth_repo.create_device(&device_typed, &device_data).await {
tracing::error!(error = ?e, "Failed to create device");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "server_error",
"error_description": "Failed to establish session"
})),
)
.into_response();
}
if let Err(e) = state.oauth_repo.upsert_account_device(did, &device_typed).await {
tracing::error!(error = ?e, "Failed to link device to account");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "server_error",
"error_description": "Failed to establish session"
})),
)
.into_response();
}
(new_id.0.clone(), Some(make_device_cookie(&new_id.0)))
}
};
tracing::info!(did = %did, device_id = %device_id, "Device session established");
match new_cookie {
Some(cookie) => (
StatusCode::OK,
[(SET_COOKIE, cookie)],
Json(serde_json::json!({
"success": true,
"device_id": device_id
})),
)
.into_response(),
None => Json(serde_json::json!({
"success": true,
"device_id": device_id
}))
.into_response(),
}
}
@@ -1,8 +1,8 @@
use crate::auth::{extract_auth_token_from_header, validate_token_with_dpop};
use crate::auth::BearerAuth;
use crate::delegation::DelegationActionType;
use crate::state::{AppState, RateLimitKind};
use crate::types::PlainPassword;
use crate::util::{build_full_url, extract_client_ip};
use crate::util::extract_client_ip;
use axum::{
Json,
extract::State,
@@ -463,58 +463,10 @@ pub struct DelegationTokenAuthSubmit {
pub async fn delegation_auth_token(
State(state): State<AppState>,
headers: HeaderMap,
auth: BearerAuth,
Json(form): Json<DelegationTokenAuthSubmit>,
) -> Response {
let auth_header = headers.get("authorization").and_then(|v| v.to_str().ok());
let extracted = match extract_auth_token_from_header(auth_header) {
Some(e) => e,
None => {
return (
StatusCode::UNAUTHORIZED,
Json(DelegationAuthResponse {
success: false,
needs_totp: None,
redirect_uri: None,
error: Some("Missing or invalid authorization header".to_string()),
}),
)
.into_response();
}
};
let dpop_proof = headers.get("dpop").and_then(|h| h.to_str().ok());
let uri = build_full_url("/oauth/delegation/auth-token");
let auth_user = match validate_token_with_dpop(
state.user_repo.as_ref(),
state.oauth_repo.as_ref(),
&extracted.token,
extracted.is_dpop,
dpop_proof,
"POST",
&uri,
false,
false,
)
.await
{
Ok(user) => user,
Err(_) => {
return (
StatusCode::UNAUTHORIZED,
Json(DelegationAuthResponse {
success: false,
needs_totp: None,
redirect_uri: None,
error: Some("Invalid or expired access token".to_string()),
}),
)
.into_response();
}
};
let controller_did = auth_user.did;
let controller_did = auth.0.did;
let delegated_did: Did = match form.delegated_did.parse() {
Ok(d) => d,
+31 -13
View File
@@ -10,7 +10,9 @@ use serde_json::json;
use sha2::Sha256;
use subtle::ConstantTimeEq;
use tranquil_db_traits::{OAuthRepository, UserRepository};
use tranquil_types::TokenId;
use tranquil_types::{ClientId, TokenId};
use crate::types::Did;
use super::scopes::ScopePermissions;
use super::{DPoPVerifier, OAuthError};
@@ -27,9 +29,9 @@ pub struct OAuthTokenInfo {
}
pub struct VerifyResult {
pub did: String,
pub token_id: String,
pub client_id: String,
pub did: Did,
pub token_id: TokenId,
pub client_id: ClientId,
pub scope: Option<String>,
}
@@ -91,10 +93,14 @@ pub async fn verify_oauth_access_token(
));
}
}
let did: Did = token_data
.did
.parse()
.map_err(|_| OAuthError::InvalidToken("Invalid DID in token".to_string()))?;
Ok(VerifyResult {
did: token_data.did,
token_id: token_id.to_string(),
client_id: token_data.client_id,
did,
token_id,
client_id: ClientId::from(token_data.client_id),
scope: token_data.scope,
})
}
@@ -202,8 +208,8 @@ pub fn generate_dpop_nonce() -> String {
}
pub struct OAuthUser {
pub did: String,
pub client_id: Option<String>,
pub did: Did,
pub client_id: Option<ClientId>,
pub scope: Option<String>,
pub is_oauth: bool,
pub permissions: ScopePermissions,
@@ -382,7 +388,7 @@ impl FromRequestParts<AppState> for OAuthUser {
}
struct LegacyAuthResult {
did: String,
did: Did,
}
async fn try_legacy_auth(
@@ -390,9 +396,21 @@ async fn try_legacy_auth(
token: &str,
) -> Result<LegacyAuthResult, ()> {
match crate::auth::validate_bearer_token(user_repo, token).await {
Ok(user) if !user.is_oauth => Ok(LegacyAuthResult {
did: user.did.to_string(),
}),
Ok(user) if !user.is_oauth => Ok(LegacyAuthResult { did: user.did }),
_ => Err(()),
}
}
pub async fn dpop_nonce_middleware(
req: axum::http::Request<axum::body::Body>,
next: axum::middleware::Next,
) -> Response {
let mut response = next.run(req).await;
let config = AuthConfig::get();
let verifier = DPoPVerifier::new(config.dpop_secret().as_bytes());
let nonce = verifier.generate_nonce();
if let Ok(nonce_val) = nonce.parse() {
response.headers_mut().insert("DPoP-Nonce", nonce_val);
}
response
}
+583
View File
@@ -0,0 +1,583 @@
mod common;
mod helpers;
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use chrono::Utc;
use common::{base_url, client, create_account_and_login, pds_endpoint};
use helpers::verify_new_account;
use reqwest::StatusCode;
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn generate_pkce() -> (String, String) {
let verifier_bytes: [u8; 32] = rand::random();
let code_verifier = URL_SAFE_NO_PAD.encode(verifier_bytes);
let mut hasher = Sha256::new();
hasher.update(code_verifier.as_bytes());
let code_challenge = URL_SAFE_NO_PAD.encode(hasher.finalize());
(code_verifier, code_challenge)
}
async fn setup_mock_client_metadata(redirect_uri: &str, dpop_bound: bool) -> MockServer {
let mock_server = MockServer::start().await;
let metadata = json!({
"client_id": mock_server.uri(),
"client_name": "Auth Extractor Test Client",
"redirect_uris": [redirect_uri],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none",
"dpop_bound_access_tokens": dpop_bound
});
Mock::given(method("GET"))
.and(path("/"))
.respond_with(ResponseTemplate::new(200).set_body_json(metadata))
.mount(&mock_server)
.await;
mock_server
}
async fn get_oauth_session(
http_client: &reqwest::Client,
url: &str,
dpop_bound: bool,
) -> (String, String, String, String) {
let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8];
let handle = format!("ae{}", suffix);
let password = "AuthExtract123!";
let create_res = http_client
.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
.json(&json!({
"handle": handle,
"email": format!("{}@example.com", handle),
"password": password
}))
.send()
.await
.unwrap();
assert_eq!(create_res.status(), StatusCode::OK);
let account: Value = create_res.json().await.unwrap();
let did = account["did"].as_str().unwrap().to_string();
verify_new_account(http_client, &did).await;
let redirect_uri = "https://example.com/auth-callback";
let mock_client = setup_mock_client_metadata(redirect_uri, dpop_bound).await;
let client_id = mock_client.uri();
let (code_verifier, code_challenge) = generate_pkce();
let par_body: Value = http_client
.post(format!("{}/oauth/par", url))
.form(&[
("response_type", "code"),
("client_id", &client_id),
("redirect_uri", redirect_uri),
("code_challenge", &code_challenge),
("code_challenge_method", "S256"),
])
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let request_uri = par_body["request_uri"].as_str().unwrap();
let auth_res = http_client
.post(format!("{}/oauth/authorize", url))
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.json(&json!({
"request_uri": request_uri,
"username": &handle,
"password": password,
"remember_device": false
}))
.send()
.await
.unwrap();
let auth_body: Value = auth_res.json().await.unwrap();
let mut location = auth_body["redirect_uri"].as_str().unwrap().to_string();
if location.contains("/oauth/consent") {
let consent_res = http_client
.post(format!("{}/oauth/authorize/consent", url))
.header("Content-Type", "application/json")
.json(&json!({
"request_uri": request_uri,
"approved_scopes": ["atproto"],
"remember": false
}))
.send()
.await
.unwrap();
let consent_body: Value = consent_res.json().await.unwrap();
location = consent_body["redirect_uri"].as_str().unwrap().to_string();
}
let code = location
.split("code=")
.nth(1)
.unwrap()
.split('&')
.next()
.unwrap();
let token_body: Value = http_client
.post(format!("{}/oauth/token", url))
.form(&[
("grant_type", "authorization_code"),
("code", code),
("redirect_uri", redirect_uri),
("code_verifier", &code_verifier),
("client_id", &client_id),
])
.send()
.await
.unwrap()
.json()
.await
.unwrap();
(
token_body["access_token"].as_str().unwrap().to_string(),
token_body["refresh_token"].as_str().unwrap().to_string(),
client_id,
did,
)
}
#[tokio::test]
async fn test_oauth_token_works_with_bearer_auth() {
let url = base_url().await;
let http_client = client();
let (access_token, _, _, did) = get_oauth_session(&http_client, url, false).await;
let res = http_client
.get(format!("{}/xrpc/com.atproto.server.getSession", url))
.bearer_auth(&access_token)
.send()
.await
.unwrap();
assert_eq!(
res.status(),
StatusCode::OK,
"OAuth token should work with BearerAuth extractor"
);
let body: Value = res.json().await.unwrap();
assert_eq!(body["did"].as_str().unwrap(), did);
}
#[tokio::test]
async fn test_session_token_still_works() {
let url = base_url().await;
let http_client = client();
let (jwt, did) = create_account_and_login(&http_client).await;
let res = http_client
.get(format!("{}/xrpc/com.atproto.server.getSession", url))
.bearer_auth(&jwt)
.send()
.await
.unwrap();
assert_eq!(
res.status(),
StatusCode::OK,
"Session token should still work"
);
let body: Value = res.json().await.unwrap();
assert_eq!(body["did"].as_str().unwrap(), did);
}
#[tokio::test]
async fn test_oauth_admin_extractor_allows_oauth_tokens() {
let url = base_url().await;
let http_client = client();
let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8];
let handle = format!("adm{}", suffix);
let password = "AdminOAuth123!";
let create_res = http_client
.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
.json(&json!({
"handle": handle,
"email": format!("{}@example.com", handle),
"password": password
}))
.send()
.await
.unwrap();
assert_eq!(create_res.status(), StatusCode::OK);
let account: Value = create_res.json().await.unwrap();
let did = account["did"].as_str().unwrap().to_string();
verify_new_account(&http_client, &did).await;
let pool = common::get_test_db_pool().await;
sqlx::query!("UPDATE users SET is_admin = TRUE WHERE did = $1", &did)
.execute(pool)
.await
.expect("Failed to mark user as admin");
let redirect_uri = "https://example.com/admin-callback";
let mock_client = setup_mock_client_metadata(redirect_uri, false).await;
let client_id = mock_client.uri();
let (code_verifier, code_challenge) = generate_pkce();
let par_body: Value = http_client
.post(format!("{}/oauth/par", url))
.form(&[
("response_type", "code"),
("client_id", &client_id),
("redirect_uri", redirect_uri),
("code_challenge", &code_challenge),
("code_challenge_method", "S256"),
])
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let request_uri = par_body["request_uri"].as_str().unwrap();
let auth_res = http_client
.post(format!("{}/oauth/authorize", url))
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.json(&json!({
"request_uri": request_uri,
"username": &handle,
"password": password,
"remember_device": false
}))
.send()
.await
.unwrap();
let auth_body: Value = auth_res.json().await.unwrap();
let mut location = auth_body["redirect_uri"].as_str().unwrap().to_string();
if location.contains("/oauth/consent") {
let consent_res = http_client
.post(format!("{}/oauth/authorize/consent", url))
.header("Content-Type", "application/json")
.json(&json!({
"request_uri": request_uri,
"approved_scopes": ["atproto"],
"remember": false
}))
.send()
.await
.unwrap();
let consent_body: Value = consent_res.json().await.unwrap();
location = consent_body["redirect_uri"].as_str().unwrap().to_string();
}
let code = location
.split("code=")
.nth(1)
.unwrap()
.split('&')
.next()
.unwrap();
let token_body: Value = http_client
.post(format!("{}/oauth/token", url))
.form(&[
("grant_type", "authorization_code"),
("code", code),
("redirect_uri", redirect_uri),
("code_verifier", &code_verifier),
("client_id", &client_id),
])
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let access_token = token_body["access_token"].as_str().unwrap();
let res = http_client
.get(format!(
"{}/xrpc/com.atproto.admin.getAccountInfos?dids={}",
url, did
))
.bearer_auth(access_token)
.send()
.await
.unwrap();
assert_eq!(
res.status(),
StatusCode::OK,
"OAuth token for admin user should work with admin endpoint"
);
}
#[tokio::test]
async fn test_expired_oauth_token_returns_proper_error() {
let url = base_url().await;
let http_client = client();
let now = Utc::now().timestamp();
let header = json!({"alg": "HS256", "typ": "at+jwt"});
let payload = json!({
"iss": url,
"sub": "did:plc:test123",
"aud": url,
"iat": now - 7200,
"exp": now - 3600,
"jti": "expired-token",
"sid": "expired-session",
"scope": "atproto",
"client_id": "https://example.com"
});
let fake_token = format!(
"{}.{}.{}",
URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap()),
URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap()),
URL_SAFE_NO_PAD.encode([1u8; 32])
);
let res = http_client
.get(format!("{}/xrpc/com.atproto.server.getSession", url))
.bearer_auth(&fake_token)
.send()
.await
.unwrap();
assert_eq!(
res.status(),
StatusCode::UNAUTHORIZED,
"Expired token should be rejected"
);
}
#[tokio::test]
async fn test_dpop_nonce_error_has_proper_headers() {
let url = base_url().await;
let pds_url = pds_endpoint();
let http_client = client();
let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8];
let handle = format!("dpop{}", suffix);
let create_res = http_client
.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
.json(&json!({
"handle": handle,
"email": format!("{}@test.com", handle),
"password": "DpopTest123!"
}))
.send()
.await
.unwrap();
assert_eq!(create_res.status(), StatusCode::OK);
let account: Value = create_res.json().await.unwrap();
let did = account["did"].as_str().unwrap();
verify_new_account(&http_client, did).await;
let redirect_uri = "https://example.com/dpop-callback";
let mock_server = MockServer::start().await;
let client_id = mock_server.uri();
let metadata = json!({
"client_id": &client_id,
"client_name": "DPoP Test Client",
"redirect_uris": [redirect_uri],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none",
"dpop_bound_access_tokens": true
});
Mock::given(method("GET"))
.and(path("/"))
.respond_with(ResponseTemplate::new(200).set_body_json(metadata))
.mount(&mock_server)
.await;
let (code_verifier, code_challenge) = generate_pkce();
let par_body: Value = http_client
.post(format!("{}/oauth/par", url))
.form(&[
("response_type", "code"),
("client_id", &client_id),
("redirect_uri", redirect_uri),
("code_challenge", &code_challenge),
("code_challenge_method", "S256"),
])
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let request_uri = par_body["request_uri"].as_str().unwrap();
let auth_res = http_client
.post(format!("{}/oauth/authorize", url))
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.json(&json!({
"request_uri": request_uri,
"username": &handle,
"password": "DpopTest123!",
"remember_device": false
}))
.send()
.await
.unwrap();
let auth_body: Value = auth_res.json().await.unwrap();
let mut location = auth_body["redirect_uri"].as_str().unwrap().to_string();
if location.contains("/oauth/consent") {
let consent_res = http_client
.post(format!("{}/oauth/authorize/consent", url))
.header("Content-Type", "application/json")
.json(&json!({
"request_uri": request_uri,
"approved_scopes": ["atproto"],
"remember": false
}))
.send()
.await
.unwrap();
let consent_body: Value = consent_res.json().await.unwrap();
location = consent_body["redirect_uri"].as_str().unwrap().to_string();
}
let code = location
.split("code=")
.nth(1)
.unwrap()
.split('&')
.next()
.unwrap();
let token_endpoint = format!("{}/oauth/token", pds_url);
let (_, dpop_proof) = generate_dpop_proof("POST", &token_endpoint, None);
let token_res = http_client
.post(format!("{}/oauth/token", url))
.header("DPoP", &dpop_proof)
.form(&[
("grant_type", "authorization_code"),
("code", code),
("redirect_uri", redirect_uri),
("code_verifier", &code_verifier),
("client_id", &client_id),
])
.send()
.await
.unwrap();
let token_status = token_res.status();
let token_nonce = token_res
.headers()
.get("dpop-nonce")
.map(|h| h.to_str().unwrap().to_string());
let token_body: Value = token_res.json().await.unwrap();
let access_token = if token_status == StatusCode::OK {
token_body["access_token"].as_str().unwrap().to_string()
} else if token_body.get("error").and_then(|e| e.as_str()) == Some("use_dpop_nonce") {
let nonce =
token_nonce.expect("Token endpoint should return DPoP-Nonce on use_dpop_nonce error");
let (_, dpop_proof_with_nonce) = generate_dpop_proof("POST", &token_endpoint, Some(&nonce));
let retry_res = http_client
.post(format!("{}/oauth/token", url))
.header("DPoP", &dpop_proof_with_nonce)
.form(&[
("grant_type", "authorization_code"),
("code", code),
("redirect_uri", redirect_uri),
("code_verifier", &code_verifier),
("client_id", &client_id),
])
.send()
.await
.unwrap();
let retry_body: Value = retry_res.json().await.unwrap();
retry_body["access_token"]
.as_str()
.expect("Should get access_token after nonce retry")
.to_string()
} else {
panic!("Token exchange failed unexpectedly: {:?}", token_body);
};
let res = http_client
.get(format!("{}/xrpc/com.atproto.server.getSession", url))
.header("Authorization", format!("DPoP {}", access_token))
.send()
.await
.unwrap();
assert_eq!(
res.status(),
StatusCode::UNAUTHORIZED,
"DPoP token without proof should fail"
);
let www_auth = res
.headers()
.get("www-authenticate")
.map(|h| h.to_str().unwrap());
assert!(www_auth.is_some(), "Should have WWW-Authenticate header");
assert!(
www_auth.unwrap().contains("use_dpop_nonce"),
"WWW-Authenticate should indicate dpop nonce required"
);
let nonce = res.headers().get("dpop-nonce").map(|h| h.to_str().unwrap());
assert!(nonce.is_some(), "Should return DPoP-Nonce header");
let body: Value = res.json().await.unwrap();
assert_eq!(body["error"].as_str().unwrap(), "use_dpop_nonce");
}
fn generate_dpop_proof(method: &str, uri: &str, nonce: Option<&str>) -> (Value, String) {
use p256::ecdsa::{SigningKey, signature::Signer};
use p256::elliptic_curve::rand_core::OsRng;
let signing_key = SigningKey::random(&mut OsRng);
let verifying_key = signing_key.verifying_key();
let point = verifying_key.to_encoded_point(false);
let x = URL_SAFE_NO_PAD.encode(point.x().unwrap());
let y = URL_SAFE_NO_PAD.encode(point.y().unwrap());
let jwk = json!({
"kty": "EC",
"crv": "P-256",
"x": x,
"y": y
});
let header = {
let h = json!({
"typ": "dpop+jwt",
"alg": "ES256",
"jwk": jwk.clone()
});
h
};
let mut payload = json!({
"jti": uuid::Uuid::new_v4().to_string(),
"htm": method,
"htu": uri,
"iat": Utc::now().timestamp()
});
if let Some(n) = nonce {
payload["nonce"] = json!(n);
}
let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap());
let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap());
let signing_input = format!("{}.{}", header_b64, payload_b64);
let signature: p256::ecdsa::Signature = signing_key.sign(signing_input.as_bytes());
let sig_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes());
let proof = format!("{}.{}", signing_input, sig_b64);
(jwk, proof)
}
+3 -3
View File
@@ -1,8 +1,8 @@
#[cfg(feature = "s3-storage")]
#[cfg(all(not(feature = "external-infra"), feature = "s3-storage"))]
use aws_config::BehaviorVersion;
#[cfg(feature = "s3-storage")]
#[cfg(all(not(feature = "external-infra"), feature = "s3-storage"))]
use aws_sdk_s3::Client as S3Client;
#[cfg(feature = "s3-storage")]
#[cfg(all(not(feature = "external-infra"), feature = "s3-storage"))]
use aws_sdk_s3::config::Credentials;
use chrono::Utc;
use reqwest::{Client, StatusCode, header};
+8 -2
View File
@@ -1373,10 +1373,16 @@ async fn test_delegation_oauth_token_sub_is_delegated_account() {
.send()
.await
.unwrap();
assert_eq!(token_res.status(), StatusCode::OK, "Token exchange should succeed");
assert_eq!(
token_res.status(),
StatusCode::OK,
"Token exchange should succeed"
);
let tokens: Value = token_res.json().await.unwrap();
let sub = tokens["sub"].as_str().expect("Token response should have sub claim");
let sub = tokens["sub"]
.as_str()
.expect("Token response should have sub claim");
assert_eq!(
sub, delegated_did,
+2
View File
@@ -7,8 +7,10 @@ license.workspace = true
[dependencies]
axum = { workspace = true }
futures = { workspace = true }
hickory-resolver = { version = "0.24", features = ["tokio-runtime"] }
reqwest = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
urlencoding = "2"
+515 -73
View File
@@ -1,3 +1,4 @@
use hickory_resolver::TokioAsyncResolver;
use reqwest::Client;
use serde::Deserialize;
use std::collections::HashMap;
@@ -16,6 +17,23 @@ struct CachedLexicon {
const CACHE_TTL_SECS: u64 = 3600;
#[derive(Debug, Deserialize)]
struct PlcDocument {
service: Vec<PlcService>,
}
#[derive(Debug, Deserialize)]
struct PlcService {
id: String,
#[serde(rename = "serviceEndpoint")]
service_endpoint: String,
}
#[derive(Debug, Deserialize)]
struct GetRecordResponse {
value: LexiconDoc,
}
#[derive(Debug, Deserialize)]
struct LexiconDoc {
defs: HashMap<String, LexiconDef>,
@@ -31,7 +49,10 @@ struct LexiconDef {
#[derive(Debug, Deserialize)]
struct PermissionEntry {
resource: String,
action: Option<Vec<String>>,
collection: Option<Vec<String>>,
lxm: Option<Vec<String>>,
aud: Option<String>,
}
pub async fn expand_include_scopes(scope_string: &str) -> String {
@@ -39,12 +60,14 @@ pub async fn expand_include_scopes(scope_string: &str) -> String {
.split_whitespace()
.map(|scope| async move {
match scope.strip_prefix("include:") {
Some(nsid) => {
let nsid_base = nsid.split('?').next().unwrap_or(nsid);
expand_permission_set(nsid_base).await.unwrap_or_else(|e| {
warn!(nsid = nsid_base, error = %e, "Failed to expand permission set, keeping original");
scope.to_string()
})
Some(rest) => {
let (nsid_base, aud) = parse_include_scope(rest);
expand_permission_set(nsid_base, aud)
.await
.unwrap_or_else(|e| {
warn!(nsid = nsid_base, error = %e, "Failed to expand permission set, keeping original");
scope.to_string()
})
}
None => scope.to_string(),
}
@@ -54,10 +77,24 @@ pub async fn expand_include_scopes(scope_string: &str) -> String {
futures::future::join_all(futures).await.join(" ")
}
async fn expand_permission_set(nsid: &str) -> Result<String, String> {
fn parse_include_scope(rest: &str) -> (&str, Option<&str>) {
rest.split_once('?')
.map(|(nsid, params)| {
let aud = params.split('&').find_map(|p| p.strip_prefix("aud="));
(nsid, aud)
})
.unwrap_or((rest, None))
}
async fn expand_permission_set(nsid: &str, aud: Option<&str>) -> Result<String, String> {
let cache_key = match aud {
Some(a) => format!("{}?aud={}", nsid, a),
None => nsid.to_string(),
};
{
let cache = LEXICON_CACHE.read().await;
if let Some(cached) = cache.get(nsid)
if let Some(cached) = cache.get(&cache_key)
&& cached.cached_at.elapsed().as_secs() < CACHE_TTL_SECS
{
debug!(nsid, "Using cached permission set expansion");
@@ -65,41 +102,7 @@ async fn expand_permission_set(nsid: &str) -> Result<String, String> {
}
}
let parts: Vec<&str> = nsid.split('.').collect();
if parts.len() < 3 {
return Err(format!("Invalid NSID format: {}", nsid));
}
let domain_parts: Vec<&str> = parts[..2].iter().rev().cloned().collect();
let domain = domain_parts.join(".");
let path = parts[2..].join("/");
let url = format!("https://{}/lexicons/{}.json", domain, path);
debug!(nsid, url = %url, "Fetching permission set lexicon");
let client = Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
let response = client
.get(&url)
.header("Accept", "application/json")
.send()
.await
.map_err(|e| format!("Failed to fetch lexicon: {}", e))?;
if !response.status().is_success() {
return Err(format!(
"Failed to fetch lexicon: HTTP {}",
response.status()
));
}
let lexicon: LexiconDoc = response
.json()
.await
.map_err(|e| format!("Failed to parse lexicon: {}", e))?;
let lexicon = fetch_lexicon_via_atproto(nsid).await?;
let main_def = lexicon
.defs
@@ -118,31 +121,17 @@ async fn expand_permission_set(nsid: &str) -> Result<String, String> {
.as_ref()
.ok_or("Missing permissions in permission-set")?;
let mut collections: Vec<String> = permissions
.iter()
.filter(|perm| perm.resource == "repo")
.filter_map(|perm| perm.collection.as_ref())
.flatten()
.cloned()
.collect();
let namespace_authority = extract_namespace_authority(nsid);
let expanded = build_expanded_scopes(permissions, aud, &namespace_authority);
if collections.is_empty() {
return Err("No repo collections found in permission-set".to_string());
if expanded.is_empty() {
return Err("No valid permissions found in permission-set".to_string());
}
collections.sort();
let collection_params: Vec<String> = collections
.iter()
.map(|c| format!("collection={}", c))
.collect();
let expanded = format!("repo?{}", collection_params.join("&"));
{
let mut cache = LEXICON_CACHE.write().await;
cache.insert(
nsid.to_string(),
cache_key,
CachedLexicon {
expanded_scope: expanded.clone(),
cached_at: std::time::Instant::now(),
@@ -154,17 +143,470 @@ async fn expand_permission_set(nsid: &str) -> Result<String, String> {
Ok(expanded)
}
#[cfg(test)]
mod tests {
#[test]
fn test_nsid_to_url() {
let nsid = "io.atcr.authFullApp";
let parts: Vec<&str> = nsid.split('.').collect();
let domain_parts: Vec<&str> = parts[..2].iter().rev().cloned().collect();
let domain = domain_parts.join(".");
let path = parts[2..].join("/");
async fn fetch_lexicon_via_atproto(nsid: &str) -> Result<LexiconDoc, String> {
let parts: Vec<&str> = nsid.split('.').collect();
if parts.len() < 3 {
return Err(format!("Invalid NSID format: {}", nsid));
}
assert_eq!(domain, "atcr.io");
assert_eq!(path, "authFullApp");
let authority = parts[..2].iter().rev().cloned().collect::<Vec<_>>().join(".");
debug!(nsid, authority = %authority, "Resolving lexicon DID authority via DNS");
let did = resolve_lexicon_did_authority(&authority).await?;
debug!(nsid, did = %did, "Resolved lexicon DID authority");
let pds_endpoint = resolve_did_to_pds(&did).await?;
debug!(nsid, pds = %pds_endpoint, "Resolved DID to PDS endpoint");
let client = Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
let url = format!(
"{}/xrpc/com.atproto.repo.getRecord?repo={}&collection=com.atproto.lexicon.schema&rkey={}",
pds_endpoint,
urlencoding::encode(&did),
urlencoding::encode(nsid)
);
debug!(nsid, url = %url, "Fetching lexicon from PDS");
let response = client
.get(&url)
.header("Accept", "application/json")
.send()
.await
.map_err(|e| format!("Failed to fetch lexicon: {}", e))?;
if !response.status().is_success() {
return Err(format!(
"Failed to fetch lexicon: HTTP {}",
response.status()
));
}
let record: GetRecordResponse = response
.json()
.await
.map_err(|e| format!("Failed to parse lexicon response: {}", e))?;
Ok(record.value)
}
async fn resolve_lexicon_did_authority(authority: &str) -> Result<String, String> {
let resolver = TokioAsyncResolver::tokio_from_system_conf()
.map_err(|e| format!("Failed to create DNS resolver: {}", e))?;
let dns_name = format!("_lexicon.{}", authority);
debug!(dns_name = %dns_name, "Looking up DNS TXT record");
let txt_records = resolver
.txt_lookup(&dns_name)
.await
.map_err(|e| format!("DNS lookup failed for {}: {}", dns_name, e))?;
txt_records
.iter()
.flat_map(|record| record.iter())
.find_map(|data| {
let txt = String::from_utf8_lossy(data);
txt.strip_prefix("did=").map(|did| did.to_string())
})
.ok_or_else(|| format!("No valid did= TXT record found at {}", dns_name))
}
async fn resolve_did_to_pds(did: &str) -> Result<String, String> {
let client = Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
let url = if did.starts_with("did:plc:") {
format!("https://plc.directory/{}", did)
} else if did.starts_with("did:web:") {
let domain = did.strip_prefix("did:web:").unwrap();
format!("https://{}/.well-known/did.json", domain)
} else {
return Err(format!("Unsupported DID method: {}", did));
};
let response = client
.get(&url)
.header("Accept", "application/json")
.send()
.await
.map_err(|e| format!("Failed to resolve DID: {}", e))?;
if !response.status().is_success() {
return Err(format!("Failed to resolve DID: HTTP {}", response.status()));
}
let doc: PlcDocument = response
.json()
.await
.map_err(|e| format!("Failed to parse DID document: {}", e))?;
doc.service
.iter()
.find(|s| s.id == "#atproto_pds")
.map(|s| s.service_endpoint.clone())
.ok_or_else(|| "No #atproto_pds service found in DID document".to_string())
}
fn extract_namespace_authority(nsid: &str) -> String {
let parts: Vec<&str> = nsid.split('.').collect();
if parts.len() >= 2 {
parts[..parts.len() - 1].join(".")
} else {
nsid.to_string()
}
}
fn is_under_authority(target_nsid: &str, authority: &str) -> bool {
target_nsid.starts_with(authority)
&& target_nsid
.chars()
.nth(authority.len())
.is_some_and(|c| c == '.')
}
const DEFAULT_ACTIONS: &[&str] = &["create", "update", "delete"];
fn build_expanded_scopes(
permissions: &[PermissionEntry],
default_aud: Option<&str>,
namespace_authority: &str,
) -> String {
let mut scopes: Vec<String> = Vec::new();
permissions.iter().for_each(|perm| match perm.resource.as_str() {
"repo" => {
if let Some(collections) = &perm.collection {
let actions: Vec<&str> = perm
.action
.as_ref()
.map(|a| a.iter().map(String::as_str).collect())
.unwrap_or_else(|| DEFAULT_ACTIONS.to_vec());
collections
.iter()
.filter(|coll| is_under_authority(coll, namespace_authority))
.for_each(|coll| {
actions.iter().for_each(|action| {
scopes.push(format!("repo:{}?action={}", coll, action));
});
});
}
}
"rpc" => {
if let Some(lxms) = &perm.lxm {
let perm_aud = perm.aud.as_deref().or(default_aud);
lxms.iter().for_each(|lxm| {
let scope = match perm_aud {
Some(aud) => format!("rpc:{}?aud={}", lxm, aud),
None => format!("rpc:{}", lxm),
};
scopes.push(scope);
});
}
}
_ => {}
});
scopes.join(" ")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_include_scope() {
let (nsid, aud) = parse_include_scope("io.atcr.authFullApp");
assert_eq!(nsid, "io.atcr.authFullApp");
assert_eq!(aud, None);
let (nsid, aud) = parse_include_scope("io.atcr.authFullApp?aud=did:web:api.bsky.app");
assert_eq!(nsid, "io.atcr.authFullApp");
assert_eq!(aud, Some("did:web:api.bsky.app"));
}
#[test]
fn test_parse_include_scope_with_multiple_params() {
let (nsid, aud) = parse_include_scope("io.atcr.authFullApp?foo=bar&aud=did:web:example.com&baz=qux");
assert_eq!(nsid, "io.atcr.authFullApp");
assert_eq!(aud, Some("did:web:example.com"));
}
#[test]
fn test_extract_namespace_authority() {
assert_eq!(
extract_namespace_authority("io.atcr.authFullApp"),
"io.atcr"
);
assert_eq!(
extract_namespace_authority("app.bsky.authFullApp"),
"app.bsky"
);
}
#[test]
fn test_extract_namespace_authority_deep_nesting() {
assert_eq!(
extract_namespace_authority("io.atcr.sailor.star.collection"),
"io.atcr.sailor.star"
);
}
#[test]
fn test_extract_namespace_authority_single_segment() {
assert_eq!(extract_namespace_authority("single"), "single");
}
#[test]
fn test_is_under_authority() {
assert!(is_under_authority("io.atcr.manifest", "io.atcr"));
assert!(is_under_authority("io.atcr.sailor.star", "io.atcr"));
assert!(!is_under_authority("app.bsky.feed.post", "io.atcr"));
assert!(!is_under_authority("io.atcr", "io.atcr"));
}
#[test]
fn test_is_under_authority_prefix_collision() {
assert!(!is_under_authority("io.atcritical.something", "io.atcr"));
assert!(is_under_authority("io.atcr.something", "io.atcr"));
}
#[test]
fn test_build_expanded_scopes_repo() {
let permissions = vec![PermissionEntry {
resource: "repo".to_string(),
action: Some(vec!["create".to_string(), "delete".to_string()]),
collection: Some(vec![
"io.atcr.manifest".to_string(),
"io.atcr.sailor.star".to_string(),
"app.bsky.feed.post".to_string(),
]),
lxm: None,
aud: None,
}];
let expanded = build_expanded_scopes(&permissions, None, "io.atcr");
assert!(expanded.contains("repo:io.atcr.manifest?action=create"));
assert!(expanded.contains("repo:io.atcr.manifest?action=delete"));
assert!(expanded.contains("repo:io.atcr.sailor.star?action=create"));
assert!(!expanded.contains("app.bsky.feed.post"));
}
#[test]
fn test_build_expanded_scopes_repo_default_actions() {
let permissions = vec![PermissionEntry {
resource: "repo".to_string(),
action: None,
collection: Some(vec!["io.atcr.manifest".to_string()]),
lxm: None,
aud: None,
}];
let expanded = build_expanded_scopes(&permissions, None, "io.atcr");
assert!(expanded.contains("repo:io.atcr.manifest?action=create"));
assert!(expanded.contains("repo:io.atcr.manifest?action=update"));
assert!(expanded.contains("repo:io.atcr.manifest?action=delete"));
}
#[test]
fn test_build_expanded_scopes_rpc() {
let permissions = vec![PermissionEntry {
resource: "rpc".to_string(),
action: None,
collection: None,
lxm: Some(vec![
"io.atcr.getManifest".to_string(),
"com.atproto.repo.getRecord".to_string(),
]),
aud: Some("*".to_string()),
}];
let expanded = build_expanded_scopes(&permissions, None, "io.atcr");
assert!(expanded.contains("rpc:io.atcr.getManifest?aud=*"));
assert!(expanded.contains("rpc:com.atproto.repo.getRecord?aud=*"));
}
#[test]
fn test_build_expanded_scopes_rpc_with_default_aud() {
let permissions = vec![PermissionEntry {
resource: "rpc".to_string(),
action: None,
collection: None,
lxm: Some(vec!["io.atcr.getManifest".to_string()]),
aud: None,
}];
let expanded = build_expanded_scopes(&permissions, Some("did:web:api.example.com"), "io.atcr");
assert!(expanded.contains("rpc:io.atcr.getManifest?aud=did:web:api.example.com"));
}
#[test]
fn test_build_expanded_scopes_rpc_no_aud() {
let permissions = vec![PermissionEntry {
resource: "rpc".to_string(),
action: None,
collection: None,
lxm: Some(vec!["io.atcr.getManifest".to_string()]),
aud: None,
}];
let expanded = build_expanded_scopes(&permissions, None, "io.atcr");
assert_eq!(expanded, "rpc:io.atcr.getManifest");
}
#[test]
fn test_build_expanded_scopes_mixed_permissions() {
let permissions = vec![
PermissionEntry {
resource: "repo".to_string(),
action: Some(vec!["create".to_string()]),
collection: Some(vec!["io.atcr.manifest".to_string()]),
lxm: None,
aud: None,
},
PermissionEntry {
resource: "rpc".to_string(),
action: None,
collection: None,
lxm: Some(vec!["com.atproto.repo.getRecord".to_string()]),
aud: Some("*".to_string()),
},
];
let expanded = build_expanded_scopes(&permissions, None, "io.atcr");
assert!(expanded.contains("repo:io.atcr.manifest?action=create"));
assert!(expanded.contains("rpc:com.atproto.repo.getRecord?aud=*"));
}
#[test]
fn test_build_expanded_scopes_unknown_resource_ignored() {
let permissions = vec![PermissionEntry {
resource: "unknown".to_string(),
action: None,
collection: Some(vec!["io.atcr.manifest".to_string()]),
lxm: None,
aud: None,
}];
let expanded = build_expanded_scopes(&permissions, None, "io.atcr");
assert!(expanded.is_empty());
}
#[test]
fn test_build_expanded_scopes_empty_permissions() {
let permissions: Vec<PermissionEntry> = vec![];
let expanded = build_expanded_scopes(&permissions, None, "io.atcr");
assert!(expanded.is_empty());
}
#[tokio::test]
async fn test_expand_include_scopes_passthrough_non_include() {
let result = expand_include_scopes("atproto transition:generic").await;
assert_eq!(result, "atproto transition:generic");
}
#[tokio::test]
async fn test_expand_include_scopes_mixed_with_regular() {
let result = expand_include_scopes("atproto repo:app.bsky.feed.post?action=create").await;
assert!(result.contains("atproto"));
assert!(result.contains("repo:app.bsky.feed.post?action=create"));
}
#[tokio::test]
async fn test_cache_population_and_retrieval() {
let cache_key = "test.cached.scope";
let cached_value = "repo:test.cached.collection?action=create";
{
let mut cache = LEXICON_CACHE.write().await;
cache.insert(
cache_key.to_string(),
CachedLexicon {
expanded_scope: cached_value.to_string(),
cached_at: std::time::Instant::now(),
},
);
}
let result = expand_permission_set(cache_key, None).await;
assert!(result.is_ok());
assert_eq!(result.unwrap(), cached_value);
{
let mut cache = LEXICON_CACHE.write().await;
cache.remove(cache_key);
}
}
#[tokio::test]
async fn test_cache_with_aud_parameter() {
let nsid = "test.aud.scope";
let aud = "did:web:example.com";
let cache_key = format!("{}?aud={}", nsid, aud);
let cached_value = "rpc:test.aud.method?aud=did:web:example.com";
{
let mut cache = LEXICON_CACHE.write().await;
cache.insert(
cache_key.clone(),
CachedLexicon {
expanded_scope: cached_value.to_string(),
cached_at: std::time::Instant::now(),
},
);
}
let result = expand_permission_set(nsid, Some(aud)).await;
assert!(result.is_ok());
assert_eq!(result.unwrap(), cached_value);
{
let mut cache = LEXICON_CACHE.write().await;
cache.remove(&cache_key);
}
}
#[tokio::test]
async fn test_expired_cache_triggers_refresh() {
let cache_key = "test.expired.scope";
{
let mut cache = LEXICON_CACHE.write().await;
cache.insert(
cache_key.to_string(),
CachedLexicon {
expanded_scope: "old_value".to_string(),
cached_at: std::time::Instant::now() - std::time::Duration::from_secs(CACHE_TTL_SECS + 1),
},
);
}
let result = expand_permission_set(cache_key, None).await;
assert!(result.is_err());
{
let mut cache = LEXICON_CACHE.write().await;
cache.remove(cache_key);
}
}
#[test]
fn test_nsid_authority_extraction_for_dns() {
let nsid = "io.atcr.authFullApp";
let parts: Vec<&str> = nsid.split('.').collect();
let authority = parts[..2].iter().rev().cloned().collect::<Vec<_>>().join(".");
assert_eq!(authority, "atcr.io");
let nsid2 = "app.bsky.feed.post";
let parts2: Vec<&str> = nsid2.split('.').collect();
let authority2 = parts2[..2].iter().rev().cloned().collect::<Vec<_>>().join(".");
assert_eq!(authority2, "bsky.app");
}
}
+39 -3
View File
@@ -126,7 +126,7 @@ impl ScopePermissions {
return Ok(());
}
let has_permission = self.find_repo_scopes().any(|repo_scope| {
let has_repo_permission = self.find_repo_scopes().any(|repo_scope| {
repo_scope.actions.contains(&action)
&& match &repo_scope.collection {
None => true,
@@ -140,7 +140,7 @@ impl ScopePermissions {
}
});
if has_permission {
if has_repo_permission {
Ok(())
} else {
Err(ScopeError::InsufficientScope {
@@ -181,6 +181,8 @@ impl ScopePermissions {
return Ok(());
}
let aud_base = aud.split('#').next().unwrap_or(aud);
let has_permission = self.find_rpc_scopes().any(|rpc_scope| {
let lxm_matches = match &rpc_scope.lxm {
None => true,
@@ -195,7 +197,10 @@ impl ScopePermissions {
let aud_matches = match &rpc_scope.aud {
None => true,
Some(scope_aud) if scope_aud == "*" => true,
Some(scope_aud) => scope_aud == aud,
Some(scope_aud) => {
let scope_aud_base = scope_aud.split('#').next().unwrap_or(scope_aud);
scope_aud_base == aud_base
}
};
lxm_matches && aud_matches
@@ -521,4 +526,35 @@ mod tests {
assert!(perms.allows_blob("image/png"));
assert!(perms.allows_rpc("did:web:api.bsky.app", "app.bsky.feed.getTimeline"));
}
#[test]
fn test_rpc_scope_with_did_fragment() {
let perms = ScopePermissions::from_scope_string(Some(
"rpc:app.bsky.feed.getAuthorFeed?aud=did:web:api.bsky.app#bsky_appview",
));
assert!(perms.allows_rpc("did:web:api.bsky.app", "app.bsky.feed.getAuthorFeed"));
assert!(perms.allows_rpc(
"did:web:api.bsky.app#bsky_appview",
"app.bsky.feed.getAuthorFeed"
));
assert!(perms.allows_rpc(
"did:web:api.bsky.app#other_service",
"app.bsky.feed.getAuthorFeed"
));
assert!(!perms.allows_rpc("did:web:other.app", "app.bsky.feed.getAuthorFeed"));
assert!(!perms.allows_rpc("did:web:api.bsky.app", "app.bsky.feed.getTimeline"));
}
#[test]
fn test_rpc_scope_without_fragment_matches_with_fragment() {
let perms = ScopePermissions::from_scope_string(Some(
"rpc:app.bsky.feed.getAuthorFeed?aud=did:web:api.bsky.app",
));
assert!(perms.allows_rpc("did:web:api.bsky.app", "app.bsky.feed.getAuthorFeed"));
assert!(perms.allows_rpc(
"did:web:api.bsky.app#bsky_appview",
"app.bsky.feed.getAuthorFeed"
));
}
}
+24 -16
View File
@@ -22,9 +22,8 @@ const EXDEV: i32 = 18;
const CID_SHARD_PREFIX_LEN: usize = 9;
fn split_cid_path(key: &str) -> Option<(&str, &str)> {
let is_cid = key.get(..3).map_or(false, |p| p.eq_ignore_ascii_case("baf"));
(key.len() > CID_SHARD_PREFIX_LEN && is_cid)
.then(|| key.split_at(CID_SHARD_PREFIX_LEN))
let is_cid = key.get(..3).is_some_and(|p| p.eq_ignore_ascii_case("baf"));
(key.len() > CID_SHARD_PREFIX_LEN && is_cid).then(|| key.split_at(CID_SHARD_PREFIX_LEN))
}
fn validate_key(key: &str) -> Result<(), StorageError> {
@@ -771,7 +770,10 @@ mod tests {
let cid = "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku";
assert_eq!(
split_cid_path(cid),
Some(("bafkreihd", "wdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"))
Some((
"bafkreihd",
"wdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"
))
);
}
@@ -780,7 +782,10 @@ mod tests {
let cid = "bafyreigdmqpykrgxyaxtlafqpqhzrb7qy2rh75nldvfd4tucqmqqme5yje";
assert_eq!(
split_cid_path(cid),
Some(("bafyreigd", "mqpykrgxyaxtlafqpqhzrb7qy2rh75nldvfd4tucqmqqme5yje"))
Some((
"bafyreigd",
"mqpykrgxyaxtlafqpqhzrb7qy2rh75nldvfd4tucqmqqme5yje"
))
);
}
@@ -810,11 +815,17 @@ mod tests {
let mixed = "BaFkReIhDwDcEfGh4DqKjV67UzCmW7OjEe6XeDzDeTojUzJevTeNxQuVyKu";
assert_eq!(
split_cid_path(upper),
Some(("BAFKREIHD", "WDCEFGH4DQKJV67UZCMW7OJEE6XEDZDETOJUZJEVTENXQUVYKU"))
Some((
"BAFKREIHD",
"WDCEFGH4DQKJV67UZCMW7OJEE6XEDZDETOJUZJEVTENXQUVYKU"
))
);
assert_eq!(
split_cid_path(mixed),
Some(("BaFkReIhD", "wDcEfGh4DqKjV67UzCmW7OjEe6XeDzDeTojUzJevTeNxQuVyKu"))
Some((
"BaFkReIhD",
"wDcEfGh4DqKjV67UzCmW7OjEe6XeDzDeTojUzJevTeNxQuVyKu"
))
);
}
@@ -829,11 +840,10 @@ mod tests {
let base = PathBuf::from("/blobs");
let cid = "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku";
let expected = PathBuf::from("/blobs/bafkreihd/wdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku");
let result = split_cid_path(cid).map_or_else(
|| base.join(cid),
|(dir, file)| base.join(dir).join(file),
);
let expected =
PathBuf::from("/blobs/bafkreihd/wdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku");
let result = split_cid_path(cid)
.map_or_else(|| base.join(cid), |(dir, file)| base.join(dir).join(file));
assert_eq!(result, expected);
}
@@ -843,10 +853,8 @@ mod tests {
let key = "temp/abc123";
let expected = PathBuf::from("/blobs/temp/abc123");
let result = split_cid_path(key).map_or_else(
|| base.join(key),
|(dir, file)| base.join(dir).join(file),
);
let result = split_cid_path(key)
.map_or_else(|| base.join(key), |(dir, file)| base.join(dir).join(file));
assert_eq!(result, expected);
}
}
+100 -35
View File
@@ -16,6 +16,11 @@ import {
unsafeAsISODate,
unsafeAsRefreshToken,
} from "./types/branded.ts";
import {
createDPoPProofForRequest,
getDPoPNonce,
setDPoPNonce,
} from "./oauth.ts";
import type {
AccountInfo,
ApiErrorCode,
@@ -91,52 +96,107 @@ export class ApiError extends Error {
}
}
let tokenRefreshCallback: (() => Promise<string | null>) | null = null;
let tokenRefreshCallback: (() => Promise<AccessToken | null>) | null = null;
export function setTokenRefreshCallback(
callback: () => Promise<string | null>,
callback: () => Promise<AccessToken | null>,
) {
tokenRefreshCallback = callback;
}
interface AuthenticatedFetchOptions {
method?: "GET" | "POST";
token: AccessToken | RefreshToken;
headers?: Record<string, string>;
body?: BodyInit;
}
async function authenticatedFetch(
url: string,
options: AuthenticatedFetchOptions,
): Promise<Response> {
const { method = "GET", token, headers = {}, body } = options;
const fullUrl = url.startsWith("http")
? url
: `${globalThis.location.origin}${url}`;
const dpopProof = await createDPoPProofForRequest(method, fullUrl, token);
const res = await fetch(url, {
method,
headers: {
...headers,
Authorization: `DPoP ${token}`,
DPoP: dpopProof,
},
body,
});
const dpopNonce = res.headers.get("DPoP-Nonce");
if (dpopNonce) {
setDPoPNonce(dpopNonce);
}
return res;
}
interface XrpcOptions {
method?: "GET" | "POST";
params?: Record<string, string>;
body?: unknown;
token?: string;
token?: AccessToken | RefreshToken;
skipRetry?: boolean;
skipDpopRetry?: boolean;
}
async function xrpc<T>(method: string, options?: XrpcOptions): Promise<T> {
const { method: httpMethod = "GET", params, body, token, skipRetry } =
options ?? {};
const {
method: httpMethod = "GET",
params,
body,
token,
skipRetry,
skipDpopRetry,
} = options ?? {};
let url = `${API_BASE}/${method}`;
if (params) {
const searchParams = new URLSearchParams(params);
url += `?${searchParams}`;
}
const headers: Record<string, string> = {};
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
if (body) {
headers["Content-Type"] = "application/json";
}
const res = await fetch(url, {
method: httpMethod,
headers,
body: body ? JSON.stringify(body) : undefined,
});
const res = token
? await authenticatedFetch(url, {
method: httpMethod,
token,
headers,
body: body ? JSON.stringify(body) : undefined,
})
: await fetch(url, {
method: httpMethod,
headers,
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) {
const errData = await res.json().catch(() => ({
error: "Unknown",
message: res.statusText,
}));
if (
res.status === 401 &&
errData.error === "use_dpop_nonce" &&
token &&
!skipDpopRetry &&
getDPoPNonce()
) {
return xrpc(method, { ...options, skipDpopRetry: true });
}
if (
res.status === 401 &&
(errData.error === "AuthenticationFailed" ||
errData.error === "ExpiredToken") &&
token && tokenRefreshCallback && !skipRetry
errData.error === "ExpiredToken" ||
errData.error === "OAuthExpiredToken") &&
token &&
tokenRefreshCallback &&
!skipRetry
) {
const newToken = await tokenRefreshCallback();
if (newToken && newToken !== token) {
@@ -536,12 +596,10 @@ export const api = {
token: AccessToken,
file: File,
): Promise<UploadBlobResponse> {
const res = await fetch("/xrpc/com.atproto.repo.uploadBlob", {
const res = await authenticatedFetch("/xrpc/com.atproto.repo.uploadBlob", {
method: "POST",
headers: {
"Authorization": `Bearer ${token}`,
"Content-Type": file.type,
},
token,
headers: { "Content-Type": file.type },
body: file,
});
if (!res.ok) {
@@ -1084,12 +1142,8 @@ export const api = {
},
async getRepo(token: AccessToken, did: Did): Promise<ArrayBuffer> {
const url = `${API_BASE}/com.atproto.sync.getRepo?did=${
encodeURIComponent(did)
}`;
const res = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
});
const url = `${API_BASE}/com.atproto.sync.getRepo?did=${encodeURIComponent(did)}`;
const res = await authenticatedFetch(url, { token });
if (!res.ok) {
const errData = await res.json().catch(() => ({
error: "Unknown",
@@ -1106,9 +1160,7 @@ export const api = {
async getBackup(token: AccessToken, id: string): Promise<Blob> {
const url = `${API_BASE}/_backup.getBackup?id=${encodeURIComponent(id)}`;
const res = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
});
const res = await authenticatedFetch(url, { token });
if (!res.ok) {
const errData = await res.json().catch(() => ({
error: "Unknown",
@@ -1146,13 +1198,10 @@ export const api = {
},
async importRepo(token: AccessToken, car: Uint8Array): Promise<void> {
const url = `${API_BASE}/com.atproto.repo.importRepo`;
const res = await fetch(url, {
const res = await authenticatedFetch(`${API_BASE}/com.atproto.repo.importRepo`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/vnd.ipld.car",
},
token,
headers: { "Content-Type": "application/vnd.ipld.car" },
body: car as unknown as BodyInit,
});
if (!res.ok) {
@@ -1163,6 +1212,22 @@ export const api = {
throw new ApiError(res.status, errData.error, errData.message);
}
},
async establishOAuthSession(token: AccessToken): Promise<{ success: boolean; device_id: string }> {
const res = await authenticatedFetch("/oauth/establish-session", {
method: "POST",
token,
headers: { "Content-Type": "application/json" },
});
if (!res.ok) {
const errData = await res.json().catch(() => ({
error: "Unknown",
message: res.statusText,
}));
throw new ApiError(res.status, errData.error, errData.message);
}
return res.json();
},
};
export const typedApi = {
+1 -1
View File
@@ -281,7 +281,7 @@ export function clearError(): void {
}
}
async function tryRefreshToken(): Promise<string | null> {
async function tryRefreshToken(): Promise<AccessToken | null> {
if (state.current.kind !== "authenticated") return null;
const currentSession = state.current.session;
try {
+18 -1
View File
@@ -240,9 +240,26 @@ export class AtprotoClient {
}&cid=${encodeURIComponent(cid)}`;
const headers: Record<string, string> = {};
if (this.accessToken) {
headers["Authorization"] = `Bearer ${this.accessToken}`;
if (this.dpopKeyPair) {
headers["Authorization"] = `DPoP ${this.accessToken}`;
const tokenHash = await computeAccessTokenHash(this.accessToken);
const dpopProof = await createDPoPProof(
this.dpopKeyPair,
"GET",
url.split("?")[0],
this.dpopNonce ?? undefined,
tokenHash,
);
headers["DPoP"] = dpopProof;
} else {
headers["Authorization"] = `Bearer ${this.accessToken}`;
}
}
const res = await fetch(url, { headers });
const newNonce = res.headers.get("DPoP-Nonce");
if (newNonce) {
this.dpopNonce = newNonce;
}
if (!res.ok) {
const err = await res.json().catch(() => ({
error: "Unknown",
+3 -1
View File
@@ -88,7 +88,9 @@ export function createInboundMigrationFlow() {
function setStep(step: InboundStep) {
state.step = step;
state.error = null;
if (step !== "error") {
state.error = null;
}
if (step !== "success") {
saveMigrationState(state);
updateStep(step);
@@ -177,7 +177,9 @@ export function createOfflineInboundMigrationFlow() {
function setStep(step: OfflineInboundStep) {
state.step = step;
state.error = null;
if (step !== "error") {
state.error = null;
}
if (step !== "success") {
saveOfflineState(state);
}
+3 -3
View File
@@ -246,15 +246,15 @@ async function computeJwkThumbprint(jwk: JsonWebKey): Promise<string> {
return base64UrlEncode(hash);
}
function getDPoPNonce(): string | null {
export function getDPoPNonce(): string | null {
return sessionStorage.getItem(DPOP_NONCE_KEY);
}
function setDPoPNonce(nonce: string): void {
export function setDPoPNonce(nonce: string): void {
sessionStorage.setItem(DPOP_NONCE_KEY, nonce);
}
function extractDPoPNonceFromResponse(response: Response): void {
export function extractDPoPNonceFromResponse(response: Response): void {
const nonce = response.headers.get("DPoP-Nonce");
if (nonce) {
setDPoPNonce(nonce);
+5
View File
@@ -779,6 +779,11 @@
"name": "Manage Account",
"description": "Manage account settings and preferences"
}
},
"unexpectedState": {
"title": "Unexpected State",
"description": "The consent page is in an unexpected state. Please check the browser console for errors.",
"reload": "Reload Page"
}
},
"accounts": {
+5
View File
@@ -785,6 +785,11 @@
"name": "Hallitse tiliä",
"description": "Hallitse tilin asetuksia ja asetuksia"
}
},
"unexpectedState": {
"title": "Odottamaton tila",
"description": "Suostumussivulla on odottamaton tila. Tarkista selaimen konsoli virheiden varalta.",
"reload": "Lataa sivu uudelleen"
}
},
"accounts": {
+5
View File
@@ -778,6 +778,11 @@
"name": "アカウント管理",
"description": "アカウント設定と設定を管理"
}
},
"unexpectedState": {
"title": "予期しない状態",
"description": "同意ページが予期しない状態です。ブラウザのコンソールでエラーを確認してください。",
"reload": "ページを再読み込み"
}
},
"accounts": {
+5
View File
@@ -778,6 +778,11 @@
"name": "계정 관리",
"description": "계정 설정 및 환경설정 관리"
}
},
"unexpectedState": {
"title": "예기치 않은 상태",
"description": "동의 페이지가 예기치 않은 상태입니다. 브라우저 콘솔에서 오류를 확인하세요.",
"reload": "페이지 새로고침"
}
},
"accounts": {
+5
View File
@@ -778,6 +778,11 @@
"name": "Hantera konto",
"description": "Hantera kontoinställningar och preferenser"
}
},
"unexpectedState": {
"title": "Oväntat tillstånd",
"description": "Samtyckes-sidan är i ett oväntat tillstånd. Kontrollera webbläsarens konsol för fel.",
"reload": "Ladda om sidan"
}
},
"accounts": {
+5
View File
@@ -778,6 +778,11 @@
"name": "管理账户",
"description": "管理账户设置和偏好"
}
},
"unexpectedState": {
"title": "意外状态",
"description": "同意页面处于意外状态。请检查浏览器控制台以查看错误。",
"reload": "重新加载页面"
}
},
"accounts": {
+37 -16
View File
@@ -2,6 +2,9 @@
import { setSession } from '../lib/auth.svelte'
import { navigate, routes } from '../lib/router.svelte'
import { _ } from '../lib/i18n'
import { api } from '../lib/api'
import { startOAuthLogin } from '../lib/oauth'
import { unsafeAsAccessToken } from '../lib/types/branded'
import {
createInboundMigrationFlow,
createOfflineInboundMigrationFlow,
@@ -143,30 +146,48 @@
direction = 'select'
}
function handleInboundComplete() {
async function handleInboundComplete() {
const session = inboundFlow?.getLocalSession()
if (session) {
setSession({
did: session.did,
handle: session.handle,
accessJwt: session.accessJwt,
refreshJwt: '',
})
try {
await api.establishOAuthSession(unsafeAsAccessToken(session.accessJwt))
clearMigrationState()
await startOAuthLogin(session.handle)
} catch (e) {
console.error('Failed to establish OAuth session, falling back to direct login:', e)
setSession({
did: session.did,
handle: session.handle,
accessJwt: session.accessJwt,
refreshJwt: '',
})
navigate(routes.dashboard)
}
} else {
navigate(routes.dashboard)
}
navigate(routes.dashboard)
}
function handleOfflineComplete() {
async function handleOfflineComplete() {
const session = offlineFlow?.getLocalSession()
if (session) {
setSession({
did: session.did,
handle: session.handle,
accessJwt: session.accessJwt,
refreshJwt: '',
})
try {
await api.establishOAuthSession(unsafeAsAccessToken(session.accessJwt))
clearOfflineState()
await startOAuthLogin(session.handle)
} catch (e) {
console.error('Failed to establish OAuth session, falling back to direct login:', e)
setSession({
did: session.did,
handle: session.handle,
accessJwt: session.accessJwt,
refreshJwt: '',
})
navigate(routes.dashboard)
}
} else {
navigate(routes.dashboard)
}
navigate(routes.dashboard)
}
</script>
+5 -31
View File
@@ -196,18 +196,18 @@
display: flex;
align-items: center;
padding: var(--space-4);
background: var(--bg-card);
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-xl);
cursor: pointer;
text-align: left;
width: 100%;
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
transition: border-color var(--transition-fast), background var(--transition-fast);
}
.account-item:hover:not(.disabled) {
border-color: var(--accent);
box-shadow: var(--shadow-sm);
background: var(--bg-tertiary);
}
.account-item.disabled {
@@ -231,37 +231,11 @@
color: var(--text-secondary);
}
button {
padding: var(--space-3);
background: var(--accent);
color: var(--text-inverse);
border: none;
border-radius: var(--radius-md);
font-size: var(--text-base);
cursor: pointer;
}
button:hover:not(:disabled) {
background: var(--accent-hover);
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
button.secondary {
background: transparent;
color: var(--accent);
border: 1px solid var(--accent);
.different-account {
margin-top: var(--space-4);
width: 100%;
}
button.secondary:hover:not(:disabled) {
background: var(--accent);
color: var(--text-inverse);
}
.different-account {
margin-top: var(--space-4);
}
+38 -3
View File
@@ -65,6 +65,7 @@
async function fetchConsentData() {
const requestUri = getRequestUri()
if (!requestUri) {
console.error('[OAuthConsent] No request_uri in URL')
error = $_('oauth.error.genericError')
loading = false
return
@@ -74,11 +75,20 @@
const response = await fetch(`/oauth/authorize/consent?request_uri=${encodeURIComponent(requestUri)}`)
if (!response.ok) {
const data = await response.json()
console.error('[OAuthConsent] Consent fetch failed:', data)
error = data.error_description || data.error || $_('oauth.error.genericError')
loading = false
return
}
const data: ConsentData = await response.json()
if (!data.scopes || !Array.isArray(data.scopes)) {
console.error('[OAuthConsent] Invalid scopes data:', data.scopes)
error = 'Invalid consent data received'
loading = false
return
}
consentData = data
scopeSelections = Object.fromEntries(
@@ -91,7 +101,8 @@
if (!data.show_consent) {
await submitConsent()
}
} catch {
} catch (e) {
console.error('[OAuthConsent] Error during consent fetch:', e)
error = $_('oauth.error.genericError')
} finally {
loading = false
@@ -104,7 +115,10 @@
}
async function submitConsent() {
if (!consentData) return
if (!consentData) {
console.error('[OAuthConsent] submitConsent called but no consentData')
return
}
submitting = true
let approvedScopes = Object.entries(scopeSelections)
@@ -128,6 +142,7 @@
if (!response.ok) {
const data = await response.json()
console.error('[OAuthConsent] Submit failed:', data)
error = data.error_description || data.error || $_('oauth.error.genericError')
submitting = false
return
@@ -136,8 +151,13 @@
const data = await response.json()
if (data.redirect_uri) {
window.location.href = data.redirect_uri
} else {
console.error('[OAuthConsent] No redirect_uri in response')
error = 'Authorization failed - no redirect received'
submitting = false
}
} catch {
} catch (e) {
console.error('[OAuthConsent] Submit error:', e)
error = $_('oauth.error.genericError')
submitting = false
}
@@ -249,6 +269,8 @@
<div class="spinner"></div>
<p>{$_('common.loading')}</p>
</div>
{:else}
<p style="color: var(--text-muted); font-size: 0.875rem;">Loading consent data...</p>
{/if}
</div>
{:else if error}
@@ -372,6 +394,19 @@
{submitting ? $_('oauth.consent.authorizing') : $_('oauth.consent.authorize')}
</button>
</div>
{:else}
<div class="error-container">
<h1>{$_('oauth.consent.unexpectedState.title')}</h1>
<p style="color: var(--text-secondary);">
{$_('oauth.consent.unexpectedState.description')}
</p>
<p style="color: var(--text-muted); font-size: 0.75rem; font-family: monospace;">
loading={loading}, error={error ? 'set' : 'null'}, consentData={consentData ? 'set' : 'null'}, submitting={submitting}
</p>
<button type="button" onclick={() => window.location.reload()}>
{$_('oauth.consent.unexpectedState.reload')}
</button>
</div>
{/if}
</div>