diff --git a/src/auth/middleware.rs b/src/auth/middleware.rs --- a/src/auth/middleware.rs +++ b/src/auth/middleware.rs @@ -220,11 +220,14 @@ } /// XRPC-specific claims extractor. /// -/// Only accepts DPoP auth (`Authorization: DPoP ` + `DPoP` proof + `X-Client-Key`). -/// Cookie auth, Bearer API keys, and service JWTs are rejected on XRPC routes. -/// Wraps `Option` — `None` means anonymous (client-key-only) access. +/// Accepts DPoP auth (`Authorization: DPoP `) or Bearer space credential +/// JWTs (`Authorization: Bearer `). Cookie auth, Bearer API keys, +/// and service JWTs are rejected on XRPC routes. #[derive(Debug, Clone)] -pub struct XrpcClaims(pub Option); +pub struct XrpcClaims { + pub identity: Option, + pub space_credential: Option, +} impl FromRequestParts for XrpcClaims { type Rejection = AppError; @@ -242,19 +245,36 @@ match header { Some(h) if h.starts_with("DPoP ") => { let token = &h[5..]; let claims = resolve_dpop_claims(state, parts, token).await?; - Ok(XrpcClaims(Some(claims))) + Ok(XrpcClaims { + identity: Some(claims), + space_credential: None, + }) } Some(h) if h.starts_with("Bearer ") => { - Err(AppError::Auth( - "XRPC routes do not accept Bearer auth. Use DPoP auth or omit the Authorization header for anonymous access.".into(), - )) - } - Some(_) => { - Err(AppError::Auth("invalid Authorization scheme".into())) + let token = &h[7..]; + match crate::spaces::credential::peek_jwt_typ(token) { + Some(typ) if typ == "space_credential" => { + let sub = + crate::spaces::credential::peek_credential_sub(token).ok_or_else( + || AppError::Auth("invalid space credential payload".into()), + )?; + Ok(XrpcClaims { + identity: Some(Claims::internal(sub)), + space_credential: Some(token.to_string()), + }) + } + _ => Err(AppError::Auth( + "XRPC routes do not accept Bearer auth. Use DPoP auth, a space credential, or omit the Authorization header for anonymous access.".into(), + )), + } } + Some(_) => Err(AppError::Auth("invalid Authorization scheme".into())), None => { // No auth header — anonymous access (client-key only) - Ok(XrpcClaims(None)) + Ok(XrpcClaims { + identity: None, + space_credential: None, + }) } } } diff --git a/src/delegation/add_delegate.rs b/src/delegation/add_delegate.rs --- a/src/delegation/add_delegate.rs +++ b/src/delegation/add_delegate.rs @@ -27,7 +27,7 @@ xrpc_claims: XrpcClaims, Json(input): Json, ) -> Result { let claims = xrpc_claims - .0 + .identity .ok_or_else(|| AppError::Auth("addDelegate requires authentication".into()))?; let caller_did = claims.did().to_string(); diff --git a/src/delegation/get_account.rs b/src/delegation/get_account.rs --- a/src/delegation/get_account.rs +++ b/src/delegation/get_account.rs @@ -21,7 +21,7 @@ xrpc_claims: XrpcClaims, Query(params): Query, ) -> Result { let claims = xrpc_claims - .0 + .identity .ok_or_else(|| AppError::Auth("getAccount requires authentication".into()))?; super::verify_client_scope(&state, &claims, ¶ms.did).await?; diff --git a/src/delegation/link_account.rs b/src/delegation/link_account.rs --- a/src/delegation/link_account.rs +++ b/src/delegation/link_account.rs @@ -24,7 +24,7 @@ xrpc_claims: XrpcClaims, Json(input): Json, ) -> Result { let claims = xrpc_claims - .0 + .identity .ok_or_else(|| AppError::Auth("linkAccount requires authentication".into()))?; let caller_did = claims.did().to_string(); diff --git a/src/delegation/list_accounts.rs b/src/delegation/list_accounts.rs --- a/src/delegation/list_accounts.rs +++ b/src/delegation/list_accounts.rs @@ -14,7 +14,7 @@ State(state): State, xrpc_claims: XrpcClaims, ) -> Result { let claims = xrpc_claims - .0 + .identity .ok_or_else(|| AppError::Auth("listAccounts requires authentication".into()))?; let caller_client_id = super::resolve_caller_client_id(&state, &claims).await?; diff --git a/src/delegation/list_delegates.rs b/src/delegation/list_delegates.rs --- a/src/delegation/list_delegates.rs +++ b/src/delegation/list_delegates.rs @@ -22,7 +22,7 @@ xrpc_claims: XrpcClaims, Query(params): Query, ) -> Result { let claims = xrpc_claims - .0 + .identity .ok_or_else(|| AppError::Auth("listDelegates requires authentication".into()))?; super::verify_client_scope(&state, &claims, ¶ms.account_did).await?; diff --git a/src/delegation/remove_delegate.rs b/src/delegation/remove_delegate.rs --- a/src/delegation/remove_delegate.rs +++ b/src/delegation/remove_delegate.rs @@ -26,7 +26,7 @@ xrpc_claims: XrpcClaims, Json(input): Json, ) -> Result { let claims = xrpc_claims - .0 + .identity .ok_or_else(|| AppError::Auth("removeDelegate requires authentication".into()))?; let caller_did = claims.did().to_string(); diff --git a/src/delegation/unlink_account.rs b/src/delegation/unlink_account.rs --- a/src/delegation/unlink_account.rs +++ b/src/delegation/unlink_account.rs @@ -24,7 +24,7 @@ xrpc_claims: XrpcClaims, Json(input): Json, ) -> Result { let claims = xrpc_claims - .0 + .identity .ok_or_else(|| AppError::Auth("unlinkAccount requires authentication".into()))?; let caller_did = claims.did().to_string(); diff --git a/src/dev_happyview/create_client.rs b/src/dev_happyview/create_client.rs --- a/src/dev_happyview/create_client.rs +++ b/src/dev_happyview/create_client.rs @@ -21,7 +21,7 @@ Json(input): Json, ) -> Result { // 1. Require DPoP auth let claims = xrpc_claims - .0 + .identity .ok_or_else(|| AppError::Auth("createApiClient requires DPoP authentication".into()))?; // 2. Rate-limit the request (procedure type) diff --git a/src/dev_happyview/delete_client.rs b/src/dev_happyview/delete_client.rs --- a/src/dev_happyview/delete_client.rs +++ b/src/dev_happyview/delete_client.rs @@ -17,7 +17,7 @@ Json(input): Json, ) -> Result { // 1. Require DPoP auth let claims = xrpc_claims - .0 + .identity .ok_or_else(|| AppError::Auth("deleteApiClient requires DPoP authentication".into()))?; // 2. Rate-limit the request (procedure type) diff --git a/src/dev_happyview/get_client.rs b/src/dev_happyview/get_client.rs --- a/src/dev_happyview/get_client.rs +++ b/src/dev_happyview/get_client.rs @@ -21,7 +21,7 @@ xrpc_claims: XrpcClaims, Query(params): Query, ) -> Result { let claims = xrpc_claims - .0 + .identity .ok_or_else(|| AppError::Auth("getApiClient requires DPoP authentication".into()))?; let check = if let Some(client_key) = claims.client_key() { diff --git a/src/dev_happyview/list_clients.rs b/src/dev_happyview/list_clients.rs --- a/src/dev_happyview/list_clients.rs +++ b/src/dev_happyview/list_clients.rs @@ -14,7 +14,7 @@ State(state): State, xrpc_claims: XrpcClaims, ) -> Result { let claims = xrpc_claims - .0 + .identity .ok_or_else(|| AppError::Auth("listApiClients requires DPoP authentication".into()))?; let check = if let Some(client_key) = claims.client_key() { diff --git a/src/repo/upload_blob.rs b/src/repo/upload_blob.rs --- a/src/repo/upload_blob.rs +++ b/src/repo/upload_blob.rs @@ -18,7 +18,7 @@ headers: HeaderMap, body: Bytes, ) -> Result { let claims = xrpc_claims - .0 + .identity .ok_or_else(|| AppError::Auth("uploadBlob requires DPoP authentication".into()))?; let check = if let Some(client_key) = claims.client_key() { let cost = state diff --git a/src/server.rs b/src/server.rs --- a/src/server.rs +++ b/src/server.rs @@ -311,7 +311,7 @@ State(state): State, xrpc_claims: XrpcClaims, ) -> Result { let claims = xrpc_claims - .0 + .identity .ok_or_else(|| AppError::Auth("getProfile requires DPoP authentication".into()))?; let check = if let Some(client_key) = claims.client_key() { let cost = state diff --git a/src/spaces/credential.rs b/src/spaces/credential.rs --- a/src/spaces/credential.rs +++ b/src/spaces/credential.rs @@ -9,6 +9,25 @@ pub const DEFAULT_CREDENTIAL_TTL_SECS: u64 = 4 * 60 * 60; // 4 hours pub const GRANT_TTL_SECS: u64 = 5 * 60; // 5 minutes +/// Peek at a JWT's header to check its `typ` field without verifying the signature. +pub fn peek_jwt_typ(token: &str) -> Option { + let header_b64 = token.split('.').next()?; + let header_bytes = URL_SAFE_NO_PAD.decode(header_b64).ok()?; + let header: serde_json::Value = serde_json::from_slice(&header_bytes).ok()?; + header["typ"].as_str().map(|s| s.to_string()) +} + +/// Peek at a space credential JWT's payload to extract the `sub` (user DID) without verifying. +pub fn peek_credential_sub(token: &str) -> Option { + let parts: Vec<&str> = token.split('.').collect(); + if parts.len() != 3 { + return None; + } + let payload_bytes = URL_SAFE_NO_PAD.decode(parts[1]).ok()?; + let claims: SpaceCredentialClaims = serde_json::from_slice(&payload_bytes).ok()?; + Some(claims.sub) +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MemberGrantClaims { pub sub: String, @@ -72,7 +91,7 @@ .map_err(|e| AppError::Internal(format!("invalid signing key: {e}")))?; let header = serde_json::json!({ "alg": "ES256", - "typ": "JWT", + "typ": "space_credential", }); let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).unwrap()); @@ -102,6 +121,12 @@ .map_err(|_| AppError::Auth("invalid credential header".into()))?; if header["alg"].as_str() != Some("ES256") { return Err(AppError::Auth("credential alg must be ES256".into())); + } + + if header["typ"].as_str() != Some("space_credential") { + return Err(AppError::Auth( + "credential typ must be space_credential".into(), + )); } let x_b64 = public_jwk["x"] @@ -386,5 +411,62 @@ let token = sign_grant(&claims, &secret).unwrap(); let result = verify_grant(&token, &secret); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("expired")); + } + + #[test] + fn credential_has_space_credential_typ() { + let keypair = generate_dpop_keypair().unwrap(); + let claims = make_claims(); + let token = sign_credential(&claims, &keypair.private_jwk).unwrap(); + assert_eq!(peek_jwt_typ(&token).as_deref(), Some("space_credential")); + } + + #[test] + fn peek_jwt_typ_returns_none_for_garbage() { + assert_eq!(peek_jwt_typ("not-a-jwt"), None); + assert_eq!(peek_jwt_typ(""), None); + } + + #[test] + fn peek_credential_sub_extracts_did() { + let keypair = generate_dpop_keypair().unwrap(); + let claims = make_claims(); + let token = sign_credential(&claims, &keypair.private_jwk).unwrap(); + assert_eq!( + peek_credential_sub(&token).as_deref(), + Some("did:plc:requester") + ); + } + + #[test] + fn peek_credential_sub_returns_none_for_garbage() { + assert_eq!(peek_credential_sub("not-a-jwt"), None); + } + + #[test] + fn verify_rejects_wrong_typ() { + let keypair = generate_dpop_keypair().unwrap(); + let claims = make_claims(); + + let d_b64 = keypair.private_jwk["d"].as_str().unwrap(); + let d_bytes = URL_SAFE_NO_PAD.decode(d_b64).unwrap(); + let signing_key = p256::ecdsa::SigningKey::from_bytes((&d_bytes[..]).into()).unwrap(); + + let header = serde_json::json!({ "alg": "ES256", "typ": "JWT" }); + let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).unwrap()); + let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims).unwrap()); + let message = format!("{}.{}", header_b64, payload_b64); + let sig: p256::ecdsa::Signature = + p256::ecdsa::signature::Signer::sign(&signing_key, message.as_bytes()); + let token = format!( + "{}.{}.{}", + header_b64, + payload_b64, + URL_SAFE_NO_PAD.encode(sig.to_bytes()) + ); + + let result = verify_credential(&token, &keypair.public_jwk); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("typ")); } } diff --git a/src/spaces/routes.rs b/src/spaces/routes.rs --- a/src/spaces/routes.rs +++ b/src/spaces/routes.rs @@ -1,5 +1,5 @@ use axum::extract::{Query, State}; -use axum::http::{HeaderMap, StatusCode}; +use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; use axum::routing::{get, post}; use axum::{Json, Router}; @@ -201,11 +201,11 @@ pub fn space_routes() -> Router { Router::new() // Space CRUD - .route(&format!("/xrpc/{NS}.space.create"), post(create_space)) - .route(&format!("/xrpc/{NS}.space.get"), get(get_space)) - .route(&format!("/xrpc/{NS}.space.list"), get(list_spaces)) - .route(&format!("/xrpc/{NS}.space.delete"), post(delete_space)) - .route(&format!("/xrpc/{NS}.space.update"), post(update_space)) + .route(&format!("/xrpc/{NS}.space.createSpace"), post(create_space)) + .route(&format!("/xrpc/{NS}.space.getSpace"), get(get_space)) + .route(&format!("/xrpc/{NS}.space.listSpaces"), get(list_spaces)) + .route(&format!("/xrpc/{NS}.space.deleteSpace"), post(delete_space)) + .route(&format!("/xrpc/{NS}.space.updateSpace"), post(update_space)) // Records .route( &format!("/xrpc/{NS}.space.createRecord"), @@ -228,6 +228,34 @@ post(remove_member), ) // Invites .route( + &format!("/xrpc/{NS}.space.createInvite"), + post(create_invite), + ) + .route( + &format!("/xrpc/{NS}.space.redeemInvite"), + post(redeem_invite), + ) + .route( + &format!("/xrpc/{NS}.space.revokeInvite"), + post(revoke_invite), + ) + .route(&format!("/xrpc/{NS}.space.listInvites"), get(list_invites)) + // Credentials + .route( + &format!("/xrpc/{NS}.space.getMemberGrant"), + post(get_member_grant), + ) + .route( + &format!("/xrpc/{NS}.space.getSpaceCredential"), + post(get_space_credential), + ) + // Legacy aliases (will be removed in a future release) + .route(&format!("/xrpc/{NS}.space.create"), post(create_space)) + .route(&format!("/xrpc/{NS}.space.get"), get(get_space)) + .route(&format!("/xrpc/{NS}.space.list"), get(list_spaces)) + .route(&format!("/xrpc/{NS}.space.delete"), post(delete_space)) + .route(&format!("/xrpc/{NS}.space.update"), post(update_space)) + .route( &format!("/xrpc/{NS}.space.invite.create"), post(create_invite), ) @@ -257,9 +285,9 @@ // --------------------------------------------------------------------------- fn require_auth(claims: &XrpcClaims) -> Result<&crate::auth::Claims, AppError> { claims - .0 + .identity .as_ref() - .ok_or_else(|| AppError::Auth("This endpoint requires DPoP authentication".into())) + .ok_or_else(|| AppError::Auth("This endpoint requires authentication".into())) } async fn resolve_space(state: &AppState, space_uri: &str) -> Result { @@ -291,13 +319,6 @@ } Err(AppError::Forbidden( "Only the space owner can perform this action".into(), )) -} - -fn extract_space_credential(headers: &HeaderMap) -> Option { - headers - .get("x-space-credential") - .and_then(|v| v.to_str().ok()) - .map(|s| s.to_string()) } async fn require_membership( @@ -552,14 +573,19 @@ async fn create_record( State(state): State, xrpc_claims: XrpcClaims, - headers: HeaderMap, Json(input): Json, ) -> Result { let claims = require_auth(&xrpc_claims)?; let did = claims.did().to_string(); let space = resolve_space(&state, &input.space).await?; - let cred = extract_space_credential(&headers); - require_membership(&state, &space, &did, true, cred.as_deref()).await?; + require_membership( + &state, + &space, + &did, + true, + xrpc_claims.space_credential.as_deref(), + ) + .await?; let rkey = generate_tid(); let cid = content_cid(&input.record); @@ -597,14 +623,19 @@ async fn put_record( State(state): State, xrpc_claims: XrpcClaims, - headers: HeaderMap, Json(input): Json, ) -> Result { let claims = require_auth(&xrpc_claims)?; let did = claims.did().to_string(); let space = resolve_space(&state, &input.space).await?; - let cred = extract_space_credential(&headers); - require_membership(&state, &space, &did, true, cred.as_deref()).await?; + require_membership( + &state, + &space, + &did, + true, + xrpc_claims.space_credential.as_deref(), + ) + .await?; let cid = content_cid(&input.record); let record_uri = format!( @@ -684,14 +715,19 @@ async fn apply_writes( State(state): State, xrpc_claims: XrpcClaims, - headers: HeaderMap, Json(input): Json, ) -> Result, AppError> { let claims = require_auth(&xrpc_claims)?; let did = claims.did().to_string(); let space = resolve_space(&state, &input.space).await?; - let cred = extract_space_credential(&headers); - require_membership(&state, &space, &did, true, cred.as_deref()).await?; + require_membership( + &state, + &space, + &did, + true, + xrpc_claims.space_credential.as_deref(), + ) + .await?; if let Some(ref expected_rev) = input.swap_commit { match &space.revision { @@ -809,13 +845,18 @@ async fn get_record( State(state): State, xrpc_claims: XrpcClaims, - headers: HeaderMap, Query(query): Query, ) -> Result, AppError> { let claims = require_auth(&xrpc_claims)?; let space = resolve_space(&state, &query.space).await?; - let cred = extract_space_credential(&headers); - require_membership(&state, &space, claims.did(), false, cred.as_deref()).await?; + require_membership( + &state, + &space, + claims.did(), + false, + xrpc_claims.space_credential.as_deref(), + ) + .await?; let record = db::get_space_record_by_parts( &state.db, @@ -837,16 +878,21 @@ async fn list_records( State(state): State, xrpc_claims: XrpcClaims, - headers: HeaderMap, Query(query): Query, ) -> Result, AppError> { let claims = require_auth(&xrpc_claims)?; let space = resolve_space(&state, &query.space).await?; - let cred = extract_space_credential(&headers); - require_membership(&state, &space, claims.did(), false, cred.as_deref()).await?; + require_membership( + &state, + &space, + claims.did(), + false, + xrpc_claims.space_credential.as_deref(), + ) + .await?; let repo = query.repo.as_deref().or_else(|| { - if cred.is_some() { + if xrpc_claims.space_credential.is_some() { None } else { Some(claims.did()) @@ -893,15 +939,20 @@ async fn list_members( State(state): State, xrpc_claims: XrpcClaims, - headers: HeaderMap, Query(query): Query, ) -> Result, AppError> { let space = resolve_space(&state, &query.space).await?; if !space.config.membership_public { let claims = require_auth(&xrpc_claims)?; - let cred = extract_space_credential(&headers); - require_membership(&state, &space, claims.did(), false, cred.as_deref()).await?; + require_membership( + &state, + &space, + claims.did(), + false, + xrpc_claims.space_credential.as_deref(), + ) + .await?; } let resolved = members::resolve_members(&state.db, state.db_backend, &space.id).await?; diff --git a/src/xrpc/mod.rs b/src/xrpc/mod.rs --- a/src/xrpc/mod.rs +++ b/src/xrpc/mod.rs @@ -258,7 +258,7 @@ parts: Parts, ) -> Result { let raw_query = raw_query.unwrap_or_default(); let mut params = parse_query_params(&raw_query); - let claims = xrpc_claims.0; + let claims = xrpc_claims.identity; let rate_key = resolve_client_key(&state, claims.as_ref(), &parts, ¶ms)?; @@ -349,7 +349,7 @@ ) -> Result { let raw_query = raw_query.unwrap_or_default(); let mut params = parse_query_params(&raw_query); let claims = xrpc_claims - .0 + .identity .ok_or_else(|| AppError::Auth("XRPC procedures require DPoP authentication".into()))?; let rate_key = resolve_client_key(&state, Some(&claims), &parts, ¶ms)?;