mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-07-28 04:12:37 +00:00
fix: oauth consolidation, include-scope improvements
This commit is contained in:
-77
@@ -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"
|
||||
}
|
||||
-77
@@ -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"
|
||||
}
|
||||
-22
@@ -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"
|
||||
}
|
||||
-31
@@ -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"
|
||||
}
|
||||
-32
@@ -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
@@ -6156,11 +6156,13 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"futures",
|
||||
"hickory-resolver",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"urlencoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 = {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -778,6 +778,11 @@
|
||||
"name": "アカウント管理",
|
||||
"description": "アカウント設定と設定を管理"
|
||||
}
|
||||
},
|
||||
"unexpectedState": {
|
||||
"title": "予期しない状態",
|
||||
"description": "同意ページが予期しない状態です。ブラウザのコンソールでエラーを確認してください。",
|
||||
"reload": "ページを再読み込み"
|
||||
}
|
||||
},
|
||||
"accounts": {
|
||||
|
||||
@@ -778,6 +778,11 @@
|
||||
"name": "계정 관리",
|
||||
"description": "계정 설정 및 환경설정 관리"
|
||||
}
|
||||
},
|
||||
"unexpectedState": {
|
||||
"title": "예기치 않은 상태",
|
||||
"description": "동의 페이지가 예기치 않은 상태입니다. 브라우저 콘솔에서 오류를 확인하세요.",
|
||||
"reload": "페이지 새로고침"
|
||||
}
|
||||
},
|
||||
"accounts": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -778,6 +778,11 @@
|
||||
"name": "管理账户",
|
||||
"description": "管理账户设置和偏好"
|
||||
}
|
||||
},
|
||||
"unexpectedState": {
|
||||
"title": "意外状态",
|
||||
"description": "同意页面处于意外状态。请检查浏览器控制台以查看错误。",
|
||||
"reload": "重新加载页面"
|
||||
}
|
||||
},
|
||||
"accounts": {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user