jcp-bridge: an OAuth access token narrows to the grant's caveats or is not minted, and a delegated grant needs its chain #321

Merged
dcharlot merged 1 commit from fix/jcp-oauth-projects-caveats into main 2026-10-18 19:02:29 -04:00

View file

@ -22,7 +22,18 @@
//! It is an interop projection, not a second source of truth: the grant is
//! verified before minting, the issuer's own key signs the JWT, and a resource
//! server enforces `scope`/`aud`/`exp`/`joule_budget_uj` with off-the-shelf JWT
//! tooling. The full JCP enforcement (attenuation chains, revocation, metered
//! tooling.
//!
//! **A projection may narrow, never widen.** A grant's caveats (JCP §4.9) are
//! conditions the token must not lose, because a resource server enforcing the
//! token enforces only what the token says. Those a standard claim can carry
//! are folded into it as a narrowing: `NotAfter` lowers `exp`, `NotBefore`
//! raises `nbf`, `ActionIn` intersects `scope`. For the rest (`PayloadIs`,
//! `MaxCostUj`, `ModelIn`, a flow ceiling, a `CallerIs` naming anyone but the
//! subject) and for a money ceiling, no claim a resource server is known to
//! enforce exists, so no token is minted. A delegated grant is projected only
//! with its chain ([`chain_to_access_token`]), verified hop by hop, so its
//! attenuation is checked rather than taken from the leaf. The full JCP enforcement (attenuation chains, revocation, metered
//! debit, receipts) still lives in the JCP runtime; this is the access token
//! that gets the grant *through the OAuth front door*.
@ -70,6 +81,20 @@ pub enum OAuthError {
/// a mix-up attack, or a misconfiguration that must be treated as one.
#[error("authorization response issuer {got} is not the expected {expected}")]
AuthorizationIssuerMixUp { expected: String, got: String },
/// The grant carries a condition no access-token claim can carry, so a
/// token would convey more than the grant: none is minted.
#[error("the grant's {0} has no access-token claim, so no token is minted")]
Unprojectable(&'static str),
/// The grant's conditions leave nothing a token could permit: no action, or
/// no instant at which it is valid.
#[error("the grant permits nothing a token could carry: {0}")]
PermitsNothing(&'static str),
/// A delegated grant is projected only with its chain.
#[error("a delegated grant is projected with its chain (chain_to_access_token)")]
Delegated,
/// `now < nbf`: the token is not yet valid.
#[error("access token not yet valid")]
NotYetValid,
}
/// The RFC 9207 authorization-response parameter naming the issuing server.
@ -110,40 +135,98 @@ pub fn check_authorization_issuer(
}
}
/// Project a verified [`SignedGrant`] into an RFC 9068 access-token JWT, signed
/// `EdDSA` by the grant **issuer**. `issued_at` is the `iat`/`nbf` (unix
/// Project a verified root [`SignedGrant`] into an RFC 9068 access-token JWT,
/// signed `EdDSA` by the grant **issuer**. `issued_at` is the `iat` (unix
/// seconds; passed in so this stays deterministic). `ttl_secs` caps the token
/// lifetime so it never outlives the grant *and* stays short-lived.
///
/// The grant's caveats narrow the token, or refuse it (see the module docs). A
/// delegated grant is refused here: project it with [`chain_to_access_token`].
pub fn grant_to_access_token(
sg: &SignedGrant,
issuer_sk: &SigningKey,
issued_at: u64,
ttl_secs: u64,
) -> Result<String, OAuthError> {
if sg.grant.parent.is_some() {
return Err(OAuthError::Delegated);
}
// Only mint a token for a grant that actually verifies.
jcp_grant::verify(sg).map_err(|e| OAuthError::Grant(format!("{e:?}")))?;
// The signer must be the grant's issuer (the authorization server).
project(std::slice::from_ref(sg), issuer_sk, issued_at, ttl_secs)
}
/// Project a delegation chain, root first, into an access token for its leaf,
/// signed by the leaf's issuer. The chain is verified hop by hop (JCP §4.2),
/// every link's expiry bounds `exp`, and every link's caveats narrow the token
/// or refuse it, so the token conveys no more than the chain does.
pub fn chain_to_access_token(
chain: &[SignedGrant],
issuer_sk: &SigningKey,
issued_at: u64,
ttl_secs: u64,
) -> Result<String, OAuthError> {
jcp_grant::verify_chain(chain).map_err(|e| OAuthError::Grant(format!("{e:?}")))?;
project(chain, issuer_sk, issued_at, ttl_secs)
}
/// The projection itself, over a verified chain (a root alone is a chain of one).
fn project(chain: &[SignedGrant], issuer_sk: &SigningKey, issued_at: u64, ttl_secs: u64) -> Result<String, OAuthError> {
let leaf = &chain.last().ok_or(OAuthError::Grant("empty chain".into()))?.grant;
// The signer must be the leaf's issuer (the authorization server).
let issuer = Did::from_verifying_key(&issuer_sk.verifying_key());
if issuer != sg.grant.issuer {
if issuer != leaf.issuer {
return Err(OAuthError::IssuerMismatch);
}
let g = &sg.grant;
let grant_exp = if g.expiry == 0 { u64::MAX } else { g.expiry };
let exp = grant_exp.min(issued_at.saturating_add(ttl_secs));
let mut exp = issued_at.saturating_add(ttl_secs);
let mut nbf = issued_at;
let mut scope: Vec<String> = leaf.capability.actions.clone();
for link in chain {
let g = &link.grant;
if g.expiry != 0 {
exp = exp.min(g.expiry);
}
// A money ceiling: no claim a resource server is known to enforce.
if g.budget_funds.is_some() {
return Err(OAuthError::Unprojectable("money ceiling"));
}
for c in &g.caveats {
match c {
jcp_schema::Caveat::NotAfter(t) => exp = exp.min(*t),
jcp_schema::Caveat::NotBefore(t) => nbf = nbf.max(*t),
jcp_schema::Caveat::ActionIn(set) => scope.retain(|a| set.contains(a)),
// The runtime requires the caller to be the subject, and `sub`
// already names the subject: a CallerIs naming it adds nothing,
// and one naming anyone else is a grant no caller can use.
jcp_schema::Caveat::CallerIs(d) if *d == leaf.subject => {}
jcp_schema::Caveat::CallerIs(_) => return Err(OAuthError::PermitsNothing("a caller other than the subject")),
jcp_schema::Caveat::PayloadIs(_) => return Err(OAuthError::Unprojectable("PayloadIs caveat")),
jcp_schema::Caveat::MaxCostUj(_) => return Err(OAuthError::Unprojectable("MaxCostUj caveat")),
jcp_schema::Caveat::ModelIn(_) => return Err(OAuthError::Unprojectable("ModelIn caveat")),
jcp_schema::Caveat::FlowBelow(_) => return Err(OAuthError::Unprojectable("flow ceiling")),
}
}
}
if scope.is_empty() {
return Err(OAuthError::PermitsNothing("no action"));
}
if nbf > exp {
return Err(OAuthError::PermitsNothing("no instant at which it is valid"));
}
let header = json!({ "alg": ALG_EDDSA, "typ": TYP_AT_JWT });
let claims = json!({
"iss": did_key(&g.issuer),
"sub": did_key(&g.subject),
"client_id": did_key(&g.subject),
"aud": g.capability.resource.to_hex(),
"iss": did_key(&leaf.issuer),
"sub": did_key(&leaf.subject),
"client_id": did_key(&leaf.subject),
"aud": leaf.capability.resource.to_hex(),
"iat": issued_at,
"nbf": issued_at,
"nbf": nbf,
"exp": exp,
"jti": hex(&g.nonce),
"scope": g.capability.actions.join(" "),
CLAIM_JOULE_BUDGET: g.budget_uj,
"jti": hex(&leaf.nonce),
"scope": scope.join(" "),
CLAIM_JOULE_BUDGET: leaf.budget_uj,
});
let signing_input = format!(
@ -156,7 +239,7 @@ pub fn grant_to_access_token(
}
/// Verify an access-token JWT against the issuer's Ed25519 public key and (if
/// `now > 0`) its expiry. Returns the decoded claims on success — a resource
/// `now > 0`) its validity window, `nbf` to `exp`. Returns the decoded claims on success — a resource
/// server reads `scope`, `aud`, and `joule_budget_uj` to enforce the grant.
pub fn verify_access_token(
token: &str,
@ -194,6 +277,9 @@ pub fn verify_access_token(
if now > exp {
return Err(OAuthError::Expired);
}
if now < claims.get("nbf").and_then(Value::as_u64).unwrap_or(0) {
return Err(OAuthError::NotYetValid);
}
}
Ok(claims)
}
@ -432,6 +518,78 @@ mod tests {
));
}
fn caveated(sk: &SigningKey, caveats: Vec<jcp_schema::Caveat>) -> SignedGrant {
let mut g = signed_grant(sk).grant;
g.caveats = caveats;
issue(sk, g).unwrap()
}
/// A caveat a standard claim can carry narrows that claim: the token's
/// window shrinks to the caveat's and its scope to the permitted actions.
#[test]
fn caveats_narrow_the_token() {
use jcp_schema::Caveat;
let sk = SigningKey::from_bytes(&[9u8; 32]);
let key = sk.verifying_key().to_bytes();
let sg = caveated(&sk, vec![Caveat::NotBefore(1_200), Caveat::NotAfter(2_000), Caveat::ActionIn(vec!["read".into()])]);
let tok = grant_to_access_token(&sg, &sk, 1_000, 3_600).unwrap();
let claims = verify_access_token(&tok, &key, 1_500).unwrap();
assert_eq!((claims["nbf"].as_u64(), claims["exp"].as_u64()), (Some(1_200), Some(2_000)));
assert_eq!(claims["scope"], "read", "write is not permitted");
assert!(matches!(verify_access_token(&tok, &key, 1_100), Err(OAuthError::NotYetValid)));
assert!(matches!(verify_access_token(&tok, &key, 2_001), Err(OAuthError::Expired)));
}
/// A condition no claim can carry refuses the token, as does one that
/// leaves the token nothing to permit. Before, each was minted without it.
#[test]
fn a_condition_no_claim_carries_refuses_the_token() {
use jcp_schema::{Caveat, Funds};
let sk = SigningKey::from_bytes(&[9u8; 32]);
let other = Did::from_verifying_key(&SigningKey::from_bytes(&[5u8; 32]).verifying_key());
for c in [Caveat::PayloadIs(ContentId::of_bytes(b"args")), Caveat::MaxCostUj(10), Caveat::ModelIn(vec![ContentId::of_bytes(b"m")])] {
assert!(matches!(grant_to_access_token(&caveated(&sk, vec![c]), &sk, 1_000, 3_600), Err(OAuthError::Unprojectable(_))));
}
for c in [Caveat::CallerIs(other), Caveat::ActionIn(vec!["delete".into()]), Caveat::NotBefore(9_000)] {
assert!(matches!(grant_to_access_token(&caveated(&sk, vec![c]), &sk, 1_000, 3_600), Err(OAuthError::PermitsNothing(_))));
}
let mut g = signed_grant(&sk).grant;
g.budget_funds = Some(Funds::new("USD", 100));
assert!(matches!(grant_to_access_token(&issue(&sk, g).unwrap(), &sk, 1_000, 3_600), Err(OAuthError::Unprojectable(_))));
let subject = signed_grant(&sk).grant.subject;
assert!(grant_to_access_token(&caveated(&sk, vec![Caveat::CallerIs(subject)]), &sk, 1_000, 3_600).is_ok());
}
/// A delegated grant is projected only with its chain, which is verified,
/// and every link bounds the token.
#[test]
fn a_delegated_grant_is_projected_with_its_chain() {
use jcp_schema::Caveat;
let root_sk = SigningKey::from_bytes(&[9u8; 32]);
let holder = SigningKey::from_bytes(&[2u8; 32]);
let root = caveated(&root_sk, vec![Caveat::NotAfter(2_500)]);
let child = jcp_grant::attenuate(
&root,
&holder,
Did::from_verifying_key(&SigningKey::from_bytes(&[6u8; 32]).verifying_key()),
Capability::new(resource(), ["read"]),
1_000,
0,
[8u8; 16],
)
.unwrap();
assert!(matches!(grant_to_access_token(&child, &holder, 1_000, 3_600), Err(OAuthError::Delegated)));
let tok = chain_to_access_token(&[root.clone(), child.clone()], &holder, 1_000, 3_600).unwrap();
let claims = verify_access_token(&tok, &holder.verifying_key().to_bytes(), 1_500).unwrap();
assert_eq!((claims["exp"].as_u64(), claims["scope"].as_str()), (Some(2_500), Some("read")));
assert_eq!(claims[CLAIM_JOULE_BUDGET], 1_000);
let mut stripped = child.grant.clone();
stripped.caveats.clear();
let stripped = issue(&holder, stripped).unwrap();
assert!(matches!(chain_to_access_token(&[root, stripped], &holder, 1_000, 3_600), Err(OAuthError::Grant(_))));
}
#[test]
fn minting_with_non_issuer_key_is_refused() {
let sk = SigningKey::from_bytes(&[9u8; 32]);