Skip to content

Commit

Permalink
Implement OAuth 2.0 token introspection (kilork#55)
Browse files Browse the repository at this point in the history
* 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 <alexander.korolev.germany@gmail.com>
  • Loading branch information
3 people authored Sep 5, 2024
1 parent f8c1ee8 commit 094def9
Show file tree
Hide file tree
Showing 6 changed files with 189 additions and 3 deletions.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
88 changes: 86 additions & 2 deletions src/client.rs
Original file line number Diff line number Diff line change
@@ -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::{
Expand Down Expand Up @@ -433,6 +436,87 @@ impl<C: CompactJson + Claims, P: Provider + Configurable> Client<P, C> {
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<I>(
&self,
token: &Token<C>,
) -> Result<TokenIntrospection<I>, 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::<mime::Mime>().map_err(|_| {
ErrorIntrospection::ParseContentType {
content_type: content_type.to_string(),
}
})?,
};

let info: TokenIntrospection<I> =
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<P, C> Client<P, C>
Expand Down
2 changes: 1 addition & 1 deletion src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Url>,
pub introspection_endpoint: Option<Url>,
#[serde(default)]
pub userinfo_endpoint: Option<Url>,
#[serde(default)]
Expand Down
14 changes: 14 additions & 0 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down Expand Up @@ -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<u8> },
}

#[cfg(test)]
mod tests {
use serde_json::json;
Expand Down
2 changes: 2 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ pub mod provider;
mod standard_claims;
mod standard_claims_subject;
mod token;
mod token_introspection;
mod userinfo;
pub mod validation;

Expand Down Expand Up @@ -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.
Expand Down
84 changes: 84 additions & 0 deletions src/token_introspection.rs
Original file line number Diff line number Diff line change
@@ -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<I> {
#[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<String>,

#[serde(default)]
/// Client identifier for the OAuth 2.0 client that requested this token.
pub client_id: Option<String>,

#[serde(default)]
/// Human-readable identifier for the resource owner who authorized this token.
pub username: Option<String>,

#[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<String>,

// 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<i64>,
#[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<i64>,
#[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<i64>,

// 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<String>,

// 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<SingleOrMultiple<String>>,

#[serde(default)]
/// String representing the issuer of this token, as defined in JWT [RFC7519](https://datatracker.ietf.org/doc/html/rfc7519).
pub iss: Option<Url>,

#[serde(default)]
/// String identifier for the token, as defined in JWT [RFC7519](https://datatracker.ietf.org/doc/html/rfc7519).
pub jti: Option<String>,

#[serde(flatten)]
/// Any custom fields which are not defined in the RFC.
pub custom: Option<I>,
}

impl<I> biscuit::CompactJson for TokenIntrospection<I> where I: CompactJson {}

0 comments on commit 094def9

Please sign in to comment.