App password scopes

This commit is contained in:
lewis
2025-12-25 21:17:09 +02:00
parent a3b5f6135a
commit 3f727b1c9d
38 changed files with 557 additions and 153 deletions
+10
View File
@@ -94,6 +94,16 @@ pub async fn proxy_handler(
}
Err(e) => {
warn!("Token validation failed: {:?}", e);
if matches!(e, crate::auth::TokenValidationError::TokenExpired) {
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "ExpiredToken",
"message": "Token has expired"
})),
)
.into_response();
}
}
}
}
+11 -2
View File
@@ -19,7 +19,7 @@ use serde::{Deserialize, Serialize};
use serde_json::json;
use std::str::FromStr;
use std::sync::Arc;
use tracing::error;
use tracing::{error, info};
const MAX_BATCH_WRITES: usize = 200;
@@ -79,6 +79,11 @@ pub async fn apply_writes(
headers: axum::http::HeaderMap,
Json(input): Json<ApplyWritesInput>,
) -> Response {
info!(
"apply_writes called: repo={}, writes={}",
input.repo,
input.writes.len()
);
let token = match crate::auth::extract_bearer_token_from_header(
headers.get("Authorization").and_then(|h| h.to_str().ok()),
) {
@@ -147,7 +152,11 @@ pub async fn apply_writes(
.into_response();
}
if is_oauth {
let has_custom_scope = scope
.as_ref()
.map(|s| s != "com.atproto.access")
.unwrap_or(false);
if is_oauth || has_custom_scope {
use std::collections::HashSet;
let create_collections: HashSet<&str> = input
.writes
+4 -4
View File
@@ -16,10 +16,10 @@ pub fn create_signed_commit(
prev: Option<Cid>,
signing_key: &SigningKey,
) -> Result<(Vec<u8>, Bytes), String> {
let did = jacquard::types::string::Did::new(did)
.map_err(|e| format!("Invalid DID: {:?}", e))?;
let rev = jacquard::types::string::Tid::from_str(rev)
.map_err(|e| format!("Invalid TID: {:?}", e))?;
let did =
jacquard::types::string::Did::new(did).map_err(|e| format!("Invalid DID: {:?}", e))?;
let rev =
jacquard::types::string::Tid::from_str(rev).map_err(|e| format!("Invalid TID: {:?}", e))?;
let unsigned = Commit::new_unsigned(did, data, rev, prev);
let signed = unsigned
.sign(signing_key)
+12 -3
View File
@@ -18,6 +18,8 @@ pub struct AppPassword {
pub name: String,
pub created_at: String,
pub privileged: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub scopes: Option<String>,
}
#[derive(Serialize)]
@@ -34,7 +36,7 @@ pub async fn list_app_passwords(
Err(e) => return ApiError::from(e).into_response(),
};
match sqlx::query!(
"SELECT name, created_at, privileged FROM app_passwords WHERE user_id = $1 ORDER BY created_at DESC",
"SELECT name, created_at, privileged, scopes FROM app_passwords WHERE user_id = $1 ORDER BY created_at DESC",
user_id
)
.fetch_all(&state.db)
@@ -47,6 +49,7 @@ pub async fn list_app_passwords(
name: row.name.clone(),
created_at: row.created_at.to_rfc3339(),
privileged: row.privileged,
scopes: row.scopes.clone(),
})
.collect();
Json(ListAppPasswordsOutput { passwords }).into_response()
@@ -62,6 +65,7 @@ pub async fn list_app_passwords(
pub struct CreateAppPasswordInput {
pub name: String,
pub privileged: Option<bool>,
pub scopes: Option<String>,
}
#[derive(Serialize)]
@@ -71,6 +75,8 @@ pub struct CreateAppPasswordOutput {
pub password: String,
pub created_at: String,
pub privileged: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub scopes: Option<String>,
}
pub async fn create_app_password(
@@ -131,14 +137,16 @@ pub async fn create_app_password(
}
};
let privileged = input.privileged.unwrap_or(false);
let scopes = input.scopes.clone();
let created_at = chrono::Utc::now();
match sqlx::query!(
"INSERT INTO app_passwords (user_id, name, password_hash, created_at, privileged) VALUES ($1, $2, $3, $4, $5)",
"INSERT INTO app_passwords (user_id, name, password_hash, created_at, privileged, scopes) VALUES ($1, $2, $3, $4, $5, $6)",
user_id,
name,
password_hash,
created_at,
privileged
privileged,
scopes
)
.execute(&state.db)
.await
@@ -148,6 +156,7 @@ pub async fn create_app_password(
password,
created_at: created_at.to_rfc3339(),
privileged,
scopes,
})
.into_response(),
Err(e) => {
+33 -19
View File
@@ -125,24 +125,28 @@ pub async fn create_session(
return ApiError::InternalError.into_response();
}
};
let password_valid = if row
let (password_valid, app_password_scopes) = if row
.password_hash
.as_ref()
.map(|h| verify(&input.password, h).unwrap_or(false))
.unwrap_or(false)
{
true
(true, None)
} else {
let app_passwords = sqlx::query!(
"SELECT password_hash FROM app_passwords WHERE user_id = $1 ORDER BY created_at DESC LIMIT 20",
"SELECT password_hash, scopes FROM app_passwords WHERE user_id = $1 ORDER BY created_at DESC LIMIT 20",
row.id
)
.fetch_all(&state.db)
.await
.unwrap_or_default();
app_passwords
let matched = app_passwords
.iter()
.any(|app| verify(&input.password, &app.password_hash).unwrap_or(false))
.find(|app| verify(&input.password, &app.password_hash).unwrap_or(false));
match matched {
Some(app) => (true, app.scopes.clone()),
None => (false, None),
}
};
if !password_valid {
warn!("Password verification failed for login attempt");
@@ -177,7 +181,11 @@ pub async fn create_session(
)
.into_response();
}
let access_meta = match crate::auth::create_access_token_with_metadata(&row.did, &key_bytes) {
let access_meta = match crate::auth::create_access_token_with_scope_metadata(
&row.did,
&key_bytes,
app_password_scopes.as_deref(),
) {
Ok(m) => m,
Err(e) => {
error!("Failed to create access token: {:?}", e);
@@ -192,14 +200,15 @@ pub async fn create_session(
}
};
if let Err(e) = sqlx::query!(
"INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at, legacy_login, mfa_verified) VALUES ($1, $2, $3, $4, $5, $6, $7)",
"INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at, legacy_login, mfa_verified, scope) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
row.did,
access_meta.jti,
refresh_meta.jti,
access_meta.expires_at,
refresh_meta.expires_at,
is_legacy_login,
false
false,
app_password_scopes
)
.execute(&state.db)
.await
@@ -388,7 +397,7 @@ pub async fn refresh_session(
.into_response();
}
let session_row = match sqlx::query!(
r#"SELECT st.id, st.did, k.key_bytes, k.encryption_version
r#"SELECT st.id, st.did, st.scope, k.key_bytes, k.encryption_version
FROM session_tokens st
JOIN users u ON st.did = u.did
JOIN user_keys k ON u.id = k.user_id
@@ -420,14 +429,17 @@ pub async fn refresh_session(
if crate::auth::verify_refresh_token(&refresh_token, &key_bytes).is_err() {
return ApiError::AuthenticationFailedMsg("Invalid refresh token".into()).into_response();
}
let new_access_meta =
match crate::auth::create_access_token_with_metadata(&session_row.did, &key_bytes) {
Ok(m) => m,
Err(e) => {
error!("Failed to create access token: {:?}", e);
return ApiError::InternalError.into_response();
}
};
let new_access_meta = match crate::auth::create_access_token_with_scope_metadata(
&session_row.did,
&key_bytes,
session_row.scope.as_deref(),
) {
Ok(m) => m,
Err(e) => {
error!("Failed to create access token: {:?}", e);
return ApiError::InternalError.into_response();
}
};
let new_refresh_meta =
match crate::auth::create_refresh_token_with_metadata(&session_row.did, &key_bytes) {
Ok(m) => m,
@@ -653,15 +665,17 @@ pub async fn confirm_signup(
return ApiError::InternalError.into_response();
}
};
let no_scope: Option<String> = None;
if let Err(e) = sqlx::query!(
"INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at, legacy_login, mfa_verified) VALUES ($1, $2, $3, $4, $5, $6, $7)",
"INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at, legacy_login, mfa_verified, scope) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
row.did,
access_meta.jti,
refresh_meta.jti,
access_meta.expires_at,
refresh_meta.expires_at,
false,
false
false,
no_scope
)
.execute(&state.db)
.await
+28 -15
View File
@@ -24,8 +24,8 @@ pub use service::{ServiceTokenClaims, ServiceTokenVerifier, is_service_token};
pub use token::{
SCOPE_ACCESS, SCOPE_APP_PASS, SCOPE_APP_PASS_PRIVILEGED, SCOPE_REFRESH, TOKEN_TYPE_ACCESS,
TOKEN_TYPE_REFRESH, TOKEN_TYPE_SERVICE, TokenWithMetadata, create_access_token,
create_access_token_with_metadata, create_refresh_token, create_refresh_token_with_metadata,
create_service_token,
create_access_token_with_metadata, create_access_token_with_scope_metadata,
create_refresh_token, create_refresh_token_with_metadata, create_service_token,
};
pub use verify::{
TokenVerifyError, get_did_from_token, get_jti_from_token, verify_access_token,
@@ -66,6 +66,11 @@ pub struct AuthenticatedUser {
impl AuthenticatedUser {
pub fn permissions(&self) -> ScopePermissions {
if let Some(ref scope) = self.scope
&& scope != SCOPE_ACCESS
{
return ScopePermissions::from_scope_string(Some(scope));
}
if !self.is_oauth {
return ScopePermissions::from_scope_string(Some("atproto"));
}
@@ -212,8 +217,8 @@ async fn validate_bearer_token_with_options_internal(
}
if !session_valid {
let session_exists = sqlx::query_scalar!(
"SELECT 1 as one FROM session_tokens WHERE did = $1 AND access_jti = $2 AND access_expires_at > NOW()",
let session_row = sqlx::query!(
"SELECT access_expires_at FROM session_tokens WHERE did = $1 AND access_jti = $2",
did,
jti
)
@@ -222,16 +227,24 @@ async fn validate_bearer_token_with_options_internal(
.ok()
.flatten();
session_valid = session_exists.is_some();
if session_valid && let Some(c) = cache {
let _ = c
.set(
&session_cache_key,
"1",
Duration::from_secs(SESSION_CACHE_TTL_SECS),
)
.await;
match session_row {
Some(row) => {
if row.access_expires_at > chrono::Utc::now() {
session_valid = true;
if let Some(c) = cache {
let _ = c
.set(
&session_cache_key,
"1",
Duration::from_secs(SESSION_CACHE_TTL_SECS),
)
.await;
}
} else {
return Err(TokenValidationError::TokenExpired);
}
}
None => {}
}
}
@@ -241,7 +254,7 @@ async fn validate_bearer_token_with_options_internal(
key_bytes: Some(decrypted_key),
is_oauth: false,
is_admin,
scope: None,
scope: token_data.claims.scope.clone(),
});
}
}
+14 -5
View File
@@ -8,13 +8,22 @@ use crate::oauth::scopes::{
AccountAction, AccountAttr, IdentityAttr, RepoAction, ScopePermissions,
};
use super::token::SCOPE_ACCESS;
fn has_custom_scope(scope: Option<&str>) -> bool {
match scope {
None => false,
Some(s) => s != SCOPE_ACCESS,
}
}
pub fn check_repo_scope(
is_oauth: bool,
scope: Option<&str>,
action: RepoAction,
collection: &str,
) -> Result<(), Response> {
if !is_oauth {
if !is_oauth && !has_custom_scope(scope) {
return Ok(());
}
@@ -32,7 +41,7 @@ pub fn check_repo_scope(
}
pub fn check_blob_scope(is_oauth: bool, scope: Option<&str>, mime: &str) -> Result<(), Response> {
if !is_oauth {
if !is_oauth && !has_custom_scope(scope) {
return Ok(());
}
@@ -55,7 +64,7 @@ pub fn check_rpc_scope(
aud: &str,
lxm: &str,
) -> Result<(), Response> {
if !is_oauth {
if !is_oauth && !has_custom_scope(scope) {
return Ok(());
}
@@ -78,7 +87,7 @@ pub fn check_account_scope(
attr: AccountAttr,
action: AccountAction,
) -> Result<(), Response> {
if !is_oauth {
if !is_oauth && !has_custom_scope(scope) {
return Ok(());
}
@@ -100,7 +109,7 @@ pub fn check_identity_scope(
scope: Option<&str>,
attr: IdentityAttr,
) -> Result<(), Response> {
if !is_oauth {
if !is_oauth && !has_custom_scope(scope) {
return Ok(());
}
+10 -1
View File
@@ -33,9 +33,18 @@ pub fn create_refresh_token(did: &str, key_bytes: &[u8]) -> Result<String> {
}
pub fn create_access_token_with_metadata(did: &str, key_bytes: &[u8]) -> Result<TokenWithMetadata> {
create_access_token_with_scope_metadata(did, key_bytes, None)
}
pub fn create_access_token_with_scope_metadata(
did: &str,
key_bytes: &[u8],
scopes: Option<&str>,
) -> Result<TokenWithMetadata> {
let scope = scopes.unwrap_or(SCOPE_ACCESS);
create_signed_token_with_metadata(
did,
SCOPE_ACCESS,
scope,
TOKEN_TYPE_ACCESS,
key_bytes,
Duration::minutes(15),
+5 -7
View File
@@ -256,12 +256,7 @@ pub fn verify_access_token_typed(
token: &str,
key_bytes: &[u8],
) -> Result<TokenData<Claims>, TokenVerifyError> {
verify_token_typed_internal(
token,
key_bytes,
Some(TOKEN_TYPE_ACCESS),
Some(&[SCOPE_ACCESS, SCOPE_APP_PASS, SCOPE_APP_PASS_PRIVILEGED]),
)
verify_token_typed_internal(token, key_bytes, Some(TOKEN_TYPE_ACCESS), None)
}
fn verify_token_typed_internal(
@@ -307,7 +302,10 @@ fn verify_token_typed_internal(
let verifying_key = VerifyingKey::from(&signing_key);
let message = format!("{}.{}", header_b64, claims_b64);
if verifying_key.verify(message.as_bytes(), &signature).is_err() {
if verifying_key
.verify(message.as_bytes(), &signature)
.is_err()
{
return Err(TokenVerifyError::Invalid);
}
+2 -1
View File
@@ -26,7 +26,8 @@ pub use scope_preference::{
pub use token::{
check_refresh_token_used, count_tokens_for_user, create_token, delete_oldest_tokens_for_user,
delete_token, delete_token_family, enforce_token_limit_for_user, get_token_by_id,
get_token_by_refresh_token, list_tokens_for_user, revoke_tokens_for_client, rotate_token,
get_token_by_previous_refresh_token, get_token_by_refresh_token, list_tokens_for_user,
revoke_tokens_for_client, rotate_token,
};
pub use two_factor::{
TwoFactorChallenge, check_user_2fa_enabled, cleanup_expired_2fa_challenges,
+47 -3
View File
@@ -122,7 +122,7 @@ pub async fn rotate_token(
)
.fetch_one(&mut *tx)
.await?;
if let Some(old_rt) = old_refresh {
if let Some(ref old_rt) = old_refresh {
sqlx::query!(
r#"
INSERT INTO oauth_used_refresh_token (refresh_token, token_id)
@@ -137,13 +137,15 @@ pub async fn rotate_token(
sqlx::query!(
r#"
UPDATE oauth_token
SET token_id = $2, current_refresh_token = $3, expires_at = $4, updated_at = NOW()
SET token_id = $2, current_refresh_token = $3, expires_at = $4, updated_at = NOW(),
previous_refresh_token = $5, rotated_at = NOW()
WHERE id = $1
"#,
old_db_id,
new_token_id,
new_refresh_token,
new_expires_at
new_expires_at,
old_refresh
)
.execute(&mut *tx)
.await?;
@@ -166,6 +168,48 @@ pub async fn check_refresh_token_used(
Ok(row)
}
const REFRESH_GRACE_PERIOD_SECS: i64 = 60;
pub async fn get_token_by_previous_refresh_token(
pool: &PgPool,
refresh_token: &str,
) -> Result<Option<(i32, TokenData)>, OAuthError> {
let grace_cutoff = Utc::now() - chrono::Duration::seconds(REFRESH_GRACE_PERIOD_SECS);
let row = sqlx::query!(
r#"
SELECT id, did, token_id, created_at, updated_at, expires_at, client_id, client_auth,
device_id, parameters, details, code, current_refresh_token, scope
FROM oauth_token
WHERE previous_refresh_token = $1 AND rotated_at > $2
"#,
refresh_token,
grace_cutoff
)
.fetch_optional(pool)
.await?;
match row {
Some(r) => Ok(Some((
r.id,
TokenData {
did: r.did,
token_id: r.token_id,
created_at: r.created_at,
updated_at: r.updated_at,
expires_at: r.expires_at,
client_id: r.client_id,
client_auth: from_json(r.client_auth)?,
device_id: r.device_id,
parameters: from_json(r.parameters)?,
details: r.details,
code: r.code,
current_refresh_token: r.current_refresh_token,
scope: r.scope,
},
))),
None => Ok(None),
}
}
pub async fn delete_token(pool: &PgPool, token_id: &str) -> Result<(), OAuthError> {
sqlx::query!(
r#"
+30
View File
@@ -175,6 +175,36 @@ pub async fn handle_refresh_token_grant(
"Refresh token grant requested"
);
if let Some(token_id) = db::check_refresh_token_used(&state.db, &refresh_token_str).await? {
if let Some((_db_id, token_data)) =
db::get_token_by_previous_refresh_token(&state.db, &refresh_token_str).await?
{
tracing::info!(
refresh_token_prefix = %&refresh_token_str[..std::cmp::min(16, refresh_token_str.len())],
"Refresh token reuse within grace period, returning existing tokens"
);
let dpop_jkt = token_data.parameters.dpop_jkt.as_deref();
let access_token = create_access_token(
&token_data.token_id,
&token_data.did,
dpop_jkt,
token_data.scope.as_deref(),
)?;
let mut response_headers = HeaderMap::new();
let config = AuthConfig::get();
let verifier = DPoPVerifier::new(config.dpop_secret().as_bytes());
response_headers.insert("DPoP-Nonce", verifier.generate_nonce().parse().unwrap());
return Ok((
response_headers,
Json(TokenResponse {
access_token,
token_type: if dpop_jkt.is_some() { "DPoP" } else { "Bearer" }.to_string(),
expires_in: ACCESS_TOKEN_EXPIRY_SECONDS as u64,
refresh_token: token_data.current_refresh_token,
scope: token_data.scope,
sub: Some(token_data.did),
}),
));
}
tracing::warn!(
refresh_token_prefix = %&refresh_token_str[..std::cmp::min(16, refresh_token_str.len())],
"Refresh token reuse detected, revoking token family"