jcp-bridge: an OAuth access token narrows to the grant's caveats or is not minted, and a delegated grant needs its chain #321
1 changed files with 175 additions and 17 deletions
|
|
@ -22,7 +22,18 @@
|
||||||
//! It is an interop projection, not a second source of truth: the grant is
|
//! 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
|
//! 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
|
//! 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
|
//! debit, receipts) still lives in the JCP runtime; this is the access token
|
||||||
//! that gets the grant *through the OAuth front door*.
|
//! 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.
|
/// a mix-up attack, or a misconfiguration that must be treated as one.
|
||||||
#[error("authorization response issuer {got} is not the expected {expected}")]
|
#[error("authorization response issuer {got} is not the expected {expected}")]
|
||||||
AuthorizationIssuerMixUp { expected: String, got: String },
|
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.
|
/// 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
|
/// Project a verified root [`SignedGrant`] into an RFC 9068 access-token JWT,
|
||||||
/// `EdDSA` by the grant **issuer**. `issued_at` is the `iat`/`nbf` (unix
|
/// signed `EdDSA` by the grant **issuer**. `issued_at` is the `iat` (unix
|
||||||
/// seconds; passed in so this stays deterministic). `ttl_secs` caps the token
|
/// seconds; passed in so this stays deterministic). `ttl_secs` caps the token
|
||||||
/// lifetime so it never outlives the grant *and* stays short-lived.
|
/// 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(
|
pub fn grant_to_access_token(
|
||||||
sg: &SignedGrant,
|
sg: &SignedGrant,
|
||||||
issuer_sk: &SigningKey,
|
issuer_sk: &SigningKey,
|
||||||
issued_at: u64,
|
issued_at: u64,
|
||||||
ttl_secs: u64,
|
ttl_secs: u64,
|
||||||
) -> Result<String, OAuthError> {
|
) -> Result<String, OAuthError> {
|
||||||
|
if sg.grant.parent.is_some() {
|
||||||
|
return Err(OAuthError::Delegated);
|
||||||
|
}
|
||||||
// Only mint a token for a grant that actually verifies.
|
// Only mint a token for a grant that actually verifies.
|
||||||
jcp_grant::verify(sg).map_err(|e| OAuthError::Grant(format!("{e:?}")))?;
|
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());
|
let issuer = Did::from_verifying_key(&issuer_sk.verifying_key());
|
||||||
if issuer != sg.grant.issuer {
|
if issuer != leaf.issuer {
|
||||||
return Err(OAuthError::IssuerMismatch);
|
return Err(OAuthError::IssuerMismatch);
|
||||||
}
|
}
|
||||||
|
|
||||||
let g = &sg.grant;
|
let mut exp = issued_at.saturating_add(ttl_secs);
|
||||||
let grant_exp = if g.expiry == 0 { u64::MAX } else { g.expiry };
|
let mut nbf = issued_at;
|
||||||
let exp = grant_exp.min(issued_at.saturating_add(ttl_secs));
|
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 header = json!({ "alg": ALG_EDDSA, "typ": TYP_AT_JWT });
|
||||||
let claims = json!({
|
let claims = json!({
|
||||||
"iss": did_key(&g.issuer),
|
"iss": did_key(&leaf.issuer),
|
||||||
"sub": did_key(&g.subject),
|
"sub": did_key(&leaf.subject),
|
||||||
"client_id": did_key(&g.subject),
|
"client_id": did_key(&leaf.subject),
|
||||||
"aud": g.capability.resource.to_hex(),
|
"aud": leaf.capability.resource.to_hex(),
|
||||||
"iat": issued_at,
|
"iat": issued_at,
|
||||||
"nbf": issued_at,
|
"nbf": nbf,
|
||||||
"exp": exp,
|
"exp": exp,
|
||||||
"jti": hex(&g.nonce),
|
"jti": hex(&leaf.nonce),
|
||||||
"scope": g.capability.actions.join(" "),
|
"scope": scope.join(" "),
|
||||||
CLAIM_JOULE_BUDGET: g.budget_uj,
|
CLAIM_JOULE_BUDGET: leaf.budget_uj,
|
||||||
});
|
});
|
||||||
|
|
||||||
let signing_input = format!(
|
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
|
/// 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.
|
/// server reads `scope`, `aud`, and `joule_budget_uj` to enforce the grant.
|
||||||
pub fn verify_access_token(
|
pub fn verify_access_token(
|
||||||
token: &str,
|
token: &str,
|
||||||
|
|
@ -194,6 +277,9 @@ pub fn verify_access_token(
|
||||||
if now > exp {
|
if now > exp {
|
||||||
return Err(OAuthError::Expired);
|
return Err(OAuthError::Expired);
|
||||||
}
|
}
|
||||||
|
if now < claims.get("nbf").and_then(Value::as_u64).unwrap_or(0) {
|
||||||
|
return Err(OAuthError::NotYetValid);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(claims)
|
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]
|
#[test]
|
||||||
fn minting_with_non_issuer_key_is_refused() {
|
fn minting_with_non_issuer_key_is_refused() {
|
||||||
let sk = SigningKey::from_bytes(&[9u8; 32]);
|
let sk = SigningKey::from_bytes(&[9u8; 32]);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue