Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement OAuth 2.0 token introspection #55

Merged
merged 2 commits into from
Sep 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 {}