`verify_jwt` setzt `validate_aud = false` — es kann den Aufrufer nicht kennen. Also blieb `aud` bisher ungeprüft, obwohl die PDS es setzt. Was die Prüfung bringt: die PDS signiert Tokens für *ihre* AppView. Ohne Audience-Check wäre ein Token, das an einen anderen Dienst mit derselben PDS-Vertrauensbeziehung geht, hier wiederverwendbar — und umgekehrt. Es ist der Unterschied zwischen "die PDS bürgt für diesen Nutzer" und "die PDS bürgt für diesen Nutzer *im Gespräch mit uns*". Dafür musste der Wert erst einmal etwas sein, das beide Seiten berechnen können: die PDS setzte ihn hart auf did:web:appview.maarcadetweet.local. Jetzt leiten ihn beide über AppConfig::appview_did() aus APPVIEW_PUBLIC_URL ab — dieselbe did:web-Regel wie schon für pds_did(). Ein Mismatch ist TokenInvalid, nicht Forbidden: das ist der Code, auf den der Client seine Token-Erneuerung stützt. Eine Instanz, die ihre APPVIEW_PUBLIC_URL ändert, heilt sich damit beim nächsten Refresh selbst, statt jeden angemeldeten Nutzer auszusperren. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
82 lines
2.8 KiB
Rust
82 lines
2.8 KiB
Rust
use anyhow::Result;
|
|
use at_crypto::jwt::JwtClaims;
|
|
use at_crypto::ecdsa::P256Keypair;
|
|
use at_shared::config::AppConfig;
|
|
|
|
pub fn server_p256_keypair(cfg: &AppConfig) -> Result<P256Keypair> {
|
|
use p256::elliptic_curve::sec1::ToEncodedPoint;
|
|
let raw = hex::decode(cfg.pds_jwt_secret.trim_start_matches("0x"))?;
|
|
if raw.len() < 32 {
|
|
anyhow::bail!("PDS_JWT_SECRET must be ≥ 32 bytes for P-256 key");
|
|
}
|
|
let mut bytes = [0u8; 32];
|
|
bytes.copy_from_slice(&raw[..32]);
|
|
let sk = p256::SecretKey::from_bytes((&bytes).into())
|
|
.map_err(|e| anyhow::anyhow!("p256 sk: {e}"))?;
|
|
let vk = sk.public_key();
|
|
let pt = vk.to_encoded_point(false);
|
|
let mut mb_raw = vec![0x80u8, 0x12u8];
|
|
mb_raw.extend_from_slice(pt.x().unwrap());
|
|
mb_raw.extend_from_slice(pt.y().unwrap());
|
|
let secret_hex = hex::encode(sk.to_bytes());
|
|
let public_multibase = at_crypto::multibase_util::encode_b58btc(&mb_raw);
|
|
Ok(P256Keypair {
|
|
secret_hex,
|
|
public_multibase,
|
|
})
|
|
}
|
|
|
|
pub fn server_p256_public_multibase(cfg: &AppConfig) -> Result<String> {
|
|
Ok(server_p256_keypair(cfg)?.public_multibase)
|
|
}
|
|
|
|
pub fn issue_access_jwt(
|
|
cfg: &AppConfig,
|
|
did: &str,
|
|
_handle: &str,
|
|
) -> Result<(String, i64)> {
|
|
let kp = server_p256_keypair(cfg)?;
|
|
let now = chrono::Utc::now().timestamp();
|
|
let exp = now + 3600;
|
|
let claims = JwtClaims {
|
|
// Same derivation as `describeServer` and `/.well-known/did.json`
|
|
// (`AppConfig::pds_did`), so a verifier can take `iss`, resolve
|
|
// the did:web document and arrive at the key this token is
|
|
// signed with. The previous inline version dropped the
|
|
// percent-encoding of the port, producing an `iss` that no
|
|
// did:web resolver could follow.
|
|
iss: cfg.pds_did(),
|
|
sub: did.to_string(),
|
|
// The AppView this token is meant for. Derived from
|
|
// `APPVIEW_PUBLIC_URL` rather than hardcoded, so the AppView can
|
|
// check it against its own identity (`AppConfig::appview_did`).
|
|
aud: cfg.appview_did(),
|
|
iat: now,
|
|
exp,
|
|
jti: Some(uuid::Uuid::new_v4().to_string()),
|
|
scope: Some("com.atproto.access".into()),
|
|
};
|
|
let token = at_crypto::jwt::issue_jwt(&kp, &claims)?;
|
|
Ok((token, exp))
|
|
}
|
|
|
|
pub fn issue_refresh_jwt(
|
|
cfg: &AppConfig,
|
|
did: &str,
|
|
) -> Result<(String, i64)> {
|
|
let kp = server_p256_keypair(cfg)?;
|
|
let now = chrono::Utc::now().timestamp();
|
|
let exp = now + 90 * 24 * 3600;
|
|
let claims = JwtClaims {
|
|
iss: "did:web:refresh.maarcadetweet.local".into(),
|
|
sub: did.to_string(),
|
|
aud: "did:web:refresh.maarcadetweet.local".into(),
|
|
iat: now,
|
|
exp,
|
|
jti: Some(uuid::Uuid::new_v4().to_string()),
|
|
scope: Some("com.atproto.refresh".into()),
|
|
};
|
|
let token = at_crypto::jwt::issue_jwt(&kp, &claims)?;
|
|
Ok((token, exp))
|
|
}
|