Skip to content

Commit

Permalink
Merge pull request #537 from sebadob/introspect-followup
Browse files Browse the repository at this point in the history
`/introspect` enhancements
  • Loading branch information
sebadob committed Aug 9, 2024
2 parents 2e84ceb + 05656f8 commit 7087a59
Show file tree
Hide file tree
Showing 8 changed files with 83 additions and 42 deletions.
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

The introspection endpoint has been fixed in case of the encoding like mentioned in bugfixes.
Additionally, authorization has been added to this endpoint. It will now make sure that the request also includes
an `AUTHORIZATION` header with either a valid `Bearer JwtToken` or `Basic B64EncodedClientId:ClientSecret` to prevent
an `AUTHORIZATION` header with either a valid `Bearer JwtToken` or `Basic ClientId:ClientSecret` to prevent
token scanning.

The way of authorization on this endpoint is not really standardized, so you may run into issues with your client
Expand All @@ -23,6 +23,9 @@ application. If so, you can disable the authentication on this endpoint with
DANGER_DISABLE_INTROSPECT_AUTH=true
```

[]()
[]()

#### Config Read

The current behavior of reading in config variables was not working as intended.
Expand Down
20 changes: 10 additions & 10 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ members = ["src/*"]
exclude = ["rauthy-client"]

[workspace.package]
version = "0.25.0-20240805"
version = "0.25.0-20240809"
edition = "2021"
authors = ["Sebastian Dobe <sebastiandobe@mailbox.org>"]
license = "Apache-2.0"
Expand Down
16 changes: 13 additions & 3 deletions src/api/src/oidc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -954,15 +954,25 @@ pub async fn post_token_info(
}

/// The token introspection endpoint for OAuth2
///
/// By default, this endpoint requires authorization.
/// You can authorize in 2 different ways:
/// 1. `Basic` auth with `client_id:client_secret`
/// 2. `Bearer` JWT token
///
/// If your client application can't provide any, you can disable authorization for this endpoint
/// by setting `DANGER_DISABLE_INTROSPECT_AUTH=true` in the Rauthy config.
/// Only do this, if you know what you are doing and have other ways to prevent public access to
/// this endpoint.
#[utoipa::path(
post,
path = "/oidc/introspect",
tag = "oidc",
request_body = TokenValidationRequest,
responses(
(status = 200, description = "Ok", body = TokenInfo),
(status = 401, description = "Unauthorized", body = ErrorResponse),
(status = 404, description = "NotFound", body = ErrorResponse),
(status = 200, description = "Ok", body = TokenInfo),
(status = 401, description = "Unauthorized", body = ErrorResponse),
(status = 404, description = "NotFound", body = ErrorResponse),
),
)]
#[post("/oidc/introspect")]
Expand Down
1 change: 1 addition & 0 deletions src/api/src/openapi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ use utoipa::{openapi, OpenApi};
oidc::get_session_xsrf,
oidc::post_token,
oidc::post_token_info,
oidc::post_token_introspect,
oidc::post_validate_token,
oidc::get_userinfo,
oidc::get_forward_auth,
Expand Down
10 changes: 9 additions & 1 deletion src/api_types/src/oidc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -411,16 +411,24 @@ pub struct SessionInfoResponse<'a> {
pub state: SessionState,
}

#[derive(Debug, Serialize, Deserialize, ToSchema)]
#[derive(Debug, Default, Serialize, Deserialize, ToSchema)]
pub struct TokenInfo {
pub active: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub sub: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub scope: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub client_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub aud: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub username: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub iat: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub nbf: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub exp: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cnf: Option<JktClaim>,
Expand Down
2 changes: 2 additions & 0 deletions src/models/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ pub struct JwtCommonClaims {
#[serde(skip_serializing_if = "Option::is_none")]
pub scope: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub preferred_username: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub did: Option<String>,
pub cnf: Option<JktClaim>,
}
Expand Down
69 changes: 43 additions & 26 deletions src/service/src/oidc/token_info.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,8 @@ use rauthy_error::{ErrorResponse, ErrorResponseType};
use rauthy_models::app_state::AppState;
use rauthy_models::entity::clients::Client;
use rauthy_models::{JwtAccessClaims, JwtCommonClaims};
use tracing::error;

/// Returns [TokenInfo](crate::models::response::TokenInfo) for the
/// [/oidc/introspect endpoint](crate::handlers::post_token_info)
pub async fn get_token_info(
data: &web::Data<AppState>,
req: &HttpRequest,
Expand All @@ -21,30 +20,35 @@ pub async fn get_token_info(
if claims_res.is_err() {
return Ok(TokenInfo {
active: false,
scope: None,
client_id: None,
username: None,
exp: None,
cnf: None,
..Default::default()
});
}
let claims = claims_res.unwrap();

if claims.audiences.is_none() {
error!("'aud' claim does not exist when it always should");
return Ok(TokenInfo {
active: false,
..Default::default()
});
}

let client_id = check_client_auth(data, req, claims.custom.azp).await?;

// scope does not exist for ID tokens, for all others unwrap is safe
let scope = claims.custom.scope;
let username = claims.subject;
let exp = claims.expires_at.unwrap().as_secs();
let cnf = claims.custom.cnf;
let aud_set = claims.audiences.unwrap().into_set();
let aud = aud_set.into_iter().collect::<Vec<_>>().first().cloned();

Ok(TokenInfo {
active: true,
scope,
sub: claims.subject,
scope: claims.custom.scope,
client_id: Some(client_id),
username,
exp: Some(exp),
cnf,
aud,
username: claims.custom.preferred_username,
iat: claims.issued_at.map(|ts| ts.as_secs()),
nbf: claims.invalid_before.map(|ts| ts.as_secs()),
exp: claims.expires_at.map(|ts| ts.as_secs()),
cnf: claims.custom.cnf,
})
}

Expand All @@ -61,43 +65,56 @@ async fn check_client_auth(
let header_value = match req.headers().get(AUTHORIZATION) {
None => {
return Err(ErrorResponse::new(
ErrorResponseType::Unauthorized,
"AUTHORIZATION header is missing",
ErrorResponseType::WWWAuthenticate("authorization-header-missing".to_string()),
"Authorization header is missing",
));
}
Some(h) => h,
};
let header = header_value.to_str().unwrap_or_default();

let client = Client::find(data, client_id).await.map_err(|_| {
ErrorResponse::new(
ErrorResponseType::WWWAuthenticate("client-not-found".to_string()),
"client does not exist anymore".to_string(),
)
})?;

if !client.enabled {
return Err(ErrorResponse::new(
ErrorResponseType::WWWAuthenticate("client-disabled".to_string()),
"client has been disabled".to_string(),
));
}

if let Some(token) = header.strip_prefix("Bearer ") {
validate_token::<JwtAccessClaims>(data, token).await?;
Ok(client_id)
Ok(client.id)
} else if let Some(basic) = header.strip_prefix("Basic ") {
let bytes = base64_decode(basic)?;
let decoded = String::from_utf8_lossy(&bytes);
let (id, secret) = match decoded.split_once(':') {
None => {
return Err(ErrorResponse::new(
ErrorResponseType::Unauthorized,
"invalid AUTHORIZATION header: cannot split into client_id:client_secret",
ErrorResponseType::WWWAuthenticate("invalid-authorization-header".to_string()),
"invalid Authorization header: cannot split into client_id:client_secret",
));
}
Some(split) => split,
};

if id != client_id {
if id != client.id {
return Err(ErrorResponse::new(
ErrorResponseType::Unauthorized,
"'client_id' from token does not match the one from the AUTHORIZATION header",
ErrorResponseType::WWWAuthenticate("invalid-client-id".to_string()),
"'client_id' from token does not match the one from the Authorization header",
));
}

let client = Client::find(data, client_id).await?;
client.validate_secret(secret, req)?;
Ok(client.id)
} else {
Err(ErrorResponse::new(
ErrorResponseType::Unauthorized,
ErrorResponseType::WWWAuthenticate("invalid-authorization-header".to_string()),
"invalid AUTHORIZATION header",
))
}
Expand Down

0 comments on commit 7087a59

Please sign in to comment.