mirror of
https://github.com/kanidm/kanidm.git
synced 2025-05-22 17:03:55 +02:00
* codespell run and spelling fixes * some clippying * minor fmt fix * making yamllint happy * adding codespell github action
3920 lines
156 KiB
Rust
3920 lines
156 KiB
Rust
//! Oauth2 resource server configurations
|
|
//!
|
|
//! This contains the in memory and loaded set of active oauth2 resource server
|
|
//! integrations, which are then able to be used an accessed from the IDM layer
|
|
//! for operations involving oauth2 authentication processing.
|
|
|
|
use std::collections::{BTreeMap, BTreeSet};
|
|
use std::convert::TryFrom;
|
|
use std::fmt;
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use base64urlsafedata::Base64UrlSafeData;
|
|
pub use compact_jwt::{JwkKeySet, OidcToken};
|
|
use compact_jwt::{JwsSigner, OidcClaims, OidcSubject};
|
|
use concread::cowcell::*;
|
|
use fernet::Fernet;
|
|
use hashbrown::HashMap;
|
|
pub use kanidm_proto::oauth2::{
|
|
AccessTokenIntrospectRequest, AccessTokenIntrospectResponse, AccessTokenRequest,
|
|
AccessTokenResponse, AuthorisationRequest, CodeChallengeMethod, ErrorResponse,
|
|
OidcDiscoveryResponse, TokenRevokeRequest,
|
|
};
|
|
use kanidm_proto::oauth2::{
|
|
ClaimType, DisplayValue, GrantType, IdTokenSignAlg, ResponseMode, ResponseType, SubjectType,
|
|
TokenEndpointAuthMethod,
|
|
};
|
|
use kanidm_proto::v1::{AuthType, UserAuthToken};
|
|
use openssl::sha;
|
|
use serde::{Deserialize, Serialize};
|
|
use time::OffsetDateTime;
|
|
use tracing::trace;
|
|
use url::{Origin, Url};
|
|
|
|
use crate::idm::account::Account;
|
|
use crate::idm::delayed::{DelayedAction, Oauth2ConsentGrant, Oauth2SessionRecord};
|
|
use crate::idm::server::{
|
|
IdmServerProxyReadTransaction, IdmServerProxyWriteTransaction, IdmServerTransaction,
|
|
};
|
|
use crate::prelude::*;
|
|
use crate::value::OAUTHSCOPE_RE;
|
|
|
|
#[derive(Serialize, Deserialize, Debug, PartialEq)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum Oauth2Error {
|
|
// Non-standard - these are used to guide some control flow.
|
|
AuthenticationRequired,
|
|
InvalidClientId,
|
|
InvalidOrigin,
|
|
// Standard
|
|
InvalidRequest,
|
|
UnauthorizedClient,
|
|
AccessDenied,
|
|
UnsupportedResponseType,
|
|
InvalidScope,
|
|
ServerError(OperationError),
|
|
TemporarilyUnavailable,
|
|
// from https://datatracker.ietf.org/doc/html/rfc6750
|
|
InvalidToken,
|
|
InsufficientScope,
|
|
// from https://datatracker.ietf.org/doc/html/rfc7009#section-2.2.1
|
|
UnsupportedTokenType,
|
|
}
|
|
|
|
impl std::fmt::Display for Oauth2Error {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.write_str(match self {
|
|
Oauth2Error::AuthenticationRequired => "authentication_required",
|
|
Oauth2Error::InvalidClientId => "invalid_client_id",
|
|
Oauth2Error::InvalidOrigin => "invalid_origin",
|
|
Oauth2Error::InvalidRequest => "invalid_request",
|
|
Oauth2Error::UnauthorizedClient => "unauthorized_client",
|
|
Oauth2Error::AccessDenied => "access_denied",
|
|
Oauth2Error::UnsupportedResponseType => "unsupported_response_type",
|
|
Oauth2Error::InvalidScope => "invalid_scope",
|
|
Oauth2Error::ServerError(_) => "server_error",
|
|
Oauth2Error::TemporarilyUnavailable => "temporarily_unavailable",
|
|
Oauth2Error::InvalidToken => "invalid_token",
|
|
Oauth2Error::InsufficientScope => "insufficient_scope",
|
|
Oauth2Error::UnsupportedTokenType => "unsupported_token_type",
|
|
})
|
|
}
|
|
}
|
|
|
|
// == internal state formats that we encrypt and send.
|
|
|
|
#[derive(Serialize, Deserialize, Debug, PartialEq)]
|
|
struct ConsentToken {
|
|
pub client_id: String,
|
|
// Must match the session id of the Uat,
|
|
pub session_id: Uuid,
|
|
// So we can ensure that we really match the same uat to prevent confusions.
|
|
pub ident_id: IdentityId,
|
|
// CSRF
|
|
pub state: String,
|
|
// The S256 code challenge.
|
|
pub code_challenge: Option<Base64UrlSafeData>,
|
|
// Where the RS wants us to go back to.
|
|
pub redirect_uri: Url,
|
|
// The scopes being granted
|
|
pub scopes: Vec<String>,
|
|
// We stash some details here for oidc.
|
|
pub nonce: Option<String>,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Debug)]
|
|
struct TokenExchangeCode {
|
|
// We don't need the client_id here, because it's signed with an RS specific
|
|
// key which gives us the assurance that it's the correct combination.
|
|
pub uat: UserAuthToken,
|
|
// The S256 code challenge.
|
|
pub code_challenge: Option<Base64UrlSafeData>,
|
|
// The original redirect uri
|
|
pub redirect_uri: Url,
|
|
// The scopes being granted
|
|
pub scopes: Vec<String>,
|
|
// We stash some details here for oidc.
|
|
pub nonce: Option<String>,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Debug)]
|
|
enum Oauth2TokenType {
|
|
Access {
|
|
scopes: Vec<String>,
|
|
parent_session_id: Uuid,
|
|
session_id: Uuid,
|
|
auth_type: AuthType,
|
|
expiry: time::OffsetDateTime,
|
|
uuid: Uuid,
|
|
iat: i64,
|
|
nbf: i64,
|
|
auth_time: Option<i64>,
|
|
},
|
|
Refresh {
|
|
parent_session_id: Uuid,
|
|
session_id: Uuid,
|
|
expiry: time::OffsetDateTime,
|
|
uuid: Uuid,
|
|
},
|
|
}
|
|
|
|
impl fmt::Display for Oauth2TokenType {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
match self {
|
|
Oauth2TokenType::Access { session_id, .. } => {
|
|
write!(f, "access_token ({}) ", session_id)
|
|
}
|
|
Oauth2TokenType::Refresh { session_id, .. } => {
|
|
write!(f, "refresh_token ({}) ", session_id)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub enum AuthoriseResponse {
|
|
ConsentRequested {
|
|
// A pretty-name of the client
|
|
client_name: String,
|
|
// A list of scopes requested / to be issued.
|
|
scopes: Vec<String>,
|
|
// Extra PII that may be requested
|
|
pii_scopes: Vec<String>,
|
|
// The users displayname (?)
|
|
// pub display_name: String,
|
|
// The token we need to be given back to allow this to proceed
|
|
consent_token: String,
|
|
},
|
|
Permitted(AuthorisePermitSuccess),
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct AuthorisePermitSuccess {
|
|
// Where the RS wants us to go back to.
|
|
pub redirect_uri: Url,
|
|
// The CSRF as a string
|
|
pub state: String,
|
|
// The exchange code as a String
|
|
pub code: String,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct Oauth2RS {
|
|
name: String,
|
|
displayname: String,
|
|
uuid: Uuid,
|
|
origin: Origin,
|
|
origin_https: bool,
|
|
scope_maps: BTreeMap<Uuid, BTreeSet<String>>,
|
|
sup_scope_maps: BTreeMap<Uuid, BTreeSet<String>>,
|
|
// Client Auth Type (basic is all we support for now.
|
|
authz_secret: String,
|
|
// Our internal exchange encryption material for this rs.
|
|
token_fernet: Fernet,
|
|
jws_signer: JwsSigner,
|
|
// jws_validator: JwsValidator,
|
|
// Some clients, especially openid ones don't do pkce. SIGH.
|
|
// Can we enforce nonce in this case?
|
|
enable_pkce: bool,
|
|
// For oidc we also need our issuer url.
|
|
iss: Url,
|
|
// For discovery we need to build and keep a number of values.
|
|
authorization_endpoint: Url,
|
|
token_endpoint: Url,
|
|
userinfo_endpoint: Url,
|
|
jwks_uri: Url,
|
|
scopes_supported: Vec<String>,
|
|
prefer_short_username: bool,
|
|
}
|
|
|
|
impl std::fmt::Debug for Oauth2RS {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
|
f.debug_struct("Oauth2RS")
|
|
.field("name", &self.name)
|
|
.field("displayname", &self.displayname)
|
|
.field("uuid", &self.uuid)
|
|
.field("origin", &self.origin)
|
|
.field("scope_maps", &self.scope_maps)
|
|
.field("sup_scope_maps", &self.sup_scope_maps)
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct Oauth2RSInner {
|
|
origin: Url,
|
|
fernet: Fernet,
|
|
rs_set: HashMap<String, Oauth2RS>,
|
|
}
|
|
|
|
pub struct Oauth2ResourceServers {
|
|
inner: CowCell<Oauth2RSInner>,
|
|
}
|
|
|
|
pub struct Oauth2ResourceServersReadTransaction {
|
|
inner: CowCellReadTxn<Oauth2RSInner>,
|
|
}
|
|
|
|
pub struct Oauth2ResourceServersWriteTransaction<'a> {
|
|
inner: CowCellWriteTxn<'a, Oauth2RSInner>,
|
|
}
|
|
|
|
impl TryFrom<(Vec<Arc<EntrySealedCommitted>>, Url)> for Oauth2ResourceServers {
|
|
type Error = OperationError;
|
|
|
|
fn try_from(value: (Vec<Arc<EntrySealedCommitted>>, Url)) -> Result<Self, Self::Error> {
|
|
let (value, origin) = value;
|
|
let fernet =
|
|
Fernet::new(&Fernet::generate_key()).ok_or(OperationError::CryptographyError)?;
|
|
let oauth2rs = Oauth2ResourceServers {
|
|
inner: CowCell::new(Oauth2RSInner {
|
|
origin,
|
|
fernet,
|
|
rs_set: HashMap::new(),
|
|
}),
|
|
};
|
|
|
|
let mut oauth2rs_wr = oauth2rs.write();
|
|
oauth2rs_wr.reload(value)?;
|
|
oauth2rs_wr.commit();
|
|
Ok(oauth2rs)
|
|
}
|
|
}
|
|
|
|
impl Oauth2ResourceServers {
|
|
pub fn read(&self) -> Oauth2ResourceServersReadTransaction {
|
|
Oauth2ResourceServersReadTransaction {
|
|
inner: self.inner.read(),
|
|
}
|
|
}
|
|
|
|
pub fn write(&self) -> Oauth2ResourceServersWriteTransaction {
|
|
Oauth2ResourceServersWriteTransaction {
|
|
inner: self.inner.write(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl<'a> Oauth2ResourceServersWriteTransaction<'a> {
|
|
pub fn reload(&mut self, value: Vec<Arc<EntrySealedCommitted>>) -> Result<(), OperationError> {
|
|
let rs_set: Result<HashMap<_, _>, _> = value
|
|
.into_iter()
|
|
.map(|ent| {
|
|
let uuid = ent.get_uuid();
|
|
admin_info!(?uuid, "Checking oauth2 configuration");
|
|
// From each entry, attempt to make an oauth2 configuration.
|
|
if !ent.attribute_equality("class", &PVCLASS_OAUTH2_RS) {
|
|
admin_error!("Missing class oauth2_resource_server");
|
|
// Check we have oauth2_resource_server class
|
|
Err(OperationError::InvalidEntryState)
|
|
} else if ent.attribute_equality("class", &PVCLASS_OAUTH2_BASIC) {
|
|
// If we have oauth2_resource_server_basic
|
|
// Now we know we can load the attrs.
|
|
trace!("name");
|
|
let name = ent
|
|
.get_ava_single_iname("oauth2_rs_name")
|
|
.map(str::to_string)
|
|
.ok_or(OperationError::InvalidValueState)?;
|
|
trace!("displayname");
|
|
let displayname = ent
|
|
.get_ava_single_utf8("displayname")
|
|
.map(str::to_string)
|
|
.ok_or(OperationError::InvalidValueState)?;
|
|
trace!("origin");
|
|
let (origin, origin_https) = ent
|
|
.get_ava_single_url("oauth2_rs_origin")
|
|
.map(|url| (url.origin(), url.scheme() == "https"))
|
|
.ok_or(OperationError::InvalidValueState)?;
|
|
|
|
let landing_valid = ent
|
|
.get_ava_single_url("oauth2_rs_origin_landing")
|
|
.map(|url| url.origin() == origin).
|
|
unwrap_or(true);
|
|
|
|
if !landing_valid {
|
|
warn!("{} has a landing page that is not part of origin. May be invalid.", name);
|
|
}
|
|
|
|
trace!("authz_secret");
|
|
let authz_secret = ent
|
|
.get_ava_single_secret("oauth2_rs_basic_secret")
|
|
.map(str::to_string)
|
|
.ok_or(OperationError::InvalidValueState)?;
|
|
trace!("token_key");
|
|
let token_fernet = ent
|
|
.get_ava_single_secret("oauth2_rs_token_key")
|
|
.ok_or(OperationError::InvalidValueState)
|
|
.and_then(|key| {
|
|
Fernet::new(key).ok_or(OperationError::CryptographyError)
|
|
})?;
|
|
|
|
trace!("scope_maps");
|
|
let scope_maps = ent
|
|
.get_ava_as_oauthscopemaps("oauth2_rs_scope_map")
|
|
.cloned()
|
|
.unwrap_or_default();
|
|
|
|
trace!("sup_scope_maps");
|
|
let sup_scope_maps = ent
|
|
.get_ava_as_oauthscopemaps("oauth2_rs_sup_scope_map")
|
|
.cloned()
|
|
.unwrap_or_default();
|
|
|
|
trace!("oauth2_jwt_legacy_crypto_enable");
|
|
let jws_signer = if ent.get_ava_single_bool("oauth2_jwt_legacy_crypto_enable").unwrap_or(false) {
|
|
trace!("rs256_private_key_der");
|
|
ent
|
|
.get_ava_single_private_binary("rs256_private_key_der")
|
|
.ok_or(OperationError::InvalidValueState)
|
|
.and_then(|key_der| {
|
|
JwsSigner::from_rs256_der(key_der).map_err(|e| {
|
|
admin_error!(err = ?e, "Unable to load Legacy RS256 JwsSigner from DER");
|
|
OperationError::CryptographyError
|
|
})
|
|
})?
|
|
} else {
|
|
trace!("es256_private_key_der");
|
|
ent
|
|
.get_ava_single_private_binary("es256_private_key_der")
|
|
.ok_or(OperationError::InvalidValueState)
|
|
.and_then(|key_der| {
|
|
JwsSigner::from_es256_der(key_der).map_err(|e| {
|
|
admin_error!(err = ?e, "Unable to load ES256 JwsSigner from DER");
|
|
OperationError::CryptographyError
|
|
})
|
|
})?
|
|
};
|
|
|
|
/*
|
|
let jws_validator = jws_signer.get_validator().map_err(|e| {
|
|
admin_error!(err = ?e, "Unable to load JwsValidator from JwsSigner");
|
|
OperationError::CryptographyError
|
|
})?;
|
|
*/
|
|
|
|
let enable_pkce = ent
|
|
.get_ava_single_bool("oauth2_allow_insecure_client_disable_pkce")
|
|
.map(|e| !e)
|
|
.unwrap_or(true);
|
|
|
|
let prefer_short_username = ent
|
|
.get_ava_single_bool("oauth2_prefer_short_username")
|
|
.unwrap_or(false);
|
|
|
|
let mut authorization_endpoint = self.inner.origin.clone();
|
|
authorization_endpoint.set_path("/ui/oauth2");
|
|
|
|
let mut token_endpoint = self.inner.origin.clone();
|
|
token_endpoint.set_path("/oauth2/token");
|
|
|
|
let mut userinfo_endpoint = self.inner.origin.clone();
|
|
userinfo_endpoint.set_path(&format!("/oauth2/openid/{}/userinfo", name));
|
|
|
|
let mut jwks_uri = self.inner.origin.clone();
|
|
jwks_uri.set_path(&format!("/oauth2/openid/{}/public_key.jwk", name));
|
|
|
|
let mut iss = self.inner.origin.clone();
|
|
iss.set_path(&format!("/oauth2/openid/{}", name));
|
|
|
|
let scopes_supported: BTreeSet<String> =
|
|
scope_maps
|
|
.values()
|
|
.flat_map(|bts| bts.iter())
|
|
|
|
.chain(
|
|
sup_scope_maps
|
|
.values()
|
|
.flat_map(|bts| bts.iter())
|
|
)
|
|
|
|
.cloned()
|
|
.collect();
|
|
let scopes_supported: Vec<_> = scopes_supported.into_iter().collect();
|
|
|
|
let client_id = name.clone();
|
|
let rscfg = Oauth2RS {
|
|
name,
|
|
displayname,
|
|
uuid,
|
|
origin,
|
|
origin_https,
|
|
scope_maps,
|
|
sup_scope_maps,
|
|
authz_secret,
|
|
token_fernet,
|
|
jws_signer,
|
|
// jws_validator,
|
|
enable_pkce,
|
|
iss,
|
|
authorization_endpoint,
|
|
token_endpoint,
|
|
userinfo_endpoint,
|
|
jwks_uri,
|
|
scopes_supported,
|
|
prefer_short_username,
|
|
};
|
|
|
|
Ok((client_id, rscfg))
|
|
} else {
|
|
Err(OperationError::InvalidEntryState)
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
rs_set.map(|mut rs_set| {
|
|
// Delay getting the inner mut (which may clone) until we know we are ok.
|
|
let inner_ref = self.inner.get_mut();
|
|
// Swap them if we are ok
|
|
std::mem::swap(&mut inner_ref.rs_set, &mut rs_set);
|
|
})
|
|
}
|
|
|
|
pub fn commit(self) {
|
|
self.inner.commit();
|
|
}
|
|
}
|
|
|
|
impl<'a> IdmServerProxyWriteTransaction<'a> {
|
|
pub fn oauth2_token_revoke(
|
|
&mut self,
|
|
client_authz: &str,
|
|
revoke_req: &TokenRevokeRequest,
|
|
ct: Duration,
|
|
) -> Result<(), Oauth2Error> {
|
|
let (client_id, secret) = parse_basic_authz(client_authz)?;
|
|
|
|
// Get the o2rs for the handle.
|
|
let o2rs = self.oauth2rs.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 ...
|
|
|
|
// Can we deserialise the token?
|
|
let token: Oauth2TokenType = o2rs
|
|
.token_fernet
|
|
.decrypt(&revoke_req.token)
|
|
.map_err(|_| {
|
|
admin_error!("Failed to decrypt token introspection request");
|
|
Oauth2Error::InvalidRequest
|
|
})
|
|
.and_then(|data| {
|
|
serde_json::from_slice(&data).map_err(|e| {
|
|
admin_error!("Failed to deserialise token - {:?}", e);
|
|
Oauth2Error::InvalidRequest
|
|
})
|
|
})?;
|
|
|
|
// From these tokens, what we need is the identifiers that *might* exist,
|
|
// such that we can remove them.
|
|
match token {
|
|
Oauth2TokenType::Access {
|
|
session_id,
|
|
expiry,
|
|
uuid,
|
|
..
|
|
}
|
|
| Oauth2TokenType::Refresh {
|
|
session_id,
|
|
expiry,
|
|
uuid,
|
|
..
|
|
} => {
|
|
// Only submit a revocation if the token is not yet expired.
|
|
let odt_ct = OffsetDateTime::unix_epoch() + ct;
|
|
if expiry <= odt_ct {
|
|
security_info!(?uuid, "access token has expired, returning inactive");
|
|
return Ok(());
|
|
}
|
|
|
|
// Consider replication. We have servers A and B. A issues our oauth2
|
|
// token to the client. The resource server then issues the revoke request
|
|
// to B. In this case A has not yet replicated the session to B, but we
|
|
// still need to ensure the revoke is respected. As a result, we don't
|
|
// actually consult if the session is present on the account, we simply
|
|
// submit the Modify::Remove. This way it's inserted into the entry changelog
|
|
// and when replication converges the session is actually removed.
|
|
|
|
let modlist = ModifyList::new_list(vec![Modify::Removed(
|
|
AttrString::from("oauth2_session"),
|
|
PartialValue::Refer(session_id),
|
|
)]);
|
|
|
|
self.qs_write
|
|
.internal_modify(&filter!(f_eq("uuid", PartialValue::Uuid(uuid))), &modlist)
|
|
.map_err(|e| {
|
|
admin_error!("Failed to modify - revoke oauth2 session {:?}", e);
|
|
Oauth2Error::ServerError(e)
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl<'a> IdmServerProxyReadTransaction<'a> {
|
|
pub fn check_oauth2_authorisation(
|
|
&self,
|
|
ident: &Identity,
|
|
uat: &UserAuthToken,
|
|
auth_req: &AuthorisationRequest,
|
|
ct: Duration,
|
|
) -> Result<AuthoriseResponse, Oauth2Error> {
|
|
// due to identity processing we already know that:
|
|
// * the session must be authenticated, and valid
|
|
// * is within it's valid time window.
|
|
trace!(?auth_req);
|
|
|
|
if auth_req.response_type != "code" {
|
|
admin_warn!("Invalid oauth2 response_type (should be 'code')");
|
|
return Err(Oauth2Error::UnsupportedResponseType);
|
|
}
|
|
|
|
/*
|
|
* 4.1.2.1. Error Response
|
|
*
|
|
* If the request fails due to a missing, invalid, or mismatching
|
|
* redirection URI, or if the client identifier is missing or invalid,
|
|
* the authorization server SHOULD inform the resource owner of the
|
|
* error and MUST NOT automatically redirect the user-agent to the
|
|
* invalid redirection URI.
|
|
*/
|
|
|
|
//
|
|
let o2rs = self
|
|
.oauth2rs
|
|
.inner
|
|
.rs_set
|
|
.get(&auth_req.client_id)
|
|
.ok_or_else(|| {
|
|
admin_warn!(
|
|
"Invalid oauth2 client_id ({}) Have you configured the oauth2 resource server?",
|
|
&auth_req.client_id
|
|
);
|
|
Oauth2Error::InvalidClientId
|
|
})?;
|
|
|
|
// redirect_uri must be part of the client_id origin.
|
|
if auth_req.redirect_uri.origin() != o2rs.origin {
|
|
admin_warn!(
|
|
origin = ?o2rs.origin,
|
|
"Invalid oauth2 redirect_uri (must be related to origin {:?}) - got {:?}",
|
|
o2rs.origin,
|
|
auth_req.redirect_uri.origin()
|
|
);
|
|
return Err(Oauth2Error::InvalidOrigin);
|
|
}
|
|
|
|
if o2rs.origin_https && auth_req.redirect_uri.scheme() != "https" {
|
|
admin_warn!(
|
|
origin = ?o2rs.origin,
|
|
"Invalid oauth2 redirect_uri (must be https for secure origin) - got {:?}", auth_req.redirect_uri.scheme()
|
|
);
|
|
return Err(Oauth2Error::InvalidOrigin);
|
|
}
|
|
|
|
let code_challenge = if let Some(pkce_request) = &auth_req.pkce_request {
|
|
if !o2rs.enable_pkce {
|
|
security_info!(?o2rs.name, "Insecure rs configuration - pkce is not enforced, but rs is requesting it!");
|
|
}
|
|
// CodeChallengeMethod must be S256
|
|
if pkce_request.code_challenge_method != CodeChallengeMethod::S256 {
|
|
admin_warn!("Invalid oauth2 code_challenge_method (must be 'S256')");
|
|
return Err(Oauth2Error::InvalidRequest);
|
|
}
|
|
Some(pkce_request.code_challenge.clone())
|
|
} else if o2rs.enable_pkce {
|
|
security_error!(?o2rs.name, "No PKCE code challenge was provided with client in enforced PKCE mode.");
|
|
return Err(Oauth2Error::InvalidRequest);
|
|
} else {
|
|
security_info!(?o2rs.name, "Insecure client configuration - pkce is not enforced.");
|
|
None
|
|
};
|
|
|
|
// TODO: https://openid.net/specs/openid-connect-basic-1_0.html#RequestParameters
|
|
// Are we going to provide the functions for these? Most of these can be "later".
|
|
// IF CHANGED: Update OidcDiscoveryResponse!!!
|
|
|
|
// TODO: https://openid.net/specs/openid-connect-basic-1_0.html#RequestParameters
|
|
// prompt - if set to login, we need to force a re-auth. But we don't want to
|
|
// if the user "only just" logged in, that's annoying. So we need a time window for
|
|
// this, to detect when we should force it to the consent req.
|
|
|
|
// TODO: display = popup vs touch vs wap etc.
|
|
|
|
// TODO: max_age, pass through with consent req. If 0, force login.
|
|
// Otherwise force a login re the uat timeout.
|
|
|
|
// TODO: ui_locales / claims_locales for the ui. Only if we don't have a Uat that
|
|
// would provide this.
|
|
|
|
// TODO: id_token_hint - a past token which can be used as a hint.
|
|
|
|
// NOTE: login_hint is handled in the UI code, not here.
|
|
|
|
// Deny any uat with an auth method of anonymous
|
|
if uat.auth_type == AuthType::Anonymous {
|
|
admin_error!(
|
|
"Invalid oauth2 request - refusing to allow user that authenticated with anonymous"
|
|
);
|
|
return Err(Oauth2Error::AccessDenied);
|
|
}
|
|
|
|
// scopes - you need to have every requested scope or this auth_req is denied.
|
|
let req_scopes: BTreeSet<String> = auth_req
|
|
.scope
|
|
.split_ascii_whitespace()
|
|
.map(str::to_string)
|
|
.collect();
|
|
if req_scopes.is_empty() {
|
|
admin_error!("Invalid oauth2 request - must contain at least one requested scope");
|
|
return Err(Oauth2Error::InvalidRequest);
|
|
}
|
|
|
|
// Check the scopes by our scope regex validation rules.
|
|
if !req_scopes.iter().all(|s| OAUTHSCOPE_RE.is_match(s)) {
|
|
admin_error!(
|
|
"Invalid oauth2 request - requested scopes failed to pass validation rules"
|
|
);
|
|
return Err(Oauth2Error::InvalidScope);
|
|
}
|
|
|
|
let uat_scopes: BTreeSet<String> = o2rs
|
|
.scope_maps
|
|
.iter()
|
|
.filter_map(|(u, m)| {
|
|
if ident.is_memberof(*u) {
|
|
Some(m.iter())
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.flatten()
|
|
.cloned()
|
|
.collect();
|
|
|
|
// Needs to use s.to_string due to &&str which can't use the str::to_string
|
|
let avail_scopes: Vec<String> = req_scopes
|
|
.intersection(&uat_scopes)
|
|
.map(|s| s.to_string())
|
|
.collect();
|
|
|
|
debug!(?o2rs.scope_maps);
|
|
|
|
// Due to the intersection above, this is correct because the equal len can only
|
|
// occur if all terms were satisfied.
|
|
if avail_scopes.len() != req_scopes.len() {
|
|
admin_warn!(
|
|
%ident,
|
|
requested_scopes = ?req_scopes,
|
|
available_scopes = ?uat_scopes,
|
|
"Identity does not have access to the requested scopes"
|
|
);
|
|
return Err(Oauth2Error::AccessDenied);
|
|
}
|
|
|
|
drop(avail_scopes);
|
|
|
|
// ⚠️ At this point, per scopes we are *authorised*
|
|
|
|
// We now access the supplemental scopes that will be granted to this session. It is important
|
|
// we DO NOT do this prior to the requested scope check, just in case we accidentally
|
|
// confuse the two!
|
|
|
|
// The set of scopes that are being granted during this auth_request. This is a combination
|
|
// of the scopes that were requested, and the scopes we supplement.
|
|
|
|
// MICRO OPTIMISATION = flag if we have openid first, so we can into_iter here rather than
|
|
// cloning.
|
|
let openid_requested = req_scopes.contains("openid");
|
|
|
|
let granted_scopes: BTreeSet<String> = o2rs
|
|
.sup_scope_maps
|
|
.iter()
|
|
.filter_map(|(u, m)| {
|
|
if ident.is_memberof(*u) {
|
|
Some(m.iter())
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.flatten()
|
|
.cloned()
|
|
.chain(req_scopes.into_iter())
|
|
.collect();
|
|
|
|
let consent_previously_granted =
|
|
if let Some(consent_scopes) = ident.get_oauth2_consent_scopes(o2rs.uuid) {
|
|
granted_scopes.eq(consent_scopes)
|
|
} else {
|
|
false
|
|
};
|
|
|
|
if consent_previously_granted {
|
|
admin_info!(
|
|
"User has previously consented, permitting. {:?}",
|
|
granted_scopes
|
|
);
|
|
|
|
// Setup for the permit success
|
|
let xchg_code = TokenExchangeCode {
|
|
uat: uat.clone(),
|
|
code_challenge,
|
|
redirect_uri: auth_req.redirect_uri.clone(),
|
|
scopes: granted_scopes.into_iter().collect(),
|
|
nonce: auth_req.nonce.clone(),
|
|
};
|
|
|
|
// Encrypt the exchange token with the fernet key of the client resource server
|
|
let code_data = serde_json::to_vec(&xchg_code).map_err(|e| {
|
|
admin_error!(err = ?e, "Unable to encode xchg_code data");
|
|
Oauth2Error::ServerError(OperationError::SerdeJsonError)
|
|
})?;
|
|
|
|
let code = o2rs.token_fernet.encrypt_at_time(&code_data, ct.as_secs());
|
|
|
|
Ok(AuthoriseResponse::Permitted(AuthorisePermitSuccess {
|
|
redirect_uri: auth_req.redirect_uri.clone(),
|
|
state: auth_req.state.clone(),
|
|
code,
|
|
}))
|
|
} else {
|
|
// Check that the scopes are the same as a previous consent (if any)
|
|
// If oidc, what PII is visible?
|
|
// TODO: Scopes map to claims:
|
|
//
|
|
// * profile - (name, family\_name, given\_name, middle\_name, nickname, preferred\_username, profile, picture, website, gender, birthdate, zoneinfo, locale, and updated\_at)
|
|
// * email - (email, email\_verified)
|
|
// * address - (address)
|
|
// * phone - (phone\_number, phone\_number\_verified)
|
|
//
|
|
// https://openid.net/specs/openid-connect-basic-1_0.html#StandardClaims
|
|
|
|
// IMPORTANT DISTINCTION - Here req scopes must contain openid, but the PII can be supplemented
|
|
// be the servers scopes!
|
|
let pii_scopes = if openid_requested {
|
|
let mut pii_scopes = Vec::with_capacity(2);
|
|
if granted_scopes.contains("email") {
|
|
pii_scopes.push("email".to_string());
|
|
pii_scopes.push("email_verified".to_string());
|
|
}
|
|
pii_scopes
|
|
} else {
|
|
Vec::with_capacity(0)
|
|
};
|
|
|
|
// Subsequent we then return an encrypted session handle which allows
|
|
// the user to indicate their consent to this authorisation.
|
|
//
|
|
// This session handle is what we use in "permit" to generate the redirect.
|
|
|
|
let consent_req = ConsentToken {
|
|
client_id: auth_req.client_id.clone(),
|
|
ident_id: ident.get_event_origin_id(),
|
|
session_id: uat.session_id,
|
|
state: auth_req.state.clone(),
|
|
code_challenge,
|
|
redirect_uri: auth_req.redirect_uri.clone(),
|
|
scopes: granted_scopes.iter().cloned().collect(),
|
|
nonce: auth_req.nonce.clone(),
|
|
};
|
|
|
|
let consent_data = serde_json::to_vec(&consent_req).map_err(|e| {
|
|
admin_error!(err = ?e, "Unable to encode consent data");
|
|
Oauth2Error::ServerError(OperationError::SerdeJsonError)
|
|
})?;
|
|
|
|
let consent_token = self
|
|
.oauth2rs
|
|
.inner
|
|
.fernet
|
|
.encrypt_at_time(&consent_data, ct.as_secs());
|
|
|
|
Ok(AuthoriseResponse::ConsentRequested {
|
|
client_name: o2rs.displayname.clone(),
|
|
scopes: granted_scopes.into_iter().collect(),
|
|
pii_scopes,
|
|
consent_token,
|
|
})
|
|
}
|
|
}
|
|
|
|
pub fn check_oauth2_authorise_permit(
|
|
&self,
|
|
ident: &Identity,
|
|
uat: &UserAuthToken,
|
|
consent_token: &str,
|
|
ct: Duration,
|
|
) -> Result<AuthorisePermitSuccess, OperationError> {
|
|
// Decode the consent req with our system fernet key. Use a ttl of 5 minutes.
|
|
let consent_req: ConsentToken = self
|
|
.oauth2rs
|
|
.inner
|
|
.fernet
|
|
.decrypt_at_time(consent_token, Some(300), ct.as_secs())
|
|
.map_err(|_| {
|
|
admin_error!("Failed to decrypt consent request");
|
|
OperationError::CryptographyError
|
|
})
|
|
.and_then(|data| {
|
|
serde_json::from_slice(&data).map_err(|e| {
|
|
admin_error!(err = ?e, "Failed to deserialise consent request");
|
|
OperationError::SerdeJsonError
|
|
})
|
|
})?;
|
|
|
|
// Validate that the ident_id matches our current ident.
|
|
if consent_req.ident_id != ident.get_event_origin_id() {
|
|
security_info!("consent request ident id does not match the identity of our UAT.");
|
|
return Err(OperationError::InvalidSessionState);
|
|
}
|
|
|
|
// Validate that the session id matches our uat.
|
|
if consent_req.session_id != uat.session_id {
|
|
security_info!("consent request session id does not match the session id of our UAT.");
|
|
return Err(OperationError::InvalidSessionState);
|
|
}
|
|
|
|
// Get the resource server config based on this client_id.
|
|
let o2rs = self
|
|
.oauth2rs
|
|
.inner
|
|
.rs_set
|
|
.get(&consent_req.client_id)
|
|
.ok_or_else(|| {
|
|
admin_error!("Invalid consent request oauth2 client_id");
|
|
OperationError::InvalidRequestState
|
|
})?;
|
|
|
|
// Extract the state, code challenge, redirect_uri
|
|
let xchg_code = TokenExchangeCode {
|
|
uat: uat.clone(),
|
|
code_challenge: consent_req.code_challenge,
|
|
redirect_uri: consent_req.redirect_uri.clone(),
|
|
scopes: consent_req.scopes.clone(),
|
|
nonce: consent_req.nonce,
|
|
};
|
|
|
|
// Encrypt the exchange token with the fernet key of the client resource server
|
|
let code_data = serde_json::to_vec(&xchg_code).map_err(|e| {
|
|
admin_error!(err = ?e, "Unable to encode xchg_code data");
|
|
OperationError::SerdeJsonError
|
|
})?;
|
|
|
|
let code = o2rs.token_fernet.encrypt_at_time(&code_data, ct.as_secs());
|
|
|
|
// Everything is DONE! Now submit that it's all happy and the user consented correctly.
|
|
// this will let them bypass consent steps in the future.
|
|
// Submit that we consented to the delayed action queue
|
|
if self
|
|
.async_tx
|
|
.send(DelayedAction::Oauth2ConsentGrant(Oauth2ConsentGrant {
|
|
target_uuid: uat.uuid,
|
|
oauth2_rs_uuid: o2rs.uuid,
|
|
scopes: consent_req.scopes,
|
|
}))
|
|
.is_err()
|
|
{
|
|
admin_warn!("unable to queue delayed oauth2 consent grant, continuing ... ");
|
|
}
|
|
|
|
Ok(AuthorisePermitSuccess {
|
|
redirect_uri: consent_req.redirect_uri,
|
|
state: consent_req.state,
|
|
code,
|
|
})
|
|
}
|
|
|
|
pub fn check_oauth2_authorise_reject(
|
|
&self,
|
|
ident: &Identity,
|
|
uat: &UserAuthToken,
|
|
consent_token: &str,
|
|
ct: Duration,
|
|
) -> Result<Url, OperationError> {
|
|
// Decode the consent req with our system fernet key. Use a ttl of 5 minutes.
|
|
let consent_req: ConsentToken = self
|
|
.oauth2rs
|
|
.inner
|
|
.fernet
|
|
.decrypt_at_time(consent_token, Some(300), ct.as_secs())
|
|
.map_err(|_| {
|
|
admin_error!("Failed to decrypt consent request");
|
|
OperationError::CryptographyError
|
|
})
|
|
.and_then(|data| {
|
|
serde_json::from_slice(&data).map_err(|e| {
|
|
admin_error!(err = ?e, "Failed to deserialise consent request");
|
|
OperationError::SerdeJsonError
|
|
})
|
|
})?;
|
|
|
|
// Validate that the ident_id matches our current ident.
|
|
if consent_req.ident_id != ident.get_event_origin_id() {
|
|
security_info!("consent request ident id does not match the identity of our UAT.");
|
|
return Err(OperationError::InvalidSessionState);
|
|
}
|
|
|
|
// Validate that the session id matches our uat.
|
|
if consent_req.session_id != uat.session_id {
|
|
security_info!("consent request sessien id does not match the session id of our UAT.");
|
|
return Err(OperationError::InvalidSessionState);
|
|
}
|
|
|
|
// Get the resource server config based on this client_id.
|
|
let _o2rs = self
|
|
.oauth2rs
|
|
.inner
|
|
.rs_set
|
|
.get(&consent_req.client_id)
|
|
.ok_or_else(|| {
|
|
admin_error!("Invalid consent request oauth2 client_id");
|
|
OperationError::InvalidRequestState
|
|
})?;
|
|
|
|
// All good, now confirm the rejection to the client application.
|
|
Ok(consent_req.redirect_uri)
|
|
}
|
|
|
|
pub fn check_oauth2_token_exchange(
|
|
&mut self,
|
|
client_authz: Option<&str>,
|
|
token_req: &AccessTokenRequest,
|
|
ct: Duration,
|
|
) -> Result<AccessTokenResponse, Oauth2Error> {
|
|
let (client_id, secret) = if let Some(client_authz) = client_authz {
|
|
parse_basic_authz(client_authz)?
|
|
} else {
|
|
match (&token_req.client_id, &token_req.client_secret) {
|
|
(Some(a), Some(b)) => (a.clone(), b.clone()),
|
|
_ => {
|
|
security_info!(
|
|
"Invalid oauth2 authentication - no basic auth or missing auth post data"
|
|
);
|
|
return Err(Oauth2Error::AuthenticationRequired);
|
|
}
|
|
}
|
|
};
|
|
|
|
// DANGER: Why do we have to do this? During the use of qs for internal search
|
|
// and other operations we need qs to be mut. But when we borrow oauth2rs here we
|
|
// cause multiple borrows to occur on struct members that freaks rust out. This *IS*
|
|
// safe however because no element of the search or write process calls the oauth2rs
|
|
// excepting for this idm layer within a single thread, meaning that stripping the
|
|
// lifetime here is safe since we are the sole accessor.
|
|
let o2rs: &Oauth2RS = unsafe {
|
|
let s = self.oauth2rs.inner.rs_set.get(&client_id).ok_or_else(|| {
|
|
admin_warn!("Invalid oauth2 client_id");
|
|
Oauth2Error::AuthenticationRequired
|
|
})?;
|
|
&*(s as *const _)
|
|
};
|
|
|
|
// 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 ...
|
|
|
|
// TODO: add refresh token grant type.
|
|
// If it's a refresh token grant, are the consent permissions the same?
|
|
if token_req.grant_type == "authorization_code" {
|
|
self.check_oauth2_token_exchange_authorization_code(o2rs, token_req, ct)
|
|
} else {
|
|
admin_warn!("Invalid oauth2 grant_type (should be 'authorization_code')");
|
|
Err(Oauth2Error::InvalidRequest)
|
|
}
|
|
}
|
|
|
|
fn check_oauth2_token_exchange_authorization_code(
|
|
&mut self,
|
|
o2rs: &Oauth2RS,
|
|
token_req: &AccessTokenRequest,
|
|
ct: Duration,
|
|
) -> Result<AccessTokenResponse, Oauth2Error> {
|
|
// Check the token_req is within the valid time, and correctly signed for
|
|
// this client.
|
|
|
|
let code_xchg: TokenExchangeCode = o2rs
|
|
.token_fernet
|
|
.decrypt_at_time(&token_req.code, Some(60), ct.as_secs())
|
|
.map_err(|_| {
|
|
admin_error!("Failed to decrypt token exchange request");
|
|
Oauth2Error::InvalidRequest
|
|
})
|
|
.and_then(|data| {
|
|
serde_json::from_slice(&data).map_err(|e| {
|
|
admin_error!("Failed to deserialise token exchange code - {:?}", e);
|
|
Oauth2Error::InvalidRequest
|
|
})
|
|
})?;
|
|
|
|
// If we have a verifier present, we MUST assert that a code challenge is present!
|
|
// It is worth noting here that code_xchg is *server issued* and encrypted, with
|
|
// a short validity period. The client controlled value is in token_req.code_verifier
|
|
if let Some(code_challenge) = code_xchg.code_challenge {
|
|
// Validate the code_verifier
|
|
let code_verifier = token_req.code_verifier
|
|
.as_deref()
|
|
.ok_or_else(|| {
|
|
security_info!("PKCE code verification failed - code challenge is present, but not verifier was provided");
|
|
Oauth2Error::InvalidRequest
|
|
})?;
|
|
let mut hasher = sha::Sha256::new();
|
|
hasher.update(code_verifier.as_bytes());
|
|
let code_verifier_hash: Vec<u8> = hasher.finish().to_vec();
|
|
|
|
if code_challenge.0 != code_verifier_hash {
|
|
security_info!(
|
|
"PKCE code verification failed - this may indicate malicious activity"
|
|
);
|
|
return Err(Oauth2Error::InvalidRequest);
|
|
}
|
|
} else if o2rs.enable_pkce {
|
|
security_info!(
|
|
"PKCE code verification failed - no code challenge present in PKCE enforced mode"
|
|
);
|
|
return Err(Oauth2Error::InvalidRequest);
|
|
} else if token_req.code_verifier.is_some() {
|
|
security_info!(
|
|
"PKCE code verification failed - a code verifier is present, but no code challenge in exchange"
|
|
);
|
|
return Err(Oauth2Error::InvalidRequest);
|
|
}
|
|
|
|
// Validate the redirect_uri is the same as the original.
|
|
if token_req.redirect_uri != code_xchg.redirect_uri {
|
|
security_info!("Invalid oauth2 redirect_uri (differs from original request uri)");
|
|
return Err(Oauth2Error::InvalidOrigin);
|
|
}
|
|
|
|
// ==== We are now GOOD TO GO! ====
|
|
|
|
// Use this to grant the access token response.
|
|
let odt_ct = OffsetDateTime::unix_epoch() + ct;
|
|
|
|
let iat = ct.as_secs() as i64;
|
|
|
|
// TODO: Make configurable from auth policy!
|
|
let (expiry, expires_in) = if let Some(expiry) = code_xchg.uat.expiry {
|
|
if expiry > odt_ct {
|
|
// Becomes a duration.
|
|
(expiry, (expiry - odt_ct).whole_seconds() as u32)
|
|
} else {
|
|
security_info!(
|
|
"User Auth Token has expired before we could publish the oauth2 response"
|
|
);
|
|
return Err(Oauth2Error::AccessDenied);
|
|
}
|
|
} else {
|
|
security_info!("User Auth Token has no expiry, setting to refresh window");
|
|
(
|
|
odt_ct + Duration::from_secs(OAUTH2_ACCESS_TOKEN_EXPIRY as u64),
|
|
OAUTH2_ACCESS_TOKEN_EXPIRY,
|
|
)
|
|
};
|
|
// let expiry = odt_ct + Duration::from_secs(expires_in as u64);
|
|
|
|
let scope = if code_xchg.scopes.is_empty() {
|
|
None
|
|
} else {
|
|
Some(code_xchg.scopes.join(" "))
|
|
};
|
|
|
|
let id_token = if code_xchg.scopes.contains(&"openid".to_string()) {
|
|
// TODO: Scopes map to claims:
|
|
//
|
|
// * profile - (name, family\_name, given\_name, middle\_name, nickname, preferred\_username, profile, picture, website, gender, birthdate, zoneinfo, locale, and updated\_at)
|
|
// * email - (email, email\_verified)
|
|
// * address - (address)
|
|
// * phone - (phone\_number, phone\_number\_verified)
|
|
//
|
|
// https://openid.net/specs/openid-connect-basic-1_0.html#StandardClaims
|
|
|
|
// TODO: Can the user consent to which claims are released? Today as we don't support most
|
|
// of them anyway, no, but in the future, we can stash these to the consent req.
|
|
|
|
// TODO: If max_age was requested in the request, we MUST provide auth_time.
|
|
|
|
// amr == auth method
|
|
let amr = Some(vec![code_xchg.uat.auth_type.to_string()]);
|
|
|
|
// TODO: Make configurable from auth policy!
|
|
let exp = iat + (expires_in as i64);
|
|
|
|
let iss = o2rs.iss.clone();
|
|
|
|
let entry = match self.qs_read.internal_search_uuid(code_xchg.uat.uuid) {
|
|
Ok(entry) => entry,
|
|
Err(err) => return Err(Oauth2Error::ServerError(err)),
|
|
};
|
|
|
|
let account = match Account::try_from_entry_ro(&entry, &mut self.qs_read) {
|
|
Ok(account) => account,
|
|
Err(err) => return Err(Oauth2Error::ServerError(err)),
|
|
};
|
|
|
|
let s_claims = s_claims_for_account(o2rs, &account, &code_xchg.scopes);
|
|
let extra_claims = extra_claims_for_account(&account, &code_xchg.scopes);
|
|
|
|
let oidc = OidcToken {
|
|
iss,
|
|
sub: OidcSubject::U(code_xchg.uat.uuid),
|
|
aud: o2rs.name.clone(),
|
|
iat,
|
|
nbf: Some(iat),
|
|
exp,
|
|
auth_time: None,
|
|
nonce: code_xchg.nonce.clone(),
|
|
at_hash: None,
|
|
acr: None,
|
|
amr,
|
|
azp: Some(o2rs.name.clone()),
|
|
jti: None,
|
|
s_claims,
|
|
claims: extra_claims,
|
|
};
|
|
|
|
trace!(?oidc);
|
|
|
|
Some(
|
|
oidc.sign(&o2rs.jws_signer)
|
|
.map(|jwt_signed| jwt_signed.to_string())
|
|
.map_err(|e| {
|
|
admin_error!(err = ?e, "Unable to encode uat data");
|
|
Oauth2Error::ServerError(OperationError::InvalidState)
|
|
})?,
|
|
)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let session_id = Uuid::new_v4();
|
|
let parent_session_id = code_xchg.uat.session_id;
|
|
|
|
// We need to record this into the record? Delayed action?
|
|
|
|
let access_token_raw = Oauth2TokenType::Access {
|
|
scopes: code_xchg.scopes,
|
|
parent_session_id,
|
|
session_id,
|
|
auth_type: code_xchg.uat.auth_type,
|
|
expiry,
|
|
uuid: code_xchg.uat.uuid,
|
|
iat,
|
|
nbf: iat,
|
|
auth_time: None,
|
|
};
|
|
|
|
let access_token_data = serde_json::to_vec(&access_token_raw).map_err(|e| {
|
|
admin_error!(err = ?e, "Unable to encode consent data");
|
|
Oauth2Error::ServerError(OperationError::SerdeJsonError)
|
|
})?;
|
|
|
|
let access_token = o2rs
|
|
.token_fernet
|
|
.encrypt_at_time(&access_token_data, ct.as_secs());
|
|
|
|
let refresh_token = None;
|
|
|
|
self.async_tx
|
|
.send(DelayedAction::Oauth2SessionRecord(Oauth2SessionRecord {
|
|
target_uuid: code_xchg.uat.uuid,
|
|
parent_session_id,
|
|
session_id,
|
|
expiry: Some(expiry),
|
|
issued_at: odt_ct,
|
|
rs_uuid: o2rs.uuid,
|
|
}))
|
|
.map_err(|e| {
|
|
admin_error!(err = ?e, "Unable to submit oauth2 session record");
|
|
Oauth2Error::ServerError(OperationError::InvalidState)
|
|
})?;
|
|
|
|
Ok(AccessTokenResponse {
|
|
access_token,
|
|
token_type: "bearer".to_string(),
|
|
expires_in,
|
|
refresh_token,
|
|
scope,
|
|
id_token,
|
|
})
|
|
}
|
|
|
|
pub fn check_oauth2_token_introspect(
|
|
&mut self,
|
|
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.oauth2rs.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 ...
|
|
|
|
let token: Oauth2TokenType = o2rs
|
|
.token_fernet
|
|
.decrypt(&intr_req.token)
|
|
.map_err(|_| {
|
|
admin_error!("Failed to decrypt token introspection request");
|
|
Oauth2Error::InvalidRequest
|
|
})
|
|
.and_then(|data| {
|
|
serde_json::from_slice(&data).map_err(|e| {
|
|
admin_error!("Failed to deserialise token - {:?}", e);
|
|
Oauth2Error::InvalidRequest
|
|
})
|
|
})?;
|
|
|
|
match token {
|
|
Oauth2TokenType::Access {
|
|
scopes,
|
|
parent_session_id,
|
|
session_id,
|
|
auth_type: _,
|
|
expiry,
|
|
uuid,
|
|
iat,
|
|
nbf,
|
|
auth_time: _,
|
|
} => {
|
|
// Has this token expired?
|
|
let odt_ct = OffsetDateTime::unix_epoch() + ct;
|
|
if expiry <= odt_ct {
|
|
security_info!(?uuid, "access token has expired, returning inactive");
|
|
return Ok(AccessTokenIntrospectResponse::inactive());
|
|
}
|
|
let exp = iat + (expiry - odt_ct).whole_seconds();
|
|
|
|
// Is the user expired, or the oauth2 session invalid?
|
|
let valid = self
|
|
.check_oauth2_account_uuid_valid(uuid, session_id, parent_session_id, iat, ct)
|
|
.map_err(|_| admin_error!("Account is not valid"));
|
|
|
|
let entry = match valid {
|
|
Ok(Some(entry)) => entry,
|
|
_ => {
|
|
security_info!(
|
|
?uuid,
|
|
"access token has no account not valid, returning inactive"
|
|
);
|
|
return Ok(AccessTokenIntrospectResponse::inactive());
|
|
}
|
|
};
|
|
|
|
let account = match Account::try_from_entry_no_groups(&entry) {
|
|
Ok(account) => account,
|
|
Err(err) => return Err(Oauth2Error::ServerError(err)),
|
|
};
|
|
|
|
// ==== good to generate response ====
|
|
|
|
let scope = if scopes.is_empty() {
|
|
None
|
|
} else {
|
|
Some(scopes.join(" "))
|
|
};
|
|
|
|
let token_type = Some("access_token".to_string());
|
|
Ok(AccessTokenIntrospectResponse {
|
|
active: true,
|
|
scope,
|
|
client_id: Some(client_id.clone()),
|
|
username: Some(account.spn),
|
|
token_type,
|
|
exp: Some(exp),
|
|
iat: Some(iat),
|
|
nbf: Some(nbf),
|
|
sub: Some(uuid.to_string()),
|
|
aud: Some(client_id),
|
|
iss: None,
|
|
jti: None,
|
|
})
|
|
}
|
|
Oauth2TokenType::Refresh { .. } => Ok(AccessTokenIntrospectResponse::inactive()),
|
|
}
|
|
}
|
|
|
|
pub fn oauth2_openid_userinfo(
|
|
&mut self,
|
|
client_id: &str,
|
|
client_authz: &str,
|
|
ct: Duration,
|
|
) -> Result<OidcToken, Oauth2Error> {
|
|
// DANGER: Why do we have to do this? During the use of qs for internal search
|
|
// and other operations we need qs to be mut. But when we borrow oauth2rs here we
|
|
// cause multiple borrows to occur on struct members that freaks rust out. This *IS*
|
|
// safe however because no element of the search or write process calls the oauth2rs
|
|
// excepting for this idm layer within a single thread, meaning that stripping the
|
|
// lifetime here is safe since we are the sole accessor.
|
|
let o2rs: &Oauth2RS = unsafe {
|
|
let s = self.oauth2rs.inner.rs_set.get(client_id).ok_or_else(|| {
|
|
admin_warn!(
|
|
"Invalid oauth2 client_id (have you configured the oauth2 resource server?)"
|
|
);
|
|
Oauth2Error::InvalidClientId
|
|
})?;
|
|
&*(s as *const _)
|
|
};
|
|
|
|
let token: Oauth2TokenType = o2rs
|
|
.token_fernet
|
|
.decrypt(client_authz)
|
|
.map_err(|_| {
|
|
admin_error!("Failed to decrypt token introspection request");
|
|
Oauth2Error::InvalidRequest
|
|
})
|
|
.and_then(|data| {
|
|
serde_json::from_slice(&data).map_err(|e| {
|
|
admin_error!("Failed to deserialise token exchange code - {:?}", e);
|
|
Oauth2Error::InvalidRequest
|
|
})
|
|
})?;
|
|
|
|
match token {
|
|
Oauth2TokenType::Access {
|
|
scopes,
|
|
parent_session_id,
|
|
session_id,
|
|
auth_type,
|
|
expiry,
|
|
uuid,
|
|
iat,
|
|
nbf,
|
|
auth_time: _,
|
|
} => {
|
|
// Has this token expired?
|
|
let odt_ct = OffsetDateTime::unix_epoch() + ct;
|
|
if expiry <= odt_ct {
|
|
security_info!(?uuid, "access token has expired, returning inactive");
|
|
return Err(Oauth2Error::InvalidToken);
|
|
}
|
|
let exp = iat + (expiry - odt_ct).whole_seconds();
|
|
|
|
// Is the user expired, or the oauth2 session invalid?
|
|
let valid = self
|
|
.check_oauth2_account_uuid_valid(uuid, session_id, parent_session_id, iat, ct)
|
|
.map_err(|_| admin_error!("Account is not valid"));
|
|
|
|
let entry = match valid {
|
|
Ok(Some(entry)) => entry,
|
|
_ => {
|
|
security_info!(
|
|
?uuid,
|
|
"access token has account not valid, returning inactive"
|
|
);
|
|
return Err(Oauth2Error::InvalidToken);
|
|
}
|
|
};
|
|
|
|
let account = match Account::try_from_entry_ro(&entry, &mut self.qs_read) {
|
|
Ok(account) => account,
|
|
Err(err) => return Err(Oauth2Error::ServerError(err)),
|
|
};
|
|
|
|
let amr = Some(vec![auth_type.to_string()]);
|
|
|
|
let iss = o2rs.iss.clone();
|
|
|
|
let s_claims = s_claims_for_account(o2rs, &account, &scopes);
|
|
let extra_claims = extra_claims_for_account(&account, &scopes);
|
|
|
|
// ==== good to generate response ====
|
|
|
|
Ok(OidcToken {
|
|
iss,
|
|
sub: OidcSubject::U(uuid),
|
|
aud: client_id.to_string(),
|
|
iat,
|
|
nbf: Some(nbf),
|
|
exp,
|
|
auth_time: None,
|
|
nonce: None,
|
|
at_hash: None,
|
|
acr: None,
|
|
amr,
|
|
azp: Some(client_id.to_string()),
|
|
jti: None,
|
|
s_claims,
|
|
claims: extra_claims,
|
|
})
|
|
}
|
|
// https://openid.net/specs/openid-connect-basic-1_0.html#UserInfoErrorResponse
|
|
Oauth2TokenType::Refresh { .. } => Err(Oauth2Error::InvalidToken),
|
|
}
|
|
}
|
|
|
|
pub fn oauth2_openid_discovery(
|
|
&self,
|
|
client_id: &str,
|
|
) -> Result<OidcDiscoveryResponse, OperationError> {
|
|
let o2rs = self.oauth2rs.inner.rs_set.get(client_id).ok_or_else(|| {
|
|
admin_warn!(
|
|
"Invalid oauth2 client_id (have you configured the oauth2 resource server?)"
|
|
);
|
|
OperationError::NoMatchingEntries
|
|
})?;
|
|
|
|
let issuer = o2rs.iss.clone();
|
|
|
|
let authorization_endpoint = o2rs.authorization_endpoint.clone();
|
|
let token_endpoint = o2rs.token_endpoint.clone();
|
|
let userinfo_endpoint = Some(o2rs.userinfo_endpoint.clone());
|
|
let jwks_uri = o2rs.jwks_uri.clone();
|
|
let scopes_supported = Some(o2rs.scopes_supported.clone());
|
|
let response_types_supported = vec![ResponseType::Code];
|
|
let response_modes_supported = vec![ResponseMode::Query];
|
|
let grant_types_supported = vec![GrantType::AuthorisationCode];
|
|
let subject_types_supported = vec![SubjectType::Public];
|
|
|
|
let id_token_signing_alg_values_supported = match &o2rs.jws_signer {
|
|
JwsSigner::ES256 { .. } => vec![IdTokenSignAlg::ES256],
|
|
JwsSigner::RS256 { .. } => vec![IdTokenSignAlg::RS256],
|
|
JwsSigner::HS256 { .. } => {
|
|
admin_warn!("Invalid oauth2 configuration - HS256 is not supported!");
|
|
vec![]
|
|
}
|
|
};
|
|
|
|
let userinfo_signing_alg_values_supported = None;
|
|
let token_endpoint_auth_methods_supported = vec![
|
|
TokenEndpointAuthMethod::ClientSecretBasic,
|
|
TokenEndpointAuthMethod::ClientSecretPost,
|
|
];
|
|
let display_values_supported = Some(vec![DisplayValue::Page]);
|
|
let claim_types_supported = vec![ClaimType::Normal];
|
|
// What claims can we offer?
|
|
let claims_supported = None;
|
|
let service_documentation = Some(URL_SERVICE_DOCUMENTATION.clone());
|
|
|
|
Ok(OidcDiscoveryResponse {
|
|
issuer,
|
|
authorization_endpoint,
|
|
token_endpoint,
|
|
userinfo_endpoint,
|
|
jwks_uri,
|
|
registration_endpoint: None,
|
|
scopes_supported,
|
|
response_types_supported,
|
|
response_modes_supported,
|
|
grant_types_supported,
|
|
acr_values_supported: None,
|
|
subject_types_supported,
|
|
id_token_signing_alg_values_supported,
|
|
id_token_encryption_alg_values_supported: None,
|
|
id_token_encryption_enc_values_supported: None,
|
|
userinfo_signing_alg_values_supported,
|
|
userinfo_encryption_alg_values_supported: None,
|
|
userinfo_encryption_enc_values_supported: None,
|
|
request_object_signing_alg_values_supported: None,
|
|
request_object_encryption_alg_values_supported: None,
|
|
request_object_encryption_enc_values_supported: None,
|
|
token_endpoint_auth_methods_supported,
|
|
token_endpoint_auth_signing_alg_values_supported: None,
|
|
display_values_supported,
|
|
claim_types_supported,
|
|
claims_supported,
|
|
service_documentation,
|
|
claims_locales_supported: None,
|
|
ui_locales_supported: None,
|
|
claims_parameter_supported: false,
|
|
// I think?
|
|
request_parameter_supported: true,
|
|
request_uri_parameter_supported: false,
|
|
require_request_uri_registration: false,
|
|
op_policy_uri: None,
|
|
op_tos_uri: None,
|
|
})
|
|
}
|
|
|
|
pub fn oauth2_openid_publickey(&self, client_id: &str) -> Result<JwkKeySet, OperationError> {
|
|
let o2rs = self.oauth2rs.inner.rs_set.get(client_id).ok_or_else(|| {
|
|
admin_warn!(
|
|
"Invalid oauth2 client_id (have you configured the oauth2 resource server?)"
|
|
);
|
|
OperationError::NoMatchingEntries
|
|
})?;
|
|
|
|
o2rs.jws_signer
|
|
.public_key_as_jwk()
|
|
.map_err(|e| {
|
|
admin_error!("Unable to retrieve public key for {} - {:?}", o2rs.name, e);
|
|
OperationError::InvalidState
|
|
})
|
|
.map(|jwk| JwkKeySet { keys: vec![jwk] })
|
|
}
|
|
}
|
|
|
|
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 ':' separator?)");
|
|
Oauth2Error::AuthenticationRequired
|
|
})?;
|
|
|
|
Ok((client_id.to_string(), secret.to_string()))
|
|
}
|
|
|
|
fn s_claims_for_account(o2rs: &Oauth2RS, account: &Account, scopes: &[String]) -> OidcClaims {
|
|
let preferred_username = if o2rs.prefer_short_username {
|
|
Some(account.name.clone())
|
|
} else {
|
|
Some(account.spn.clone())
|
|
};
|
|
|
|
let (email, email_verified) = if scopes.contains(&"email".to_string()) {
|
|
if let Some(mp) = &account.mail_primary {
|
|
(Some(mp.clone()), Some(true))
|
|
} else {
|
|
(None, None)
|
|
}
|
|
} else {
|
|
(None, None)
|
|
};
|
|
|
|
OidcClaims {
|
|
// Map from displayname
|
|
name: Some(account.displayname.clone()),
|
|
scopes: scopes.to_vec(),
|
|
preferred_username,
|
|
email,
|
|
email_verified,
|
|
..Default::default()
|
|
}
|
|
}
|
|
fn extra_claims_for_account(
|
|
account: &Account,
|
|
scopes: &[String],
|
|
) -> BTreeMap<String, serde_json::Value> {
|
|
let mut extra_claims = BTreeMap::new();
|
|
if scopes.contains(&"groups".to_string()) {
|
|
extra_claims.insert(
|
|
"groups".to_string(),
|
|
account.groups.iter().map(|x| x.to_proto().uuid).collect(),
|
|
);
|
|
}
|
|
extra_claims
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::convert::TryFrom;
|
|
use std::str::FromStr;
|
|
use std::time::Duration;
|
|
|
|
use base64urlsafedata::Base64UrlSafeData;
|
|
use compact_jwt::{JwaAlg, Jwk, JwkUse, JwsValidator, OidcSubject, OidcUnverified};
|
|
use kanidm_proto::oauth2::*;
|
|
use kanidm_proto::v1::{AuthType, UserAuthToken};
|
|
use openssl::sha;
|
|
|
|
use crate::idm::delayed::DelayedAction;
|
|
use crate::idm::oauth2::{AuthoriseResponse, Oauth2Error};
|
|
use crate::idm::server::{IdmServer, IdmServerTransaction};
|
|
use crate::prelude::*;
|
|
|
|
use async_std::task;
|
|
|
|
const TEST_CURRENT_TIME: u64 = 6000;
|
|
const UAT_EXPIRE: u64 = 5;
|
|
const TOKEN_EXPIRE: u64 = 900;
|
|
|
|
macro_rules! create_code_verifier {
|
|
($key:expr) => {{
|
|
let code_verifier = $key.to_string();
|
|
let mut hasher = sha::Sha256::new();
|
|
hasher.update(code_verifier.as_bytes());
|
|
let code_challenge: Vec<u8> = hasher.finish().iter().copied().collect();
|
|
(Some(code_verifier), code_challenge)
|
|
}};
|
|
}
|
|
|
|
macro_rules! good_authorisation_request {
|
|
(
|
|
$idms_prox_read:expr,
|
|
$ident:expr,
|
|
$uat:expr,
|
|
$ct:expr,
|
|
$code_challenge:expr,
|
|
$scope:expr
|
|
) => {{
|
|
let auth_req = AuthorisationRequest {
|
|
response_type: "code".to_string(),
|
|
client_id: "test_resource_server".to_string(),
|
|
state: "123".to_string(),
|
|
pkce_request: Some(PkceRequest {
|
|
code_challenge: Base64UrlSafeData($code_challenge),
|
|
code_challenge_method: CodeChallengeMethod::S256,
|
|
}),
|
|
redirect_uri: Url::parse("https://demo.example.com/oauth2/result").unwrap(),
|
|
scope: $scope,
|
|
nonce: Some("abcdef".to_string()),
|
|
oidc_ext: Default::default(),
|
|
unknown_keys: Default::default(),
|
|
};
|
|
|
|
$idms_prox_read
|
|
.check_oauth2_authorisation($ident, $uat, &auth_req, $ct)
|
|
.expect("Oauth2 authorisation failed")
|
|
}};
|
|
}
|
|
|
|
// setup an oauth2 instance.
|
|
fn setup_oauth2_resource_server(
|
|
idms: &IdmServer,
|
|
ct: Duration,
|
|
enable_pkce: bool,
|
|
enable_legacy_crypto: bool,
|
|
prefer_short_username: bool,
|
|
) -> (String, UserAuthToken, Identity, Uuid) {
|
|
let mut idms_prox_write = task::block_on(idms.proxy_write(ct));
|
|
|
|
let uuid = Uuid::new_v4();
|
|
|
|
let e: Entry<EntryInit, EntryNew> = entry_init!(
|
|
("class", Value::new_class("object")),
|
|
("class", Value::new_class("oauth2_resource_server")),
|
|
("class", Value::new_class("oauth2_resource_server_basic")),
|
|
("uuid", Value::Uuid(uuid)),
|
|
("oauth2_rs_name", Value::new_iname("test_resource_server")),
|
|
("displayname", Value::new_utf8s("test_resource_server")),
|
|
(
|
|
"oauth2_rs_origin",
|
|
Value::new_url_s("https://demo.example.com").unwrap()
|
|
),
|
|
// System admins
|
|
(
|
|
"oauth2_rs_scope_map",
|
|
Value::new_oauthscopemap(UUID_SYSTEM_ADMINS, btreeset!["groups".to_string()])
|
|
.expect("invalid oauthscope")
|
|
),
|
|
(
|
|
"oauth2_rs_scope_map",
|
|
Value::new_oauthscopemap(UUID_IDM_ALL_ACCOUNTS, btreeset!["openid".to_string()])
|
|
.expect("invalid oauthscope")
|
|
),
|
|
(
|
|
"oauth2_rs_sup_scope_map",
|
|
Value::new_oauthscopemap(
|
|
UUID_IDM_ALL_ACCOUNTS,
|
|
btreeset!["supplement".to_string()]
|
|
)
|
|
.expect("invalid oauthscope")
|
|
),
|
|
(
|
|
"oauth2_allow_insecure_client_disable_pkce",
|
|
Value::new_bool(!enable_pkce)
|
|
),
|
|
(
|
|
"oauth2_jwt_legacy_crypto_enable",
|
|
Value::new_bool(enable_legacy_crypto)
|
|
),
|
|
(
|
|
"oauth2_prefer_short_username",
|
|
Value::new_bool(prefer_short_username)
|
|
)
|
|
);
|
|
let ce = CreateEvent::new_internal(vec![e]);
|
|
assert!(idms_prox_write.qs_write.create(&ce).is_ok());
|
|
|
|
let entry = idms_prox_write
|
|
.qs_write
|
|
.internal_search_uuid(uuid)
|
|
.expect("Failed to retrieve oauth2 resource entry ");
|
|
let secret = entry
|
|
.get_ava_single_secret("oauth2_rs_basic_secret")
|
|
.map(str::to_string)
|
|
.expect("No oauth2_rs_basic_secret found");
|
|
|
|
// Setup the uat we'll be using.
|
|
let account = idms_prox_write
|
|
.target_to_account(UUID_ADMIN)
|
|
.expect("account must exist");
|
|
let session_id = uuid::Uuid::new_v4();
|
|
let uat = account
|
|
.to_userauthtoken(
|
|
session_id,
|
|
ct,
|
|
AuthType::PasswordMfa,
|
|
Some(AUTH_SESSION_EXPIRY),
|
|
)
|
|
.expect("Unable to create uat");
|
|
let ident = idms_prox_write
|
|
.process_uat_to_identity(&uat, ct)
|
|
.expect("Unable to process uat");
|
|
|
|
idms_prox_write.commit().expect("failed to commit");
|
|
|
|
(secret, uat, ident, uuid)
|
|
}
|
|
|
|
fn setup_idm_admin(
|
|
idms: &IdmServer,
|
|
ct: Duration,
|
|
authtype: AuthType,
|
|
) -> (UserAuthToken, Identity) {
|
|
let mut idms_prox_write = task::block_on(idms.proxy_write(ct));
|
|
let account = idms_prox_write
|
|
.target_to_account(UUID_IDM_ADMIN)
|
|
.expect("account must exist");
|
|
let session_id = uuid::Uuid::new_v4();
|
|
let uat = account
|
|
.to_userauthtoken(session_id, ct, authtype, Some(AUTH_SESSION_EXPIRY))
|
|
.expect("Unable to create uat");
|
|
let ident = idms_prox_write
|
|
.process_uat_to_identity(&uat, ct)
|
|
.expect("Unable to process uat");
|
|
|
|
idms_prox_write.commit().expect("failed to commit");
|
|
|
|
(uat, ident)
|
|
}
|
|
|
|
#[test]
|
|
fn test_idm_oauth2_basic_function() {
|
|
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, true, false, false);
|
|
|
|
let mut idms_prox_read = task::block_on(idms.proxy_read());
|
|
|
|
// Get an ident/uat for now.
|
|
|
|
// == 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,
|
|
"openid".to_string()
|
|
);
|
|
|
|
// Should be in the consent phase;
|
|
let consent_token =
|
|
if let AuthoriseResponse::ConsentRequested { consent_token, .. } =
|
|
consent_request
|
|
{
|
|
consent_token
|
|
} else {
|
|
unreachable!();
|
|
};
|
|
|
|
// == 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_token, ct)
|
|
.expect("Failed to perform oauth2 permit");
|
|
|
|
// Assert that the consent was submitted
|
|
match idms_delayed.async_rx.blocking_recv() {
|
|
Some(DelayedAction::Oauth2ConsentGrant(_)) => {}
|
|
_ => assert!(false),
|
|
}
|
|
|
|
// Check we are reflecting the CSRF properly.
|
|
assert!(permit_success.state == "123");
|
|
|
|
// == Submit the token exchange code.
|
|
|
|
let token_req = AccessTokenRequest {
|
|
grant_type: "authorization_code".to_string(),
|
|
code: permit_success.code,
|
|
redirect_uri: Url::parse("https://demo.example.com/oauth2/result").unwrap(),
|
|
client_id: Some("test_resource_server".to_string()),
|
|
client_secret: Some(secret),
|
|
// From the first step.
|
|
code_verifier,
|
|
};
|
|
|
|
let token_response = idms_prox_read
|
|
.check_oauth2_token_exchange(None, &token_req, ct)
|
|
.expect("Failed to perform oauth2 token exchange");
|
|
|
|
// Assert that the session creation was submitted
|
|
match idms_delayed.async_rx.blocking_recv() {
|
|
Some(DelayedAction::Oauth2SessionRecord(_)) => {}
|
|
_ => assert!(false),
|
|
}
|
|
|
|
// 🎉 We got a token! In the future we can then check introspection from this point.
|
|
assert!(token_response.token_type == "bearer");
|
|
}
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn test_idm_oauth2_invalid_authorisation_requests() {
|
|
run_idm_test!(|_qs: &QueryServer,
|
|
idms: &IdmServer,
|
|
_idms_delayed: &mut IdmServerDelayed| {
|
|
// Test invalid oauth2 authorisation states/requests.
|
|
let ct = Duration::from_secs(TEST_CURRENT_TIME);
|
|
let (_secret, uat, ident, _) =
|
|
setup_oauth2_resource_server(idms, ct, true, false, false);
|
|
|
|
let (anon_uat, anon_ident) = setup_idm_admin(idms, ct, AuthType::Anonymous);
|
|
let (idm_admin_uat, idm_admin_ident) = setup_idm_admin(idms, ct, AuthType::PasswordMfa);
|
|
|
|
// Need a uat from a user not in the group. Probs anonymous.
|
|
let idms_prox_read = task::block_on(idms.proxy_read());
|
|
|
|
let (_code_verifier, code_challenge) = create_code_verifier!("Whar Garble");
|
|
|
|
let pkce_request = Some(PkceRequest {
|
|
code_challenge: Base64UrlSafeData(code_challenge.clone()),
|
|
code_challenge_method: CodeChallengeMethod::S256,
|
|
});
|
|
|
|
// * response type != code.
|
|
let auth_req = AuthorisationRequest {
|
|
response_type: "NOTCODE".to_string(),
|
|
client_id: "test_resource_server".to_string(),
|
|
state: "123".to_string(),
|
|
pkce_request: pkce_request.clone(),
|
|
redirect_uri: Url::parse("https://demo.example.com/oauth2/result").unwrap(),
|
|
scope: "openid".to_string(),
|
|
nonce: None,
|
|
oidc_ext: Default::default(),
|
|
unknown_keys: Default::default(),
|
|
};
|
|
|
|
assert!(
|
|
idms_prox_read
|
|
.check_oauth2_authorisation(&ident, &uat, &auth_req, ct)
|
|
.unwrap_err()
|
|
== Oauth2Error::UnsupportedResponseType
|
|
);
|
|
|
|
// * No pkce in pkce enforced mode.
|
|
let auth_req = AuthorisationRequest {
|
|
response_type: "code".to_string(),
|
|
client_id: "test_resource_server".to_string(),
|
|
state: "123".to_string(),
|
|
pkce_request: None,
|
|
redirect_uri: Url::parse("https://demo.example.com/oauth2/result").unwrap(),
|
|
scope: "openid".to_string(),
|
|
nonce: None,
|
|
oidc_ext: Default::default(),
|
|
unknown_keys: Default::default(),
|
|
};
|
|
|
|
assert!(
|
|
idms_prox_read
|
|
.check_oauth2_authorisation(&ident, &uat, &auth_req, ct)
|
|
.unwrap_err()
|
|
== Oauth2Error::InvalidRequest
|
|
);
|
|
|
|
// * invalid rs name
|
|
let auth_req = AuthorisationRequest {
|
|
response_type: "code".to_string(),
|
|
client_id: "NOT A REAL RESOURCE SERVER".to_string(),
|
|
state: "123".to_string(),
|
|
pkce_request: pkce_request.clone(),
|
|
redirect_uri: Url::parse("https://demo.example.com/oauth2/result").unwrap(),
|
|
scope: "openid".to_string(),
|
|
nonce: None,
|
|
oidc_ext: Default::default(),
|
|
unknown_keys: Default::default(),
|
|
};
|
|
|
|
assert!(
|
|
idms_prox_read
|
|
.check_oauth2_authorisation(&ident, &uat, &auth_req, ct)
|
|
.unwrap_err()
|
|
== Oauth2Error::InvalidClientId
|
|
);
|
|
|
|
// * mis match origin in the redirect.
|
|
let auth_req = AuthorisationRequest {
|
|
response_type: "code".to_string(),
|
|
client_id: "test_resource_server".to_string(),
|
|
state: "123".to_string(),
|
|
pkce_request: pkce_request.clone(),
|
|
redirect_uri: Url::parse("https://totes.not.sus.org/oauth2/result").unwrap(),
|
|
scope: "openid".to_string(),
|
|
nonce: None,
|
|
oidc_ext: Default::default(),
|
|
unknown_keys: Default::default(),
|
|
};
|
|
|
|
assert!(
|
|
idms_prox_read
|
|
.check_oauth2_authorisation(&ident, &uat, &auth_req, ct)
|
|
.unwrap_err()
|
|
== Oauth2Error::InvalidOrigin
|
|
);
|
|
|
|
// Requested scope is not available
|
|
let auth_req = AuthorisationRequest {
|
|
response_type: "code".to_string(),
|
|
client_id: "test_resource_server".to_string(),
|
|
state: "123".to_string(),
|
|
pkce_request: pkce_request.clone(),
|
|
redirect_uri: Url::parse("https://demo.example.com/oauth2/result").unwrap(),
|
|
scope: "invalid_scope read".to_string(),
|
|
nonce: None,
|
|
oidc_ext: Default::default(),
|
|
unknown_keys: Default::default(),
|
|
};
|
|
|
|
assert!(
|
|
idms_prox_read
|
|
.check_oauth2_authorisation(&ident, &uat, &auth_req, ct)
|
|
.unwrap_err()
|
|
== Oauth2Error::AccessDenied
|
|
);
|
|
|
|
// Not a member of the group.
|
|
let auth_req = AuthorisationRequest {
|
|
response_type: "code".to_string(),
|
|
client_id: "test_resource_server".to_string(),
|
|
state: "123".to_string(),
|
|
pkce_request: pkce_request.clone(),
|
|
redirect_uri: Url::parse("https://demo.example.com/oauth2/result").unwrap(),
|
|
scope: "read openid".to_string(),
|
|
nonce: None,
|
|
oidc_ext: Default::default(),
|
|
unknown_keys: Default::default(),
|
|
};
|
|
|
|
assert!(
|
|
idms_prox_read
|
|
.check_oauth2_authorisation(&idm_admin_ident, &idm_admin_uat, &auth_req, ct)
|
|
.unwrap_err()
|
|
== Oauth2Error::AccessDenied
|
|
);
|
|
|
|
// Deny Anonymous auth methods
|
|
let auth_req = AuthorisationRequest {
|
|
response_type: "code".to_string(),
|
|
client_id: "test_resource_server".to_string(),
|
|
state: "123".to_string(),
|
|
pkce_request: pkce_request.clone(),
|
|
redirect_uri: Url::parse("https://demo.example.com/oauth2/result").unwrap(),
|
|
scope: "read openid".to_string(),
|
|
nonce: None,
|
|
oidc_ext: Default::default(),
|
|
unknown_keys: Default::default(),
|
|
};
|
|
|
|
assert!(
|
|
idms_prox_read
|
|
.check_oauth2_authorisation(&anon_ident, &anon_uat, &auth_req, ct)
|
|
.unwrap_err()
|
|
== Oauth2Error::AccessDenied
|
|
);
|
|
})
|
|
}
|
|
|
|
#[test]
|
|
fn test_idm_oauth2_invalid_authorisation_permit_requests() {
|
|
run_idm_test!(|_qs: &QueryServer,
|
|
idms: &IdmServer,
|
|
_idms_delayed: &mut IdmServerDelayed| {
|
|
// Test invalid oauth2 authorisation states/requests.
|
|
let ct = Duration::from_secs(TEST_CURRENT_TIME);
|
|
let (_secret, uat, ident, _) =
|
|
setup_oauth2_resource_server(idms, ct, true, false, false);
|
|
|
|
let (uat2, ident2) = {
|
|
let mut idms_prox_write = task::block_on(idms.proxy_write(ct));
|
|
let account = idms_prox_write
|
|
.target_to_account(UUID_IDM_ADMIN)
|
|
.expect("account must exist");
|
|
let session_id = uuid::Uuid::new_v4();
|
|
let uat2 = account
|
|
.to_userauthtoken(
|
|
session_id,
|
|
ct,
|
|
AuthType::PasswordMfa,
|
|
Some(AUTH_SESSION_EXPIRY),
|
|
)
|
|
.expect("Unable to create uat");
|
|
let ident2 = idms_prox_write
|
|
.process_uat_to_identity(&uat2, ct)
|
|
.expect("Unable to process uat");
|
|
(uat2, ident2)
|
|
};
|
|
|
|
let idms_prox_read = task::block_on(idms.proxy_read());
|
|
|
|
let (_code_verifier, code_challenge) = create_code_verifier!("Whar Garble");
|
|
|
|
let consent_request = good_authorisation_request!(
|
|
idms_prox_read,
|
|
&ident,
|
|
&uat,
|
|
ct,
|
|
code_challenge,
|
|
"openid".to_string()
|
|
);
|
|
|
|
let consent_token = if let AuthoriseResponse::ConsentRequested {
|
|
consent_token, ..
|
|
} = consent_request
|
|
{
|
|
consent_token
|
|
} else {
|
|
unreachable!();
|
|
};
|
|
|
|
// Invalid permits
|
|
// * expired token, aka past ttl.
|
|
assert!(
|
|
idms_prox_read
|
|
.check_oauth2_authorise_permit(
|
|
&ident,
|
|
&uat,
|
|
&consent_token,
|
|
ct + Duration::from_secs(TOKEN_EXPIRE),
|
|
)
|
|
.unwrap_err()
|
|
== OperationError::CryptographyError
|
|
);
|
|
|
|
// * incorrect ident
|
|
// We get another uat, but for a different user, and we'll introduce these
|
|
// inconsistently to cause confusion.
|
|
|
|
assert!(
|
|
idms_prox_read
|
|
.check_oauth2_authorise_permit(&ident2, &uat, &consent_token, ct,)
|
|
.unwrap_err()
|
|
== OperationError::InvalidSessionState
|
|
);
|
|
|
|
// * incorrect session id
|
|
assert!(
|
|
idms_prox_read
|
|
.check_oauth2_authorise_permit(&ident, &uat2, &consent_token, ct,)
|
|
.unwrap_err()
|
|
== OperationError::InvalidSessionState
|
|
);
|
|
})
|
|
}
|
|
|
|
#[test]
|
|
fn test_idm_oauth2_invalid_token_exchange_requests() {
|
|
run_idm_test!(
|
|
|_qs: &QueryServer, idms: &IdmServer, idms_delayed: &mut IdmServerDelayed| {
|
|
let ct = Duration::from_secs(TEST_CURRENT_TIME);
|
|
let (secret, mut uat, ident, _) =
|
|
setup_oauth2_resource_server(idms, ct, true, false, false);
|
|
|
|
// ⚠️ We set the uat expiry time to 5 seconds from TEST_CURRENT_TIME. This
|
|
// allows all our other tests to pass, but it means when we specifically put the
|
|
// clock forward a fraction, the fernet tokens are still valid, but the uat
|
|
// is not.
|
|
// IE
|
|
// |---------------------|------------------|
|
|
// TEST_CURRENT_TIME UAT_EXPIRE TOKEN_EXPIRE
|
|
//
|
|
// This lets us check a variety of time based cases.
|
|
uat.expiry = Some(
|
|
time::OffsetDateTime::unix_epoch()
|
|
+ Duration::from_secs(TEST_CURRENT_TIME + UAT_EXPIRE - 1),
|
|
);
|
|
|
|
let mut idms_prox_read = task::block_on(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,
|
|
"openid".to_string()
|
|
);
|
|
|
|
let consent_token =
|
|
if let AuthoriseResponse::ConsentRequested { consent_token, .. } =
|
|
consent_request
|
|
{
|
|
consent_token
|
|
} else {
|
|
unreachable!();
|
|
};
|
|
|
|
// == 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_token, ct)
|
|
.expect("Failed to perform oauth2 permit");
|
|
|
|
// Assert that the consent was submitted
|
|
match idms_delayed.async_rx.blocking_recv() {
|
|
Some(DelayedAction::Oauth2ConsentGrant(_)) => {}
|
|
_ => assert!(false),
|
|
}
|
|
|
|
// == Submit the token exchange code.
|
|
|
|
// Invalid token exchange
|
|
// * invalid client_authz (not base64)
|
|
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,
|
|
client_secret: None,
|
|
// From the first step.
|
|
code_verifier: code_verifier.clone(),
|
|
};
|
|
|
|
assert!(
|
|
idms_prox_read
|
|
.check_oauth2_token_exchange(Some("not base64"), &token_req, ct)
|
|
.unwrap_err()
|
|
== Oauth2Error::AuthenticationRequired
|
|
);
|
|
|
|
// * doesn't have :
|
|
let client_authz = Some(base64::encode(format!("test_resource_server {}", secret)));
|
|
assert!(
|
|
idms_prox_read
|
|
.check_oauth2_token_exchange(client_authz.as_deref(), &token_req, ct)
|
|
.unwrap_err()
|
|
== Oauth2Error::AuthenticationRequired
|
|
);
|
|
|
|
// * invalid client_id
|
|
let client_authz = Some(base64::encode(format!("NOT A REAL SERVER:{}", secret)));
|
|
assert!(
|
|
idms_prox_read
|
|
.check_oauth2_token_exchange(client_authz.as_deref(), &token_req, ct)
|
|
.unwrap_err()
|
|
== Oauth2Error::AuthenticationRequired
|
|
);
|
|
|
|
// * valid client_id, but invalid secret
|
|
let client_authz = Some(base64::encode("test_resource_server:12345"));
|
|
assert!(
|
|
idms_prox_read
|
|
.check_oauth2_token_exchange(client_authz.as_deref(), &token_req, ct)
|
|
.unwrap_err()
|
|
== Oauth2Error::AuthenticationRequired
|
|
);
|
|
|
|
// ✅ Now the valid client_authz is in place.
|
|
let client_authz = Some(base64::encode(format!("test_resource_server:{}", secret)));
|
|
// * expired exchange code (took too long)
|
|
assert!(
|
|
idms_prox_read
|
|
.check_oauth2_token_exchange(
|
|
client_authz.as_deref(),
|
|
&token_req,
|
|
ct + Duration::from_secs(TOKEN_EXPIRE)
|
|
)
|
|
.unwrap_err()
|
|
== Oauth2Error::InvalidRequest
|
|
);
|
|
|
|
// * Uat has expired!
|
|
// NOTE: This is setup EARLY in the test, by manipulation of the UAT expiry.
|
|
assert!(
|
|
idms_prox_read
|
|
.check_oauth2_token_exchange(
|
|
client_authz.as_deref(),
|
|
&token_req,
|
|
ct + Duration::from_secs(UAT_EXPIRE)
|
|
)
|
|
.unwrap_err()
|
|
== Oauth2Error::AccessDenied
|
|
);
|
|
|
|
// * incorrect grant_type
|
|
let token_req = AccessTokenRequest {
|
|
grant_type: "INCORRECT GRANT TYPE".to_string(),
|
|
code: permit_success.code.clone(),
|
|
redirect_uri: Url::parse("https://demo.example.com/oauth2/result").unwrap(),
|
|
client_id: None,
|
|
client_secret: None,
|
|
code_verifier: code_verifier.clone(),
|
|
};
|
|
assert!(
|
|
idms_prox_read
|
|
.check_oauth2_token_exchange(client_authz.as_deref(), &token_req, ct)
|
|
.unwrap_err()
|
|
== Oauth2Error::InvalidRequest
|
|
);
|
|
|
|
// * Incorrect redirect uri
|
|
let token_req = AccessTokenRequest {
|
|
grant_type: "authorization_code".to_string(),
|
|
code: permit_success.code.clone(),
|
|
redirect_uri: Url::parse("https://totes.not.sus.org/oauth2/result").unwrap(),
|
|
client_id: None,
|
|
client_secret: None,
|
|
code_verifier,
|
|
};
|
|
assert!(
|
|
idms_prox_read
|
|
.check_oauth2_token_exchange(client_authz.as_deref(), &token_req, ct)
|
|
.unwrap_err()
|
|
== Oauth2Error::InvalidOrigin
|
|
);
|
|
|
|
// * code verifier incorrect
|
|
let token_req = AccessTokenRequest {
|
|
grant_type: "authorization_code".to_string(),
|
|
code: permit_success.code,
|
|
redirect_uri: Url::parse("https://demo.example.com/oauth2/result").unwrap(),
|
|
client_id: None,
|
|
client_secret: None,
|
|
code_verifier: Some("12345".to_string()),
|
|
};
|
|
assert!(
|
|
idms_prox_read
|
|
.check_oauth2_token_exchange(client_authz.as_deref(), &token_req, ct)
|
|
.unwrap_err()
|
|
== Oauth2Error::InvalidRequest
|
|
);
|
|
}
|
|
)
|
|
}
|
|
|
|
#[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, true, false, false);
|
|
let client_authz = Some(base64::encode(format!("test_resource_server:{}", secret)));
|
|
|
|
let mut idms_prox_read = task::block_on(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,
|
|
"openid".to_string()
|
|
);
|
|
|
|
let consent_token =
|
|
if let AuthoriseResponse::ConsentRequested { consent_token, .. } =
|
|
consent_request
|
|
{
|
|
consent_token
|
|
} else {
|
|
unreachable!();
|
|
};
|
|
|
|
// == 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_token, ct)
|
|
.expect("Failed to perform oauth2 permit");
|
|
|
|
// Assert that the consent was submitted
|
|
match idms_delayed.async_rx.blocking_recv() {
|
|
Some(DelayedAction::Oauth2ConsentGrant(_)) => {}
|
|
_ => assert!(false),
|
|
}
|
|
|
|
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,
|
|
client_secret: None,
|
|
code_verifier,
|
|
};
|
|
let oauth2_token = idms_prox_read
|
|
.check_oauth2_token_exchange(client_authz.as_deref(), &token_req, ct)
|
|
.expect("Unable to exchange for oauth2 token");
|
|
|
|
// Assert that the session creation was submitted
|
|
match idms_delayed.async_rx.blocking_recv() {
|
|
Some(DelayedAction::Oauth2SessionRecord(_)) => {}
|
|
_ => assert!(false),
|
|
}
|
|
|
|
// 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.as_deref().unwrap(),
|
|
&intr_request,
|
|
ct,
|
|
)
|
|
.expect("Failed to inspect token");
|
|
|
|
eprintln!("👉 {:?}", intr_response);
|
|
assert!(intr_response.active);
|
|
assert!(intr_response.scope.as_deref() == Some("openid supplement"));
|
|
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 mut idms_prox_write = task::block_on(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 mut idms_prox_read = task::block_on(idms.proxy_read());
|
|
let intr_response = idms_prox_read
|
|
.check_oauth2_token_introspect(&client_authz.unwrap(), &intr_request, ct)
|
|
.expect("Failed to inspect token");
|
|
|
|
assert!(!intr_response.active);
|
|
}
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn test_idm_oauth2_token_revoke() {
|
|
run_idm_test!(
|
|
|_qs: &QueryServer, idms: &IdmServer, idms_delayed: &mut IdmServerDelayed| {
|
|
// First, setup to get a token.
|
|
let ct = Duration::from_secs(TEST_CURRENT_TIME);
|
|
let (secret, uat, ident, _) =
|
|
setup_oauth2_resource_server(idms, ct, true, false, false);
|
|
let client_authz = Some(base64::encode(format!("test_resource_server:{}", secret)));
|
|
|
|
let mut idms_prox_read = task::block_on(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,
|
|
"openid".to_string()
|
|
);
|
|
|
|
let consent_token =
|
|
if let AuthoriseResponse::ConsentRequested { consent_token, .. } =
|
|
consent_request
|
|
{
|
|
consent_token
|
|
} else {
|
|
unreachable!();
|
|
};
|
|
|
|
// == 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_token, ct)
|
|
.expect("Failed to perform oauth2 permit");
|
|
|
|
// Assert that the consent was submitted
|
|
match idms_delayed.async_rx.blocking_recv() {
|
|
Some(DelayedAction::Oauth2ConsentGrant(_)) => {}
|
|
_ => assert!(false),
|
|
}
|
|
|
|
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,
|
|
client_secret: None,
|
|
code_verifier,
|
|
};
|
|
let oauth2_token = idms_prox_read
|
|
.check_oauth2_token_exchange(client_authz.as_deref(), &token_req, ct)
|
|
.expect("Unable to exchange for oauth2 token");
|
|
|
|
drop(idms_prox_read);
|
|
|
|
// Assert that the session creation was submitted
|
|
match idms_delayed.async_rx.blocking_recv() {
|
|
Some(DelayedAction::Oauth2SessionRecord(osr)) => {
|
|
// Process it to ensure the record exists.
|
|
let mut idms_prox_write = task::block_on(idms.proxy_write(ct));
|
|
|
|
assert!(idms_prox_write.process_oauth2sessionrecord(&osr).is_ok());
|
|
|
|
assert!(idms_prox_write.commit().is_ok());
|
|
}
|
|
_ => assert!(false),
|
|
}
|
|
|
|
// Okay, now we have the token, we can check behaviours with the revoke interface.
|
|
|
|
// First, assert it is valid, similar to the introspect api.
|
|
let mut idms_prox_read = task::block_on(idms.proxy_read());
|
|
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.as_deref().unwrap(),
|
|
&intr_request,
|
|
ct,
|
|
)
|
|
.expect("Failed to inspect token");
|
|
eprintln!("👉 {:?}", intr_response);
|
|
assert!(intr_response.active);
|
|
drop(idms_prox_read);
|
|
|
|
// First, the revoke needs basic auth. Provide incorrect auth, and we fail.
|
|
let mut idms_prox_write = task::block_on(idms.proxy_write(ct));
|
|
|
|
let bad_client_authz = Some(base64::encode("test_resource_server:12345"));
|
|
let revoke_request = TokenRevokeRequest {
|
|
token: oauth2_token.access_token.clone(),
|
|
token_type_hint: None,
|
|
};
|
|
let e = idms_prox_write
|
|
.oauth2_token_revoke(bad_client_authz.as_deref().unwrap(), &revoke_request, ct)
|
|
.unwrap_err();
|
|
assert!(matches!(e, Oauth2Error::AuthenticationRequired));
|
|
assert!(idms_prox_write.commit().is_ok());
|
|
|
|
// Now submit a non-existent/invalid token. Does not affect our tokens validity.
|
|
let mut idms_prox_write = task::block_on(idms.proxy_write(ct));
|
|
let revoke_request = TokenRevokeRequest {
|
|
token: "this is an invalid token, nothing will happen!".to_string(),
|
|
token_type_hint: None,
|
|
};
|
|
let e = idms_prox_write
|
|
.oauth2_token_revoke(client_authz.as_deref().unwrap(), &revoke_request, ct)
|
|
.unwrap_err();
|
|
assert!(matches!(e, Oauth2Error::InvalidRequest));
|
|
assert!(idms_prox_write.commit().is_ok());
|
|
|
|
// Check our token is still valid.
|
|
let mut idms_prox_read = task::block_on(idms.proxy_read());
|
|
let intr_response = idms_prox_read
|
|
.check_oauth2_token_introspect(
|
|
client_authz.as_deref().unwrap(),
|
|
&intr_request,
|
|
ct,
|
|
)
|
|
.expect("Failed to inspect token");
|
|
assert!(intr_response.active);
|
|
drop(idms_prox_read);
|
|
|
|
// Finally revoke it.
|
|
let mut idms_prox_write = task::block_on(idms.proxy_write(ct));
|
|
let revoke_request = TokenRevokeRequest {
|
|
token: oauth2_token.access_token.clone(),
|
|
token_type_hint: None,
|
|
};
|
|
assert!(idms_prox_write
|
|
.oauth2_token_revoke(client_authz.as_deref().unwrap(), &revoke_request, ct,)
|
|
.is_ok());
|
|
assert!(idms_prox_write.commit().is_ok());
|
|
|
|
// Check it is still valid - this is because we are still in the GRACE window.
|
|
let mut idms_prox_read = task::block_on(idms.proxy_read());
|
|
let intr_response = idms_prox_read
|
|
.check_oauth2_token_introspect(
|
|
client_authz.as_deref().unwrap(),
|
|
&intr_request,
|
|
ct,
|
|
)
|
|
.expect("Failed to inspect token");
|
|
|
|
assert!(intr_response.active);
|
|
drop(idms_prox_read);
|
|
|
|
// Check after the grace window, it will be invalid.
|
|
let ct = ct + GRACE_WINDOW;
|
|
|
|
// Assert it is now invalid.
|
|
let mut idms_prox_read = task::block_on(idms.proxy_read());
|
|
let intr_response = idms_prox_read
|
|
.check_oauth2_token_introspect(
|
|
client_authz.as_deref().unwrap(),
|
|
&intr_request,
|
|
ct,
|
|
)
|
|
.expect("Failed to inspect token");
|
|
|
|
assert!(!intr_response.active);
|
|
drop(idms_prox_read);
|
|
|
|
// A second invalidation of the token "does nothing".
|
|
let mut idms_prox_write = task::block_on(idms.proxy_write(ct));
|
|
let revoke_request = TokenRevokeRequest {
|
|
token: oauth2_token.access_token.clone(),
|
|
token_type_hint: None,
|
|
};
|
|
assert!(idms_prox_write
|
|
.oauth2_token_revoke(client_authz.as_deref().unwrap(), &revoke_request, ct,)
|
|
.is_ok());
|
|
assert!(idms_prox_write.commit().is_ok());
|
|
}
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn test_idm_oauth2_session_cleanup_post_rs_delete() {
|
|
run_idm_test!(
|
|
|_qs: &QueryServer, idms: &IdmServer, idms_delayed: &mut IdmServerDelayed| {
|
|
// First, setup to get a token.
|
|
let ct = Duration::from_secs(TEST_CURRENT_TIME);
|
|
let (secret, uat, ident, _) =
|
|
setup_oauth2_resource_server(idms, ct, true, false, false);
|
|
let client_authz = Some(base64::encode(format!("test_resource_server:{}", secret)));
|
|
|
|
let mut idms_prox_read = task::block_on(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,
|
|
"openid".to_string()
|
|
);
|
|
|
|
let consent_token =
|
|
if let AuthoriseResponse::ConsentRequested { consent_token, .. } =
|
|
consent_request
|
|
{
|
|
consent_token
|
|
} else {
|
|
unreachable!();
|
|
};
|
|
|
|
// == 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_token, ct)
|
|
.expect("Failed to perform oauth2 permit");
|
|
|
|
// Assert that the consent was submitted
|
|
match idms_delayed.async_rx.blocking_recv() {
|
|
Some(DelayedAction::Oauth2ConsentGrant(_)) => {}
|
|
_ => assert!(false),
|
|
}
|
|
|
|
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,
|
|
client_secret: None,
|
|
code_verifier,
|
|
};
|
|
let _oauth2_token = idms_prox_read
|
|
.check_oauth2_token_exchange(client_authz.as_deref(), &token_req, ct)
|
|
.expect("Unable to exchange for oauth2 token");
|
|
|
|
drop(idms_prox_read);
|
|
|
|
// Process it to ensure the record exists.
|
|
let mut idms_prox_write = task::block_on(idms.proxy_write(ct));
|
|
|
|
// Assert that the session creation was submitted
|
|
let session_id = match idms_delayed.async_rx.blocking_recv() {
|
|
Some(DelayedAction::Oauth2SessionRecord(osr)) => {
|
|
assert!(idms_prox_write.process_oauth2sessionrecord(&osr).is_ok());
|
|
osr.session_id
|
|
}
|
|
_ => {
|
|
unreachable!();
|
|
}
|
|
};
|
|
|
|
// Check it is now there
|
|
let entry = idms_prox_write
|
|
.qs_write
|
|
.internal_search_uuid(UUID_ADMIN)
|
|
.expect("failed");
|
|
let valid = entry
|
|
.get_ava_as_oauth2session_map("oauth2_session")
|
|
.map(|map| map.get(&session_id).is_some())
|
|
.unwrap_or(false);
|
|
assert!(valid);
|
|
|
|
// Delete the resource server.
|
|
|
|
let de = unsafe {
|
|
DeleteEvent::new_internal_invalid(filter!(f_eq(
|
|
"oauth2_rs_name",
|
|
PartialValue::new_iname("test_resource_server")
|
|
)))
|
|
};
|
|
|
|
assert!(idms_prox_write.qs_write.delete(&de).is_ok());
|
|
|
|
// Assert the session is gone. This is cleaned up as an artifact of the referential
|
|
// integrity plugin.
|
|
let entry = idms_prox_write
|
|
.qs_write
|
|
.internal_search_uuid(UUID_ADMIN)
|
|
.expect("failed");
|
|
let valid = entry
|
|
.get_ava_as_oauth2session_map("oauth2_session")
|
|
.map(|map| map.get(&session_id).is_some())
|
|
.unwrap_or(false);
|
|
assert!(!valid);
|
|
|
|
assert!(idms_prox_write.commit().is_ok());
|
|
}
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn test_idm_oauth2_authorisation_reject() {
|
|
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, true, false, false);
|
|
|
|
let (uat2, ident2) = {
|
|
let mut idms_prox_write = task::block_on(idms.proxy_write(ct));
|
|
let account = idms_prox_write
|
|
.target_to_account(UUID_IDM_ADMIN)
|
|
.expect("account must exist");
|
|
let session_id = uuid::Uuid::new_v4();
|
|
let uat2 = account
|
|
.to_userauthtoken(
|
|
session_id,
|
|
ct,
|
|
AuthType::PasswordMfa,
|
|
Some(AUTH_SESSION_EXPIRY),
|
|
)
|
|
.expect("Unable to create uat");
|
|
let ident2 = idms_prox_write
|
|
.process_uat_to_identity(&uat2, ct)
|
|
.expect("Unable to process uat");
|
|
(uat2, ident2)
|
|
};
|
|
|
|
let idms_prox_read = task::block_on(idms.proxy_read());
|
|
let redirect_uri = Url::parse("https://demo.example.com/oauth2/result").unwrap();
|
|
let (_code_verifier, code_challenge) = create_code_verifier!("Whar Garble");
|
|
|
|
// Check reject behaviour
|
|
let consent_request = good_authorisation_request!(
|
|
idms_prox_read,
|
|
&ident,
|
|
&uat,
|
|
ct,
|
|
code_challenge,
|
|
"openid".to_string()
|
|
);
|
|
|
|
let consent_token = if let AuthoriseResponse::ConsentRequested {
|
|
consent_token, ..
|
|
} = consent_request
|
|
{
|
|
consent_token
|
|
} else {
|
|
unreachable!();
|
|
};
|
|
|
|
let reject_success = idms_prox_read
|
|
.check_oauth2_authorise_reject(&ident, &uat, &consent_token, ct)
|
|
.expect("Failed to perform oauth2 reject");
|
|
|
|
assert!(reject_success == redirect_uri);
|
|
|
|
// Too much time past to reject
|
|
let past_ct = Duration::from_secs(TEST_CURRENT_TIME + 301);
|
|
assert!(
|
|
idms_prox_read
|
|
.check_oauth2_authorise_reject(&ident, &uat, &consent_token, past_ct)
|
|
.unwrap_err()
|
|
== OperationError::CryptographyError
|
|
);
|
|
|
|
// Invalid consent token
|
|
assert!(
|
|
idms_prox_read
|
|
.check_oauth2_authorise_reject(&ident, &uat, "not a token", ct)
|
|
.unwrap_err()
|
|
== OperationError::CryptographyError
|
|
);
|
|
|
|
// Wrong UAT
|
|
assert!(
|
|
idms_prox_read
|
|
.check_oauth2_authorise_reject(&ident, &uat2, &consent_token, ct)
|
|
.unwrap_err()
|
|
== OperationError::InvalidSessionState
|
|
);
|
|
// Wrong ident
|
|
assert!(
|
|
idms_prox_read
|
|
.check_oauth2_authorise_reject(&ident2, &uat, &consent_token, ct)
|
|
.unwrap_err()
|
|
== OperationError::InvalidSessionState
|
|
);
|
|
})
|
|
}
|
|
|
|
#[test]
|
|
fn test_idm_oauth2_openid_discovery() {
|
|
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, true, false, false);
|
|
|
|
let idms_prox_read = task::block_on(idms.proxy_read());
|
|
|
|
// check the discovery end point works as we expect
|
|
assert!(
|
|
idms_prox_read
|
|
.oauth2_openid_discovery("nosuchclient")
|
|
.unwrap_err()
|
|
== OperationError::NoMatchingEntries
|
|
);
|
|
|
|
assert!(
|
|
idms_prox_read
|
|
.oauth2_openid_publickey("nosuchclient")
|
|
.unwrap_err()
|
|
== OperationError::NoMatchingEntries
|
|
);
|
|
|
|
let discovery = idms_prox_read
|
|
.oauth2_openid_discovery("test_resource_server")
|
|
.expect("Failed to get discovery");
|
|
|
|
let mut jwkset = idms_prox_read
|
|
.oauth2_openid_publickey("test_resource_server")
|
|
.expect("Failed to get public key");
|
|
|
|
let jwk = jwkset.keys.pop().expect("no such jwk");
|
|
|
|
match jwk {
|
|
Jwk::EC { alg, use_, kid, .. } => {
|
|
match (
|
|
alg.unwrap(),
|
|
&discovery.id_token_signing_alg_values_supported[0],
|
|
) {
|
|
(JwaAlg::ES256, IdTokenSignAlg::ES256) => {}
|
|
_ => panic!(),
|
|
};
|
|
assert!(use_.unwrap() == JwkUse::Sig);
|
|
assert!(kid.is_some())
|
|
}
|
|
_ => panic!(),
|
|
};
|
|
|
|
assert!(
|
|
discovery.issuer
|
|
== Url::parse("https://idm.example.com/oauth2/openid/test_resource_server")
|
|
.unwrap()
|
|
);
|
|
|
|
assert!(
|
|
discovery.authorization_endpoint
|
|
== Url::parse("https://idm.example.com/ui/oauth2").unwrap()
|
|
);
|
|
|
|
assert!(
|
|
discovery.token_endpoint
|
|
== Url::parse("https://idm.example.com/oauth2/token").unwrap()
|
|
);
|
|
|
|
assert!(
|
|
discovery.userinfo_endpoint
|
|
== Some(
|
|
Url::parse(
|
|
"https://idm.example.com/oauth2/openid/test_resource_server/userinfo"
|
|
)
|
|
.unwrap()
|
|
)
|
|
);
|
|
|
|
assert!(
|
|
discovery.jwks_uri
|
|
== Url::parse(
|
|
"https://idm.example.com/oauth2/openid/test_resource_server/public_key.jwk"
|
|
)
|
|
.unwrap()
|
|
);
|
|
|
|
eprintln!("{:?}", discovery.scopes_supported);
|
|
assert!(
|
|
discovery.scopes_supported
|
|
== Some(vec![
|
|
"groups".to_string(),
|
|
"openid".to_string(),
|
|
"supplement".to_string(),
|
|
])
|
|
);
|
|
|
|
assert!(discovery.response_types_supported == vec![ResponseType::Code]);
|
|
assert!(discovery.response_modes_supported == vec![ResponseMode::Query]);
|
|
assert!(discovery.grant_types_supported == vec![GrantType::AuthorisationCode]);
|
|
assert!(discovery.subject_types_supported == vec![SubjectType::Public]);
|
|
assert!(discovery.id_token_signing_alg_values_supported == vec![IdTokenSignAlg::ES256]);
|
|
assert!(discovery.userinfo_signing_alg_values_supported.is_none());
|
|
assert!(
|
|
discovery.token_endpoint_auth_methods_supported
|
|
== vec![
|
|
TokenEndpointAuthMethod::ClientSecretBasic,
|
|
TokenEndpointAuthMethod::ClientSecretPost
|
|
]
|
|
);
|
|
assert!(discovery.display_values_supported == Some(vec![DisplayValue::Page]));
|
|
assert!(discovery.claim_types_supported == vec![ClaimType::Normal]);
|
|
assert!(discovery.claims_supported.is_none());
|
|
assert!(discovery.service_documentation.is_some());
|
|
|
|
assert!(discovery.registration_endpoint.is_none());
|
|
assert!(discovery.acr_values_supported.is_none());
|
|
assert!(discovery.id_token_encryption_alg_values_supported.is_none());
|
|
assert!(discovery.id_token_encryption_enc_values_supported.is_none());
|
|
assert!(discovery.userinfo_encryption_alg_values_supported.is_none());
|
|
assert!(discovery.userinfo_encryption_enc_values_supported.is_none());
|
|
assert!(discovery
|
|
.request_object_signing_alg_values_supported
|
|
.is_none());
|
|
assert!(discovery
|
|
.request_object_encryption_alg_values_supported
|
|
.is_none());
|
|
assert!(discovery
|
|
.request_object_encryption_enc_values_supported
|
|
.is_none());
|
|
assert!(discovery
|
|
.token_endpoint_auth_signing_alg_values_supported
|
|
.is_none());
|
|
assert!(discovery.claims_locales_supported.is_none());
|
|
assert!(discovery.ui_locales_supported.is_none());
|
|
assert!(discovery.op_policy_uri.is_none());
|
|
assert!(discovery.op_tos_uri.is_none());
|
|
assert!(!discovery.claims_parameter_supported);
|
|
assert!(!discovery.request_uri_parameter_supported);
|
|
assert!(!discovery.require_request_uri_registration);
|
|
assert!(discovery.request_parameter_supported);
|
|
})
|
|
}
|
|
|
|
#[test]
|
|
fn test_idm_oauth2_openid_extensions() {
|
|
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, true, false, false);
|
|
let client_authz = Some(base64::encode(format!("test_resource_server:{}", secret)));
|
|
|
|
let mut idms_prox_read = task::block_on(idms.proxy_read());
|
|
|
|
let (code_verifier, code_challenge) = create_code_verifier!("Whar Garble");
|
|
|
|
let consent_request = good_authorisation_request!(
|
|
idms_prox_read,
|
|
&ident,
|
|
&uat,
|
|
ct,
|
|
code_challenge,
|
|
"openid".to_string()
|
|
);
|
|
|
|
let consent_token =
|
|
if let AuthoriseResponse::ConsentRequested { consent_token, .. } =
|
|
consent_request
|
|
{
|
|
consent_token
|
|
} else {
|
|
unreachable!();
|
|
};
|
|
|
|
// == 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_token, ct)
|
|
.expect("Failed to perform oauth2 permit");
|
|
|
|
// Assert that the consent was submitted
|
|
match idms_delayed.async_rx.blocking_recv() {
|
|
Some(DelayedAction::Oauth2ConsentGrant(_)) => {}
|
|
_ => assert!(false),
|
|
}
|
|
|
|
// == Submit the token exchange code.
|
|
let token_req = AccessTokenRequest {
|
|
grant_type: "authorization_code".to_string(),
|
|
code: permit_success.code,
|
|
redirect_uri: Url::parse("https://demo.example.com/oauth2/result").unwrap(),
|
|
client_id: None,
|
|
client_secret: None,
|
|
// From the first step.
|
|
code_verifier,
|
|
};
|
|
|
|
let token_response = idms_prox_read
|
|
.check_oauth2_token_exchange(client_authz.as_deref(), &token_req, ct)
|
|
.expect("Failed to perform oauth2 token exchange");
|
|
|
|
// Assert that the session creation was submitted
|
|
match idms_delayed.async_rx.blocking_recv() {
|
|
Some(DelayedAction::Oauth2SessionRecord(_)) => {}
|
|
_ => assert!(false),
|
|
}
|
|
|
|
// 🎉 We got a token!
|
|
assert!(token_response.token_type == "bearer");
|
|
|
|
let id_token = token_response.id_token.expect("No id_token in response!");
|
|
let access_token = token_response.access_token;
|
|
|
|
let mut jwkset = idms_prox_read
|
|
.oauth2_openid_publickey("test_resource_server")
|
|
.expect("Failed to get public key");
|
|
let public_jwk = jwkset.keys.pop().expect("no such jwk");
|
|
|
|
let jws_validator =
|
|
JwsValidator::try_from(&public_jwk).expect("failed to build validator");
|
|
|
|
let oidc_unverified =
|
|
OidcUnverified::from_str(&id_token).expect("Failed to parse id_token");
|
|
|
|
let iat = ct.as_secs() as i64;
|
|
|
|
let oidc = oidc_unverified
|
|
.validate(&jws_validator, iat)
|
|
.expect("Failed to verify oidc");
|
|
|
|
// Are the id_token values what we expect?
|
|
assert!(
|
|
oidc.iss
|
|
== Url::parse("https://idm.example.com/oauth2/openid/test_resource_server")
|
|
.unwrap()
|
|
);
|
|
assert!(oidc.sub == OidcSubject::U(UUID_ADMIN));
|
|
assert!(oidc.aud == "test_resource_server");
|
|
assert!(oidc.iat == iat);
|
|
assert!(oidc.nbf == Some(iat));
|
|
assert!(oidc.exp == iat + (AUTH_SESSION_EXPIRY as i64));
|
|
assert!(oidc.auth_time.is_none());
|
|
// Is nonce correctly passed through?
|
|
assert!(oidc.nonce == Some("abcdef".to_string()));
|
|
assert!(oidc.at_hash.is_none());
|
|
assert!(oidc.acr.is_none());
|
|
assert!(oidc.amr == Some(vec!["passwordmfa".to_string()]));
|
|
assert!(oidc.azp == Some("test_resource_server".to_string()));
|
|
assert!(oidc.jti.is_none());
|
|
assert!(oidc.s_claims.name == Some("System Administrator".to_string()));
|
|
assert!(oidc.s_claims.preferred_username == Some("admin@example.com".to_string()));
|
|
assert!(
|
|
oidc.s_claims.scopes == vec!["openid".to_string(), "supplement".to_string()]
|
|
);
|
|
assert!(oidc.claims.is_empty());
|
|
// Does our access token work with the userinfo endpoint?
|
|
// Do the id_token details line up to the userinfo?
|
|
let userinfo = idms_prox_read
|
|
.oauth2_openid_userinfo("test_resource_server", &access_token, ct)
|
|
.expect("failed to get userinfo");
|
|
|
|
assert!(oidc.iss == userinfo.iss);
|
|
assert!(oidc.sub == userinfo.sub);
|
|
assert!(oidc.aud == userinfo.aud);
|
|
assert!(oidc.iat == userinfo.iat);
|
|
assert!(oidc.nbf == userinfo.nbf);
|
|
assert!(oidc.exp == userinfo.exp);
|
|
assert!(userinfo.auth_time.is_none());
|
|
assert!(userinfo.nonce.is_none());
|
|
assert!(userinfo.at_hash.is_none());
|
|
assert!(userinfo.acr.is_none());
|
|
assert!(oidc.amr == userinfo.amr);
|
|
assert!(oidc.azp == userinfo.azp);
|
|
assert!(userinfo.jti.is_none());
|
|
assert!(oidc.s_claims == userinfo.s_claims);
|
|
assert!(userinfo.claims.is_empty());
|
|
}
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn test_idm_oauth2_openid_short_username() {
|
|
run_idm_test!(
|
|
|_qs: &QueryServer, idms: &IdmServer, idms_delayed: &mut IdmServerDelayed| {
|
|
// we run the same test as test_idm_oauth2_openid_extensions()
|
|
// but change the preferred_username setting on the RS
|
|
let ct = Duration::from_secs(TEST_CURRENT_TIME);
|
|
let (secret, uat, ident, _) =
|
|
setup_oauth2_resource_server(idms, ct, true, false, true);
|
|
let client_authz = Some(base64::encode(format!("test_resource_server:{}", secret)));
|
|
|
|
let mut idms_prox_read = task::block_on(idms.proxy_read());
|
|
|
|
let (code_verifier, code_challenge) = create_code_verifier!("Whar Garble");
|
|
|
|
let consent_request = good_authorisation_request!(
|
|
idms_prox_read,
|
|
&ident,
|
|
&uat,
|
|
ct,
|
|
code_challenge,
|
|
"openid".to_string()
|
|
);
|
|
|
|
let consent_token =
|
|
if let AuthoriseResponse::ConsentRequested { consent_token, .. } =
|
|
consent_request
|
|
{
|
|
consent_token
|
|
} else {
|
|
unreachable!();
|
|
};
|
|
|
|
// == 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_token, ct)
|
|
.expect("Failed to perform oauth2 permit");
|
|
|
|
// Assert that the consent was submitted
|
|
match idms_delayed.async_rx.blocking_recv() {
|
|
Some(DelayedAction::Oauth2ConsentGrant(_)) => {}
|
|
_ => assert!(false),
|
|
}
|
|
|
|
// == Submit the token exchange code.
|
|
let token_req = AccessTokenRequest {
|
|
grant_type: "authorization_code".to_string(),
|
|
code: permit_success.code,
|
|
redirect_uri: Url::parse("https://demo.example.com/oauth2/result").unwrap(),
|
|
client_id: None,
|
|
client_secret: None,
|
|
// From the first step.
|
|
code_verifier,
|
|
};
|
|
|
|
let token_response = idms_prox_read
|
|
.check_oauth2_token_exchange(client_authz.as_deref(), &token_req, ct)
|
|
.expect("Failed to perform oauth2 token exchange");
|
|
|
|
// Assert that the session creation was submitted
|
|
match idms_delayed.async_rx.blocking_recv() {
|
|
Some(DelayedAction::Oauth2SessionRecord(_)) => {}
|
|
_ => assert!(false),
|
|
}
|
|
|
|
let id_token = token_response.id_token.expect("No id_token in response!");
|
|
let access_token = token_response.access_token;
|
|
|
|
let mut jwkset = idms_prox_read
|
|
.oauth2_openid_publickey("test_resource_server")
|
|
.expect("Failed to get public key");
|
|
let public_jwk = jwkset.keys.pop().expect("no such jwk");
|
|
|
|
let jws_validator =
|
|
JwsValidator::try_from(&public_jwk).expect("failed to build validator");
|
|
|
|
let oidc_unverified =
|
|
OidcUnverified::from_str(&id_token).expect("Failed to parse id_token");
|
|
|
|
let iat = ct.as_secs() as i64;
|
|
|
|
let oidc = oidc_unverified
|
|
.validate(&jws_validator, iat)
|
|
.expect("Failed to verify oidc");
|
|
|
|
// Do we have the short username in the token claims?
|
|
assert!(oidc.s_claims.preferred_username == Some("admin".to_string()));
|
|
// Do the id_token details line up to the userinfo?
|
|
let userinfo = idms_prox_read
|
|
.oauth2_openid_userinfo("test_resource_server", &access_token, ct)
|
|
.expect("failed to get userinfo");
|
|
|
|
assert!(oidc.s_claims == userinfo.s_claims);
|
|
}
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn test_idm_oauth2_openid_group_claims() {
|
|
run_idm_test!(
|
|
|_qs: &QueryServer, idms: &IdmServer, idms_delayed: &mut IdmServerDelayed| {
|
|
// we run the same test as test_idm_oauth2_openid_extensions()
|
|
// but change the preferred_username setting on the RS
|
|
let ct = Duration::from_secs(TEST_CURRENT_TIME);
|
|
let (secret, uat, ident, _) =
|
|
setup_oauth2_resource_server(idms, ct, true, false, true);
|
|
let client_authz = Some(base64::encode(format!("test_resource_server:{}", secret)));
|
|
|
|
let mut idms_prox_read = task::block_on(idms.proxy_read());
|
|
|
|
let (code_verifier, code_challenge) = create_code_verifier!("Whar Garble");
|
|
|
|
let consent_request = good_authorisation_request!(
|
|
idms_prox_read,
|
|
&ident,
|
|
&uat,
|
|
ct,
|
|
code_challenge,
|
|
"openid groups".to_string()
|
|
);
|
|
|
|
let consent_token =
|
|
if let AuthoriseResponse::ConsentRequested { consent_token, .. } =
|
|
consent_request
|
|
{
|
|
consent_token
|
|
} else {
|
|
unreachable!();
|
|
};
|
|
|
|
// == 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_token, ct)
|
|
.expect("Failed to perform oauth2 permit");
|
|
|
|
// Assert that the consent was submitted
|
|
match idms_delayed.async_rx.blocking_recv() {
|
|
Some(DelayedAction::Oauth2ConsentGrant(_)) => {}
|
|
_ => assert!(false),
|
|
}
|
|
|
|
// == Submit the token exchange code.
|
|
let token_req = AccessTokenRequest {
|
|
grant_type: "authorization_code".to_string(),
|
|
code: permit_success.code,
|
|
redirect_uri: Url::parse("https://demo.example.com/oauth2/result").unwrap(),
|
|
client_id: None,
|
|
client_secret: None,
|
|
// From the first step.
|
|
code_verifier,
|
|
};
|
|
|
|
let token_response = idms_prox_read
|
|
.check_oauth2_token_exchange(client_authz.as_deref(), &token_req, ct)
|
|
.expect("Failed to perform oauth2 token exchange");
|
|
|
|
// Assert that the session creation was submitted
|
|
match idms_delayed.async_rx.blocking_recv() {
|
|
Some(DelayedAction::Oauth2SessionRecord(_)) => {}
|
|
_ => assert!(false),
|
|
}
|
|
|
|
let id_token = token_response.id_token.expect("No id_token in response!");
|
|
let access_token = token_response.access_token;
|
|
|
|
let mut jwkset = idms_prox_read
|
|
.oauth2_openid_publickey("test_resource_server")
|
|
.expect("Failed to get public key");
|
|
let public_jwk = jwkset.keys.pop().expect("no such jwk");
|
|
|
|
let jws_validator =
|
|
JwsValidator::try_from(&public_jwk).expect("failed to build validator");
|
|
|
|
let oidc_unverified =
|
|
OidcUnverified::from_str(&id_token).expect("Failed to parse id_token");
|
|
|
|
let iat = ct.as_secs() as i64;
|
|
|
|
let oidc = oidc_unverified
|
|
.validate(&jws_validator, iat)
|
|
.expect("Failed to verify oidc");
|
|
|
|
// does our id_token contain the expected groups?
|
|
assert!(oidc.claims.contains_key(&"groups".to_string()));
|
|
|
|
assert!(oidc
|
|
.claims
|
|
.get(&"groups".to_string())
|
|
.expect("unable to find key")
|
|
.as_array()
|
|
.unwrap()
|
|
.contains(&serde_json::json!(STR_UUID_IDM_ALL_ACCOUNTS)));
|
|
|
|
// Do the id_token details line up to the userinfo?
|
|
let userinfo = idms_prox_read
|
|
.oauth2_openid_userinfo("test_resource_server", &access_token, ct)
|
|
.expect("failed to get userinfo");
|
|
|
|
// does the userinfo endpoint provide the same groups?
|
|
assert!(
|
|
oidc.claims.get(&"groups".to_string())
|
|
== userinfo.claims.get(&"groups".to_string())
|
|
);
|
|
}
|
|
)
|
|
}
|
|
|
|
// Check insecure pkce behaviour.
|
|
#[test]
|
|
fn test_idm_oauth2_insecure_pkce() {
|
|
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, false, false, false);
|
|
|
|
let idms_prox_read = task::block_on(idms.proxy_read());
|
|
|
|
// == Setup the authorisation request
|
|
let (_code_verifier, code_challenge) = create_code_verifier!("Whar Garble");
|
|
|
|
// Even in disable pkce mode, we will allow pkce
|
|
let _consent_request = good_authorisation_request!(
|
|
idms_prox_read,
|
|
&ident,
|
|
&uat,
|
|
ct,
|
|
code_challenge,
|
|
"openid".to_string()
|
|
);
|
|
|
|
// Check we allow none.
|
|
let auth_req = AuthorisationRequest {
|
|
response_type: "code".to_string(),
|
|
client_id: "test_resource_server".to_string(),
|
|
state: "123".to_string(),
|
|
pkce_request: None,
|
|
redirect_uri: Url::parse("https://demo.example.com/oauth2/result").unwrap(),
|
|
scope: "openid".to_string(),
|
|
nonce: Some("abcdef".to_string()),
|
|
oidc_ext: Default::default(),
|
|
unknown_keys: Default::default(),
|
|
};
|
|
|
|
idms_prox_read
|
|
.check_oauth2_authorisation(&ident, &uat, &auth_req, ct)
|
|
.expect("Oauth2 authorisation failed");
|
|
})
|
|
}
|
|
|
|
#[test]
|
|
fn test_idm_oauth2_openid_legacy_crypto() {
|
|
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, false, true, false);
|
|
let mut idms_prox_read = task::block_on(idms.proxy_read());
|
|
// The public key url should offer an rs key
|
|
// discovery should offer RS256
|
|
let discovery = idms_prox_read
|
|
.oauth2_openid_discovery("test_resource_server")
|
|
.expect("Failed to get discovery");
|
|
|
|
let mut jwkset = idms_prox_read
|
|
.oauth2_openid_publickey("test_resource_server")
|
|
.expect("Failed to get public key");
|
|
|
|
let jwk = jwkset.keys.pop().expect("no such jwk");
|
|
let public_jwk = jwk.clone();
|
|
|
|
match jwk {
|
|
Jwk::RSA { alg, use_, kid, .. } => {
|
|
match (
|
|
alg.unwrap(),
|
|
&discovery.id_token_signing_alg_values_supported[0],
|
|
) {
|
|
(JwaAlg::RS256, IdTokenSignAlg::RS256) => {}
|
|
_ => panic!(),
|
|
};
|
|
assert!(use_.unwrap() == JwkUse::Sig);
|
|
assert!(kid.is_some());
|
|
}
|
|
_ => panic!(),
|
|
};
|
|
|
|
// Check that the id_token is signed with the correct key.
|
|
let (code_verifier, code_challenge) = create_code_verifier!("Whar Garble");
|
|
|
|
let consent_request = good_authorisation_request!(
|
|
idms_prox_read,
|
|
&ident,
|
|
&uat,
|
|
ct,
|
|
code_challenge,
|
|
"openid".to_string()
|
|
);
|
|
|
|
let consent_token =
|
|
if let AuthoriseResponse::ConsentRequested { consent_token, .. } =
|
|
consent_request
|
|
{
|
|
consent_token
|
|
} else {
|
|
unreachable!();
|
|
};
|
|
|
|
// == 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_token, ct)
|
|
.expect("Failed to perform oauth2 permit");
|
|
|
|
// Assert that the consent was submitted
|
|
match idms_delayed.async_rx.blocking_recv() {
|
|
Some(DelayedAction::Oauth2ConsentGrant(_)) => {}
|
|
_ => assert!(false),
|
|
}
|
|
|
|
// == Submit the token exchange code.
|
|
let token_req = AccessTokenRequest {
|
|
grant_type: "authorization_code".to_string(),
|
|
code: permit_success.code,
|
|
redirect_uri: Url::parse("https://demo.example.com/oauth2/result").unwrap(),
|
|
client_id: Some("test_resource_server".to_string()),
|
|
client_secret: Some(secret),
|
|
// From the first step.
|
|
code_verifier,
|
|
};
|
|
|
|
let token_response = idms_prox_read
|
|
.check_oauth2_token_exchange(None, &token_req, ct)
|
|
.expect("Failed to perform oauth2 token exchange");
|
|
|
|
// Assert that the session creation was submitted
|
|
match idms_delayed.async_rx.blocking_recv() {
|
|
Some(DelayedAction::Oauth2SessionRecord(_)) => {}
|
|
_ => assert!(false),
|
|
}
|
|
|
|
// 🎉 We got a token!
|
|
assert!(token_response.token_type == "bearer");
|
|
let id_token = token_response.id_token.expect("No id_token in response!");
|
|
|
|
let jws_validator =
|
|
JwsValidator::try_from(&public_jwk).expect("failed to build validator");
|
|
|
|
let oidc_unverified =
|
|
OidcUnverified::from_str(&id_token).expect("Failed to parse id_token");
|
|
|
|
let iat = ct.as_secs() as i64;
|
|
|
|
let oidc = oidc_unverified
|
|
.validate(&jws_validator, iat)
|
|
.expect("Failed to verify oidc");
|
|
|
|
assert!(oidc.sub == OidcSubject::U(UUID_ADMIN));
|
|
}
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn test_idm_oauth2_consent_granted_and_changed_workflow() {
|
|
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, true, false, false);
|
|
|
|
let idms_prox_read = task::block_on(idms.proxy_read());
|
|
|
|
let (_code_verifier, code_challenge) = create_code_verifier!("Whar Garble");
|
|
let consent_request = good_authorisation_request!(
|
|
idms_prox_read,
|
|
&ident,
|
|
&uat,
|
|
ct,
|
|
code_challenge,
|
|
"openid".to_string()
|
|
);
|
|
|
|
// Should be in the consent phase;
|
|
let consent_token =
|
|
if let AuthoriseResponse::ConsentRequested { consent_token, .. } =
|
|
consent_request
|
|
{
|
|
consent_token
|
|
} else {
|
|
unreachable!();
|
|
};
|
|
|
|
// == 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_token, ct)
|
|
.expect("Failed to perform oauth2 permit");
|
|
|
|
drop(idms_prox_read);
|
|
|
|
// Assert that the consent was submitted
|
|
let o2cg = match idms_delayed.async_rx.blocking_recv() {
|
|
Some(DelayedAction::Oauth2ConsentGrant(o2cg)) => o2cg,
|
|
_ => unreachable!(),
|
|
};
|
|
|
|
// Manually submit the consent.
|
|
let mut idms_prox_write = task::block_on(idms.proxy_write(ct));
|
|
assert!(idms_prox_write.process_oauth2consentgrant(&o2cg).is_ok());
|
|
assert!(idms_prox_write.commit().is_ok());
|
|
|
|
// == Now try the authorise again, should be in the permitted state.
|
|
let mut idms_prox_read = task::block_on(idms.proxy_read());
|
|
|
|
// We need to reload our identity
|
|
let ident = idms_prox_read
|
|
.process_uat_to_identity(&uat, ct)
|
|
.expect("Unable to process uat");
|
|
|
|
let (_code_verifier, code_challenge) = create_code_verifier!("Whar Garble");
|
|
let consent_request = good_authorisation_request!(
|
|
idms_prox_read,
|
|
&ident,
|
|
&uat,
|
|
ct,
|
|
code_challenge,
|
|
"openid".to_string()
|
|
);
|
|
|
|
// Should be in the consent phase;
|
|
let _permit_success =
|
|
if let AuthoriseResponse::Permitted(permit_success) = consent_request {
|
|
permit_success
|
|
} else {
|
|
unreachable!();
|
|
};
|
|
|
|
drop(idms_prox_read);
|
|
|
|
// Great! Now change the scopes on the oauth2 instance, this revokes the permit.
|
|
let mut idms_prox_write = task::block_on(idms.proxy_write(ct));
|
|
|
|
let me_extend_scopes = unsafe {
|
|
ModifyEvent::new_internal_invalid(
|
|
filter!(f_eq(
|
|
"oauth2_rs_name",
|
|
PartialValue::new_iname("test_resource_server")
|
|
)),
|
|
ModifyList::new_list(vec![Modify::Present(
|
|
AttrString::from("oauth2_rs_scope_map"),
|
|
Value::new_oauthscopemap(
|
|
UUID_IDM_ALL_ACCOUNTS,
|
|
btreeset!["email".to_string(), "openid".to_string()],
|
|
)
|
|
.expect("invalid oauthscope"),
|
|
)]),
|
|
)
|
|
};
|
|
|
|
assert!(idms_prox_write.qs_write.modify(&me_extend_scopes).is_ok());
|
|
assert!(idms_prox_write.commit().is_ok());
|
|
|
|
// And do the workflow once more to see if we need to consent again.
|
|
|
|
let mut idms_prox_read = task::block_on(idms.proxy_read());
|
|
|
|
// We need to reload our identity
|
|
let ident = idms_prox_read
|
|
.process_uat_to_identity(&uat, ct)
|
|
.expect("Unable to process uat");
|
|
|
|
let (_code_verifier, code_challenge) = create_code_verifier!("Whar Garble");
|
|
|
|
let auth_req = AuthorisationRequest {
|
|
response_type: "code".to_string(),
|
|
client_id: "test_resource_server".to_string(),
|
|
state: "123".to_string(),
|
|
pkce_request: Some(PkceRequest {
|
|
code_challenge: Base64UrlSafeData(code_challenge),
|
|
code_challenge_method: CodeChallengeMethod::S256,
|
|
}),
|
|
redirect_uri: Url::parse("https://demo.example.com/oauth2/result").unwrap(),
|
|
scope: "openid email".to_string(),
|
|
nonce: Some("abcdef".to_string()),
|
|
oidc_ext: Default::default(),
|
|
unknown_keys: Default::default(),
|
|
};
|
|
|
|
let consent_request = idms_prox_read
|
|
.check_oauth2_authorisation(&ident, &uat, &auth_req, ct)
|
|
.expect("Oauth2 authorisation failed");
|
|
|
|
// Should be in the consent phase;
|
|
let _consent_token =
|
|
if let AuthoriseResponse::ConsentRequested { consent_token, .. } =
|
|
consent_request
|
|
{
|
|
consent_token
|
|
} else {
|
|
unreachable!();
|
|
};
|
|
|
|
drop(idms_prox_read);
|
|
|
|
// Success! We had to consent again due to the change :)
|
|
|
|
// Now change the supplemental scopes on the oauth2 instance, this revokes the permit.
|
|
let mut idms_prox_write = task::block_on(idms.proxy_write(ct));
|
|
|
|
let me_extend_scopes = unsafe {
|
|
ModifyEvent::new_internal_invalid(
|
|
filter!(f_eq(
|
|
"oauth2_rs_name",
|
|
PartialValue::new_iname("test_resource_server")
|
|
)),
|
|
ModifyList::new_list(vec![Modify::Present(
|
|
AttrString::from("oauth2_rs_sup_scope_map"),
|
|
Value::new_oauthscopemap(
|
|
UUID_IDM_ALL_ACCOUNTS,
|
|
btreeset!["newscope".to_string()],
|
|
)
|
|
.expect("invalid oauthscope"),
|
|
)]),
|
|
)
|
|
};
|
|
|
|
assert!(idms_prox_write.qs_write.modify(&me_extend_scopes).is_ok());
|
|
assert!(idms_prox_write.commit().is_ok());
|
|
|
|
// And do the workflow once more to see if we need to consent again.
|
|
|
|
let mut idms_prox_read = task::block_on(idms.proxy_read());
|
|
|
|
// We need to reload our identity
|
|
let ident = idms_prox_read
|
|
.process_uat_to_identity(&uat, ct)
|
|
.expect("Unable to process uat");
|
|
|
|
let (_code_verifier, code_challenge) = create_code_verifier!("Whar Garble");
|
|
|
|
let auth_req = AuthorisationRequest {
|
|
response_type: "code".to_string(),
|
|
client_id: "test_resource_server".to_string(),
|
|
state: "123".to_string(),
|
|
pkce_request: Some(PkceRequest {
|
|
code_challenge: Base64UrlSafeData(code_challenge),
|
|
code_challenge_method: CodeChallengeMethod::S256,
|
|
}),
|
|
redirect_uri: Url::parse("https://demo.example.com/oauth2/result").unwrap(),
|
|
// Note the scope isn't requested here!
|
|
scope: "openid email".to_string(),
|
|
nonce: Some("abcdef".to_string()),
|
|
oidc_ext: Default::default(),
|
|
unknown_keys: Default::default(),
|
|
};
|
|
|
|
let consent_request = idms_prox_read
|
|
.check_oauth2_authorisation(&ident, &uat, &auth_req, ct)
|
|
.expect("Oauth2 authorisation failed");
|
|
|
|
// Should be present in the consent phase however!
|
|
let _consent_token = if let AuthoriseResponse::ConsentRequested {
|
|
consent_token,
|
|
scopes,
|
|
..
|
|
} = consent_request
|
|
{
|
|
assert!(scopes.contains(&"newscope".to_string()));
|
|
consent_token
|
|
} else {
|
|
unreachable!();
|
|
};
|
|
}
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn test_idm_oauth2_consent_granted_refint_cleanup_on_delete() {
|
|
run_idm_test!(
|
|
|_qs: &QueryServer, idms: &IdmServer, idms_delayed: &mut IdmServerDelayed| {
|
|
let ct = Duration::from_secs(TEST_CURRENT_TIME);
|
|
let (_secret, uat, ident, o2rs_uuid) =
|
|
setup_oauth2_resource_server(idms, ct, true, false, false);
|
|
|
|
// Assert there are no consent maps yet.
|
|
assert!(ident.get_oauth2_consent_scopes(o2rs_uuid).is_none());
|
|
|
|
let idms_prox_read = task::block_on(idms.proxy_read());
|
|
|
|
let (_code_verifier, code_challenge) = create_code_verifier!("Whar Garble");
|
|
let consent_request = good_authorisation_request!(
|
|
idms_prox_read,
|
|
&ident,
|
|
&uat,
|
|
ct,
|
|
code_challenge,
|
|
"openid".to_string()
|
|
);
|
|
|
|
// Should be in the consent phase;
|
|
let consent_token =
|
|
if let AuthoriseResponse::ConsentRequested { consent_token, .. } =
|
|
consent_request
|
|
{
|
|
consent_token
|
|
} else {
|
|
unreachable!();
|
|
};
|
|
|
|
// == 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_token, ct)
|
|
.expect("Failed to perform oauth2 permit");
|
|
|
|
drop(idms_prox_read);
|
|
|
|
// Assert that the consent was submitted
|
|
let o2cg = match idms_delayed.async_rx.blocking_recv() {
|
|
Some(DelayedAction::Oauth2ConsentGrant(o2cg)) => o2cg,
|
|
_ => unreachable!(),
|
|
};
|
|
|
|
// Manually submit the consent.
|
|
let mut idms_prox_write = task::block_on(idms.proxy_write(ct));
|
|
assert!(idms_prox_write.process_oauth2consentgrant(&o2cg).is_ok());
|
|
|
|
let ident = idms_prox_write
|
|
.process_uat_to_identity(&uat, ct)
|
|
.expect("Unable to process uat");
|
|
|
|
// Assert that the ident now has the consents.
|
|
assert!(
|
|
ident.get_oauth2_consent_scopes(o2rs_uuid)
|
|
== Some(&btreeset!["openid".to_string(), "supplement".to_string()])
|
|
);
|
|
|
|
// Now trigger the delete of the RS
|
|
let de = unsafe {
|
|
DeleteEvent::new_internal_invalid(filter!(f_eq(
|
|
"oauth2_rs_name",
|
|
PartialValue::new_iname("test_resource_server")
|
|
)))
|
|
};
|
|
|
|
assert!(idms_prox_write.qs_write.delete(&de).is_ok());
|
|
// Assert the consent maps are gone.
|
|
let ident = idms_prox_write
|
|
.process_uat_to_identity(&uat, ct)
|
|
.expect("Unable to process uat");
|
|
assert!(ident.get_oauth2_consent_scopes(o2rs_uuid).is_none());
|
|
|
|
assert!(idms_prox_write.commit().is_ok());
|
|
}
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
// https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics#section-4.8
|
|
//
|
|
// It was reported we were vulnerable to this attack, but that isn't the case. First
|
|
// this attack relies on stripping the *code_challenge* from the internals of the returned
|
|
// code exchange token. This isn't possible due to our use of encryption of the code exchange
|
|
// token. If that code challenge *could* be removed, then the attacker could use the code exchange
|
|
// with no verifier or an incorrect verifier.
|
|
//
|
|
// Due to the logic in our server, if a code exchange contains a code challenge we always enforce
|
|
// it is correctly used!
|
|
//
|
|
// This left a single odd case where if a client did an authorisation request without a pkce
|
|
// verifier, but then a verifier was submitted during the code exchange, that the server would
|
|
// *ignore* the verifier parameter. In this case, no stripping of the code challenge was done,
|
|
// and the client could have simply also submitted *no* verifier anyway. It could be that
|
|
// an attacker could gain a code exchange with no code challenge and then force a victim to
|
|
// exchange that code exchange with out the verifier, but I'm not sure what damage that would
|
|
// lead to? Regardless, we test for and close off that possible hole in this test.
|
|
//
|
|
fn test_idm_oauth2_1076_pkce_downgrade() {
|
|
run_idm_test!(
|
|
|_qs: &QueryServer, idms: &IdmServer, idms_delayed: &mut IdmServerDelayed| {
|
|
let ct = Duration::from_secs(TEST_CURRENT_TIME);
|
|
// Enable pkce is set to FALSE
|
|
let (secret, uat, ident, _) =
|
|
setup_oauth2_resource_server(idms, ct, false, false, false);
|
|
|
|
let mut idms_prox_read = task::block_on(idms.proxy_read());
|
|
|
|
// Get an ident/uat for now.
|
|
|
|
// == Setup the authorisation request
|
|
// We attempt pkce even though the rs is set to not support pkce.
|
|
let (code_verifier, _code_challenge) = create_code_verifier!("Whar Garble");
|
|
|
|
// First, the user does not request pkce in their exchange.
|
|
let auth_req = AuthorisationRequest {
|
|
response_type: "code".to_string(),
|
|
client_id: "test_resource_server".to_string(),
|
|
state: "123".to_string(),
|
|
pkce_request: None,
|
|
redirect_uri: Url::parse("https://demo.example.com/oauth2/result").unwrap(),
|
|
scope: "openid".to_string(),
|
|
nonce: None,
|
|
oidc_ext: Default::default(),
|
|
unknown_keys: Default::default(),
|
|
};
|
|
|
|
let consent_request = idms_prox_read
|
|
.check_oauth2_authorisation(&ident, &uat, &auth_req, ct)
|
|
.expect("Failed to perform oauth2 authorisation request.");
|
|
|
|
// Should be in the consent phase;
|
|
let consent_token =
|
|
if let AuthoriseResponse::ConsentRequested { consent_token, .. } =
|
|
consent_request
|
|
{
|
|
consent_token
|
|
} else {
|
|
unreachable!();
|
|
};
|
|
|
|
// == 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_token, ct)
|
|
.expect("Failed to perform oauth2 permit");
|
|
|
|
// Assert that the consent was submitted
|
|
match idms_delayed.async_rx.blocking_recv() {
|
|
Some(DelayedAction::Oauth2ConsentGrant(_)) => {}
|
|
_ => assert!(false),
|
|
}
|
|
|
|
// == Submit the token exchange code.
|
|
// This exchange failed because we submitted a verifier when the code exchange
|
|
// has NO code challenge present.
|
|
let token_req = AccessTokenRequest {
|
|
grant_type: "authorization_code".to_string(),
|
|
code: permit_success.code,
|
|
redirect_uri: Url::parse("https://demo.example.com/oauth2/result").unwrap(),
|
|
client_id: Some("test_resource_server".to_string()),
|
|
client_secret: Some(secret),
|
|
// Note the code verifier is set to "something else"
|
|
code_verifier,
|
|
};
|
|
|
|
// Assert the exchange fails.
|
|
assert!(matches!(
|
|
idms_prox_read.check_oauth2_token_exchange(None, &token_req, ct),
|
|
Err(Oauth2Error::InvalidRequest)
|
|
))
|
|
}
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
// https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics#section-2.1
|
|
//
|
|
// If the origin configured is https, do not allow downgrading to http on redirect
|
|
fn test_idm_oauth2_redir_http_downgrade() {
|
|
run_idm_test!(
|
|
|_qs: &QueryServer, idms: &IdmServer, idms_delayed: &mut IdmServerDelayed| {
|
|
let ct = Duration::from_secs(TEST_CURRENT_TIME);
|
|
// Enable pkce is set to FALSE
|
|
let (secret, uat, ident, _) =
|
|
setup_oauth2_resource_server(idms, ct, false, false, false);
|
|
|
|
let mut idms_prox_read = task::block_on(idms.proxy_read());
|
|
|
|
// Get an ident/uat for now.
|
|
|
|
// == Setup the authorisation request
|
|
// We attempt pkce even though the rs is set to not support pkce.
|
|
let (code_verifier, code_challenge) = create_code_verifier!("Whar Garble");
|
|
|
|
// First, NOTE the lack of https on the redir uri.
|
|
let auth_req = AuthorisationRequest {
|
|
response_type: "code".to_string(),
|
|
client_id: "test_resource_server".to_string(),
|
|
state: "123".to_string(),
|
|
pkce_request: Some(PkceRequest {
|
|
code_challenge: Base64UrlSafeData(code_challenge.clone()),
|
|
code_challenge_method: CodeChallengeMethod::S256,
|
|
}),
|
|
redirect_uri: Url::parse("http://demo.example.com/oauth2/result").unwrap(),
|
|
scope: "openid".to_string(),
|
|
nonce: None,
|
|
oidc_ext: Default::default(),
|
|
unknown_keys: Default::default(),
|
|
};
|
|
|
|
assert!(
|
|
idms_prox_read
|
|
.check_oauth2_authorisation(&ident, &uat, &auth_req, ct)
|
|
.unwrap_err()
|
|
== Oauth2Error::InvalidOrigin
|
|
);
|
|
|
|
// This does have https
|
|
let consent_request = good_authorisation_request!(
|
|
idms_prox_read,
|
|
&ident,
|
|
&uat,
|
|
ct,
|
|
code_challenge,
|
|
"openid".to_string()
|
|
);
|
|
|
|
// Should be in the consent phase;
|
|
let consent_token =
|
|
if let AuthoriseResponse::ConsentRequested { consent_token, .. } =
|
|
consent_request
|
|
{
|
|
consent_token
|
|
} else {
|
|
unreachable!();
|
|
};
|
|
|
|
// == 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_token, ct)
|
|
.expect("Failed to perform oauth2 permit");
|
|
|
|
// Assert that the consent was submitted
|
|
match idms_delayed.async_rx.blocking_recv() {
|
|
Some(DelayedAction::Oauth2ConsentGrant(_)) => {}
|
|
_ => assert!(false),
|
|
}
|
|
|
|
// == Submit the token exchange code.
|
|
// NOTE the url is http again
|
|
let token_req = AccessTokenRequest {
|
|
grant_type: "authorization_code".to_string(),
|
|
code: permit_success.code,
|
|
redirect_uri: Url::parse("http://demo.example.com/oauth2/result").unwrap(),
|
|
client_id: Some("test_resource_server".to_string()),
|
|
client_secret: Some(secret),
|
|
// Note the code verifier is set to "something else"
|
|
code_verifier,
|
|
};
|
|
|
|
// Assert the exchange fails.
|
|
assert!(matches!(
|
|
idms_prox_read.check_oauth2_token_exchange(None, &token_req, ct),
|
|
Err(Oauth2Error::InvalidOrigin)
|
|
))
|
|
}
|
|
)
|
|
}
|
|
}
|