From 094def9dab62c9955b03ae08fffbf75be4238b9d Mon Sep 17 00:00:00 2001 From: Alex Wied <543423+centromere@users.noreply.github.com> Date: Thu, 5 Sep 2024 17:36:55 -0400 Subject: [PATCH] Implement OAuth 2.0 token introspection (#55) * Implement OAuth 2.0 token introspection * review: docs, fmt and error type fix --------- Co-authored-by: Alex Wied <2-alex@users.noreply.amorystreet.org> Co-authored-by: Alexander Korolev --- README.md | 2 + src/client.rs | 88 +++++++++++++++++++++++++++++++++++++- src/config.rs | 2 +- src/error.rs | 14 ++++++ src/lib.rs | 2 + src/token_introspection.rs | 84 ++++++++++++++++++++++++++++++++++++ 6 files changed, 189 insertions(+), 3 deletions(-) create mode 100644 src/token_introspection.rs diff --git a/README.md b/README.md index 4e0467b..a66d8b9 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ Implements [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-cor Implements [UMA2](https://docs.kantarainitiative.org/uma/wg/oauth-uma-federated-authz-2.0-09.html) - User Managed Access, an extension to OIDC/OAuth2. Use feature flag `uma2` to enable this feature. +Implements [OAuth 2.0 Token Introspection](https://datatracker.ietf.org/doc/html/rfc7662). + It supports Microsoft OIDC with feature `microsoft`. This adds methods for authentication and token validation, those skip issuer check. Originally developed as a quick adaptation to leverage async/await functionality, based on [inth-oauth2](https://crates.io/crates/inth-oauth2) and [oidc](https://crates.io/crates/oidc), the library has since evolved into a mature and robust solution, offering expanded features and improved performance. diff --git a/src/client.rs b/src/client.rs index 4deef52..347583d 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1,13 +1,16 @@ use crate::{ bearer::TemporalBearerGuard, discovered, - error::{ClientError, Decode, Error, Jose, Userinfo as ErrorUserinfo}, + error::{ + ClientError, Decode, Error, Introspection as ErrorIntrospection, Jose, + Userinfo as ErrorUserinfo, + }, standard_claims_subject::StandardClaimsSubject, validation::{ validate_token_aud, validate_token_exp, validate_token_issuer, validate_token_nonce, }, Bearer, Claims, Config, Configurable, Discovered, IdToken, OAuth2Error, Options, Provider, - StandardClaims, Token, Userinfo, + StandardClaims, Token, TokenIntrospection, Userinfo, }; use biscuit::{ @@ -433,6 +436,87 @@ impl Client { None => Err(ErrorUserinfo::NoUrl.into()), } } + + /// Get a token introspection json document for a given token at the provider's token introspection endpoint. + /// Returns [Token Introspection Response](https://datatracker.ietf.org/doc/html/rfc7662#section-2.2) + /// as [TokenIntrospection] struct. + /// + /// # Errors + /// + /// - [Error::Http] if something goes wrong getting the document + /// - [Error::Insecure] if the token introspection url is not https + /// - [Error::Json] if the response is not a valid TokenIntrospection document + /// - [ErrorIntrospection::MissingContentType] if content-type header is missing + /// - [ErrorIntrospection::NoUrl] if this provider doesn't have a token introspection endpoint + /// - [ErrorIntrospection::ParseContentType] if content-type header is not parsable + /// - [ErrorIntrospection::WrongContentType] if content-type header is not accepted + pub async fn request_token_introspection( + &self, + token: &Token, + ) -> Result, Error> + where + I: CompactJson, + { + match self.config().introspection_endpoint { + Some(ref url) => { + let access_token = token.bearer.access_token.to_string(); + + let body = { + let mut body = Serializer::new(String::new()); + body.append_pair("token", &access_token); + body.finish() + }; + + let response = self + .http_client + .post(url.clone()) + .basic_auth(&self.client_id, self.client_secret.as_ref()) + .header(ACCEPT, "application/json") + .header(CONTENT_TYPE, "application/x-www-form-urlencoded") + .body(body) + .send() + .await? + .error_for_status()?; + + let content_type = response + .headers() + .get(&CONTENT_TYPE) + .and_then(|content_type| content_type.to_str().ok()) + .ok_or(ErrorIntrospection::MissingContentType)?; + + let mime_type = match content_type { + "application/json" => mime::APPLICATION_JSON, + content_type => content_type.parse::().map_err(|_| { + ErrorIntrospection::ParseContentType { + content_type: content_type.to_string(), + } + })?, + }; + + let info: TokenIntrospection = + match (mime_type.type_(), mime_type.subtype().as_str()) { + (mime::APPLICATION, "json") => { + let info_value: Value = response.json().await?; + if info_value.get("error").is_some() { + let oauth2_error: OAuth2Error = serde_json::from_value(info_value)?; + return Err(Error::ClientError(oauth2_error.into())); + } + serde_json::from_value(info_value)? + } + _ => { + return Err(ErrorIntrospection::WrongContentType { + content_type: content_type.to_string(), + body: response.bytes().await?.to_vec(), + } + .into()) + } + }; + + Ok(info) + } + None => Err(ErrorIntrospection::NoUrl.into()), + } + } } impl Client diff --git a/src/config.rs b/src/config.rs index 591bd96..dd91e03 100644 --- a/src/config.rs +++ b/src/config.rs @@ -10,7 +10,7 @@ pub struct Config { // TODO For now, we only support code flows. pub token_endpoint: Url, #[serde(default)] - pub token_introspection_endpoint: Option, + pub introspection_endpoint: Option, #[serde(default)] pub userinfo_endpoint: Option, #[serde(default)] diff --git a/src/error.rs b/src/error.rs index 7b11ca2..8ca0bcc 100644 --- a/src/error.rs +++ b/src/error.rs @@ -177,6 +177,8 @@ pub enum Error { Validation(#[from] Validation), #[error(transparent)] Userinfo(#[from] Userinfo), + #[error(transparent)] + Introspection(#[from] Introspection), #[error("Url must use TLS: '{0}'")] Insecure(::reqwest::Url), #[error("Scope must contain Openid")] @@ -263,6 +265,18 @@ pub enum Userinfo { #[error("The sub (subject) Claim MUST always be returned in the UserInfo Response")] pub struct StandardClaimsSubjectMissing; +#[derive(Debug, Error)] +pub enum Introspection { + #[error("Config has no introspection url")] + NoUrl, + #[error("The Introspection Endpoint MUST return a content-type header to indicate which format is being returned")] + MissingContentType, + #[error("Not parsable content type header: {content_type}")] + ParseContentType { content_type: String }, + #[error("Wrong content type header: {content_type}. The following are accepted content types: application/json")] + WrongContentType { content_type: String, body: Vec }, +} + #[cfg(test)] mod tests { use serde_json::json; diff --git a/src/lib.rs b/src/lib.rs index 44dd198..7705057 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -32,6 +32,7 @@ pub mod provider; mod standard_claims; mod standard_claims_subject; mod token; +mod token_introspection; mod userinfo; pub mod validation; @@ -59,6 +60,7 @@ pub use provider::Provider; pub use standard_claims::StandardClaims; pub use standard_claims_subject::StandardClaimsSubject; pub use token::Token; +pub use token_introspection::TokenIntrospection; pub use userinfo::Userinfo; /// Reimport `biscuit` dependency. diff --git a/src/token_introspection.rs b/src/token_introspection.rs new file mode 100644 index 0000000..d158aaf --- /dev/null +++ b/src/token_introspection.rs @@ -0,0 +1,84 @@ +use crate::SingleOrMultiple; +use biscuit::CompactJson; +use serde::{Deserialize, Serialize}; +use url::Url; + +/// This struct contains all fields defined in [the spec](https://datatracker.ietf.org/doc/html/rfc7662#section-2.2). +#[derive(Debug, Deserialize, Serialize, Clone, Eq, PartialEq)] +pub struct TokenIntrospection { + #[serde(default)] + /// Boolean indicator of whether or not the presented token is currently active. The specifics + /// of a token's "active" state will vary depending on the implementation of the authorization + /// server and the information it keeps about its tokens, but a "true" value return for the + /// "active" property will generally indicate that a given token has been issued by this + /// authorization server, has not been revoked by the resource owner, and is within its given + /// time window of validity (e.g., after its issuance time and before its expiration time). + /// See [Section 4](https://datatracker.ietf.org/doc/html/rfc7662#section-4) for information on + /// implementation of such checks. + pub active: bool, + + #[serde(default)] + /// A JSON string containing a space-separated list of scopes associated with this token, + /// in the format described in [Section 3.3](https://datatracker.ietf.org/doc/html/rfc7662#section-3.3) + /// of OAuth 2.0 [RFC6749](https://datatracker.ietf.org/doc/html/rfc6749). + pub scope: Option, + + #[serde(default)] + /// Client identifier for the OAuth 2.0 client that requested this token. + pub client_id: Option, + + #[serde(default)] + /// Human-readable identifier for the resource owner who authorized this token. + pub username: Option, + + #[serde(default)] + /// Type of the token as defined in [Section 5.1](https://datatracker.ietf.org/doc/html/rfc7662#section-5.1) + /// of OAuth 2.0 [RFC6749](https://datatracker.ietf.org/doc/html/rfc6749). + pub token_type: Option, + + // Not perfectly accurate for what time values we can get back... + // By spec, this is an arbitrarilly large number. In practice, an + // i64 unix time is up to 293 billion years from 1970. + // + // Make sure this cannot silently underflow, see: + // https://github.com/serde-rs/json/blob/8e01f44f479b3ea96b299efc0da9131e7aff35dc/src/de.rs#L341 + #[serde(default)] + /// Integer timestamp, measured in the number of seconds since January 1 1970 UTC, indicating + /// when this token will expire, as defined in JWT [RFC7519](https://datatracker.ietf.org/doc/html/rfc7519). + pub exp: Option, + #[serde(default)] + /// Integer timestamp, measured in the number of seconds since January 1 1970 UTC, indicating + /// when this token was originally issued, as defined in JWT [RFC7519](https://datatracker.ietf.org/doc/html/rfc7519). + pub iat: Option, + #[serde(default)] + /// Integer timestamp, measured in the number of seconds since January 1 1970 UTC, indicating + /// when this token is not to be used before, as defined in JWT [RFC7519](https://datatracker.ietf.org/doc/html/rfc7519). + pub nbf: Option, + + // Max 255 ASCII chars + // Can't deserialize a [u8; 255] + #[serde(default)] + /// Subject of the token, as defined in JWT [RFC7519](https://datatracker.ietf.org/doc/html/rfc7519). + /// Usually a machine-readable identifier of the resource owner who authorized this token. + pub sub: Option, + + // Either an array of audiences, or just the client_id + #[serde(default)] + /// Service-specific string identifier or list of string identifiers representing the intended + /// audience for this token, as defined in JWT [RFC7519](https://datatracker.ietf.org/doc/html/rfc7519). + pub aud: Option>, + + #[serde(default)] + /// String representing the issuer of this token, as defined in JWT [RFC7519](https://datatracker.ietf.org/doc/html/rfc7519). + pub iss: Option, + + #[serde(default)] + /// String identifier for the token, as defined in JWT [RFC7519](https://datatracker.ietf.org/doc/html/rfc7519). + pub jti: Option, + + #[serde(flatten)] + /// Any custom fields which are not defined in the RFC. + pub custom: Option, +} + +impl biscuit::CompactJson for TokenIntrospection where I: CompactJson {}