diff --git a/jcp/jcp-rs/crates/jcp-bridge/src/oauth.rs b/jcp/jcp-rs/crates/jcp-bridge/src/oauth.rs index 8ae31915b..5d04fc399 100644 --- a/jcp/jcp-rs/crates/jcp-bridge/src/oauth.rs +++ b/jcp/jcp-rs/crates/jcp-bridge/src/oauth.rs @@ -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 { + 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 { + 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 { + 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 = 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) -> 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]);