diff --git a/README.md b/README.md index fa81771d..8ea5f737 100644 --- a/README.md +++ b/README.md @@ -16,9 +16,9 @@ OpenID for Verifiable Credentials (OID4VC) consists of the following specificati Currently the Implicit Flow is consists of four major parts: -- A `Provider` that can accept a `SiopRequest` and generate a `SiopResponse` by creating an `IdToken`, adding its key identifier to the header of the `id_token`, signing the `id_token` and wrap it into a `SiopResponse`. It can also send the `SiopResponse` using the `redirect_uri` parameter. -- A `RelyingParty` struct which can validate a `SiopResponse` by validating its `IdToken` using a key identifier (which is extracted from the `id_token`) and its public key. -- The `Subject` trait can be implemented on a custom struct representing the signing logic of a DID method. A `Provider` can ingest an object that implements the `Subject` trait so that during generation of a `SiopResponse` the DID method syntax, key identifier and signing method of the specific `Subject` can be used. +- A `Provider` that can accept a `AuthorizationRequest` and generate a `AuthorizationResponse` by creating an `IdToken`, adding its key identifier to the header of the `id_token`, signing the `id_token` and wrap it into a `AuthorizationResponse`. It can also send the `AuthorizationResponse` using the `redirect_uri` parameter. +- A `RelyingParty` struct which can validate a `AuthorizationResponse` by validating its `IdToken` using a key identifier (which is extracted from the `id_token`) and its public key. +- The `Subject` trait can be implemented on a custom struct representing the signing logic of a DID method. A `Provider` can ingest an object that implements the `Subject` trait so that during generation of a `AuthorizationResponse` the DID method syntax, key identifier and signing method of the specific `Subject` can be used. - The `Validator` trait can be implemented on a custom struct representing the validating logic of a DID method. When ingested by a `RelyingParty`, it can resolve the public key that is needed for validating an `IdToken`. ## Example @@ -29,12 +29,12 @@ use async_trait::async_trait; use chrono::{Duration, Utc}; use ed25519_dalek::{Keypair, Signature, Signer}; use lazy_static::lazy_static; -use rand::rngs::OsRng; -use siopv2::{ - claims::{Claim, ClaimRequests}, - request::ResponseType, StandardClaim, - IdToken, Provider, Registration, RelyingParty, RequestUrl, Scope, SiopRequest, SiopResponse, Subject, Validator, +use openid4vc::{ + claims::{ClaimRequests, ClaimValue, IndividualClaimRequest}, + request::ResponseType, + Provider, Registration, RelyingParty, RequestUrl, AuthorizationResponse, Scope, AuthorizationRequest, StandardClaims, Subject, Validator, }; +use rand::rngs::OsRng; use wiremock::{ http::Method, matchers::{method, path}, @@ -102,7 +102,7 @@ async fn main() { let relying_party = RelyingParty::new(validator); // Create a new RequestUrl with response mode `post` for cross-device communication. - let request: SiopRequest = RequestUrl::builder() + let request: AuthorizationRequest = RequestUrl::builder() .response_type(ResponseType::IdToken) .client_id("did:mymethod:relyingparty".to_string()) .scope(Scope::openid()) @@ -114,8 +114,8 @@ async fn main() { .with_id_token_signing_alg_values_supported(vec!["EdDSA".to_string()]), ) .claims(ClaimRequests { - id_token: Some(StandardClaim { - name: Some(Claim::default()), + id_token: Some(StandardClaims { + name: Some(IndividualClaimRequest::default()), ..Default::default() }), ..Default::default() @@ -126,14 +126,14 @@ async fn main() { .and_then(TryInto::try_into) .unwrap(); - // Create a new `request_uri` endpoint on the mock server and load it with the JWT encoded `SiopRequest`. + // Create a new `request_uri` endpoint on the mock server and load it with the JWT encoded `AuthorizationRequest`. Mock::given(method("GET")) .and(path("/request_uri")) .respond_with(ResponseTemplate::new(200).set_body_string(relying_party.encode(&request).await.unwrap())) .mount(&mock_server) .await; - // Create a new `redirect_uri` endpoint on the mock server where the `Provider` will send the `SiopResponse`. + // Create a new `redirect_uri` endpoint on the mock server where the `Provider` will send the `AuthorizationResponse`. Mock::given(method("POST")) .and(path("/redirect_uri")) .respond_with(ResponseTemplate::new(200)) @@ -165,35 +165,35 @@ async fn main() { // Let the provider generate a response based on the validated request. The response is an `IdToken` which is // encoded as a JWT. let response = provider - .generate_response(request, StandardClaim::default()) + .generate_response( + request, + StandardClaims { + name: Some(ClaimValue("Jane Doe".to_string())), + ..Default::default() + }, + ) .await .unwrap(); // The provider sends it's response to the mock server's `redirect_uri` endpoint. provider.send_response(response).await.unwrap(); - // Assert that the SiopResponse was successfully received by the mock server at the expected endpoint. + // Assert that the AuthorizationResponse was successfully received by the mock server at the expected endpoint. let post_request = mock_server.received_requests().await.unwrap()[1].clone(); assert_eq!(post_request.method, Method::Post); assert_eq!(post_request.url.path(), "/redirect_uri"); - let response: SiopResponse = serde_urlencoded::from_bytes(post_request.body.as_slice()).unwrap(); + let response: AuthorizationResponse = serde_urlencoded::from_bytes(post_request.body.as_slice()).unwrap(); // The `RelyingParty` then validates the response by decoding the header of the id_token, by fetching the public // key corresponding to the key identifier and finally decoding the id_token using the public key and by // validating the signature. let id_token = relying_party.validate_response(&response).await.unwrap(); - let IdToken { - iss, sub, aud, nonce, .. - } = IdToken::new( - "did:mymethod:subject".to_string(), - "did:mymethod:subject".to_string(), - "did:mymethod:relyingparty".to_string(), - "n-0S6_WzA2Mj".to_string(), - (Utc::now() + Duration::minutes(10)).timestamp(), + assert_eq!( + id_token.standard_claims(), + &StandardClaims { + name: Some(ClaimValue("Jane Doe".to_string())), + ..Default::default() + } ); - assert_eq!(id_token.iss, iss); - assert_eq!(id_token.sub, sub); - assert_eq!(id_token.aud, aud); - assert_eq!(id_token.nonce, nonce); } ``` diff --git a/src/claims.rs b/src/claims.rs index 69bfa2c9..6828880b 100644 --- a/src/claims.rs +++ b/src/claims.rs @@ -1,14 +1,33 @@ -use crate::scope::{Scope, ScopeValue}; +use crate::{ + parse_other, + scope::{Scope, ScopeValue}, +}; use serde::{Deserialize, Deserializer, Serialize}; use serde_with::skip_serializing_none; -/// Functions as the `claims` parameter inside a [`crate::SiopRequest`]. +/// Functions as the `claims` parameter inside a [`crate::AuthorizationRequest`]. #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ClaimRequests { pub user_claims: Option, pub id_token: Option, } +impl TryFrom for ClaimRequests { + type Error = anyhow::Error; + + fn try_from(value: serde_json::Value) -> Result { + serde_json::from_value(value).map_err(Into::into) + } +} + +impl TryFrom<&str> for ClaimRequests { + type Error = anyhow::Error; + + fn try_from(value: &str) -> Result { + serde_json::from_str(value).map_err(Into::into) + } +} + mod sealed { /// [`Claim`] trait that is implemented by both [`ClaimValue`] and [`ClaimRequest`]. pub trait Claim { @@ -82,19 +101,6 @@ impl IndividualClaimRequest { object_member!(other, serde_json::Map); } -// When a struct has fields of type `Option>`, by default these fields are deserialized as -// `Some(Object {})` instead of None when the corresponding values are missing. -// The `parse_other()` helper function ensures that these fields are deserialized as `None` when no value is present. -fn parse_other<'de, D>(deserializer: D) -> Result>, D::Error> -where - D: Deserializer<'de>, -{ - serde_json::Value::deserialize(deserializer).map(|value| match value { - serde_json::Value::Object(object) if !object.is_empty() => Some(object), - _ => None, - }) -} - /// An individual claim request as defined in [OpenID Connect Core 1.0, section 5.5.1](https://openid.net/specs/openid-connect-core-1_0.html#IndividualClaimsRequests). /// Individual claims can be requested by simply some key with a `null` value, or by using the `essential`, `value`, /// and `values` fields. Additional information about the requested claim MAY be added to the claim request. This @@ -128,7 +134,7 @@ pub type StandardClaimsValues = StandardClaims>; /// This struct represents the standard claims as defined in the /// [OpenID Connect Core 1.0 Specification](https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims) /// specification. It can be used either for requesting claims using [`IndividualClaimRequest`]'s in the `claims` -/// parameter of a [`crate::SiopRequest`], or for returning actual [`ClaimValue`]'s in an [`crate::IdToken`]. +/// parameter of a [`crate::AuthorizationRequest`], or for returning actual [`ClaimValue`]'s in an [`crate::IdToken`]. #[skip_serializing_none] #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] diff --git a/src/id_token.rs b/src/id_token.rs deleted file mode 100644 index 16edf7de..00000000 --- a/src/id_token.rs +++ /dev/null @@ -1,46 +0,0 @@ -use crate::{claims::StandardClaims, StandardClaimsValues}; -use chrono::Utc; -use getset::Setters; -use serde::{Deserialize, Serialize}; - -// TODO: make fully feature complete and implement builder pattern: https://github.com/impierce/siopv2/issues/20 -/// An SIOPv2 [`IdToken`] as specified in the [SIOPv2 specification](https://openid.net/specs/openid-connect-self-issued-v2-1_0.html#name-self-issued-id-token). -#[derive(Serialize, Deserialize, Debug, Setters, Default, PartialEq)] -pub struct IdToken { - pub iss: String, - // TODO: sub should be part of the standard claims? - pub sub: String, - #[getset(set)] - #[serde(flatten)] - pub standard_claims: StandardClaimsValues, - pub aud: String, - pub exp: i64, - pub iat: i64, - pub nonce: String, - pub state: Option, -} - -impl IdToken { - pub fn new(iss: String, sub: String, aud: String, nonce: String, exp: i64) -> Self { - IdToken { - iss, - sub, - standard_claims: StandardClaims::default(), - aud, - exp, - iat: Utc::now().timestamp(), - nonce, - state: None, - } - } - - pub fn state(mut self, state: Option) -> Self { - self.state = state; - self - } - - pub fn claims(mut self, claims: StandardClaimsValues) -> Self { - self.standard_claims = claims; - self - } -} diff --git a/src/key_method.rs b/src/key_method.rs index 6af076b9..c5ded5b1 100644 --- a/src/key_method.rs +++ b/src/key_method.rs @@ -91,8 +91,7 @@ async fn resolve_public_key(kid: &str) -> Result> { #[cfg(test)] mod tests { use super::*; - use crate::{IdToken, Provider, RelyingParty}; - use chrono::{Duration, Utc}; + use crate::{Provider, RelyingParty}; #[tokio::test] async fn test_key_subject() { @@ -124,17 +123,6 @@ mod tests { // Let the relying party validate the response. let relying_party = RelyingParty::new(KeySubject::new()); - let id_token = relying_party.validate_response(&response).await.unwrap(); - - let IdToken { aud, nonce, .. } = IdToken::new( - "".to_string(), - "".to_string(), - "did:key:z6MkiTcXZ1JxooACo99YcfkugH6Kifzj7ZupSDCmLEABpjpF".to_string(), - "n-0S6_WzA2Mj".to_string(), - (Utc::now() + Duration::minutes(10)).timestamp(), - ); - assert_eq!(id_token.iss, id_token.sub); - assert_eq!(id_token.aud, aud); - assert_eq!(id_token.nonce, nonce); + assert!(relying_party.validate_response(&response).await.is_ok()); } } diff --git a/src/lib.rs b/src/lib.rs index e7916759..1ae36462 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,29 +1,60 @@ pub mod claims; -pub mod id_token; pub mod jwt; pub mod key_method; pub mod provider; pub mod registration; pub mod relying_party; pub mod request; -pub mod request_builder; pub mod response; pub mod scope; pub mod subject; +pub mod token; pub mod validator; -pub use claims::{StandardClaimsRequests, StandardClaimsValues}; -pub use id_token::IdToken; +pub use claims::{ClaimRequests, StandardClaimsRequests, StandardClaimsValues}; pub use jwt::JsonWebToken; pub use provider::Provider; pub use registration::Registration; pub use relying_party::RelyingParty; -pub use request::{RequestUrl, SiopRequest}; -pub use request_builder::RequestUrlBuilder; -pub use response::SiopResponse; +pub use request::{request_builder::RequestUrlBuilder, AuthorizationRequest, RequestUrl}; +pub use response::AuthorizationResponse; pub use scope::Scope; pub use subject::Subject; +pub use token::{id_token::IdToken, id_token_builder::IdTokenBuilder}; pub use validator::Validator; +use serde::{Deserialize, Deserializer}; + #[cfg(test)] pub mod test_utils; + +#[macro_export] +macro_rules! builder_fn { + ($name:ident, $ty:ty) => { + #[allow(clippy::should_implement_trait)] + pub fn $name(mut self, value: impl Into<$ty>) -> Self { + self.$name.replace(value.into()); + self + } + }; + ($field:ident, $name:ident, $ty:ty) => { + #[allow(clippy::should_implement_trait)] + pub fn $name(mut self, value: impl Into<$ty>) -> Self { + self.$field.$name.replace(value.into()); + self + } + }; +} + +// When a struct has fields of type `Option>`, by default these fields are deserialized as +// `Some(Object {})` instead of None when the corresponding values are missing. +// The `parse_other()` helper function ensures that these fields are deserialized as `None` when no value is present. +pub fn parse_other<'de, D>(deserializer: D) -> Result>, D::Error> +where + D: Deserializer<'de>, +{ + serde_json::Value::deserialize(deserializer).map(|value| match value { + serde_json::Value::Object(object) if !object.is_empty() => Some(object), + _ => None, + }) +} diff --git a/src/provider.rs b/src/provider.rs index b9b37977..eca6a825 100644 --- a/src/provider.rs +++ b/src/provider.rs @@ -1,9 +1,11 @@ -use crate::{IdToken, RequestUrl, SiopRequest, SiopResponse, StandardClaimsValues, Subject, Validator}; +use crate::{ + AuthorizationRequest, AuthorizationResponse, IdToken, RequestUrl, StandardClaimsValues, Subject, Validator, +}; use anyhow::{anyhow, Result}; use chrono::{Duration, Utc}; /// A Self-Issued OpenID Provider (SIOP), which is responsible for generating and signing [`IdToken`]'s in response to -/// [`SiopRequest`]'s from [crate::relying_party::RelyingParty]'s (RPs). The [`Provider`] acts as a trusted intermediary between the RPs and +/// [`AuthorizationRequest`]'s from [crate::relying_party::RelyingParty]'s (RPs). The [`Provider`] acts as a trusted intermediary between the RPs and /// the user who is trying to authenticate. #[derive(Default)] pub struct Provider @@ -27,12 +29,12 @@ where } /// TODO: Add more validation rules. - /// Takes a [`RequestUrl`] and returns a [`SiopRequest`]. The [`RequestUrl`] can either be a [`SiopRequest`] or a + /// Takes a [`RequestUrl`] and returns a [`AuthorizationRequest`]. The [`RequestUrl`] can either be a [`AuthorizationRequest`] or a /// request by value. If the [`RequestUrl`] is a request by value, the request is decoded by the [`Subject`] of the [`Provider`]. /// If the request is valid, the request is returned. - pub async fn validate_request(&self, request: RequestUrl) -> Result { + pub async fn validate_request(&self, request: RequestUrl) -> Result { let request = match request { - RequestUrl::Request(request) => *request, + RequestUrl::AuthorizationRequest(request) => *request, RequestUrl::RequestUri { request_uri } => { let client = reqwest::Client::new(); let builder = client.get(request_uri); @@ -53,35 +55,37 @@ where }) } - // TODO: needs refactoring. - /// Generates a [`SiopResponse`] in response to a [`SiopRequest`] and the user's claims. The [`SiopResponse`] + /// Generates a [`AuthorizationResponse`] in response to a [`AuthorizationRequest`] and the user's claims. The [`AuthorizationResponse`] /// contains an [`IdToken`], which is signed by the [`Subject`] of the [`Provider`]. pub async fn generate_response( &self, - request: SiopRequest, + request: AuthorizationRequest, user_claims: StandardClaimsValues, - ) -> Result { + ) -> Result { let subject_did = self.subject.did()?; - let id_token = { - let mut id_token = IdToken::new( - subject_did.to_string(), - subject_did.to_string(), - request.client_id().clone(), - request.nonce().clone(), - (Utc::now() + Duration::minutes(10)).timestamp(), - ) - .state(request.state().clone()); - // Include the user claims in the id token. - id_token.standard_claims = user_claims; - id_token - }; + + let id_token = IdToken::builder() + .iss(subject_did.to_string()) + .sub(subject_did.to_string()) + .aud(request.client_id().to_owned()) + .nonce(request.nonce().to_owned()) + .exp((Utc::now() + Duration::minutes(10)).timestamp()) + .iat((Utc::now()).timestamp()) + .claims(user_claims) + .build()?; let jwt = self.subject.encode(id_token).await?; - Ok(SiopResponse::new(request.redirect_uri().clone(), jwt)) + let mut builder = AuthorizationResponse::builder() + .redirect_uri(request.redirect_uri().to_owned()) + .id_token(jwt); + if let Some(state) = request.state() { + builder = builder.state(state.clone()); + } + builder.build() } - pub async fn send_response(&self, response: SiopResponse) -> Result<()> { + pub async fn send_response(&self, response: AuthorizationResponse) -> Result<()> { let client = reqwest::Client::new(); let builder = client.post(response.redirect_uri()).form(&response); builder.send().await?.text().await?; diff --git a/src/relying_party.rs b/src/relying_party.rs index b64dd400..7e4873c4 100644 --- a/src/relying_party.rs +++ b/src/relying_party.rs @@ -1,4 +1,4 @@ -use crate::{IdToken, SiopRequest, SiopResponse, Subject, Validator}; +use crate::{AuthorizationRequest, AuthorizationResponse, IdToken, Subject, Validator}; use anyhow::Result; pub struct RelyingParty @@ -16,14 +16,19 @@ where RelyingParty { validator } } - pub async fn encode(&self, request: &SiopRequest) -> Result { + pub async fn encode(&self, request: &AuthorizationRequest) -> Result { self.validator.encode(request).await } - /// Validates a [`SiopResponse`] by decoding the header of the id_token, fetching the public key corresponding to + /// Validates a [`AuthorizationResponse`] by decoding the header of the id_token, fetching the public key corresponding to /// the key identifier and finally decoding the id_token using the public key and by validating the signature. - pub async fn validate_response(&self, response: &SiopResponse) -> Result { - let token = response.id_token.clone(); + // TODO: Validate the claims in the id_token as described here: + // https://openid.net/specs/openid-connect-self-issued-v2-1_0.html#name-self-issued-id-token-valida + pub async fn validate_response(&self, response: &AuthorizationResponse) -> Result { + let token = response + .id_token() + .to_owned() + .ok_or(anyhow::anyhow!("No id_token parameter in response"))?; let id_token: IdToken = self.validator.decode(token).await?; Ok(id_token) } @@ -33,15 +38,14 @@ where mod tests { use super::*; use crate::{ - claims::{Address, ClaimRequests, IndividualClaimRequest, StandardClaims}, + claims::{Address, IndividualClaimRequest}, request::ResponseType, scope::{Scope, ScopeValue}, test_utils::{MemoryStorage, MockSubject, Storage}, - IdToken, Provider, Registration, RequestUrl, StandardClaimsRequests, + Provider, Registration, RequestUrl, StandardClaimsRequests, StandardClaimsValues, }; use chrono::{Duration, Utc}; use lazy_static::lazy_static; - use serde_json::{json, Value}; use wiremock::{ http::Method, matchers::{method, path}, @@ -49,7 +53,7 @@ mod tests { }; lazy_static! { - pub static ref USER_CLAIMS: Value = json!( + pub static ref USER_CLAIMS: serde_json::Value = serde_json::json!( { "name": "Jane Doe", "given_name": "Jane", @@ -88,7 +92,7 @@ mod tests { let relying_party = RelyingParty::new(validator); // Create a new RequestUrl with response mode `post` for cross-device communication. - let request: SiopRequest = RequestUrl::builder() + let request: AuthorizationRequest = RequestUrl::builder() .response_type(ResponseType::IdToken) .client_id("did:mock:1".to_string()) .scope(Scope::from(vec![ScopeValue::OpenId, ScopeValue::Phone])) @@ -99,30 +103,32 @@ mod tests { .with_subject_syntax_types_supported(vec!["did:mock".to_string()]) .with_id_token_signing_alg_values_supported(vec!["EdDSA".to_string()]), ) - .claims(ClaimRequests { - id_token: Some(StandardClaimsRequests { - name: Some(IndividualClaimRequest::Null), - email: Some(IndividualClaimRequest::object().essential(true)), - address: Some(IndividualClaimRequest::Null), - updated_at: Some(IndividualClaimRequest::Null), - ..Default::default() - }), - ..Default::default() - }) + .claims( + r#"{ + "id_token": { + "name": null, + "email": { + "essential": true + }, + "address": null, + "updated_at": null + } + }"#, + ) .exp((Utc::now() + Duration::minutes(10)).timestamp()) .nonce("n-0S6_WzA2Mj".to_string()) .build() .and_then(TryInto::try_into) .unwrap(); - // Create a new `request_uri` endpoint on the mock server and load it with the JWT encoded `SiopRequest`. + // Create a new `request_uri` endpoint on the mock server and load it with the JWT encoded `AuthorizationRequest`. Mock::given(method("GET")) .and(path("/request_uri")) .respond_with(ResponseTemplate::new(200).set_body_string(relying_party.encode(&request).await.unwrap())) .mount(&mock_server) .await; - // Create a new `redirect_uri` endpoint on the mock server where the `Provider` will send the `SiopResponse`. + // Create a new `redirect_uri` endpoint on the mock server where the `Provider` will send the `AuthorizationResponse`. Mock::given(method("POST")) .and(path("/redirect_uri")) .respond_with(ResponseTemplate::new(200)) @@ -153,7 +159,7 @@ mod tests { let request_claims = request.id_token_request_claims().unwrap(); assert_eq!( request_claims, - StandardClaims { + StandardClaimsRequests { name: Some(IndividualClaimRequest::Null), email: Some(IndividualClaimRequest::object().essential(true)), address: Some(IndividualClaimRequest::Null), @@ -179,32 +185,19 @@ mod tests { // The provider sends it's response to the mock server's `redirect_uri` endpoint. provider.send_response(response).await.unwrap(); - // Assert that the SiopResponse was successfully received by the mock server at the expected endpoint. + // Assert that the AuthorizationResponse was successfully received by the mock server at the expected endpoint. let post_request = mock_server.received_requests().await.unwrap()[1].clone(); assert_eq!(post_request.method, Method::Post); assert_eq!(post_request.url.path(), "/redirect_uri"); - let response: SiopResponse = serde_urlencoded::from_bytes(post_request.body.as_slice()).unwrap(); + let response: AuthorizationResponse = serde_urlencoded::from_bytes(post_request.body.as_slice()).unwrap(); // The `RelyingParty` then validates the response by decoding the header of the id_token, by fetching the public // key corresponding to the key identifier and finally decoding the id_token using the public key and by // validating the signature. let id_token = relying_party.validate_response(&response).await.unwrap(); - let IdToken { - iss, sub, aud, nonce, .. - } = IdToken::new( - "did:mock:123".to_string(), - "did:mock:123".to_string(), - "did:mock:1".to_string(), - "n-0S6_WzA2Mj".to_string(), - (Utc::now() + Duration::minutes(10)).timestamp(), - ); - assert_eq!(id_token.iss, iss); - assert_eq!(id_token.sub, sub); - assert_eq!(id_token.aud, aud); - assert_eq!(id_token.nonce, nonce); assert_eq!( - id_token.standard_claims, - StandardClaims { + id_token.standard_claims().to_owned(), + StandardClaimsValues { name: Some("Jane Doe".to_string()), email: Some("jane.doe@example.com".to_string()), updated_at: Some(1311280970), diff --git a/src/request.rs b/src/request/mod.rs similarity index 92% rename from src/request.rs rename to src/request/mod.rs index f8591567..c29e2107 100644 --- a/src/request.rs +++ b/src/request/mod.rs @@ -7,6 +7,8 @@ use serde_json::{Map, Value}; use std::convert::TryInto; use std::str::FromStr; +pub mod request_builder; + /// As specified in the /// [SIOPv2 specification](https://openid.net/specs/openid-connect-self-issued-v2-1_0.html#name-self-issued-openid-provider-a) /// [`RelyingParty`]'s can either send a request as a query parameter or as a request URI. @@ -26,7 +28,7 @@ use std::str::FromStr; /// } /// ); /// -/// // An example of a form-urlencoded request that is parsed as a `RequestUrl::Request` variant. +/// // An example of a form-urlencoded request that is parsed as a `RequestUrl::AuthorizationRequest` variant. /// let request_url = RequestUrl::from_str( /// "\ /// siopv2://idtoken?\ @@ -43,14 +45,14 @@ use std::str::FromStr; /// ) /// .unwrap(); /// assert!(match request_url { -/// RequestUrl::Request(_) => Ok(()), +/// RequestUrl::AuthorizationRequest(_) => Ok(()), /// RequestUrl::RequestUri { .. } => Err(()), /// }.is_ok()); /// ``` #[derive(Deserialize, Debug, PartialEq, Clone, Serialize)] #[serde(untagged, deny_unknown_fields)] pub enum RequestUrl { - Request(Box), + AuthorizationRequest(Box), // TODO: Add client_id parameter. RequestUri { request_uri: String }, } @@ -61,13 +63,13 @@ impl RequestUrl { } } -impl TryInto for RequestUrl { +impl TryInto for RequestUrl { type Error = anyhow::Error; - fn try_into(self) -> Result { + fn try_into(self) -> Result { match self { - RequestUrl::Request(request) => Ok(*request), - RequestUrl::RequestUri { .. } => Err(anyhow!("Request is a request URI.")), + RequestUrl::AuthorizationRequest(request) => Ok(*request), + RequestUrl::RequestUri { .. } => Err(anyhow!("AuthorizationRequest is a request URI.")), } } } @@ -123,11 +125,11 @@ pub enum ResponseType { IdToken, } -/// [`SiopRequest`] is a request from a [crate::relying_party::RelyingParty] (RP) to a [crate::provider::Provider] (SIOP). +/// [`AuthorizationRequest`] is a request from a [crate::relying_party::RelyingParty] (RP) to a [crate::provider::Provider] (SIOP). #[allow(dead_code)] #[derive(Debug, Getters, PartialEq, Clone, Default, Serialize, Deserialize)] #[serde(deny_unknown_fields)] -pub struct SiopRequest { +pub struct AuthorizationRequest { pub(crate) response_type: ResponseType, pub(crate) response_mode: Option, #[getset(get = "pub")] @@ -151,7 +153,7 @@ pub struct SiopRequest { pub(crate) state: Option, } -impl SiopRequest { +impl AuthorizationRequest { pub fn is_cross_device_request(&self) -> bool { self.response_mode == Some("post".to_string()) } @@ -192,7 +194,7 @@ mod tests { #[test] fn test_valid_request() { - // A form urlencoded string without a `request_uri` parameter should deserialize into the `RequestUrl::Request` variant. + // A form urlencoded string without a `request_uri` parameter should deserialize into the `RequestUrl::AuthorizationRequest` variant. let request_url = RequestUrl::from_str( "\ siopv2://idtoken?\ @@ -210,7 +212,7 @@ mod tests { .unwrap(); assert_eq!( request_url.clone(), - RequestUrl::Request(Box::new(SiopRequest { + RequestUrl::AuthorizationRequest(Box::new(AuthorizationRequest { response_type: ResponseType::IdToken, response_mode: Some("post".to_string()), client_id: "did:example:\ diff --git a/src/request_builder.rs b/src/request/request_builder.rs similarity index 56% rename from src/request_builder.rs rename to src/request/request_builder.rs index 6a9ab1b3..c6ac4bc1 100644 --- a/src/request_builder.rs +++ b/src/request/request_builder.rs @@ -1,6 +1,6 @@ use crate::{ claims::ClaimRequests, - request::{RequestUrl, ResponseType, SiopRequest}, + request::{AuthorizationRequest, RequestUrl, ResponseType}, Registration, Scope, }; use anyhow::{anyhow, Result}; @@ -13,7 +13,7 @@ pub struct RequestUrlBuilder { response_mode: Option, client_id: Option, scope: Option, - claims: Option, + claims: Option>, redirect_uri: Option, nonce: Option, registration: Option, @@ -43,46 +43,47 @@ impl RequestUrlBuilder { let request_uri = self.request_uri.take(); match (request_uri, self.is_empty()) { (Some(request_uri), true) => Ok(RequestUrl::RequestUri { request_uri }), - (None, _) => { - let request = SiopRequest { - response_type: self - .response_type - .clone() - .ok_or(anyhow!("response_type parameter is required."))?, - response_mode: self.response_mode.clone(), - client_id: self - .client_id - .clone() - .ok_or(anyhow!("client_id parameter is required."))?, - scope: self.scope.clone().ok_or(anyhow!("scope parameter is required."))?, - claims: self.claims.clone(), - redirect_uri: self - .redirect_uri - .clone() - .ok_or(anyhow!("redirect_uri parameter is required."))?, - nonce: self.nonce.clone().ok_or(anyhow!("nonce parameter is required."))?, - registration: self.registration.clone(), - iss: self.iss.clone(), - iat: self.iat, - exp: self.exp, - nbf: self.nbf, - jti: self.jti.clone(), - state: self.state.clone(), - }; - Ok(RequestUrl::Request(Box::new(request))) - } + (None, _) => Ok(RequestUrl::AuthorizationRequest(Box::new(AuthorizationRequest { + response_type: self + .response_type + .take() + .ok_or(anyhow!("response_type parameter is required."))?, + response_mode: self.response_mode.take(), + client_id: self + .client_id + .take() + .ok_or(anyhow!("client_id parameter is required."))?, + scope: self.scope.take().ok_or(anyhow!("scope parameter is required."))?, + claims: self.claims.take().transpose()?, + redirect_uri: self + .redirect_uri + .take() + .ok_or(anyhow!("redirect_uri parameter is required."))?, + nonce: self.nonce.take().ok_or(anyhow!("nonce parameter is required."))?, + registration: self.registration.take(), + iss: self.iss.take(), + iat: self.iat, + exp: self.exp, + nbf: self.nbf, + jti: self.jti.take(), + state: self.state.take(), + }))), _ => Err(anyhow!( "request_uri and other parameters cannot be set at the same time." )), } } + pub fn claims>(mut self, value: T) -> Self { + self.claims = Some(value.try_into().map_err(|_| anyhow!("failed to convert"))); + self + } + builder_fn!(request_uri, String); builder_fn!(response_type, ResponseType); builder_fn!(response_mode, String); builder_fn!(client_id, String); builder_fn!(scope, Scope); - builder_fn!(claims, ClaimRequests); builder_fn!(redirect_uri, String); builder_fn!(nonce, String); builder_fn!(registration, Registration); @@ -97,6 +98,7 @@ impl RequestUrlBuilder { #[cfg(test)] mod tests { use super::*; + use crate::{claims::IndividualClaimRequest, ClaimRequests, StandardClaimsRequests}; #[test] fn test_valid_request_builder() { @@ -106,17 +108,30 @@ mod tests { .scope(Scope::openid()) .redirect_uri("https://example.com".to_string()) .nonce("nonce".to_string()) + .claims( + r#"{ + "id_token": { + "name": null + } + }"#, + ) .build() .unwrap(); assert_eq!( request_url, - RequestUrl::Request(Box::new(SiopRequest { + RequestUrl::AuthorizationRequest(Box::new(AuthorizationRequest { response_type: ResponseType::IdToken, response_mode: None, client_id: "did:example:123".to_string(), scope: Scope::openid(), - claims: None, + claims: Some(ClaimRequests { + id_token: Some(StandardClaimsRequests { + name: Some(IndividualClaimRequest::Null), + ..Default::default() + }), + ..Default::default() + }), redirect_uri: "https://example.com".to_string(), nonce: "nonce".to_string(), registration: None, @@ -133,15 +148,32 @@ mod tests { #[test] fn test_invalid_request_builder() { // A request builder with a `request_uri` parameter should fail to build. - let request_url = RequestUrl::builder() + assert!(RequestUrl::builder() .response_type(ResponseType::IdToken) .client_id("did:example:123".to_string()) .scope(Scope::openid()) .redirect_uri("https://example.com".to_string()) .nonce("nonce".to_string()) .request_uri("https://example.com/request_uri".to_string()) - .build(); - assert!(request_url.is_err()); + .build() + .is_err()); + + // A request builder without an invalid claim request should fail to build. + assert!(RequestUrl::builder() + .response_type(ResponseType::IdToken) + .client_id("did:example:123".to_string()) + .scope(Scope::openid()) + .redirect_uri("https://example.com".to_string()) + .nonce("nonce".to_string()) + .claims( + r#"{ + "id_token": { + "name": "invalid" + } + }"#, + ) + .build() + .is_err()); } #[test] diff --git a/src/response.rs b/src/response.rs index a38c19a7..f9891035 100644 --- a/src/response.rs +++ b/src/response.rs @@ -1,17 +1,171 @@ +use crate::builder_fn; +use anyhow::{anyhow, Result}; use getset::Getters; use serde::{Deserialize, Serialize}; +use serde_with::skip_serializing_none; -/// Current implementation only supports the `id_token` response type and the cross-device implicit flow. -#[derive(Serialize, Deserialize, Debug, Getters)] -pub struct SiopResponse { +/// Represents the parameters of an OpenID4VP response. It can hold a Verifiable Presentation Token and a Presentation +/// Submission, or a JWT containing them. +#[derive(Serialize, Deserialize, Debug, PartialEq)] +#[serde(untagged)] +pub enum Openid4vpParams { + Jwt { + response: String, + }, + Params { + vp_token: String, + presentation_submission: String, + }, +} + +/// Represents an Authorization AuthorizationResponse. It can hold an ID Token, a Verifiable Presentation Token, a Presentation +/// Submission, or a combination of them. +#[derive(Serialize, Default, Deserialize, Debug, Getters, PartialEq)] +#[skip_serializing_none] +pub struct AuthorizationResponse { #[serde(skip)] #[getset(get = "pub")] redirect_uri: String, - pub id_token: String, + #[getset(get = "pub")] + id_token: Option, + #[serde(flatten)] + openid4vp_response: Option, + state: Option, +} + +impl AuthorizationResponse { + pub fn builder() -> ResponseBuilder { + ResponseBuilder::new() + } +} + +#[derive(Default)] +pub struct ResponseBuilder { + redirect_uri: Option, + id_token: Option, + vp_token: Option, + presentation_submission: Option, + openid4vp_response_jwt: Option, + state: Option, +} + +impl ResponseBuilder { + pub fn new() -> Self { + ResponseBuilder::default() + } + + pub fn build(&mut self) -> Result { + let redirect_uri = self + .redirect_uri + .take() + .ok_or(anyhow!("redirect_uri parameter is required."))?; + + let openid4vp_response = match ( + self.vp_token.take(), + self.presentation_submission.take(), + self.openid4vp_response_jwt.take(), + ) { + (Some(vp_token), Some(presentation_submission), None) => Ok(Some(Openid4vpParams::Params { + vp_token, + presentation_submission, + })), + (None, None, Some(response)) => Ok(Some(Openid4vpParams::Jwt { response })), + (None, None, None) => Ok(None), + (Some(_), None, None) => Err(anyhow!( + "`presentation_submission` parameter is required when using `vp_token` parameter." + )), + (None, Some(_), None) => Err(anyhow!( + "`vp_token` parameter is required when using `presentation_submission` parameter." + )), + _ => Err(anyhow!( + "`response` parameter can not be used with `vp_token` and `presentation_submission` parameters." + )), + }?; + + Ok(AuthorizationResponse { + redirect_uri, + id_token: self.id_token.take(), + openid4vp_response, + state: self.state.take(), + }) + } + + builder_fn!(redirect_uri, String); + builder_fn!(id_token, String); + builder_fn!(vp_token, String); + builder_fn!(presentation_submission, String); + builder_fn!(openid4vp_response_jwt, String); + builder_fn!(state, String); } -impl SiopResponse { - pub fn new(redirect_uri: String, id_token: String) -> Self { - SiopResponse { redirect_uri, id_token } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_valid_response() { + assert!(AuthorizationResponse::builder() + .redirect_uri("redirect".to_string()) + .id_token("id_token".to_string()) + .build() + .is_ok()); + + assert!(AuthorizationResponse::builder() + .redirect_uri("redirect".to_string()) + .vp_token("vp_token".to_string()) + .presentation_submission("presentation_submission".to_string()) + .build() + .is_ok()); + + assert!(AuthorizationResponse::builder() + .redirect_uri("redirect".to_string()) + .id_token("id_token".to_string()) + .vp_token("vp_token".to_string()) + .presentation_submission("presentation_submission".to_string()) + .build() + .is_ok()); + } + + #[test] + fn test_invalid_response() { + assert_eq!( + AuthorizationResponse::builder() + .id_token("id_token".to_string()) + .build() + .unwrap_err() + .to_string(), + "redirect_uri parameter is required." + ); + + assert_eq!( + AuthorizationResponse::builder() + .redirect_uri("redirect".to_string()) + .vp_token("vp_token".to_string()) + .build() + .unwrap_err() + .to_string(), + "`presentation_submission` parameter is required when using `vp_token` parameter." + ); + + assert_eq!( + AuthorizationResponse::builder() + .redirect_uri("redirect".to_string()) + .presentation_submission("presentation_submission".to_string()) + .build() + .unwrap_err() + .to_string(), + "`vp_token` parameter is required when using `presentation_submission` parameter." + ); + + assert_eq!( + AuthorizationResponse::builder() + .redirect_uri("redirect".to_string()) + .presentation_submission("presentation_submission".to_string()) + .openid4vp_response_jwt("response".to_string()) + .build() + .unwrap_err() + .to_string(), + "`response` parameter can not be used with `vp_token` and `presentation_submission` parameters." + ); } } diff --git a/src/subject.rs b/src/subject.rs index 98dc5ffc..a89827a1 100644 --- a/src/subject.rs +++ b/src/subject.rs @@ -54,18 +54,18 @@ mod tests { let subject = MockSubject::new("did:mock:123".to_string(), "key_identifier".to_string()).unwrap(); let encoded = subject.encode(claims).await.unwrap(); let decoded = subject.decode::(encoded).await.unwrap(); + assert_eq!( decoded, - IdToken { - iss: "did:example:123".to_string(), - sub: "did:example:123".to_string(), - standard_claims: Default::default(), - aud: "did:example:456".to_string(), - exp: 9223372036854775807, - iat: 1593436422, - nonce: "nonce".to_string(), - state: None, - } + IdToken::builder() + .iss("did:example:123".to_string()) + .sub("did:example:123".to_string()) + .aud("did:example:456".to_string()) + .exp(9223372036854775807i64) + .iat(1593436422) + .nonce("nonce".to_string()) + .build() + .unwrap() ) } } diff --git a/src/token/id_token.rs b/src/token/id_token.rs new file mode 100644 index 00000000..c44533ce --- /dev/null +++ b/src/token/id_token.rs @@ -0,0 +1,98 @@ +use super::id_token_builder::IdTokenBuilder; +use crate::{parse_other, StandardClaimsValues}; +use getset::Getters; +use serde::{Deserialize, Serialize}; +use serde_with::skip_serializing_none; + +/// An SIOPv2 [`IdToken`] as specified in the [SIOPv2 specification](https://openid.net/specs/openid-connect-self-issued-v2-1_0.html#name-self-issued-id-token) +/// and [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html#IDToken). +#[skip_serializing_none] +#[derive(Serialize, Deserialize, Debug, Getters, Default, PartialEq)] +pub struct IdToken { + #[serde(flatten)] + #[getset(get = "pub")] + pub(super) rfc7519_claims: RFC7519Claims, + #[serde(flatten)] + #[getset(get = "pub")] + pub(super) standard_claims: StandardClaimsValues, + pub(super) auth_time: Option, + pub(super) nonce: Option, + pub(super) acr: Option, + pub(super) amr: Option>, + pub(super) azp: Option, + pub(super) sub_jwk: Option, + #[serde(flatten, deserialize_with = "parse_other")] + pub(super) other: Option>, +} + +impl IdToken { + pub fn builder() -> IdTokenBuilder { + IdTokenBuilder::new() + } +} + +// TODO: Make feature complete. +#[derive(Serialize, Deserialize, Debug, Default, PartialEq)] +pub struct SubJwk { + pub(super) kty: String, + pub(super) n: String, + pub(super) e: String, +} + +/// Set of IANA registered claims by the Internet Engineering Task Force (IETF) in +/// [RFC 7519](https://tools.ietf.org/html/rfc7519#section-4.1). +#[derive(Serialize, Deserialize, Debug, Default, PartialEq)] +pub struct RFC7519Claims { + pub(super) iss: Option, + pub(super) sub: Option, + pub(super) aud: Option, + pub(super) exp: Option, + pub(super) nbf: Option, + pub(super) iat: Option, + pub(super) jti: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_id_token() { + let id_token: IdToken = serde_json::from_str( + r#"{ + "iss": "NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs", + "sub": "NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs", + "aud": "https://client.example.org/cb", + "nonce": "n-0S6_WzA2Mj", + "exp": 1311281970, + "iat": 1311280970, + "sub_jwk": { + "kty": "RSA", + "n": "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw", + "e": "AQAB" + } + }"#, + ) + .unwrap(); + assert_eq!( + id_token, + IdToken { + rfc7519_claims: RFC7519Claims { + iss: Some("NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs".to_string()), + sub: Some("NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs".to_string()), + aud: Some("https://client.example.org/cb".to_string()), + exp: Some(1311281970), + iat: Some(1311280970), + ..Default::default() + }, + nonce: Some("n-0S6_WzA2Mj".to_string()), + sub_jwk: Some(SubJwk { + kty: "RSA".to_string(), + n: "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw".to_string(), + e: "AQAB".to_string(), + }), + ..Default::default() + } + ); + } +} diff --git a/src/token/id_token_builder.rs b/src/token/id_token_builder.rs new file mode 100644 index 00000000..680b5742 --- /dev/null +++ b/src/token/id_token_builder.rs @@ -0,0 +1,117 @@ +use super::id_token::{RFC7519Claims, SubJwk}; +use crate::{builder_fn, IdToken, StandardClaimsValues}; + +#[derive(Default)] +pub struct IdTokenBuilder { + rfc7519_claims: RFC7519Claims, + standard_claims: StandardClaimsValues, + auth_time: Option, + nonce: Option, + acr: Option, + amr: Option>, + azp: Option, + sub_jwk: Option, + other: Option>, +} + +impl IdTokenBuilder { + pub fn new() -> Self { + IdTokenBuilder::default() + } + + pub fn build(self) -> anyhow::Result { + anyhow::ensure!(self.rfc7519_claims.iss.is_some(), "iss claim is required"); + anyhow::ensure!(self.rfc7519_claims.sub.is_some(), "sub claim is required"); + anyhow::ensure!( + self.rfc7519_claims.sub.as_ref().filter(|s| s.len() <= 255).is_some(), + "sub claim MUST NOT exceed 255 ASCII characters in length" + ); + anyhow::ensure!(self.rfc7519_claims.aud.is_some(), "aud claim is required"); + anyhow::ensure!(self.rfc7519_claims.exp.is_some(), "exp claim is required"); + anyhow::ensure!(self.rfc7519_claims.iat.is_some(), "iat claim is required"); + anyhow::ensure!( + self.rfc7519_claims.iss == self.rfc7519_claims.sub, + "iss and sub must be equal" + ); + + Ok(IdToken { + rfc7519_claims: self.rfc7519_claims, + standard_claims: self.standard_claims, + auth_time: self.auth_time, + nonce: self.nonce, + acr: self.acr, + amr: self.amr, + azp: self.azp, + sub_jwk: self.sub_jwk, + other: self.other, + }) + } + + pub fn claims(mut self, claims: StandardClaimsValues) -> Self { + self.standard_claims = claims; + self + } + + builder_fn!(rfc7519_claims, iss, String); + builder_fn!(rfc7519_claims, sub, String); + builder_fn!(rfc7519_claims, aud, String); + builder_fn!(rfc7519_claims, exp, i64); + builder_fn!(rfc7519_claims, nbf, i64); + builder_fn!(rfc7519_claims, iat, i64); + builder_fn!(rfc7519_claims, jti, String); + builder_fn!(auth_time, i64); + builder_fn!(nonce, String); + builder_fn!(acr, String); + builder_fn!(amr, Vec); + builder_fn!(azp, String); + builder_fn!(sub_jwk, SubJwk); + builder_fn!(other, serde_json::Map); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_valid_build() { + assert!(IdTokenBuilder::new() + .iss("NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs") + .sub("NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs") + .aud("https://client.example.org/cb") + .exp(1311281970) + .iat(1311280970) + .build() + .is_ok()); + } + + #[test] + fn test_invalid_build() { + assert!(IdTokenBuilder::new().build().is_err()); + + assert!(IdTokenBuilder::new() + .iss("iss") + .build() + .unwrap_err() + .to_string() + .contains("sub claim is required")); + + assert!(IdTokenBuilder::new() + .iss("iss") + .sub("x".repeat(256)) + .build() + .unwrap_err() + .to_string() + .contains("sub claim MUST NOT exceed 255 ASCII characters in length")); + + assert!(IdTokenBuilder::new() + .iss("iss") + .sub("sub") + .aud("aud") + .exp(0) + .iat(0) + .build() + .unwrap_err() + .to_string() + .contains("iss and sub must be equal")); + } +} diff --git a/src/token/mod.rs b/src/token/mod.rs new file mode 100644 index 00000000..a4646031 --- /dev/null +++ b/src/token/mod.rs @@ -0,0 +1,2 @@ +pub mod id_token; +pub mod id_token_builder;