mirror of
https://github.com/kanidm/kanidm.git
synced 2025-02-24 04:57:00 +01:00
20211010 rfc7662 token introspect (#607)
This commit is contained in:
parent
4ef064e4ed
commit
761bed0569
|
@ -67,7 +67,7 @@ Kanidm will expose it's oauth2 apis at the urls:
|
||||||
For an authorisation to proceed, the resource server will request a list of scopes, which are
|
For an authorisation to proceed, the resource server will request a list of scopes, which are
|
||||||
unique to that resource server. For example, when a user wishes to login to the admin panel
|
unique to that resource server. For example, when a user wishes to login to the admin panel
|
||||||
of the resource server, it may request the "admin" scope from kanidm for authorisation. But when
|
of the resource server, it may request the "admin" scope from kanidm for authorisation. But when
|
||||||
a user wants to login, it may only request "acces" as a scope from kanidm.
|
a user wants to login, it may only request "access" as a scope from kanidm.
|
||||||
|
|
||||||
As each resource server may have it's own scopes and understanding of these, Kanidm isolates
|
As each resource server may have it's own scopes and understanding of these, Kanidm isolates
|
||||||
scopes to each resource server connected to Kanidm. Kanidm has two methods of granting scopes to accounts (users).
|
scopes to each resource server connected to Kanidm. Kanidm has two methods of granting scopes to accounts (users).
|
||||||
|
|
|
@ -2,7 +2,10 @@ mod common;
|
||||||
use crate::common::{run_test, ADMIN_TEST_PASSWORD};
|
use crate::common::{run_test, ADMIN_TEST_PASSWORD};
|
||||||
use kanidm_client::KanidmClient;
|
use kanidm_client::KanidmClient;
|
||||||
|
|
||||||
use kanidm_proto::oauth2::{AccessTokenRequest, AccessTokenResponse, ConsentRequest};
|
use kanidm_proto::oauth2::{
|
||||||
|
AccessTokenIntrospectRequest, AccessTokenIntrospectResponse, AccessTokenRequest,
|
||||||
|
AccessTokenResponse, ConsentRequest,
|
||||||
|
};
|
||||||
use oauth2_ext::PkceCodeChallenge;
|
use oauth2_ext::PkceCodeChallenge;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
@ -169,7 +172,7 @@ fn test_oauth2_basic_flow() {
|
||||||
|
|
||||||
let response = client
|
let response = client
|
||||||
.post(format!("{}/oauth2/token", url))
|
.post(format!("{}/oauth2/token", url))
|
||||||
.basic_auth("test_integration", Some(client_secret))
|
.basic_auth("test_integration", Some(client_secret.clone()))
|
||||||
.form(&form_req)
|
.form(&form_req)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
|
@ -180,12 +183,45 @@ fn test_oauth2_basic_flow() {
|
||||||
|
|
||||||
// The body is a json AccessTokenResponse
|
// The body is a json AccessTokenResponse
|
||||||
|
|
||||||
let _atr = response
|
let atr = response
|
||||||
.json::<AccessTokenResponse>()
|
.json::<AccessTokenResponse>()
|
||||||
.await
|
.await
|
||||||
.expect("Unable to decode AccessTokenResponse");
|
.expect("Unable to decode AccessTokenResponse");
|
||||||
|
|
||||||
// Step 4 - inspect the granted token.
|
// Step 4 - inspect the granted token.
|
||||||
|
let intr_request = AccessTokenIntrospectRequest {
|
||||||
|
token: atr.access_token.clone(),
|
||||||
|
token_type_hint: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.post(format!("{}/oauth2/token/introspect", url))
|
||||||
|
.basic_auth("test_integration", Some(client_secret))
|
||||||
|
.form(&intr_request)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("Failed to send token introspect request.");
|
||||||
|
|
||||||
|
assert!(response.status() == reqwest::StatusCode::OK);
|
||||||
|
assert_no_cache!(response);
|
||||||
|
|
||||||
|
let tir = response
|
||||||
|
.json::<AccessTokenIntrospectResponse>()
|
||||||
|
.await
|
||||||
|
.expect("Unable to decode AccessTokenIntrospectResponse");
|
||||||
|
|
||||||
|
assert!(tir.active);
|
||||||
|
assert!(tir.scope.is_some());
|
||||||
|
assert!(tir.client_id.as_deref() == Some("test_integration"));
|
||||||
|
assert!(tir.username.as_deref() == Some("admin@example.com"));
|
||||||
|
assert!(tir.token_type.as_deref() == Some("access_token"));
|
||||||
|
assert!(tir.exp.is_some());
|
||||||
|
assert!(tir.iat.is_some());
|
||||||
|
assert!(tir.nbf.is_some());
|
||||||
|
assert!(tir.sub.is_some());
|
||||||
|
assert!(tir.aud.is_none());
|
||||||
|
assert!(tir.iss.is_none());
|
||||||
|
assert!(tir.jti.is_none());
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -78,6 +78,60 @@ pub struct AccessTokenResponse {
|
||||||
pub scope: Option<String>,
|
pub scope: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
|
pub struct AccessTokenIntrospectRequest {
|
||||||
|
pub token: String,
|
||||||
|
/// https://datatracker.ietf.org/doc/html/rfc7009#section-4.1.2
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub token_type_hint: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
|
pub struct AccessTokenIntrospectResponse {
|
||||||
|
pub active: bool,
|
||||||
|
#[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 username: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub token_type: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub exp: Option<i64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub iat: Option<i64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub nbf: Option<i64>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub sub: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub aud: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub iss: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub jti: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AccessTokenIntrospectResponse {
|
||||||
|
pub fn inactive() -> Self {
|
||||||
|
AccessTokenIntrospectResponse {
|
||||||
|
active: false,
|
||||||
|
scope: None,
|
||||||
|
client_id: None,
|
||||||
|
username: None,
|
||||||
|
token_type: None,
|
||||||
|
exp: None,
|
||||||
|
iat: None,
|
||||||
|
nbf: None,
|
||||||
|
sub: None,
|
||||||
|
aud: None,
|
||||||
|
iss: None,
|
||||||
|
jti: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug)]
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
pub struct ErrorResponse {
|
pub struct ErrorResponse {
|
||||||
pub error: String,
|
pub error: String,
|
||||||
|
|
|
@ -18,8 +18,8 @@ use kanidm_proto::v1::{BackupCodesView, OperationError, RadiusAuthToken};
|
||||||
|
|
||||||
use crate::filter::{Filter, FilterInvalid};
|
use crate::filter::{Filter, FilterInvalid};
|
||||||
use crate::idm::oauth2::{
|
use crate::idm::oauth2::{
|
||||||
AccessTokenRequest, AccessTokenResponse, AuthorisationRequest, AuthorisePermitSuccess,
|
AccessTokenIntrospectRequest, AccessTokenIntrospectResponse, AccessTokenRequest,
|
||||||
ConsentRequest, Oauth2Error,
|
AccessTokenResponse, AuthorisationRequest, AuthorisePermitSuccess, ConsentRequest, Oauth2Error,
|
||||||
};
|
};
|
||||||
use crate::idm::server::{IdmServer, IdmServerTransaction};
|
use crate::idm::server::{IdmServer, IdmServerTransaction};
|
||||||
use crate::ldap::{LdapBoundToken, LdapResponseState, LdapServer};
|
use crate::ldap::{LdapBoundToken, LdapResponseState, LdapServer};
|
||||||
|
@ -1053,6 +1053,27 @@ impl QueryServerReadV1 {
|
||||||
res
|
res
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[instrument(
|
||||||
|
level = "trace",
|
||||||
|
name = "oauth2_token_introspect",
|
||||||
|
skip(self, client_authz, intr_req, eventid)
|
||||||
|
fields(uuid = ?eventid)
|
||||||
|
)]
|
||||||
|
pub async fn handle_oauth2_token_introspect(
|
||||||
|
&self,
|
||||||
|
client_authz: String,
|
||||||
|
intr_req: AccessTokenIntrospectRequest,
|
||||||
|
eventid: Uuid,
|
||||||
|
) -> Result<AccessTokenIntrospectResponse, Oauth2Error> {
|
||||||
|
let ct = duration_from_epoch_now();
|
||||||
|
let idms_prox_read = self.idms.proxy_read_async().await;
|
||||||
|
let res = spanned!("actors::v1_read::handle<Oauth2TokenIntrospect>", {
|
||||||
|
// Now we can send to the idm server for introspection checking.
|
||||||
|
idms_prox_read.check_oauth2_token_introspect(&client_authz, &intr_req, ct)
|
||||||
|
});
|
||||||
|
res
|
||||||
|
}
|
||||||
|
|
||||||
#[instrument(
|
#[instrument(
|
||||||
level = "trace",
|
level = "trace",
|
||||||
name = "auth_valid",
|
name = "auth_valid",
|
||||||
|
|
|
@ -399,11 +399,9 @@ pub fn create_https_server(
|
||||||
.post(oauth2_authorise_permit_post)
|
.post(oauth2_authorise_permit_post)
|
||||||
.get(oauth2_authorise_permit_get);
|
.get(oauth2_authorise_permit_get);
|
||||||
oauth2_process.at("/token").post(oauth2_token_post);
|
oauth2_process.at("/token").post(oauth2_token_post);
|
||||||
/*
|
|
||||||
oauth2_process
|
oauth2_process
|
||||||
.at("/token/introspect")
|
.at("/token/introspect")
|
||||||
.get(oauth2_token_introspect_get);
|
.post(oauth2_token_introspect_post);
|
||||||
*/
|
|
||||||
|
|
||||||
let mut raw_route = appserver.at("/v1/raw");
|
let mut raw_route = appserver.at("/v1/raw");
|
||||||
raw_route.at("/create").post(create);
|
raw_route.at("/create").post(create);
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
use super::v1::{json_rest_event_get, json_rest_event_post};
|
use super::v1::{json_rest_event_get, json_rest_event_post};
|
||||||
use super::{to_tide_response, AppState, RequestExtensions};
|
use super::{to_tide_response, AppState, RequestExtensions};
|
||||||
use crate::idm::oauth2::{
|
use crate::idm::oauth2::{
|
||||||
AccessTokenRequest, AuthorisationRequest, AuthorisePermitSuccess, ErrorResponse, Oauth2Error,
|
AccessTokenIntrospectRequest, AccessTokenRequest, AuthorisationRequest, AuthorisePermitSuccess,
|
||||||
|
ErrorResponse, Oauth2Error,
|
||||||
};
|
};
|
||||||
use crate::prelude::*;
|
use crate::prelude::*;
|
||||||
use kanidm_proto::v1::Entry as ProtoEntry;
|
use kanidm_proto::v1::Entry as ProtoEntry;
|
||||||
|
@ -401,7 +402,70 @@ pub async fn get_openid_configuration(_req: tide::Request<AppState>) -> tide::Re
|
||||||
Ok(res)
|
Ok(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
pub async fn oauth2_token_introspect_post(mut req: tide::Request<AppState>) -> tide::Result {
|
||||||
pub async fn oauth2_token_introspect_get(req: tide::Request<AppState>) -> tide::Result {
|
// This is called directly by the resource server, where we then issue
|
||||||
|
// information about this token to the caller.
|
||||||
|
let (eventid, hvalue) = req.new_eventid();
|
||||||
|
|
||||||
|
let client_authz = req
|
||||||
|
.header("authorization")
|
||||||
|
.and_then(|hv| hv.get(0))
|
||||||
|
.and_then(|h| h.as_str().strip_prefix("Basic "))
|
||||||
|
.map(str::to_string)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
error!("Basic Authentication Not Provided");
|
||||||
|
tide::Error::from_str(
|
||||||
|
tide::StatusCode::Unauthorized,
|
||||||
|
"Invalid Basic Authorisation",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Get the introspection request, could we accept json or form? Prob needs content type here.
|
||||||
|
let intr_req: AccessTokenIntrospectRequest = req.body_form().await.map_err(|e| {
|
||||||
|
request_error!("{:?}", e);
|
||||||
|
tide::Error::from_str(
|
||||||
|
tide::StatusCode::BadRequest,
|
||||||
|
"Invalid Oauth2 AccessTokenIntrospectRequest",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
request_trace!("Introspect Request - {:?}", intr_req);
|
||||||
|
|
||||||
|
let res = req
|
||||||
|
.state()
|
||||||
|
.qe_r_ref
|
||||||
|
.handle_oauth2_token_introspect(client_authz, intr_req, eventid)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match res {
|
||||||
|
Ok(atr) => {
|
||||||
|
let mut res = tide::Response::new(200);
|
||||||
|
tide::Body::from_json(&atr).map(|b| {
|
||||||
|
res.set_body(b);
|
||||||
|
res
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Err(Oauth2Error::AuthenticationRequired) => {
|
||||||
|
// This will trigger our ui to auth and retry.
|
||||||
|
Ok(tide::Response::new(tide::StatusCode::Unauthorized))
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
// https://datatracker.ietf.org/doc/html/rfc6749#section-5.2
|
||||||
|
let err = ErrorResponse {
|
||||||
|
error: e.to_string(),
|
||||||
|
error_description: None,
|
||||||
|
error_uri: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut res = tide::Response::new(400);
|
||||||
|
tide::Body::from_json(&err).map(|b| {
|
||||||
|
res.set_body(b);
|
||||||
|
res
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.map(|mut res| {
|
||||||
|
res.insert_header("X-KANIDM-OPID", hvalue);
|
||||||
|
res
|
||||||
|
})
|
||||||
}
|
}
|
||||||
*/
|
|
||||||
|
|
|
@ -6,6 +6,7 @@
|
||||||
//!
|
//!
|
||||||
|
|
||||||
use crate::identity::IdentityId;
|
use crate::identity::IdentityId;
|
||||||
|
use crate::idm::server::{IdmServerProxyReadTransaction, IdmServerTransaction};
|
||||||
use crate::prelude::*;
|
use crate::prelude::*;
|
||||||
use concread::cowcell::*;
|
use concread::cowcell::*;
|
||||||
use fernet::Fernet;
|
use fernet::Fernet;
|
||||||
|
@ -13,6 +14,7 @@ use hashbrown::HashMap;
|
||||||
use kanidm_proto::v1::UserAuthToken;
|
use kanidm_proto::v1::UserAuthToken;
|
||||||
use openssl::sha;
|
use openssl::sha;
|
||||||
use std::collections::{BTreeMap, BTreeSet};
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
use std::fmt;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use time::OffsetDateTime;
|
use time::OffsetDateTime;
|
||||||
use tracing::trace;
|
use tracing::trace;
|
||||||
|
@ -20,8 +22,8 @@ use url::{Origin, Url};
|
||||||
use webauthn_rs::base64_data::Base64UrlSafeData;
|
use webauthn_rs::base64_data::Base64UrlSafeData;
|
||||||
|
|
||||||
pub use kanidm_proto::oauth2::{
|
pub use kanidm_proto::oauth2::{
|
||||||
AccessTokenRequest, AccessTokenResponse, AuthorisationRequest, CodeChallengeMethod,
|
AccessTokenIntrospectRequest, AccessTokenIntrospectResponse, AccessTokenRequest,
|
||||||
ConsentRequest, ErrorResponse,
|
AccessTokenResponse, AuthorisationRequest, CodeChallengeMethod, ConsentRequest, ErrorResponse,
|
||||||
};
|
};
|
||||||
|
|
||||||
use std::convert::TryFrom;
|
use std::convert::TryFrom;
|
||||||
|
@ -82,8 +84,6 @@ struct ConsentToken {
|
||||||
pub scopes: Vec<String>,
|
pub scopes: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// consent token?
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug)]
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
struct TokenExchangeCode {
|
struct TokenExchangeCode {
|
||||||
// We don't need the client_id here, because it's signed with an RS specific
|
// We don't need the client_id here, because it's signed with an RS specific
|
||||||
|
@ -97,6 +97,32 @@ struct TokenExchangeCode {
|
||||||
pub scopes: Vec<String>,
|
pub scopes: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
|
enum Oauth2TokenType {
|
||||||
|
Access,
|
||||||
|
Refresh,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for Oauth2TokenType {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Oauth2TokenType::Access => write!(f, "access_token"),
|
||||||
|
Oauth2TokenType::Refresh => write!(f, "refresh_token"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
|
struct Oauth2UserToken {
|
||||||
|
pub toktype: Oauth2TokenType,
|
||||||
|
pub uat: UserAuthToken,
|
||||||
|
pub scopes: Vec<String>,
|
||||||
|
// Oauth2 exp is seperate to uat expiry
|
||||||
|
pub exp: i64,
|
||||||
|
pub iat: i64,
|
||||||
|
pub nbf: i64,
|
||||||
|
}
|
||||||
|
|
||||||
// consentPermitResponse
|
// consentPermitResponse
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
|
@ -476,34 +502,10 @@ impl Oauth2ResourceServersReadTransaction {
|
||||||
return Err(Oauth2Error::InvalidRequest);
|
return Err(Oauth2Error::InvalidRequest);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check the client_authz
|
let (client_id, secret) = parse_basic_authz(client_authz)?;
|
||||||
let authz = base64::decode(&client_authz)
|
|
||||||
.map_err(|_| {
|
|
||||||
admin_error!("Basic authz invalid base64");
|
|
||||||
Oauth2Error::AuthenticationRequired
|
|
||||||
})
|
|
||||||
.and_then(|data| {
|
|
||||||
String::from_utf8(data).map_err(|_| {
|
|
||||||
admin_error!("Basic authz invalid utf8");
|
|
||||||
Oauth2Error::AuthenticationRequired
|
|
||||||
})
|
|
||||||
})?;
|
|
||||||
|
|
||||||
// Get the first :, it should be our delim.
|
|
||||||
//
|
|
||||||
let mut split_iter = authz.split(':');
|
|
||||||
|
|
||||||
let client_id = split_iter.next().ok_or_else(|| {
|
|
||||||
admin_error!("Basic authz invalid format (corrupt input?)");
|
|
||||||
Oauth2Error::AuthenticationRequired
|
|
||||||
})?;
|
|
||||||
let secret = split_iter.next().ok_or_else(|| {
|
|
||||||
admin_error!("Basic authz invalid format (missing ':' seperator?)");
|
|
||||||
Oauth2Error::AuthenticationRequired
|
|
||||||
})?;
|
|
||||||
|
|
||||||
// Get the o2rs for the handle.
|
// Get the o2rs for the handle.
|
||||||
let o2rs = self.inner.rs_set.get(client_id).ok_or_else(|| {
|
let o2rs = self.inner.rs_set.get(&client_id).ok_or_else(|| {
|
||||||
admin_warn!("Invalid oauth2 client_id");
|
admin_warn!("Invalid oauth2 client_id");
|
||||||
Oauth2Error::AuthenticationRequired
|
Oauth2Error::AuthenticationRequired
|
||||||
})?;
|
})?;
|
||||||
|
@ -568,8 +570,20 @@ impl Oauth2ResourceServersReadTransaction {
|
||||||
Some(code_xchg.scopes.join(" "))
|
Some(code_xchg.scopes.join(" "))
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let iat = ct.as_secs() as i64;
|
||||||
|
|
||||||
|
let o2uat = Oauth2UserToken {
|
||||||
|
toktype: Oauth2TokenType::Access,
|
||||||
|
uat: code_xchg.uat,
|
||||||
|
scopes: code_xchg.scopes,
|
||||||
|
iat,
|
||||||
|
nbf: iat,
|
||||||
|
// TODO: Make configurable!
|
||||||
|
exp: iat + 480,
|
||||||
|
};
|
||||||
|
|
||||||
// If we are type == Uat, then we re-use the same encryption material here.
|
// If we are type == Uat, then we re-use the same encryption material here.
|
||||||
let access_token_data = serde_json::to_vec(&code_xchg.uat).map_err(|e| {
|
let access_token_data = serde_json::to_vec(&o2uat).map_err(|e| {
|
||||||
admin_error!(err = ?e, "Unable to encode uat data");
|
admin_error!(err = ?e, "Unable to encode uat data");
|
||||||
Oauth2Error::ServerError(OperationError::SerdeJsonError)
|
Oauth2Error::ServerError(OperationError::SerdeJsonError)
|
||||||
})?;
|
})?;
|
||||||
|
@ -588,6 +602,110 @@ impl Oauth2ResourceServersReadTransaction {
|
||||||
scope,
|
scope,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn check_oauth2_token_introspect(
|
||||||
|
&self,
|
||||||
|
idms: &IdmServerProxyReadTransaction<'_>,
|
||||||
|
client_authz: &str,
|
||||||
|
intr_req: &AccessTokenIntrospectRequest,
|
||||||
|
ct: Duration,
|
||||||
|
) -> Result<AccessTokenIntrospectResponse, Oauth2Error> {
|
||||||
|
let (client_id, secret) = parse_basic_authz(client_authz)?;
|
||||||
|
|
||||||
|
// Get the o2rs for the handle.
|
||||||
|
let o2rs = self.inner.rs_set.get(&client_id).ok_or_else(|| {
|
||||||
|
admin_warn!("Invalid oauth2 client_id");
|
||||||
|
Oauth2Error::AuthenticationRequired
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// check the secret.
|
||||||
|
if o2rs.authz_secret != secret {
|
||||||
|
security_info!("Invalid oauth2 client_id secret");
|
||||||
|
return Err(Oauth2Error::AuthenticationRequired);
|
||||||
|
}
|
||||||
|
// We are authenticated! Yay! Now we can actually check things ...
|
||||||
|
|
||||||
|
// In these cases instead of error, we need to return Active:false
|
||||||
|
// Check the Token is correctly signed.
|
||||||
|
let access_token: Result<Oauth2UserToken, _> = o2rs
|
||||||
|
.token_fernet
|
||||||
|
.decrypt_at_time(&intr_req.token, None, ct.as_secs())
|
||||||
|
.map_err(|_| {
|
||||||
|
admin_error!("Failed to decrypt access token introspect request");
|
||||||
|
Oauth2Error::InvalidRequest
|
||||||
|
})
|
||||||
|
.and_then(|data| {
|
||||||
|
serde_json::from_slice(&data).map_err(|e| {
|
||||||
|
admin_error!("Failed to deserialise access token - {:?}", e);
|
||||||
|
Oauth2Error::InvalidRequest
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
let access_token = match access_token {
|
||||||
|
Ok(a) => a,
|
||||||
|
Err(_) => return Ok(AccessTokenIntrospectResponse::inactive()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let valid = idms
|
||||||
|
.check_uat_valid(&access_token.uat, ct)
|
||||||
|
.map_err(|_| admin_error!("Account is not valid"));
|
||||||
|
|
||||||
|
match valid {
|
||||||
|
Ok(true) => {}
|
||||||
|
_ => return Ok(AccessTokenIntrospectResponse::inactive()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let scope = if access_token.scopes.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(access_token.scopes.join(" "))
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(AccessTokenIntrospectResponse {
|
||||||
|
active: true,
|
||||||
|
scope,
|
||||||
|
client_id: Some(client_id),
|
||||||
|
username: Some(access_token.uat.spn.clone()),
|
||||||
|
token_type: Some(access_token.toktype.to_string()),
|
||||||
|
exp: Some(access_token.exp),
|
||||||
|
iat: Some(access_token.iat),
|
||||||
|
nbf: Some(access_token.nbf),
|
||||||
|
sub: Some(access_token.uat.uuid.to_string()),
|
||||||
|
aud: None,
|
||||||
|
iss: None,
|
||||||
|
jti: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_basic_authz(client_authz: &str) -> Result<(String, String), Oauth2Error> {
|
||||||
|
// Check the client_authz
|
||||||
|
let authz = base64::decode(&client_authz)
|
||||||
|
.map_err(|_| {
|
||||||
|
admin_error!("Basic authz invalid base64");
|
||||||
|
Oauth2Error::AuthenticationRequired
|
||||||
|
})
|
||||||
|
.and_then(|data| {
|
||||||
|
String::from_utf8(data).map_err(|_| {
|
||||||
|
admin_error!("Basic authz invalid utf8");
|
||||||
|
Oauth2Error::AuthenticationRequired
|
||||||
|
})
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Get the first :, it should be our delim.
|
||||||
|
//
|
||||||
|
let mut split_iter = authz.split(':');
|
||||||
|
|
||||||
|
let client_id = split_iter.next().ok_or_else(|| {
|
||||||
|
admin_error!("Basic authz invalid format (corrupt input?)");
|
||||||
|
Oauth2Error::AuthenticationRequired
|
||||||
|
})?;
|
||||||
|
let secret = split_iter.next().ok_or_else(|| {
|
||||||
|
admin_error!("Basic authz invalid format (missing ':' seperator?)");
|
||||||
|
Oauth2Error::AuthenticationRequired
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok((client_id.to_string(), secret.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
@ -597,6 +715,9 @@ mod tests {
|
||||||
use crate::idm::server::{IdmServer, IdmServerTransaction};
|
use crate::idm::server::{IdmServer, IdmServerTransaction};
|
||||||
use crate::prelude::*;
|
use crate::prelude::*;
|
||||||
|
|
||||||
|
use crate::event::ModifyEvent;
|
||||||
|
use crate::modify::{Modify, ModifyList};
|
||||||
|
|
||||||
use kanidm_proto::oauth2::*;
|
use kanidm_proto::oauth2::*;
|
||||||
use kanidm_proto::v1::{AuthType, UserAuthToken};
|
use kanidm_proto::v1::{AuthType, UserAuthToken};
|
||||||
use webauthn_rs::base64_data::Base64UrlSafeData;
|
use webauthn_rs::base64_data::Base64UrlSafeData;
|
||||||
|
@ -1102,4 +1223,83 @@ mod tests {
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_idm_oauth2_token_introspect() {
|
||||||
|
run_idm_test!(|_qs: &QueryServer,
|
||||||
|
idms: &IdmServer,
|
||||||
|
_idms_delayed: &mut IdmServerDelayed| {
|
||||||
|
let ct = Duration::from_secs(TEST_CURRENT_TIME);
|
||||||
|
let (secret, uat, ident) = setup_oauth2_resource_server(idms, ct);
|
||||||
|
let client_authz = base64::encode(format!("test_resource_server:{}", secret));
|
||||||
|
|
||||||
|
let idms_prox_read = idms.proxy_read();
|
||||||
|
|
||||||
|
// == Setup the authorisation request
|
||||||
|
let (code_verifier, code_challenge) = create_code_verifier!("Whar Garble");
|
||||||
|
let consent_request =
|
||||||
|
good_authorisation_request!(idms_prox_read, &ident, &uat, ct, code_challenge);
|
||||||
|
|
||||||
|
// == Manually submit the consent token to the permit for the permit_success
|
||||||
|
let permit_success = idms_prox_read
|
||||||
|
.check_oauth2_authorise_permit(&ident, &uat, &consent_request.consent_token, ct)
|
||||||
|
.expect("Failed to perform oauth2 permit");
|
||||||
|
|
||||||
|
let token_req = AccessTokenRequest {
|
||||||
|
grant_type: "authorization_code".to_string(),
|
||||||
|
code: permit_success.code.clone(),
|
||||||
|
redirect_uri: Url::parse("https://demo.example.com/oauth2/result").unwrap(),
|
||||||
|
client_id: None,
|
||||||
|
code_verifier,
|
||||||
|
};
|
||||||
|
let oauth2_token = idms_prox_read
|
||||||
|
.check_oauth2_token_exchange(&client_authz, &token_req, ct)
|
||||||
|
.expect("Unable to exchange for oauth2 token");
|
||||||
|
|
||||||
|
// Okay, now we have the token, we can check it works with introspect.
|
||||||
|
let intr_request = AccessTokenIntrospectRequest {
|
||||||
|
token: oauth2_token.access_token.clone(),
|
||||||
|
token_type_hint: None,
|
||||||
|
};
|
||||||
|
let intr_response = idms_prox_read
|
||||||
|
.check_oauth2_token_introspect(&client_authz, &intr_request, ct)
|
||||||
|
.expect("Failed to inspect token");
|
||||||
|
|
||||||
|
assert!(intr_response.active);
|
||||||
|
assert!(intr_response.scope.as_deref() == Some("test"));
|
||||||
|
assert!(intr_response.client_id.as_deref() == Some("test_resource_server"));
|
||||||
|
assert!(intr_response.username.as_deref() == Some("admin@example.com"));
|
||||||
|
assert!(intr_response.token_type.as_deref() == Some("access_token"));
|
||||||
|
assert!(intr_response.iat == Some(ct.as_secs() as i64));
|
||||||
|
assert!(intr_response.nbf == Some(ct.as_secs() as i64));
|
||||||
|
|
||||||
|
drop(idms_prox_read);
|
||||||
|
// start a write,
|
||||||
|
|
||||||
|
let idms_prox_write = idms.proxy_write(ct);
|
||||||
|
// Expire the account, should cause introspect to return inactive.
|
||||||
|
let v_expire = Value::new_datetime_epoch(Duration::from_secs(TEST_CURRENT_TIME - 1));
|
||||||
|
let me_inv_m = unsafe {
|
||||||
|
ModifyEvent::new_internal_invalid(
|
||||||
|
filter!(f_eq("name", PartialValue::new_iname("admin"))),
|
||||||
|
ModifyList::new_list(vec![Modify::Present(
|
||||||
|
AttrString::from("account_expire"),
|
||||||
|
v_expire,
|
||||||
|
)]),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
// go!
|
||||||
|
assert!(idms_prox_write.qs_write.modify(&me_inv_m).is_ok());
|
||||||
|
assert!(idms_prox_write.commit().is_ok());
|
||||||
|
|
||||||
|
// start a new read
|
||||||
|
// check again.
|
||||||
|
let idms_prox_read = idms.proxy_read();
|
||||||
|
let intr_response = idms_prox_read
|
||||||
|
.check_oauth2_token_introspect(&client_authz, &intr_request, ct)
|
||||||
|
.expect("Failed to inspect token");
|
||||||
|
|
||||||
|
assert!(!intr_response.active);
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,8 +15,9 @@ use crate::idm::event::{
|
||||||
};
|
};
|
||||||
use crate::idm::mfareg::{MfaRegCred, MfaRegNext, MfaRegSession};
|
use crate::idm::mfareg::{MfaRegCred, MfaRegNext, MfaRegSession};
|
||||||
use crate::idm::oauth2::{
|
use crate::idm::oauth2::{
|
||||||
AccessTokenRequest, AccessTokenResponse, AuthorisationRequest, AuthorisePermitSuccess,
|
AccessTokenIntrospectRequest, AccessTokenIntrospectResponse, AccessTokenRequest,
|
||||||
ConsentRequest, Oauth2Error, Oauth2ResourceServers, Oauth2ResourceServersReadTransaction,
|
AccessTokenResponse, AuthorisationRequest, AuthorisePermitSuccess, ConsentRequest, Oauth2Error,
|
||||||
|
Oauth2ResourceServers, Oauth2ResourceServersReadTransaction,
|
||||||
Oauth2ResourceServersWriteTransaction,
|
Oauth2ResourceServersWriteTransaction,
|
||||||
};
|
};
|
||||||
use crate::idm::radius::RadiusAccount;
|
use crate::idm::radius::RadiusAccount;
|
||||||
|
@ -346,7 +347,6 @@ pub trait IdmServerTransaction<'a> {
|
||||||
|
|
||||||
fn get_uat_bundy_txn(&self) -> &HS512;
|
fn get_uat_bundy_txn(&self) -> &HS512;
|
||||||
|
|
||||||
// ! TRACING INTEGRATED
|
|
||||||
fn validate_and_parse_uat(
|
fn validate_and_parse_uat(
|
||||||
&self,
|
&self,
|
||||||
token: Option<&str>,
|
token: Option<&str>,
|
||||||
|
@ -373,7 +373,22 @@ pub trait IdmServerTransaction<'a> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ! TRACING INTEGRATED
|
fn check_uat_valid(&self, uat: &UserAuthToken, ct: Duration) -> Result<bool, OperationError> {
|
||||||
|
let entry = self
|
||||||
|
.get_qs_txn()
|
||||||
|
.internal_search_uuid(&uat.uuid)
|
||||||
|
.map_err(|e| {
|
||||||
|
admin_error!(?e, "from_ro_uat failed");
|
||||||
|
e
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(Account::check_within_valid_time(
|
||||||
|
ct,
|
||||||
|
entry.get_ava_single_datetime("account_valid_from").as_ref(),
|
||||||
|
entry.get_ava_single_datetime("account_expire").as_ref(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
fn process_uat_to_identity(
|
fn process_uat_to_identity(
|
||||||
&self,
|
&self,
|
||||||
uat: &UserAuthToken,
|
uat: &UserAuthToken,
|
||||||
|
@ -1076,6 +1091,16 @@ impl<'a> IdmServerProxyReadTransaction<'a> {
|
||||||
self.oauth2rs
|
self.oauth2rs
|
||||||
.check_oauth2_token_exchange(client_authz, token_req, ct)
|
.check_oauth2_token_exchange(client_authz, token_req, ct)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn check_oauth2_token_introspect(
|
||||||
|
&self,
|
||||||
|
client_authz: &str,
|
||||||
|
intr_req: &AccessTokenIntrospectRequest,
|
||||||
|
ct: Duration,
|
||||||
|
) -> Result<AccessTokenIntrospectResponse, Oauth2Error> {
|
||||||
|
self.oauth2rs
|
||||||
|
.check_oauth2_token_introspect(self, client_authz, intr_req, ct)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> IdmServerTransaction<'a> for IdmServerProxyWriteTransaction<'a> {
|
impl<'a> IdmServerTransaction<'a> for IdmServerProxyWriteTransaction<'a> {
|
||||||
|
|
Loading…
Reference in a new issue