From 45fef043ee128052b210e9abb419cf085d0e3dfe Mon Sep 17 00:00:00 2001 From: Lewis Date: Wed, 7 Oct 2026 12:44:39 +0300 Subject: [PATCH] knot2: mint repo tokens ready for spindles Lewis: May this revision serve well! --- consts/consts.go | 1 + crates/knot-capability/src/lib.rs | 4 + knot2/crates/knot-atproto/src/jwt.rs | 158 ++++++++++++-- knot2/crates/knot-atproto/src/lib.rs | 5 +- knot2/crates/knot-lexicons/Cargo.toml | 2 +- knot2/crates/knot-xrpc/src/lib.rs | 2 + knot2/crates/knot-xrpc/src/mint.rs | 88 ++++++++ knot2/crates/knot-xrpc/src/social.rs | 6 +- knot2/crates/knot-xrpc/tests/common/mod.rs | 10 + knot2/crates/knot-xrpc/tests/mint.rs | 203 ++++++++++++++++++ knot2/crates/knot-xrpc/tests/reads.rs | 1 + lexicons/org/tangled/repo/mintToken.json | 59 +++++ .../types/org/tangled/repo/mintToken.ts | 53 +++++ 13 files changed, 569 insertions(+), 23 deletions(-) create mode 100644 knot2/crates/knot-xrpc/src/mint.rs create mode 100644 knot2/crates/knot-xrpc/tests/mint.rs create mode 100644 lexicons/org/tangled/repo/mintToken.json create mode 100644 web/src/lib/api/lexicons/types/org/tangled/repo/mintToken.ts diff --git a/consts/consts.go b/consts/consts.go index 94a7f6698..156c1d6c0 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -14,5 +14,6 @@ const ( CapKnotACL Capability = "knot-acl" CapRepoDidInput Capability = "repo-did-input" CapKeepCommit Capability = "knot-keepcommit" + CapRepoTokenMint Capability = "knot-repo-token-mint" CapKnotSocialWrites Capability = "knot-social-writes" ) diff --git a/crates/knot-capability/src/lib.rs b/crates/knot-capability/src/lib.rs index bde51a23e..08a338fc3 100644 --- a/crates/knot-capability/src/lib.rs +++ b/crates/knot-capability/src/lib.rs @@ -5,6 +5,7 @@ pub enum Capability { RepoDidInput, KeepCommit, AtprotoFirehose, + RepoTokenMint, SocialWrites, } @@ -15,6 +16,7 @@ impl Capability { Capability::RepoDidInput, Capability::KeepCommit, Capability::AtprotoFirehose, + Capability::RepoTokenMint, Capability::SocialWrites, ]; @@ -23,6 +25,7 @@ impl Capability { Capability::KnotAcl, Capability::RepoDidInput, Capability::AtprotoFirehose, + Capability::RepoTokenMint, Capability::SocialWrites, ]; @@ -33,6 +36,7 @@ impl Capability { Capability::RepoDidInput => "repo-did-input", Capability::KeepCommit => "knot-keepcommit", Capability::AtprotoFirehose => "atproto-firehose", + Capability::RepoTokenMint => "knot-repo-token-mint", Capability::SocialWrites => "knot-social-writes", } } diff --git a/knot2/crates/knot-atproto/src/jwt.rs b/knot2/crates/knot-atproto/src/jwt.rs index 20d6eb063..9ea06a80b 100644 --- a/knot2/crates/knot-atproto/src/jwt.rs +++ b/knot2/crates/knot-atproto/src/jwt.rs @@ -7,17 +7,21 @@ use knot_types::service_auth::{ }; use knot_types::{ AccountDid, CowStr, Did, DidService, KnotId, Nsid, RepoDid, ServiceDid, UnixSeconds, + lowercase_hex, }; +use serde::Serialize; pub(crate) const CLOCK_SKEW_SECS: i64 = 60; pub(crate) const SERVICE_TOKEN_LIFETIME_SECS: i64 = 60; +const REPO_TOKEN_MAX_LIFETIME_SECS: i64 = 60; const MAX_TOKEN_LIFETIME_SECS: i64 = 300; const MAX_NONCE_BYTES: usize = 256; #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct JwtNonce(String); -#[derive(Clone, PartialEq, Eq)] +#[derive(Clone, PartialEq, Eq, Serialize)] +#[serde(transparent)] pub struct ServiceJwt(String); impl std::fmt::Debug for ServiceJwt { @@ -49,6 +53,10 @@ impl std::fmt::Display for ServiceJwt { } impl JwtNonce { + pub fn from_entropy(bytes: [u8; 16]) -> Self { + Self(lowercase_hex(&bytes)) + } + pub fn new(value: impl Into) -> Result { let value = value.into(); let len = value.len(); @@ -105,6 +113,30 @@ pub enum JwtError { }, } +fn alg_of(signer: &dyn Signer) -> CowStr<'static> { + CowStr::new_static(match signer.scheme() { + SignatureScheme::Secp256k1 => "ES256K", + SignatureScheme::P256 => "ES256", + }) +} + +fn sign_jwt(signer: &dyn Signer, claims: &T) -> ServiceJwt { + let header = JwtHeader { + alg: alg_of(signer), + typ: CowStr::new_static("JWT"), + }; + let header_b64 = + URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).expect("jwt header serializes")); + let payload_b64 = + URL_SAFE_NO_PAD.encode(serde_json::to_vec(claims).expect("service-auth claims serialize")); + let signing_input = format!("{header_b64}.{payload_b64}"); + let signature = signer.sign(signing_input.as_bytes()); + ServiceJwt(format!( + "{signing_input}.{}", + URL_SAFE_NO_PAD.encode(signature.as_bytes()) + )) +} + pub(crate) fn mint( signer: &dyn Signer, issuer: &KnotId, @@ -113,14 +145,6 @@ pub(crate) fn mint( nonce: JwtNonce, now_unix: UnixSeconds, ) -> ServiceJwt { - let alg = match signer.scheme() { - SignatureScheme::Secp256k1 => "ES256K", - SignatureScheme::P256 => "ES256", - }; - let header = JwtHeader { - alg: CowStr::new_static(alg), - typ: CowStr::new_static("JWT"), - }; let claims = ServiceAuthClaims { iss: Did::new_owned(issuer.as_str()).expect("knot DID parses as a DID"), aud: DidService::new_owned(audience.as_str()).expect("service DID parses as a DID"), @@ -131,16 +155,76 @@ pub(crate) fn mint( jti: Some(nonce.as_str().into()), lxm: Some(method.clone()), }; - let header_b64 = - URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).expect("jwt header serializes")); - let payload_b64 = - URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims).expect("service-auth claims serialize")); - let signing_input = format!("{header_b64}.{payload_b64}"); - let signature = signer.sign(signing_input.as_bytes()); - ServiceJwt(format!( - "{signing_input}.{}", - URL_SAFE_NO_PAD.encode(signature.as_bytes()) - )) + sign_jwt(signer, &claims) +} + +#[derive(Serialize)] +struct ControllerClaim<'a> { + sub: &'a AccountDid, +} + +#[derive(Serialize)] +struct RepoTokenClaims<'a> { + iss: &'a RepoDid, + aud: &'a DidService, + exp: UnixSeconds, + iat: UnixSeconds, + jti: &'a str, + lxm: &'a Nsid, + act: ControllerClaim<'a>, +} + +#[derive(Debug, thiserror::Error)] +pub enum MintExpiryError { +#[error("Exp must be in the future.")] + NotAfterMint, + #[error("Exp is {lifespan} seconds after minting, past the {REPO_TOKEN_MAX_LIFETIME_SECS}-second limit.")] + TooLong { lifespan: i64 }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RepoTokenExpiry(UnixSeconds); + +impl RepoTokenExpiry { + pub fn mint(exp: i64, now: UnixSeconds) -> Result { + let requested = UnixSeconds::new(exp); + match requested.get().saturating_sub(now.get()) { + lifespan if lifespan <= 0 => Err(MintExpiryError::NotAfterMint), + lifespan if lifespan > REPO_TOKEN_MAX_LIFETIME_SECS => { + Err(MintExpiryError::TooLong { lifespan }) + } + _ => Ok(Self(requested)), + } + } + + pub fn get(self) -> UnixSeconds { + self.0 + } +} + +pub struct RepoTokenRequest<'a> { + pub repo: &'a RepoDid, + pub audience: &'a DidService, + pub method: &'a Nsid, + pub nonce: &'a JwtNonce, + pub controller: &'a AccountDid, + pub now: UnixSeconds, + pub expires_at: RepoTokenExpiry, +} + +pub fn mint_repo_token(signer: &dyn Signer, request: RepoTokenRequest<'_>) -> ServiceJwt { + let claims = RepoTokenClaims { + iss: request.repo, + aud: request.audience, + exp: request.expires_at.get(), + iat: request.now, + jti: request.nonce.as_str(), + lxm: request.method, + act: ControllerClaim { + sub: request.controller, + }, + }; + sign_jwt(signer, &claims) } pub fn parse(token: &ServiceJwt) -> Result { @@ -511,6 +595,42 @@ mod tests { assert_ne!(nonce, JwtNonce::new("other").unwrap()); } + #[test] + fn repo_token_expiry_should_be_bounded_to_given_mint() { + let now = UnixSeconds::new(1_000); + assert_eq!( + RepoTokenExpiry::mint(1_060, now).unwrap().get(), + UnixSeconds::new(1_060) + ); + assert_eq!( + RepoTokenExpiry::mint(1_001, now).unwrap().get(), + UnixSeconds::new(1_001) + ); + assert!(matches!( + RepoTokenExpiry::mint(1_000, now), + Err(MintExpiryError::NotAfterMint) + )); + assert!(matches!( + RepoTokenExpiry::mint(999, now), + Err(MintExpiryError::NotAfterMint) + )); + assert!(matches!( + RepoTokenExpiry::mint(1_061, now), + Err(MintExpiryError::TooLong { lifespan: 61 }) + )); + } + + #[test] + fn entropy_ought_to_mint_hex_nonce_without_revalidating() { + let mut bytes = [0u8; 16]; + bytes[0] = 0xde; + bytes[15] = 0xff; + assert_eq!( + JwtNonce::from_entropy(bytes).as_str(), + "de0000000000000000000000000000ff" + ); + } + #[test] fn an_ed25519_issuer_key_is_unsupported() { let signing = signer(1); diff --git a/knot2/crates/knot-atproto/src/lib.rs b/knot2/crates/knot-atproto/src/lib.rs index 10073f19e..06cb5f892 100644 --- a/knot2/crates/knot-atproto/src/lib.rs +++ b/knot2/crates/knot-atproto/src/lib.rs @@ -12,7 +12,10 @@ pub use auth::{OauthAuthorizer, PointerAuth, PointerAuthorizer, ServiceAuth}; pub use identity::{ IdentityError, MintNonce, PreparedRepoDid, knot_did_document, prepare_repo_did, }; -pub use jwt::{JwtError, JwtNonce, ServiceJwt, token_audience}; +pub use jwt::{ + JwtError, JwtNonce, MintExpiryError, RepoTokenExpiry, RepoTokenRequest, ServiceJwt, + mint_repo_token, token_audience, +}; pub use plc::{PDS_SERVICE_TYPE, RepoTarget, update_operation}; pub use pointer::PointerReceipt; pub use pubkeys::{KeyParseError, parse_authorized_key}; diff --git a/knot2/crates/knot-lexicons/Cargo.toml b/knot2/crates/knot-lexicons/Cargo.toml index af9105d83..a47088037 100644 --- a/knot2/crates/knot-lexicons/Cargo.toml +++ b/knot2/crates/knot-lexicons/Cargo.toml @@ -27,7 +27,7 @@ default = ["sh_tangled"] com_atproto = [] com_bad_example = [] org_tangled = [] -sh_tangled = ["com_atproto"] +sh_tangled = ["com_atproto", "org_tangled"] streaming = [] [lints.clippy] diff --git a/knot2/crates/knot-xrpc/src/lib.rs b/knot2/crates/knot-xrpc/src/lib.rs index 5be519dbf..6472af094 100644 --- a/knot2/crates/knot-xrpc/src/lib.rs +++ b/knot2/crates/knot-xrpc/src/lib.rs @@ -18,6 +18,7 @@ mod locks; pub mod materialize; mod members; mod merge; +mod mint; mod out; mod patchtext; pub mod plc; @@ -392,6 +393,7 @@ pub fn router(state: Arc>) -> Router post(repos::set_contribution_policy::), ) .route(repos::RESERVE_ROUTE, post(repos::reserve_key::)) + .route(mint::MINT_TOKEN_ROUTE, post(mint::mint_token::)) .route( branches::SET_DEFAULT_ROUTE, post(branches::set_default_branch::), diff --git a/knot2/crates/knot-xrpc/src/mint.rs b/knot2/crates/knot-xrpc/src/mint.rs new file mode 100644 index 000000000..efd22ddc2 --- /dev/null +++ b/knot2/crates/knot-xrpc/src/mint.rs @@ -0,0 +1,88 @@ +use std::sync::Arc; + +use axum::Json; +use axum::body::Bytes; +use axum::extract::State; +use axum::http::HeaderMap; +use axum::response::{IntoResponse, Response}; +use knot_runtime::{Clock, HttpTransport}; +use knot_types::{DidService, Nsid, RepoDid}; + +use crate::XrpcState; +use crate::authorize_push; +use crate::decode; +use crate::error::XrpcError; +use crate::reads::require_hosted; +use knot_atproto::{JwtNonce, RepoTokenExpiry}; + +pub(crate) const MINT_TOKEN_ROUTE: &str = "/xrpc/org.tangled.repo.mintToken"; + +const MINT_DENIED: &str = "A repo token grants the same authority as a push, and \ + you aren't on the roster; please ask the repo owner to add you as a collaborator, \ + or a knot admin to act for you."; + +#[derive(serde::Deserialize)] +struct MintTokenInput { + repo: RepoDid, + aud: DidService, + lxm: Nsid, + exp: i64, +} + +#[derive(serde::Serialize)] +struct MintTokenOutput { + token: knot_atproto::ServiceJwt, +} + +pub(crate) async fn mint_token( + State(state): State>>, + headers: HeaderMap, + method: crate::Method, + body: Bytes, +) -> Result { + let MintTokenInput { + repo, + aud, + lxm, + exp, + } = decode(&body)?; + let repo = require_hosted(&state, repo)?; + let actor = state + .authenticate_write_for_repo(&headers, &method, &repo) + .await?; + authorize_push(&state, &actor, &repo, MINT_DENIED).await?; + + let now = state.now(); + let expires_at = RepoTokenExpiry::mint(exp, now) + .map_err(|rejected| XrpcError::invalid_request(rejected.to_string()))?; + + let mut entropy = [0u8; 16]; + state.entropy.fill(&mut entropy); + let nonce = JwtNonce::from_entropy(entropy); + + let signer = state.secrets.signer(&state.knot_did)?; + let token = knot_atproto::mint_repo_token( + &signer, + knot_atproto::RepoTokenRequest { + repo: &repo, + audience: &aud, + method: &lxm, + nonce: &nonce, + controller: &actor, + now, + expires_at, + }, + ); + + tracing::info!( + route = MINT_TOKEN_ROUTE, + repo = repo.as_str(), + actor = actor.as_str(), + jti = nonce.as_str(), + aud = aud.as_str(), + lxm = lxm.as_str(), + "minted a repo token" + ); + + Ok(Json(MintTokenOutput { token }).into_response()) +} diff --git a/knot2/crates/knot-xrpc/src/social.rs b/knot2/crates/knot-xrpc/src/social.rs index f7940aec7..465aeee04 100644 --- a/knot2/crates/knot-xrpc/src/social.rs +++ b/knot2/crates/knot-xrpc/src/social.rs @@ -2069,8 +2069,10 @@ async fn patch_open( let collection = patch_collection(); let parsed = typed::(record, &collection)?.parse(&collection)?; let session = session(state, headers, method, repo).await?; - let ticket_address = - RecordAddress::new(ticket_collection(), RecordRkey::minted(parsed.ticket.clone())); + let ticket_address = RecordAddress::new( + ticket_collection(), + RecordRkey::minted(parsed.ticket.clone()), + ); let record = PatchRecord::new( parsed.ticket.clone(), parsed.range, diff --git a/knot2/crates/knot-xrpc/tests/common/mod.rs b/knot2/crates/knot-xrpc/tests/common/mod.rs index 45f53da86..38a304116 100644 --- a/knot2/crates/knot-xrpc/tests/common/mod.rs +++ b/knot2/crates/knot-xrpc/tests/common/mod.rs @@ -623,6 +623,16 @@ impl World { self.collaborator(repo, change, at); } + pub fn remove_collaborator(&self, repo: &RepoDid, subject: &AccountDid, at: i64) { + self.collaborator( + repo, + CollaboratorsChange::Remove(Removal { + subject: subject.clone(), + }), + at, + ); + } + fn member(&self, change: MembersChange, at: i64) { self.on_meta::(change, at); self.state.index.refresh_members().unwrap(); diff --git a/knot2/crates/knot-xrpc/tests/mint.rs b/knot2/crates/knot-xrpc/tests/mint.rs new file mode 100644 index 000000000..e2b39af6d --- /dev/null +++ b/knot2/crates/knot-xrpc/tests/mint.rs @@ -0,0 +1,203 @@ +mod common; + +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use http::StatusCode; + +use knot_atproto::ServiceJwt; +use knot_types::service_auth::{PublicKey, parse_jwt, verify_signature}; +use knot_types::{AccountDid, DidService, Nsid, RepoDid}; + +use common::{OWNER, World, empty_repo, post_as, post_authed}; + +const MINT: &str = "/xrpc/org.tangled.repo.mintToken"; + +fn owner() -> AccountDid { + AccountDid::new(OWNER).expect("OWNER parses as an account DID") +} + +fn stranger() -> AccountDid { + AccountDid::new("did:plc:stranger").expect("the stranger DID parses") +} + +fn collaborator() -> AccountDid { + AccountDid::new("did:plc:limpet").expect("the collaborator DID parses") +} + +fn foreign_repo() -> RepoDid { + RepoDid::new("did:plc:not_hosted_anywhere").expect("the foreign repo DID parses") +} + +fn spindle() -> DidService { + DidService::new_owned("did:web:spindle.test").expect("the spindle fixture DID parses") +} + +fn secret_method() -> Nsid { + Nsid::new_owned("org.tangled.secret.addSecret").expect("the secret method NSID parses") +} + +fn mint_body(repo: &RepoDid, exp: i64) -> serde_json::Value { + serde_json::json!({ + "repo": repo, + "aud": spindle(), + "lxm": secret_method(), + "exp": exp, + }) +} + +fn mint_as( + world: &World, + repo: &RepoDid, + actor: &AccountDid, + exp: i64, +) -> (StatusCode, serde_json::Value) { + let body = mint_body(repo, exp); + futures::executor::block_on(post_as(world, MINT, actor.as_str(), repo.as_str(), body)) +} + +fn token_in(response: &serde_json::Value) -> ServiceJwt { + ServiceJwt::new(response["token"].as_str().expect("The mint returns a token.")) + .expect("This minted token has three JWT segments.") +} + +fn claims_of(token: &ServiceJwt) -> serde_json::Value { + let payload = token + .as_str() + .split('.') + .nth(1) + .expect("This token has a payload segment."); + serde_json::from_slice( + &URL_SAFE_NO_PAD + .decode(payload) + .expect("This payload is base64url."), + ) + .expect("This payload is JSON.") +} + +#[tokio::test] +async fn owner_mint_should_be_signed_by_knot_key_for_repo() { + let world = World::new(); + let (repo, _bare, _work) = empty_repo(&world, "kelp"); + + let (status, response) = mint_as(&world, &repo, &owner(), 1_030); + assert_eq!(status, StatusCode::OK, "{response}"); + let token = token_in(&response); + + let parsed = parse_jwt(token.as_str()).expect("This minted token parsez as a JWT."); + let signer = world.state.secrets.signer(&world.state.knot_did).unwrap(); + let key = PublicKey::from_k256_bytes(knot_runtime::Signer::public_key(&signer).as_bytes()) + .expect("The knot key converts to a verifying key."); + verify_signature(&parsed, &key).expect("This repo token verifies under the knot's key."); + + let claims = claims_of(&token); + assert_eq!(claims["iss"], repo.as_str()); + assert_eq!(claims["aud"], spindle().as_str()); + assert_eq!(claims["lxm"], secret_method().as_str()); + assert_eq!(claims["iat"], 1_000); + assert_eq!(claims["exp"], 1_030); + assert_eq!(claims["act"]["sub"], owner().as_str()); + assert!(!claims["jti"].as_str().unwrap().is_empty()); + + let (_, second) = mint_as(&world, &repo, &owner(), 1_030); + assert_ne!( + claims_of(&token_in(&second))["jti"], + claims["jti"], + "Every mint gets its own JTI." + ); +} + +#[tokio::test] +async fn stranger_mint_ought_to_return_403_mentioning_roster() { + let world = World::new(); + let (repo, _bare, _work) = empty_repo(&world, "kelp"); + + let (status, response) = mint_as(&world, &repo, &stranger(), 1_030); + assert_eq!(status, StatusCode::FORBIDDEN, "{response}"); + let message = response["message"] + .as_str() + .expect("This 403 has a message."); + assert!(message.contains("roster"), "{message}"); + assert!(message.contains("collaborator"), "{message}"); +} + +#[tokio::test] +async fn freshly_revoked_collaborator_must_not_be_able_to_mint() { + let world = World::new(); + let (repo, _bare, _work) = empty_repo(&world, "kelp"); + let collaborator = collaborator(); + world.add_collaborator(&repo, collaborator.as_str(), owner().as_str(), 1_000); + + let (admitted, _) = mint_as(&world, &repo, &collaborator, 1_030); + assert_eq!(admitted, StatusCode::OK); + + world.remove_collaborator(&repo, &collaborator, 1_001); + let (status, response) = mint_as(&world, &repo, &collaborator, 1_030); + assert_eq!(status, StatusCode::FORBIDDEN, "{response}"); + assert!( + response["message"] + .as_str() + .expect("This 403 has a message.") + .contains("roster"), + "{response}" + ); +} + +#[tokio::test] +async fn out_of_bounds_exp_should_be_refused() { + let world = World::new(); + let (repo, _bare, _work) = empty_repo(&world, "kelp"); + + for (exp, want) in [(999, "future"), (1_100, "60")] { + let (status, response) = mint_as(&world, &repo, &owner(), exp); + assert_eq!(status, StatusCode::BAD_REQUEST, "{response}"); + let message = response["message"].as_str().unwrap_or_default(); + assert!(message.contains(want), "exp {exp}: {message}"); + } +} + +#[tokio::test] +async fn mint_without_lxm_ought_to_be_refused() { + let world = World::new(); + let (repo, _bare, _work) = empty_repo(&world, "kelp"); + + let body = serde_json::json!({ + "repo": repo, + "aud": spindle(), + "exp": 1_030, + }); + let (status, response) = + futures::executor::block_on(post_as(&world, MINT, owner().as_str(), repo.as_str(), body)); + assert_eq!(status, StatusCode::BAD_REQUEST, "{response}"); +} + +#[tokio::test] +async fn calling_session_should_be_addressed_to_repo() { + let world = World::new(); + let (repo, _bare, _work) = empty_repo(&world, "kelp"); + + let body = mint_body(&repo, 1_030); + let (status, response) = + futures::executor::block_on(post_authed(&world, MINT, owner().as_str(), body)); + assert_eq!(status, StatusCode::UNAUTHORIZED, "{response}"); +} + +#[tokio::test] +async fn mint_for_unhosted_repo_should_return_404() { + let world = World::new(); + let _ = empty_repo(&world, "kelp"); + + let body = serde_json::json!({ + "repo": foreign_repo(), + "aud": spindle(), + "lxm": secret_method(), + "exp": 1_030, + }); + let (status, response) = futures::executor::block_on(post_as( + &world, + MINT, + owner().as_str(), + foreign_repo().as_str(), + body, + )); + assert_eq!(status, StatusCode::NOT_FOUND, "{response}"); +} \ No newline at end of file diff --git a/knot2/crates/knot-xrpc/tests/reads.rs b/knot2/crates/knot-xrpc/tests/reads.rs index 8d6c2b57b..72f47f0e6 100644 --- a/knot2/crates/knot-xrpc/tests/reads.rs +++ b/knot2/crates/knot-xrpc/tests/reads.rs @@ -2021,6 +2021,7 @@ async fn service_metadata_endpoints_answer() { Capability::KnotAcl, Capability::RepoDidInput, Capability::AtprotoFirehose, + Capability::RepoTokenMint, Capability::SocialWrites, ] .map(Capability::token); diff --git a/lexicons/org/tangled/repo/mintToken.json b/lexicons/org/tangled/repo/mintToken.json new file mode 100644 index 000000000..c9c95c288 --- /dev/null +++ b/lexicons/org/tangled/repo/mintToken.json @@ -0,0 +1,59 @@ +{ + "lexicon": 1, + "id": "org.tangled.repo.mintToken", + "defs": { + "main": { + "type": "procedure", + "description": "Mint a service-auth JWT whose issuer is this repo's DID, signed by the knot that owns the repo's signing key. The session calling it needs what a push needs: the repo owner, an accepted collaborator, or a knot admin. The token puts the caller in its act claim, for audit at the receiving service; authorization there reads the issuer alone.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": [ + "repo", + "aud", + "lxm", + "exp" + ], + "properties": { + "repo": { + "type": "string", + "format": "did", + "description": "DID of the repo that becomes the token's issuer. Must equal the audience of the calling session." + }, + "aud": { + "type": "string", + "format": "did", + "description": "Audience of the minted token: a DID service reference for the service the token is addressed to. The knot checks only well-formedness, as a PDS does." + }, + "lxm": { + "type": "string", + "format": "nsid", + "description": "Lexicon method the minted token binds to." + }, + "exp": { + "type": "integer", + "format": "int64", + "description": "Unix seconds. Must be in the future and at most 60 seconds past the minting." + } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": [ + "token" + ], + "properties": { + "token": { + "type": "string", + "description": "The signed service-auth JWT." + } + } + } + } + } + } +} diff --git a/web/src/lib/api/lexicons/types/org/tangled/repo/mintToken.ts b/web/src/lib/api/lexicons/types/org/tangled/repo/mintToken.ts new file mode 100644 index 000000000..1ecb96e6e --- /dev/null +++ b/web/src/lib/api/lexicons/types/org/tangled/repo/mintToken.ts @@ -0,0 +1,53 @@ +import type {} from "@atcute/lexicons"; +import * as v from "@atcute/lexicons/validations"; +import type {} from "@atcute/lexicons/ambient"; + +const _mainSchema = /*#__PURE__*/ v.procedure("org.tangled.repo.mintToken", { + params: null, + input: { + type: "lex", + schema: /*#__PURE__*/ v.object({ + /** + * Audience of the minted token: a DID service reference for the service the token is addressed to. The knot checks only well-formedness, as a PDS does. + */ + aud: /*#__PURE__*/ v.didString(), + /** + * Unix seconds. Must be in the future and at most 60 seconds past the minting. + */ + exp: /*#__PURE__*/ v.integer(), + /** + * Lexicon method the minted token binds to. + */ + lxm: /*#__PURE__*/ v.nsidString(), + /** + * DID of the repo that becomes the token's issuer. Must equal the audience of the calling session. + */ + repo: /*#__PURE__*/ v.didString(), + }), + }, + output: { + type: "lex", + schema: /*#__PURE__*/ v.object({ + /** + * The signed service-auth JWT. + */ + token: /*#__PURE__*/ v.string(), + }), + }, +}); + +type main$schematype = typeof _mainSchema; + +export interface mainSchema extends main$schematype {} + +export const mainSchema = _mainSchema as mainSchema; + +export interface $params {} +export interface $input extends v.InferXRPCBodyInput {} +export interface $output extends v.InferXRPCBodyInput {} + +declare module "@atcute/lexicons/ambient" { + interface XRPCProcedures { + "org.tangled.repo.mintToken": mainSchema; + } +} -- 2.51.2