Skip to content

Commit

Permalink
feat: add Extension trait for SIOPv2 and OID4VP (#60)
Browse files Browse the repository at this point in the history
* fix: remove `iota_method`

* test: add test-utils feature, bump ed25519-dalek dep

* feat: add `JsonObject`

* refactor: remove `serialize_unit_struct`, use `#[serde(tag = ...)]` instead

* style: use `JsonObject`

* feat: add `Extension` trait

* feat: implement `Extension` trait for `siopv2`

* feat: implement `Extension` trait for `oid4vp`

* fix: update manager

* fix: use `MustBe` macro to enforce `response_type` values

* style: sort dependencies

* fix: remove `siopv2_oid4vp`

* feat: derive `Clone` trait for request handlers

* chore: remove `oid4vp` and `oid4vci` dependencies in `siopv2`

* fix: remove obsolete assignment

* chore: remove comment

* chore: add TODO's

* chore: remove unused method
  • Loading branch information
nanderstabel committed Mar 12, 2024
1 parent ad60719 commit d05bd55
Show file tree
Hide file tree
Showing 56 changed files with 1,426 additions and 1,739 deletions.
16 changes: 12 additions & 4 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,22 @@ license = "Apache-2.0"
repository = "https://github.com/impierce/openid4vc"

[workspace.dependencies]
tokio = { version = "1.26.0", features = ["rt", "macros", "rt-multi-thread"] }
chrono = "0.4"
getset = "0.1"
identity_core = { version = "1.0.0-rc.1" }
identity_credential = { version = "=0.7.0-alpha.7", default-features = false, features = ["validator", "credential", "presentation"] }
is_empty = "0.2"
jsonwebtoken = "8.2"
monostate = "0.1"
reqwest = { version = "0.11", default-features = false, features = ["json", "rustls-tls"] }
reqwest-middleware = "0.2"
reqwest-retry = "0.3"
serde = { version = "1.0", features = ["derive"]}
serde_json = "1.0"
serde_urlencoded = "0.7"
serde_with = "3.0"
reqwest = { version = "0.11", default-features = false, features = ["json", "rustls-tls"] }
tokio = { version = "1.26.0", features = ["rt", "macros", "rt-multi-thread"] }
url = { version = "2", features = ["serde"] }
getset = "0.1"
identity_credential = { version = "=0.7.0-alpha.7", default-features = false, features = ["validator", "credential", "presentation"] }

# TODO: Fix these dependencies once publishing to crates.io is automated.
[dependencies]
Expand Down
6 changes: 3 additions & 3 deletions dif-presentation-exchange/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ license.workspace = true
repository.workspace = true

[dependencies]
getset.workspace = true
jsonpath_lib = "0.3"
jsonschema = "0.17"
serde.workspace = true
serde_json.workspace = true
serde_with.workspace = true
url.workspace = true
getset.workspace = true
jsonpath_lib = "0.3"
jsonschema = "0.17"
26 changes: 16 additions & 10 deletions oid4vc-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,26 @@ version = "0.1.0"
edition = "2021"

[dependencies]
serde.workspace = true
serde_json = "1.0"
serde_with = "2.3"
anyhow = "1.0.70"
getset = "0.1.2"
jsonwebtoken = "8.2.0"
base64-url = "2.0.0"
async-trait = "0.1.68"
base64-url = "2.0.0"
derivative = "2.2.0"
derive_more = "0.99.16"
did_url = "0.1.0"
ed25519-dalek = { version = "2.0.0", features = ["rand_core"] }
getset = "0.1.2"
is_empty = "0.2.0"
rand = "0.7"
jsonwebtoken = "8.2.0"
lazy_static = "1.4.0"
rand = "0.8"
serde.workspace = true
serde_json = "1.0"
serde_urlencoded.workspace = true
serde_with = "2.3"
url.workspace = true

[dev-dependencies]
ed25519-dalek = "1.0.1"
lazy_static = "1.4.0"
derivative = "2.2.0"
tokio.workspace = true

[features]
test-utils = []
198 changes: 198 additions & 0 deletions oid4vc-core/src/authorization_request.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
use crate::{
openid4vc_extension::{Extension, Generic, OpenID4VC, RequestHandle},
JsonObject, RFC7519Claims,
};
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use serde_json::json;

/// A `Body` is a set of claims that are sent by a client to a provider. It can be `ByValue`, `ByReference`, or an `Object`.
pub trait Body: Serialize + std::fmt::Debug {
fn client_id(&self) -> &String;
}

/// An `Object` is a set of claims that are sent by a client to a provider. On top of some generic claims, it also
/// contains a set of claims specific to an [`Extension`].
#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)]
pub struct Object<E: Extension = Generic> {
#[serde(flatten)]
pub rfc7519_claims: RFC7519Claims,
pub client_id: String,
pub redirect_uri: url::Url,
pub state: Option<String>,
#[serde(flatten)]
pub extension: <E::RequestHandle as RequestHandle>::Parameters,
}

impl<E: Extension> Object<E> {
/// Converts a [`Object`] with a [`Generic`] [`Extension`] to a [`Object`] with a specific [`Extension`].
fn from_generic(original: &Object<Generic>) -> anyhow::Result<Self> {
Ok(Object {
rfc7519_claims: original.rfc7519_claims.clone(),
client_id: original.client_id.clone(),
redirect_uri: original.redirect_uri.clone(),
state: original.state.clone(),
extension: serde_json::from_value(original.extension.clone())?,
})
}
}

impl<E: Extension> std::str::FromStr for Object<E> {
type Err = anyhow::Error;

fn from_str(s: &str) -> Result<Self, Self::Err> {
let url = url::Url::parse(s)?;
let query = url.query().ok_or_else(|| anyhow::anyhow!("No query found."))?;
let map = serde_urlencoded::from_str::<JsonObject>(query)?
.into_iter()
.filter_map(|(k, v)| match v {
serde_json::Value::String(s) => Some(Ok((
k,
serde_json::from_str(&s).unwrap_or(serde_json::Value::String(s)),
))),
_ => None,
})
.collect::<Result<_, anyhow::Error>>()?;
let authorization_request: Object<E> = serde_json::from_value(serde_json::Value::Object(map))?;
Ok(authorization_request)
}
}

impl<E: Extension> Body for Object<E> {
fn client_id(&self) -> &String {
&self.client_id
}
}

#[derive(Serialize, Deserialize, Debug, PartialEq)]
pub struct ByReference {
pub client_id: String,
pub request_uri: url::Url,
}

impl Body for ByReference {
fn client_id(&self) -> &String {
&self.client_id
}
}

#[derive(Serialize, Deserialize, Debug, PartialEq)]
pub struct ByValue {
pub client_id: String,
pub request: String,
}
impl Body for ByValue {
fn client_id(&self) -> &String {
&self.client_id
}
}

/// A [`AuthorizationRequest`] is a request that is sent by a client to a provider. It contains a set of claims in the
/// form of a [`Body`] which can be [`ByValue`], [`ByReference`], or an [`Object`].
#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)]
pub struct AuthorizationRequest<B: Body> {
#[serde(flatten)]
pub body: B,
}

impl<E: Extension + OpenID4VC> AuthorizationRequest<Object<E>> {
/// Converts a [`AuthorizationRequest`] with a [`Generic`] [`Extension`] to a [`AuthorizationRequest`] with a specific [`Extension`].
pub fn from_generic(
original: &AuthorizationRequest<Object<Generic>>,
) -> anyhow::Result<AuthorizationRequest<Object<E>>> {
Ok(AuthorizationRequest {
body: Object::from_generic(&original.body)?,
})
}
}

impl<E: Extension> AuthorizationRequest<Object<E>> {
/// Returns a [`AuthorizationRequest`]'s builder.
pub fn builder() -> <E::RequestHandle as RequestHandle>::Builder {
<E::RequestHandle as RequestHandle>::Builder::default()
}
}

/// In order to convert a string to a [`AuthorizationRequest`], we need to try to parse each value as a JSON object. This way we
/// can catch any non-primitive types. If the value is not a JSON object or an Array, we just leave it as a string.
impl<B: Body + DeserializeOwned> std::str::FromStr for AuthorizationRequest<B> {
type Err = anyhow::Error;

fn from_str(s: &str) -> Result<Self, Self::Err> {
let url = url::Url::parse(s)?;
let query = url.query().ok_or_else(|| anyhow::anyhow!("No query found."))?;
let map = serde_urlencoded::from_str::<JsonObject>(query)?
.into_iter()
.filter_map(|(k, v)| match v {
serde_json::Value::String(s) => Some(Ok((
k,
serde_json::from_str(&s).unwrap_or(serde_json::Value::String(s)),
))),
_ => None,
})
.collect::<Result<_, anyhow::Error>>()?;
let authorization_request: AuthorizationRequest<B> = serde_json::from_value(serde_json::Value::Object(map))?;
Ok(authorization_request)
}
}

/// In order to convert a [`AuthorizationRequest`] to a string, we need to convert all the values to strings. This is because
/// `serde_urlencoded` does not support serializing non-primitive types.
// TODO: Find a way to dynamically generate the `siopv2://idtoken?` part of the URL. This will require some refactoring
// for the `AuthorizationRequest` struct.
impl<B: Body> std::fmt::Display for AuthorizationRequest<B> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let map: JsonObject = json!(self)
.as_object()
.ok_or(std::fmt::Error)?
.iter()
.filter_map(|(k, v)| match v {
serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
Some((k.to_owned(), serde_json::Value::String(serde_json::to_string(v).ok()?)))
}
serde_json::Value::String(_) => Some((k.to_owned(), v.to_owned())),
_ => None,
})
.collect();

let encoded = serde_urlencoded::to_string(map).map_err(|_| std::fmt::Error)?;
write!(f, "siopv2://idtoken?{}", encoded)
}
}

#[cfg(test)]
mod tests {
use super::*;
use std::str::FromStr;

#[test]
fn test() {
let authorization_request = AuthorizationRequest::<Object> {
body: Object {
rfc7519_claims: Default::default(),
client_id: "did:example:123".to_string(),
redirect_uri: "https://www.example.com".parse().unwrap(),
state: Some("state".to_string()),
extension: json!({
"response_mode": "direct_post",
"nonce": "nonce",
"claims": {
"id_token": {
"email": {
"essential": true
}
}
}
}),
},
};

// Convert the authorization request to a form urlencoded string.
let form_urlencoded = authorization_request.to_string();

// Convert the form urlencoded string back to a authorization request.
assert_eq!(
AuthorizationRequest::<Object>::from_str(&form_urlencoded).unwrap(),
authorization_request
);
}
}
15 changes: 15 additions & 0 deletions oid4vc-core/src/authorization_response.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
use crate::openid4vc_extension::{Extension, ResponseHandle};
use serde::{Deserialize, Serialize};
use serde_with::skip_serializing_none;

/// The [`AuthorizationResponse`] is a set of claims that are sent by a provider to a client. On top of some generic
/// claims, it also contains a set of claims specific to an [`Extension`].
#[skip_serializing_none]
#[derive(Serialize, Deserialize, Debug, PartialEq)]
pub struct AuthorizationResponse<E: Extension> {
#[serde(skip)]
pub redirect_uri: String,
pub state: Option<String>,
#[serde(flatten)]
pub extension: <E::ResponseHandle as ResponseHandle>::Parameters,
}
Original file line number Diff line number Diff line change
@@ -1,15 +1,23 @@
use crate::SubjectSyntaxType;
use getset::Getters;
use oid4vc_core::SubjectSyntaxType;
use serde::{Deserialize, Serialize};
use serde_with::skip_serializing_none;
use url::Url;

/// [`ClientMetadata`] is a request parameter used by a [`crate::RelyingParty`] to communicate its capabilities to a [`crate::Provider`].
#[skip_serializing_none]
#[derive(Getters, Debug, PartialEq, Clone, Default, Deserialize, Serialize)]
pub struct ClientMetadata {
// TODO: Move to siopv2 crate.
#[getset(get = "pub")]
subject_syntax_types_supported: Option<Vec<SubjectSyntaxType>>,
id_token_signing_alg_values_supported: Option<Vec<String>>,
pub subject_syntax_types_supported: Option<Vec<SubjectSyntaxType>>,
// TODO: Move to siopv2 crate.
#[getset(get = "pub")]
pub id_token_signing_alg_values_supported: Option<Vec<String>>,
#[getset(get = "pub")]
pub client_name: Option<String>,
#[getset(get = "pub")]
pub logo_uri: Option<Url>,
}

impl ClientMetadata {
Expand Down Expand Up @@ -37,8 +45,7 @@ impl ClientMetadata {
#[cfg(test)]
mod tests {
use super::*;
use crate::RequestUrl;
use oid4vc_core::DidMethod;
use crate::DidMethod;
use std::str::FromStr;

#[test]
Expand All @@ -59,26 +66,5 @@ mod tests {
SubjectSyntaxType::JwkThumbprint,
])
);

let request_url = RequestUrl::from_str(
"\
siopv2://idtoken?\
scope=openid\
&response_type=id_token\
&client_id=did%3Aexample%3AEiDrihTRe0GMdc3K16kgJB3Xbl9Hb8oqVHjzm6ufHcYDGA\
&redirect_uri=https%3A%2F%2Fclient.example.org%2Fcb\
&response_mode=post\
&client_metadata=%7B%22subject_syntax_types_supported%22%3A\
%5B%22did%3Atest%22%5D%2C%0A%20%20%20%20\
%22id_token_signing_alg_values_supported%22%3A%5B%22EdDSA%22%5D%7D\
&nonce=n-0S6_WzA2Mj\
",
)
.unwrap();

assert_eq!(
RequestUrl::from_str(&RequestUrl::to_string(&request_url)).unwrap(),
request_url
);
}
}
3 changes: 2 additions & 1 deletion oid4vc-core/src/jwt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ where

pub fn encode<C, S>(signer: Arc<S>, header: Header, claims: C) -> Result<String>
where
C: Serialize + Send,
C: Serialize,
S: Sign + ?Sized,
{
let kid = signer.key_id().ok_or(anyhow!("No key identifier found."))?;
Expand All @@ -74,6 +74,7 @@ where
Ok(base64_url::encode(serde_json::to_vec(value)?.as_slice()))
}

#[cfg(feature = "test-utils")]
#[cfg(test)]
mod tests {
use super::*;
Expand Down
Loading

0 comments on commit d05bd55

Please sign in to comment.