use self::crypto::now_secs;
use aes_gcm::Aes256Gcm;
use std::path::PathBuf;
use anyhow::Context as _;
use knot_types::AccountDid;
use serde::{Deserialize, Serialize};
use crate::config::ValidatedConfig;
use crate::datastore::record::{RecordGuard, decode_json, encode_json};
use crate::datastore::{Record as _, SealedStore, schema};
mod crypto;
mod flow;
mod pds_client;
#[cfg(test)]
pub(crate) mod tests;
pub(crate) use flow::deliver_code;
pub use flow::run_interactive_auth;
pub use pds_client::{
create_record_oauth, delete_record_oauth, get_record_oauth, put_record_oauth,
};
#[cfg(test)]
pub(crate) use tests::seed_test_session;
use self::crypto::{KeyPair, derive_gcm_key, seal_bytes, unseal_bytes};
use self::flow::refresh_session;
pub(crate) const GRANULAR_SCOPE: &str =
"atproto repo:org.tangled.repo.declaration?action=create&action=update&action=delete";
pub(crate) fn client_id(config: &ValidatedConfig) -> String {
format!(
"https://{}/oauth/client-metadata.json",
config.hostname.as_str()
)
}
pub(crate) fn redirect_uri(config: &ValidatedConfig) -> String {
format!("https://{}/oauth/callback", config.hostname.as_str())
}
pub(crate) fn oauth_dir(config: &ValidatedConfig) -> PathBuf {
config.oauth_dir()
}
/// the pds keeps the public half from the first client metadata. a second key fails every assertion.
pub(crate) fn ensure_client_key(config: &ValidatedConfig) -> anyhow::Result<KeyPair> {
client_key_in(&SealedStore::open(config), &derive_gcm_key(config)?)
}
pub(crate) fn client_key_in(store: &SealedStore, cipher: &Aes256Gcm) -> anyhow::Result<KeyPair> {
let sealed = store
.record::<schema::OauthClientKey>(&())
.publish_once(|| {
let key = KeyPair::generate();
seal_bytes(cipher, &key.to_bytes())
})?;
let plain = unseal_bytes(cipher, &sealed).context("decrypt client key")?;
KeyPair::from_bytes(&plain)
}
pub fn client_metadata_document(config: &ValidatedConfig) -> anyhow::Result<serde_json::Value> {
let client_key = ensure_client_key(config)?;
let jwk = client_key.jwk(Some("knot-static-client"));
let cid = client_id(config);
let ruri = redirect_uri(config);
Ok(serde_json::json!({
"client_id": cid,
"client_name": format!("knot-static ({})", config.hostname.as_str()),
"client_uri": format!("https://{}", config.hostname.as_str()),
"application_type": "web",
"grant_types": ["authorization_code", "refresh_token"],
"scope": GRANULAR_SCOPE,
"response_types": ["code"],
"redirect_uris": [ruri],
"dpop_bound_access_tokens": true,
"token_endpoint_auth_method": "private_key_jwt",
"token_endpoint_auth_signing_alg": "ES256",
"jwks": {
"keys": [jwk]
}
}))
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StoredSession {
owner_did: AccountDid,
dpop_scalar: Vec<u8>,
access_token: String,
refresh_token: String,
scope: String,
token_endpoint: String,
pds_url: String,
expires_at: i64,
dpop_nonce: Option<String>,
}
fn session_of(cipher: &Aes256Gcm, sealed: &[u8]) -> anyhow::Result<StoredSession> {
let plain = unseal_bytes(cipher, sealed).context("decrypt session")?;
decode_json(
schema::OauthSession::VERSION,
schema::OauthSession::NAME,
&plain,
)
}
/// refresh tokens are single use, and a pds may revoke the whole token family on a replay.
/// refreshing outside the lock is what causes that replay.
pub(crate) fn save_session_under(
guard: &RecordGuard<'_, schema::OauthSession>,
cipher: &Aes256Gcm,
session: &StoredSession,
) -> anyhow::Result<()> {
let plain = encode_json(schema::OauthSession::VERSION, session)?;
let sealed = seal_bytes(cipher, &plain)?;
guard.put(&sealed)
}
pub(crate) fn stored_session(
guard: &RecordGuard<'_, schema::OauthSession>,
cipher: &Aes256Gcm,
) -> anyhow::Result<Option<StoredSession>> {
let Some(sealed) = guard.read()? else {
return Ok(None);
};
let plain = unseal_bytes(cipher, &sealed).context("decrypt session")?;
decode_json(
schema::OauthSession::VERSION,
schema::OauthSession::NAME,
&plain,
)
.map(Some)
}
pub async fn get_or_refresh_session(
config: &ValidatedConfig,
owner_did: &AccountDid,
) -> anyhow::Result<Option<StoredSession>> {
let store = SealedStore::open(config);
let record = store.record::<schema::OauthSession>(owner_did);
// held through the refresh. only one process spends the single-use refresh token.
let guard = record.guard()?;
let cipher = derive_gcm_key(config)?;
let Some(mut session) = stored_session(&guard, &cipher)? else {
return Ok(None);
};
if now_secs() + 300 >= session.expires_at
&& !refresh_session(config, &guard, &mut session).await?
{
return Ok(None);
}
Ok(Some(session))
}
pub async fn ensure_auth_for_owner(
config: &ValidatedConfig,
owner_did: &AccountDid,
interactive: bool,
) -> anyhow::Result<Option<StoredSession>> {
if let Some(session) = get_or_refresh_session(config, owner_did).await? {
return Ok(Some(session));
}
if !interactive {
return Ok(None);
}
let session = run_interactive_auth(config, owner_did).await?;
Ok(Some(session))
}
pub async fn print_status(config: &ValidatedConfig) -> anyhow::Result<()> {
println!("Knot OAuth client ID: {}", client_id(config));
println!("Knot OAuth redirect URI: {}", redirect_uri(config));
let dir = oauth_dir(config);
println!("OAuth state directory: {}", dir.display());
println!(
"Client key present: {}",
dir.join("client_key.sealed").exists()
);
let sessions_dir = dir.join("sessions");
if !sessions_dir.exists() {
println!("No OAuth sessions found.");
return Ok(());
}
println!("\nSessions:");
for entry in std::fs::read_dir(&sessions_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("sealed") {
continue;
}
// the owner comes from the session because `:` maps to `_` in file names, which can't be reversed.
let cipher = derive_gcm_key(config)?;
let session = std::fs::read(&path)
.context("read sealed session")
.and_then(|sealed| session_of(&cipher, &sealed));
match session {
Ok(session) => {
let exp_in = session.expires_at - now_secs();
let status = if exp_in > 0 {
format!("valid ({exp_in}s remaining)")
} else {
format!(
"access token expired (refreshable: {})",
!session.refresh_token.is_empty()
)
};
println!(
" - {}: scope={:?}, status={}",
session.owner_did, session.scope, status
);
}
Err(error) => println!(" - {}: {error:#}", path.display()),
}
}
Ok(())
}