OAuth2 Device flow foundations (#3098)

This commit is contained in:
James Hodgkinson 2024-10-26 12:08:48 +10:00 committed by GitHub
parent b0824fef18
commit 5a709520dc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 1970 additions and 440 deletions

View file

@ -0,0 +1,31 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
// README at: https://github.com/devcontainers/templates/tree/main/src/rust
{
"name": "Rust",
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
"image": "mcr.microsoft.com/devcontainers/rust:1-1-bookworm",
"features": {
},
// Use 'mounts' to make the cargo cache persistent in a Docker Volume.
"mounts": [
{
"source": "devcontainer-cargo-cache-${devcontainerId}",
"target": "/usr/local/cargo",
"type": "volume"
}
],
// Features to add to the dev container. More info: https://containers.dev/features.
// "features": {},
// Use 'forwardPorts' to make a list of ports inside the container available locally.
"forwardPorts": [8443],
// Use 'postCreateCommand' to run commands after the container is created.
"postCreateCommand": "rustup update && rustup default stable && rustup component add rustfmt clippy && sudo apt-get update && sudo apt-get install -y sccache ripgrep libssl-dev pkg-config jq libpam0g-dev libudev-dev cmake build-essential && cargo install cargo-audit mdbook-mermaid mdbook && cargo install mdbook-alerts --version 0.6.4 && cargo install deno --locked"
// Configure tool-specific properties.
// "customizations": {},
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
// "remoteUser": "root"
}

596
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -15,6 +15,7 @@ resolver = "2"
members = [
"proto",
"tools/cli",
"tools/device_flow",
"tools/iam_migrations/freeipa",
"tools/iam_migrations/ldap",
"tools/orca",

View file

@ -1,3 +1,4 @@
<!-- markdownlint-disable MD025 MD042 -->
# Kanidm
- [Introduction to Kanidm](introduction_to_kanidm.md)
@ -83,6 +84,7 @@
- [Cryptography Key Domains (2024)](developers/designs/cryptography_key_domains.md)
- [Domain Join - Machine Accounts](developers/designs/domain_join_machine_accounts.md)
- [Elevated Priv Mode](developers/designs/elevated_priv_mode.md)
- [OAuth2 Device Flow](developers/designs/oauth2_device_flow.md)
- [OAuth2 Refresh Tokens](developers/designs/oauth2_refresh_tokens.md)
- [Replication Coordinator](developers/designs/replication_coordinator.md)
- [Replication Design and Notes](developers/designs/replication_design_and_notes.md)

View file

@ -0,0 +1,35 @@
# OAuth2 Device Flow
The general idea is that there's two flows.
## Device/Backend
- Start an auth flow
- Prompt the user with the link
- On an interval, check the status
- Still pending? Wait.
- Otherwise, handle the result.
## User
- Go to the "check user code" page
- Ensure user is authenticated
- Confirm that the user's happy for this auth session to happen
- This last step is the usual OAuth2 permissions/scope prompt
```mermaid
flowchart TD
DeviceStatus -->|Pending| DeviceStatus
D[Device] -->|Start Backend flow| BackendFlowStart(Prompt User with details)
BackendFlowStart -->|User Clicks Link| DeviceGet
BackendFlowStart -->|Check Status| DeviceStatus
DeviceStatus -->|Result - error or success| End
DeviceGet -->|Not Logged in, Valid Token| LoginFlow(Login Flow)
DeviceGet -->|Invalid Token, Reprompt| DeviceGet
LoginFlow --> DeviceGet
DeviceGet -->|Logged in, Valid Token| ConfirmAccess(User Prompted to authorize)
ConfirmAccess -->|Confirmed| End(Done!)
```

View file

@ -1,4 +1,5 @@
use crate::{ClientError, KanidmClient};
use kanidm_proto::attribute::Attribute;
use kanidm_proto::constants::{
ATTR_DISPLAYNAME, ATTR_ES256_PRIVATE_KEY_DER, ATTR_NAME,
ATTR_OAUTH2_ALLOW_INSECURE_CLIENT_DISABLE_PKCE, ATTR_OAUTH2_ALLOW_LOCALHOST_REDIRECT,
@ -453,4 +454,32 @@ impl KanidmClient {
)
.await
}
pub async fn idm_oauth2_client_device_flow_update(
&self,
id: &str,
value: bool,
) -> Result<(), ClientError> {
match value {
true => {
let mut update_oauth2_rs = Entry {
attrs: BTreeMap::new(),
};
update_oauth2_rs.attrs.insert(
Attribute::OAuth2DeviceFlowEnable.into(),
vec![value.to_string()],
);
self.perform_patch_request(format!("/v1/oauth2/{}", id).as_str(), update_oauth2_rs)
.await
}
false => {
self.perform_delete_request(&format!(
"/v1/oauth2/{}/_attr/{}",
id,
Attribute::OAuth2DeviceFlowEnable.as_str()
))
.await
}
}
}
}

View file

@ -16,11 +16,15 @@ test = true
doctest = true
[features]
# default = ["dev-oauth2-device-flow"]
wasm = ["webauthn-rs-proto/wasm"]
test = []
dev-oauth2-device-flow = []
[dependencies]
base32 = { workspace = true }
base64 = { workspace = true }
clap = { workspace = true }
num_enum = { workspace = true }
scim_proto = { workspace = true }

View file

@ -112,6 +112,7 @@ pub enum Attribute {
OAuth2AllowInsecureClientDisablePkce,
OAuth2AllowLocalhostRedirect,
OAuth2ConsentScopeMap,
OAuth2DeviceFlowEnable,
OAuth2JwtLegacyCryptoEnable,
OAuth2PreferShortUsername,
OAuth2RsBasicSecret,
@ -338,6 +339,7 @@ impl Attribute {
}
Attribute::OAuth2AllowLocalhostRedirect => ATTR_OAUTH2_ALLOW_LOCALHOST_REDIRECT,
Attribute::OAuth2ConsentScopeMap => ATTR_OAUTH2_CONSENT_SCOPE_MAP,
Attribute::OAuth2DeviceFlowEnable => ATTR_OAUTH2_DEVICE_FLOW_ENABLE,
Attribute::OAuth2JwtLegacyCryptoEnable => ATTR_OAUTH2_JWT_LEGACY_CRYPTO_ENABLE,
Attribute::OAuth2PreferShortUsername => ATTR_OAUTH2_PREFER_SHORT_USERNAME,
Attribute::OAuth2RsBasicSecret => ATTR_OAUTH2_RS_BASIC_SECRET,
@ -518,6 +520,7 @@ impl Attribute {
}
ATTR_OAUTH2_ALLOW_LOCALHOST_REDIRECT => Attribute::OAuth2AllowLocalhostRedirect,
ATTR_OAUTH2_CONSENT_SCOPE_MAP => Attribute::OAuth2ConsentScopeMap,
ATTR_OAUTH2_DEVICE_FLOW_ENABLE => Attribute::OAuth2DeviceFlowEnable,
ATTR_OAUTH2_JWT_LEGACY_CRYPTO_ENABLE => Attribute::OAuth2JwtLegacyCryptoEnable,
ATTR_OAUTH2_PREFER_SHORT_USERNAME => Attribute::OAuth2PreferShortUsername,
ATTR_OAUTH2_RS_BASIC_SECRET => Attribute::OAuth2RsBasicSecret,
@ -596,7 +599,10 @@ impl Attribute {
#[allow(clippy::unreachable)]
#[cfg(test)]
_ => {
unreachable!()
unreachable!(
"Check that you've implemented the Attribute conversion for {:?}",
value
);
}
}
}
@ -608,6 +614,12 @@ impl fmt::Display for Attribute {
}
}
impl From<Attribute> for String {
fn from(attr: Attribute) -> String {
attr.to_string()
}
}
#[cfg(test)]
mod test {
use super::Attribute;
@ -632,7 +644,12 @@ mod test {
let the_list = all::<Attribute>().collect::<Vec<_>>();
for attr in the_list {
let attr2 = Attribute::from(attr.as_str());
assert!(attr == attr2);
assert!(
attr == attr2,
"Round-trip failed for {} <=> {} check you've implemented a from and to string",
attr,
attr2
);
}
}
}

View file

@ -149,6 +149,7 @@ pub const ATTR_OAUTH2_ALLOW_INSECURE_CLIENT_DISABLE_PKCE: &str =
"oauth2_allow_insecure_client_disable_pkce";
pub const ATTR_OAUTH2_ALLOW_LOCALHOST_REDIRECT: &str = "oauth2_allow_localhost_redirect";
pub const ATTR_OAUTH2_CONSENT_SCOPE_MAP: &str = "oauth2_consent_scope_map";
pub const ATTR_OAUTH2_DEVICE_FLOW_ENABLE: &str = "oauth2_device_flow_enable";
pub const ATTR_OAUTH2_JWT_LEGACY_CRYPTO_ENABLE: &str = "oauth2_jwt_legacy_crypto_enable";
pub const ATTR_OAUTH2_PREFER_SHORT_USERNAME: &str = "oauth2_prefer_short_username";
pub const ATTR_OAUTH2_RS_BASIC_SECRET: &str = "oauth2_rs_basic_secret";
@ -258,6 +259,7 @@ pub const KVERSION: &str = "X-KANIDM-VERSION";
pub const X_FORWARDED_FOR: &str = "x-forwarded-for";
// OAuth
pub const OAUTH2_DEVICE_CODE_SESSION: &str = "oauth2_device_code_session";
pub const OAUTH2_RESOURCE_SERVER: &str = "oauth2_resource_server";
pub const OAUTH2_RESOURCE_SERVER_BASIC: &str = "oauth2_resource_server_basic";
pub const OAUTH2_RESOURCE_SERVER_PUBLIC: &str = "oauth2_resource_server_public";

View file

@ -12,5 +12,16 @@ pub const OAUTH2_AUTHORISE: &str = "/oauth2/authorise";
pub const OAUTH2_AUTHORISE_PERMIT: &str = "/oauth2/authorise/permit";
/// ⚠️ ⚠️ WARNING DO NOT CHANGE THIS ⚠️ ⚠️
pub const OAUTH2_AUTHORISE_REJECT: &str = "/oauth2/authorise/reject";
/// ⚠️ ⚠️ WARNING DO NOT CHANGE THIS ⚠️ ⚠️
pub const OAUTH2_AUTHORISE_DEVICE: &str = "/oauth2/device";
/// ⚠️ ⚠️ WARNING DO NOT CHANGE THIS ⚠️ ⚠️
pub const OAUTH2_TOKEN_ENDPOINT: &str = "/oauth2/token";
/// ⚠️ ⚠️ WARNING DO NOT CHANGE THIS ⚠️ ⚠️
pub const OAUTH2_TOKEN_INTROSPECT_ENDPOINT: &str = "/oauth2/token/introspect";
/// ⚠️ ⚠️ WARNING DO NOT CHANGE THIS ⚠️ ⚠️
pub const OAUTH2_TOKEN_REVOKE_ENDPOINT: &str = "/oauth2/token/revoke";
/// ⚠️ ⚠️ WARNING DO NOT CHANGE THIS ⚠️ ⚠️
pub const OAUTH2_DEVICE_LOGIN: &str = "/oauth2/device"; // starts with /ui
pub const V1_AUTH_VALID: &str = "/v1/auth/valid";

View file

@ -2,12 +2,19 @@
use std::collections::{BTreeMap, BTreeSet};
use base64::{engine::general_purpose::STANDARD, Engine as _};
use serde::{Deserialize, Serialize};
use serde_with::base64::{Base64, UrlSafe};
use serde_with::formats::SpaceSeparator;
use serde_with::{base64, formats, serde_as, skip_serializing_none, StringWithSeparator};
use serde_with::{formats, serde_as, skip_serializing_none, StringWithSeparator};
use url::Url;
use uuid::Uuid;
/// How many seconds a device code is valid for.
pub const OAUTH2_DEVICE_CODE_EXPIRY_SECONDS: u64 = 300;
/// How often a client device can query the status of the token
pub const OAUTH2_DEVICE_CODE_INTERVAL_SECONDS: u64 = 5;
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone, Copy)]
pub enum CodeChallengeMethod {
// default to plain if not requested as S256. Reject the auth?
@ -19,7 +26,7 @@ pub enum CodeChallengeMethod {
#[serde_as]
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct PkceRequest {
#[serde_as(as = "base64::Base64<base64::UrlSafe, formats::Unpadded>")]
#[serde_as(as = "Base64<UrlSafe, formats::Unpadded>")]
pub code_challenge: Vec<u8>,
pub code_challenge_method: CodeChallengeMethod,
}
@ -102,6 +109,13 @@ pub enum GrantTypeReq {
#[serde_as(as = "Option<StringWithSeparator::<SpaceSeparator, String>>")]
scope: Option<BTreeSet<String>>,
},
/// ref <https://www.rfc-editor.org/rfc/rfc8628#section-3.4>
#[serde(rename = "urn:ietf:params:oauth:grant-type:device_code")]
DeviceCode {
device_code: String,
// #[serde_as(as = "Option<StringWithSeparator::<SpaceSeparator, String>>")]
scope: Option<BTreeSet<String>>,
},
}
/// An Access Token request. This requires a set of grant-type parameters to satisfy the request.
@ -448,6 +462,9 @@ pub struct OidcDiscoveryResponse {
pub introspection_endpoint: Option<Url>,
pub introspection_endpoint_auth_methods_supported: Vec<TokenEndpointAuthMethod>,
pub introspection_endpoint_auth_signing_alg_values_supported: Option<Vec<IdTokenSignAlg>>,
/// Ref <https://www.rfc-editor.org/rfc/rfc8628#section-4>
pub device_authorization_endpoint: Option<Url>,
}
/// The response to an OAuth2 rfc8414 metadata request
@ -504,6 +521,39 @@ pub struct ErrorResponse {
pub error_uri: Option<Url>,
}
#[derive(Debug, Serialize, Deserialize)]
/// Ref <https://www.rfc-editor.org/rfc/rfc8628#section-3.2>
pub struct DeviceAuthorizationResponse {
/// Base64-encoded bundle of 16 bytes
device_code: String,
/// xxx-yyy-zzz where x/y/z are digits. Stored internally as a u32 because we'll drop the dashes and parse as a number.
user_code: String,
verification_uri: Url,
verification_uri_complete: Url,
expires_in: u64,
interval: u64,
}
impl DeviceAuthorizationResponse {
pub fn new(verification_uri: Url, device_code: [u8; 16], user_code: String) -> Self {
let mut verification_uri_complete = verification_uri.clone();
verification_uri_complete
.query_pairs_mut()
.append_pair("user_code", &user_code);
let device_code = STANDARD.encode(device_code);
Self {
verification_uri_complete,
device_code,
user_code,
verification_uri,
expires_in: OAUTH2_DEVICE_CODE_EXPIRY_SECONDS,
interval: OAUTH2_DEVICE_CODE_INTERVAL_SECONDS,
}
}
}
#[cfg(test)]
mod tests {
use super::{AccessTokenRequest, GrantTypeReq};

View file

@ -18,10 +18,11 @@ doctest = false
[features]
default = ["ui_htmx"]
ui_htmx = []
dev-oauth2-device-flow = []
[dependencies]
async-trait = { workspace = true }
askama = { workspace = true }
askama = { workspace = true, features = ["with-axum"] }
askama_axum = { workspace = true }
axum = { workspace = true }
axum-htmx = { workspace = true }

View file

@ -12,6 +12,7 @@ use time::OffsetDateTime;
use tracing::{info, instrument, trace};
use uuid::Uuid;
use kanidmd_lib::{
event::{CreateEvent, DeleteEvent, ModifyEvent, ReviveRecycledEvent},
filter::{Filter, FilterInvalid},
@ -33,6 +34,9 @@ use kanidmd_lib::{
use kanidmd_lib::prelude::*;
#[cfg(feature = "dev-oauth2-device-flow")]
use std::collections::BTreeSet;
use super::QueryServerWriteV1;
impl QueryServerWriteV1 {
@ -1701,4 +1705,26 @@ impl QueryServerWriteV1 {
.oauth2_token_revoke(&client_auth_info, &intr_req, ct)
.and_then(|()| idms_prox_write.commit().map_err(Oauth2Error::ServerError))
}
#[cfg(feature = "dev-oauth2-device-flow")]
pub async fn handle_oauth2_device_flow_start(
&self,
client_auth_info: ClientAuthInfo,
client_id: &str,
scope: &Option<BTreeSet<String>>,
eventid: Uuid,
) -> Result<kanidm_proto::oauth2::DeviceAuthorizationResponse, Oauth2Error> {
let ct = duration_from_epoch_now();
let mut idms_prox_write = self
.idms
.proxy_write(ct)
.await
.map_err(Oauth2Error::ServerError)?;
idms_prox_write
.handle_oauth2_start_device_flow(client_auth_info, client_id, scope, eventid)
.and_then(|res| {
idms_prox_write.commit().map_err(Oauth2Error::ServerError)?;
Ok(res)
})
}
}

View file

@ -1,3 +1,5 @@
use std::collections::{BTreeMap, BTreeSet};
use super::errors::WebError;
use super::middleware::KOpId;
use super::ServerState;
@ -5,11 +7,13 @@ use crate::https::extractors::VerifiedClientInformation;
use axum::{
body::Body,
extract::{Path, Query, State},
http::header::{
http::{
header::{
ACCESS_CONTROL_ALLOW_HEADERS, ACCESS_CONTROL_ALLOW_ORIGIN, CONTENT_TYPE, LOCATION,
WWW_AUTHENTICATE,
},
http::{HeaderValue, StatusCode},
HeaderValue, StatusCode,
},
middleware::from_fn,
response::{IntoResponse, Response},
routing::{get, post},
@ -22,6 +26,9 @@ use kanidm_proto::constants::uri::{
};
use kanidm_proto::constants::APPLICATION_JSON;
use kanidm_proto::oauth2::AuthorisationResponse;
#[cfg(feature = "dev-oauth2-device-flow")]
use kanidm_proto::oauth2::DeviceAuthorizationResponse;
use kanidmd_lib::idm::oauth2::{
AccessTokenIntrospectRequest, AccessTokenRequest, AuthorisationRequest, AuthorisePermitSuccess,
AuthoriseResponse, ErrorResponse, Oauth2Error, TokenRevokeRequest,
@ -30,6 +37,12 @@ use kanidmd_lib::prelude::f_eq;
use kanidmd_lib::prelude::*;
use kanidmd_lib::value::PartialValue;
use serde::{Deserialize, Serialize};
use serde_with::formats::CommaSeparator;
use serde_with::{serde_as, StringWithSeparator};
#[cfg(feature = "dev-oauth2-device-flow")]
use uri::OAUTH2_AUTHORISE_DEVICE;
use uri::{OAUTH2_TOKEN_ENDPOINT, OAUTH2_TOKEN_INTROSPECT_ENDPOINT, OAUTH2_TOKEN_REVOKE_ENDPOINT};
// TODO: merge this into a value in WebError later
pub struct HTTPOauth2Error(Oauth2Error);
@ -724,6 +737,38 @@ pub async fn oauth2_preflight_options() -> Response {
.into_response()
}
#[serde_as]
#[derive(Deserialize, Debug, Serialize)]
pub(crate) struct DeviceFlowForm {
client_id: String,
#[serde_as(as = "Option<StringWithSeparator::<CommaSeparator, String>>")]
scope: Option<BTreeSet<String>>,
#[serde(flatten)]
extra: BTreeMap<String, String>, // catches any extra nonsense that gets sent through
}
/// Device flow! [RFC8628](https://datatracker.ietf.org/doc/html/rfc8628)
#[cfg(feature = "dev-oauth2-device-flow")]
#[instrument(level = "info", skip(state, kopid, client_auth_info))]
pub(crate) async fn oauth2_authorise_device_post(
State(state): State<ServerState>,
Extension(kopid): Extension<KOpId>,
VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
Form(form): Form<DeviceFlowForm>,
) -> Result<Json<DeviceAuthorizationResponse>, HTTPOauth2Error> {
state
.qe_w_ref
.handle_oauth2_device_flow_start(
client_auth_info,
&form.client_id,
&form.scope,
kopid.eventid,
)
.await
.map(Json::from)
.map_err(HTTPOauth2Error)
}
pub fn route_setup(state: ServerState) -> Router<ServerState> {
// this has all the openid-related routes
let openid_router = Router::new()
@ -753,7 +798,7 @@ pub fn route_setup(state: ServerState) -> Router<ServerState> {
)
.with_state(state.clone());
Router::new()
let mut router = Router::new()
.route("/oauth2", get(super::v1_oauth2::oauth2_get))
// ⚠️ ⚠️ WARNING ⚠️ ⚠️
// IF YOU CHANGE THESE VALUES YOU MUST UPDATE OIDC DISCOVERY URLS
@ -772,21 +817,30 @@ pub fn route_setup(state: ServerState) -> Router<ServerState> {
.route(
OAUTH2_AUTHORISE_REJECT,
post(oauth2_authorise_reject_post).get(oauth2_authorise_reject_get),
)
);
// ⚠️ ⚠️ WARNING ⚠️ ⚠️
// IF YOU CHANGE THESE VALUES YOU MUST UPDATE OIDC DISCOVERY URLS
#[cfg(feature = "dev-oauth2-device-flow")]
{
router = router.route(OAUTH2_AUTHORISE_DEVICE, post(oauth2_authorise_device_post))
}
// ⚠️ ⚠️ WARNING ⚠️ ⚠️
// IF YOU CHANGE THESE VALUES YOU MUST UPDATE OIDC DISCOVERY URLS
router = router
.route(
"/oauth2/token",
OAUTH2_TOKEN_ENDPOINT,
post(oauth2_token_post).options(oauth2_preflight_options),
)
// ⚠️ ⚠️ WARNING ⚠️ ⚠️
// IF YOU CHANGE THESE VALUES YOU MUST UPDATE OIDC DISCOVERY URLS
.route(
"/oauth2/token/introspect",
OAUTH2_TOKEN_INTROSPECT_ENDPOINT,
post(oauth2_token_introspect_post),
)
.route("/oauth2/token/revoke", post(oauth2_token_revoke_post))
.route(OAUTH2_TOKEN_REVOKE_ENDPOINT, post(oauth2_token_revoke_post))
.merge(openid_router)
.with_state(state)
.layer(from_fn(super::middleware::caching::dont_cache_me))
.layer(from_fn(super::middleware::caching::dont_cache_me));
router
}

View file

@ -11,10 +11,7 @@ use axum_htmx::HxRequestGuardLayer;
use constants::Urls;
use kanidmd_lib::prelude::{OperationError, Uuid};
use crate::https::{
// extractors::VerifiedClientInformation, middleware::KOpId, v1::SessionId,
ServerState,
};
use crate::https::ServerState;
mod apps;
mod constants;
@ -33,7 +30,7 @@ struct UnrecoverableErrorView {
}
pub fn view_router() -> Router<ServerState> {
let unguarded_router = Router::new()
let mut unguarded_router = Router::new()
.route(
"/",
get(|| async { Redirect::permanent(Urls::Login.as_ref()) }),
@ -44,7 +41,16 @@ pub fn view_router() -> Router<ServerState> {
.route("/profile", get(profile::view_profile_get))
.route("/profile/unlock", get(profile::view_profile_unlock_get))
.route("/logout", get(login::view_logout_get))
.route("/oauth2", get(oauth2::view_index_get))
.route("/oauth2", get(oauth2::view_index_get));
#[cfg(feature = "dev-oauth2-device-flow")]
{
unguarded_router = unguarded_router.route(
kanidmd_lib::prelude::uri::OAUTH2_DEVICE_LOGIN,
get(oauth2::view_device_get).post(oauth2::view_device_post),
);
}
unguarded_router = unguarded_router
.route("/oauth2/resume", get(oauth2::view_resume_get))
.route("/oauth2/consent", post(oauth2::view_consent_post))
// The login routes are htmx-free to make them simpler, which means

View file

@ -1,16 +1,17 @@
use crate::https::{extractors::VerifiedClientInformation, middleware::KOpId, ServerState};
use kanidmd_lib::idm::oauth2::{
AuthorisationRequest, AuthorisePermitSuccess, AuthoriseResponse, Oauth2Error,
};
use kanidmd_lib::prelude::*;
use crate::https::{extractors::VerifiedClientInformation, middleware::KOpId, ServerState};
use kanidm_proto::internal::COOKIE_OAUTH2_REQ;
use std::collections::BTreeSet;
use askama::Template;
#[cfg(feature = "dev-oauth2-device-flow")]
use axum::http::StatusCode;
use axum::{
extract::{Query, State},
http::header::ACCESS_CONTROL_ALLOW_ORIGIN,
@ -31,6 +32,7 @@ struct ConsentRequestView {
// scopes: BTreeSet<String>,
pii_scopes: BTreeSet<String>,
consent_token: String,
redirect: Option<String>,
}
#[derive(Template)]
@ -54,19 +56,18 @@ pub async fn view_resume_get(
Extension(kopid): Extension<KOpId>,
VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
jar: CookieJar,
) -> Response {
) -> Result<Response, UnrecoverableErrorView> {
let maybe_auth_req =
cookies::get_signed::<AuthorisationRequest>(&state, &jar, COOKIE_OAUTH2_REQ);
if let Some(auth_req) = maybe_auth_req {
oauth2_auth_req(state, kopid, client_auth_info, jar, auth_req).await
Ok(oauth2_auth_req(state, kopid, client_auth_info, jar, auth_req).await)
} else {
error!("unable to resume session, no auth_req was found in the cookie");
UnrecoverableErrorView {
Err(UnrecoverableErrorView {
err_code: OperationError::InvalidState,
operation_id: kopid.eventid,
}
.into_response()
})
}
}
@ -128,6 +129,7 @@ async fn oauth2_auth_req(
// scopes,
pii_scopes,
consent_token,
redirect: None,
}
.into_response()
}
@ -185,6 +187,9 @@ async fn oauth2_auth_req(
#[derive(Debug, Clone, Deserialize)]
pub struct ConsentForm {
consent_token: String,
#[serde(default)]
#[allow(dead_code)] // TODO: do smoething with this
redirect: Option<String>,
}
pub async fn view_consent_post(
@ -193,7 +198,7 @@ pub async fn view_consent_post(
VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
jar: CookieJar,
Form(consent_form): Form<ConsentForm>,
) -> Response {
) -> Result<Response, UnrecoverableErrorView> {
let res = state
.qe_w_ref
.handle_oauth2_authorise_permit(client_auth_info, consent_form.consent_token, kopid.eventid)
@ -207,12 +212,26 @@ pub async fn view_consent_post(
}) => {
let jar = cookies::destroy(jar, COOKIE_OAUTH2_REQ);
if let Some(redirect) = consent_form.redirect {
Ok((
jar,
[
(HX_REDIRECT, redirect_uri.as_str().to_string()),
(
ACCESS_CONTROL_ALLOW_ORIGIN.as_str(),
redirect_uri.origin().ascii_serialization(),
),
],
Redirect::to(&redirect),
)
.into_response())
} else {
redirect_uri
.query_pairs_mut()
.clear()
.append_pair("state", &state)
.append_pair("code", &code);
(
Ok((
jar,
[
(HX_REDIRECT, redirect_uri.as_str().to_string()),
@ -223,7 +242,8 @@ pub async fn view_consent_post(
],
Redirect::to(redirect_uri.as_str()),
)
.into_response()
.into_response())
}
}
Err(err_code) => {
error!(
@ -232,11 +252,64 @@ pub async fn view_consent_post(
&err_code.to_string()
);
UnrecoverableErrorView {
Err(UnrecoverableErrorView {
err_code: OperationError::InvalidState,
operation_id: kopid.eventid,
}
.into_response()
})
}
}
}
#[derive(Template, Debug, Clone)]
#[cfg(feature = "dev-oauth2-device-flow")]
#[template(path = "oauth2_device_login.html")]
pub struct Oauth2DeviceLoginView {
domain_custom_image: bool,
title: String,
user_code: String,
}
#[derive(Debug, Clone, Deserialize)]
#[cfg(feature = "dev-oauth2-device-flow")]
pub(crate) struct QueryUserCode {
pub user_code: Option<String>,
}
#[axum::debug_handler]
#[cfg(feature = "dev-oauth2-device-flow")]
pub async fn view_device_get(
State(state): State<ServerState>,
Extension(_kopid): Extension<KOpId>,
VerifiedClientInformation(_client_auth_info): VerifiedClientInformation,
Query(user_code): Query<QueryUserCode>,
) -> Result<Oauth2DeviceLoginView, (StatusCode, String)> {
// TODO: if we have a valid auth session and the user code is valid, prompt the user to allow the session to start
Ok(Oauth2DeviceLoginView {
domain_custom_image: state.qe_r_ref.domain_info_read().has_custom_image(),
title: "Device Login".to_string(),
user_code: user_code.user_code.unwrap_or("".to_string()),
})
}
#[derive(Deserialize)]
#[cfg(feature = "dev-oauth2-device-flow")]
pub struct Oauth2DeviceLoginForm {
user_code: String,
confirm_login: bool,
}
#[cfg(feature = "dev-oauth2-device-flow")]
#[axum::debug_handler]
pub async fn view_device_post(
State(_state): State<ServerState>,
Extension(_kopid): Extension<KOpId>,
VerifiedClientInformation(_client_auth_info): VerifiedClientInformation,
Form(form): Form<Oauth2DeviceLoginForm>,
) -> Result<String, (StatusCode, &'static str)> {
debug!("User code: {}", form.user_code);
debug!("User confirmed: {}", form.confirm_login);
// TODO: when the user POST's this form we need to check the user code and see if it's valid
// then start a login flow which ends up authorizing the token at the end.
Err((StatusCode::NOT_IMPLEMENTED, "Not implemented yet"))
}

View file

@ -56,7 +56,6 @@ pub(crate) async fn view_profile_get(
})
}
// #[axum::debug_handler]
pub(crate) async fn view_profile_unlock_get(
State(state): State<ServerState>,
VerifiedClientInformation(client_auth_info): VerifiedClientInformation,

View file

@ -25,6 +25,9 @@
</div>
(% endif %)
<form id="login" action="/ui/oauth2/consent" method="post">
(% if let Some(redirect) = redirect %)
<input type="hidden" id="redirect" name="redirect" value="(( redirect ))" />
(% endif %)
<input type="hidden" id="consent_token" name="consent_token" value="(( consent_token ))" />
<button autofocus=true class="w-100 btn btn-lg btn-primary" type="submit">Proceed</button>
</form>

View file

@ -0,0 +1,35 @@
(% extends "base.html" %)
(% block body %)
<main id="main" class="flex-shrink-0 form-signin">
<center>
(% if domain_custom_image %)
<img src="/ui/images/domain" alt="Kanidm" class="kanidm_logo" />
(% else %)
<img src="/pkg/img/logo-square.svg" alt="Kanidm" class="kanidm_logo" />
(% endif %)
<br />
<label for="user_code" class="form-label">Please enter the code
provided to log in!</label>
<form id="login" action="/ui/oauth2/device" method="post">
<div class="input-group mb-3">
<input
autofocus=true
class="autofocus form-control"
id="user_code"
name="user_code"
type="text"
autocomplete
value="(( user_code ))"
required=true />
</div>
<div class="input-group mb-3 justify-content-md-center">
<button
type="submit"
class="btn btn-dark">Continue</button>
</div>
</form>
</center>
</main>
(% endblock %)

View file

@ -26,9 +26,10 @@ name = "image_benches"
harness = false
[features]
# default = [ "libsqlite3-sys/bundled", "openssl/vendored" ]
default = []
dhat-heap = ["dep:dhat"]
dhat-ad-hoc = ["dep:dhat"]
dev-oauth2-device-flow = [] # still-in-development oauth2 device flow support
[dependencies]
base64 = { workspace = true }
@ -105,7 +106,11 @@ svg = { workspace = true }
whoami = { workspace = true }
[dev-dependencies]
compact_jwt = { workspace = true, features = ["openssl", "hsm-crypto", "unsafe_release_without_verify"] }
compact_jwt = { workspace = true, features = [
"openssl",
"hsm-crypto",
"unsafe_release_without_verify",
] }
criterion = { workspace = true, features = ["html_reports"] }
futures = { workspace = true }
kanidmd_lib_macros = { workspace = true }

View file

@ -908,6 +908,117 @@ lazy_static! {
};
}
lazy_static! {
pub static ref IDM_ACP_OAUTH2_MANAGE_DL9: BuiltinAcp = BuiltinAcp {
classes: vec![
EntryClass::Object,
EntryClass::AccessControlProfile,
EntryClass::AccessControlCreate,
EntryClass::AccessControlDelete,
EntryClass::AccessControlModify,
EntryClass::AccessControlSearch
],
name: "idm_acp_hp_oauth2_manage_priv",
uuid: UUID_IDM_ACP_OAUTH2_MANAGE_V1,
description: "Builtin IDM Control for managing OAuth2 resource server integrations.",
receiver: BuiltinAcpReceiver::Group(vec![UUID_IDM_OAUTH2_ADMINS]),
target: BuiltinAcpTarget::Filter(ProtoFilter::And(vec![
match_class_filter!(EntryClass::OAuth2ResourceServer),
FILTER_ANDNOT_TOMBSTONE_OR_RECYCLED.clone(),
])),
search_attrs: vec![
Attribute::Class,
Attribute::Description,
Attribute::DisplayName,
Attribute::Name,
Attribute::Spn,
Attribute::OAuth2Session,
Attribute::OAuth2RsOrigin,
Attribute::OAuth2RsOriginLanding,
Attribute::OAuth2RsScopeMap,
Attribute::OAuth2RsSupScopeMap,
Attribute::OAuth2RsBasicSecret,
Attribute::OAuth2RsTokenKey,
Attribute::Es256PrivateKeyDer,
Attribute::OAuth2AllowInsecureClientDisablePkce,
Attribute::Rs256PrivateKeyDer,
Attribute::OAuth2JwtLegacyCryptoEnable,
Attribute::OAuth2PreferShortUsername,
Attribute::OAuth2AllowLocalhostRedirect,
Attribute::OAuth2RsClaimMap,
Attribute::Image,
Attribute::OAuth2StrictRedirectUri,
Attribute::OAuth2DeviceFlowEnable,
],
modify_removed_attrs: vec![
Attribute::Description,
Attribute::DisplayName,
Attribute::Name,
Attribute::OAuth2Session,
Attribute::OAuth2RsOrigin,
Attribute::OAuth2RsOriginLanding,
Attribute::OAuth2RsScopeMap,
Attribute::OAuth2RsSupScopeMap,
Attribute::OAuth2RsBasicSecret,
Attribute::OAuth2RsTokenKey,
Attribute::Es256PrivateKeyDer,
Attribute::OAuth2AllowInsecureClientDisablePkce,
Attribute::Rs256PrivateKeyDer,
Attribute::OAuth2JwtLegacyCryptoEnable,
Attribute::OAuth2PreferShortUsername,
Attribute::OAuth2AllowLocalhostRedirect,
Attribute::OAuth2RsClaimMap,
Attribute::Image,
Attribute::OAuth2StrictRedirectUri,
Attribute::OAuth2DeviceFlowEnable,
],
modify_present_attrs: vec![
Attribute::Description,
Attribute::DisplayName,
Attribute::Name,
Attribute::OAuth2RsOrigin,
Attribute::OAuth2RsOriginLanding,
Attribute::OAuth2RsSupScopeMap,
Attribute::OAuth2RsScopeMap,
Attribute::OAuth2AllowInsecureClientDisablePkce,
Attribute::OAuth2JwtLegacyCryptoEnable,
Attribute::OAuth2PreferShortUsername,
Attribute::OAuth2AllowLocalhostRedirect,
Attribute::OAuth2RsClaimMap,
Attribute::Image,
Attribute::OAuth2StrictRedirectUri,
Attribute::OAuth2DeviceFlowEnable,
],
create_attrs: vec![
Attribute::Class,
Attribute::Description,
Attribute::Name,
Attribute::DisplayName,
Attribute::OAuth2RsName,
Attribute::OAuth2RsOrigin,
Attribute::OAuth2RsOriginLanding,
Attribute::OAuth2RsSupScopeMap,
Attribute::OAuth2RsScopeMap,
Attribute::OAuth2AllowInsecureClientDisablePkce,
Attribute::OAuth2JwtLegacyCryptoEnable,
Attribute::OAuth2PreferShortUsername,
Attribute::OAuth2AllowLocalhostRedirect,
Attribute::OAuth2RsClaimMap,
Attribute::Image,
Attribute::OAuth2StrictRedirectUri,
Attribute::OAuth2DeviceFlowEnable,
],
create_classes: vec![
EntryClass::Object,
EntryClass::Account,
EntryClass::OAuth2ResourceServer,
EntryClass::OAuth2ResourceServerBasic,
EntryClass::OAuth2ResourceServerPublic,
],
..Default::default()
};
}
lazy_static! {
pub static ref IDM_ACP_DOMAIN_ADMIN_DL6: BuiltinAcp = BuiltinAcp {
classes: vec![

View file

@ -52,6 +52,7 @@ pub enum EntryClass {
OAuth2ResourceServer,
OAuth2ResourceServerBasic,
OAuth2ResourceServerPublic,
OAuth2DeviceCodeSession,
Object,
OrgPerson,
Person,
@ -102,6 +103,7 @@ impl From<EntryClass> for &'static str {
EntryClass::KeyObjectJweA128GCM => ENTRYCLASS_KEY_OBJECT_JWE_A128GCM,
EntryClass::KeyObjectInternal => ENTRYCLASS_KEY_OBJECT_INTERNAL,
EntryClass::MemberOf => ENTRYCLASS_MEMBER_OF,
EntryClass::OAuth2DeviceCodeSession => OAUTH2_DEVICE_CODE_SESSION,
EntryClass::OAuth2ResourceServer => OAUTH2_RESOURCE_SERVER,
EntryClass::OAuth2ResourceServerBasic => OAUTH2_RESOURCE_SERVER_BASIC,
EntryClass::OAuth2ResourceServerPublic => OAUTH2_RESOURCE_SERVER_PUBLIC,

View file

@ -54,24 +54,24 @@ pub type DomainVersion = u32;
/// previously.
pub const DOMAIN_LEVEL_0: DomainVersion = 0;
/// Deprcated as of 1.3.0
/// Deprecated as of 1.3.0
pub const DOMAIN_LEVEL_5: DomainVersion = 5;
/// Domain Level introduced with 1.2.0.
/// Deprcated as of 1.4.0
/// Deprecated as of 1.4.0
pub const DOMAIN_LEVEL_6: DomainVersion = 6;
pub const PATCH_LEVEL_1: u32 = 1;
/// Domain Level introduced with 1.3.0.
/// Deprcated as of 1.5.0
/// Deprecated as of 1.5.0
pub const DOMAIN_LEVEL_7: DomainVersion = 7;
/// Domain Level introduced with 1.4.0.
/// Deprcated as of 1.6.0
/// Deprecated as of 1.6.0
pub const DOMAIN_LEVEL_8: DomainVersion = 8;
/// Domain Level introduced with 1.5.0.
/// Deprcated as of 1.7.0
/// Deprecated as of 1.7.0
pub const DOMAIN_LEVEL_9: DomainVersion = 9;
// The minimum level that we can re-migrate from.

View file

@ -468,6 +468,16 @@ pub static ref SCHEMA_ATTR_OAUTH2_STRICT_REDIRECT_URI_DL7: SchemaAttribute = Sch
..Default::default()
};
pub static ref SCHEMA_ATTR_OAUTH2_DEVICE_FLOW_ENABLE_DL9: SchemaAttribute = SchemaAttribute {
uuid: UUID_SCHEMA_ATTR_OAUTH2_DEVICE_FLOW_ENABLE,
name: Attribute::OAuth2DeviceFlowEnable,
description: "Represents if OAuth2 Device Flow is permitted on this client.".to_string(),
syntax: SyntaxType::Boolean,
..Default::default()
};
pub static ref SCHEMA_ATTR_ES256_PRIVATE_KEY_DER: SchemaAttribute = SchemaAttribute {
uuid: UUID_SCHEMA_ATTR_ES256_PRIVATE_KEY_DER,
name: Attribute::Es256PrivateKeyDer,
@ -1280,6 +1290,33 @@ pub static ref SCHEMA_CLASS_OAUTH2_RS_DL7: SchemaClass = SchemaClass {
..Default::default()
};
pub static ref SCHEMA_CLASS_OAUTH2_RS_DL9: SchemaClass = SchemaClass {
uuid: UUID_SCHEMA_CLASS_OAUTH2_RS,
name: EntryClass::OAuth2ResourceServer.into(),
description: "The class representing a configured OAuth2 Client".to_string(),
systemmay: vec![
Attribute::Description,
Attribute::OAuth2RsScopeMap,
Attribute::OAuth2RsSupScopeMap,
Attribute::Rs256PrivateKeyDer,
Attribute::OAuth2JwtLegacyCryptoEnable,
Attribute::OAuth2PreferShortUsername,
Attribute::Image,
Attribute::OAuth2RsClaimMap,
Attribute::OAuth2Session,
Attribute::OAuth2RsOrigin,
Attribute::OAuth2StrictRedirectUri,
Attribute::OAuth2DeviceFlowEnable,
],
systemmust: vec![
Attribute::OAuth2RsOriginLanding,
Attribute::OAuth2RsTokenKey,
Attribute::Es256PrivateKeyDer,
],
..Default::default()
};
pub static ref SCHEMA_CLASS_OAUTH2_RS_BASIC_DL5: SchemaClass = SchemaClass {
uuid: UUID_SCHEMA_CLASS_OAUTH2_RS_BASIC,
name: EntryClass::OAuth2ResourceServerBasic.into(),

View file

@ -441,6 +441,8 @@ pub const UUID_IDM_ACP_APPLICATION_ENTRY_MANAGER: Uuid =
uuid!("00000000-0000-0000-0000-ffffff000072");
pub const UUID_IDM_ACP_APPLICATION_MANAGE: Uuid = uuid!("00000000-0000-0000-0000-ffffff000073");
pub const UUID_IDM_ACP_MAIL_SERVERS: Uuid = uuid!("00000000-0000-0000-0000-ffffff000074");
pub const UUID_SCHEMA_ATTR_OAUTH2_DEVICE_FLOW_ENABLE: Uuid =
uuid!("00000000-0000-0000-0000-ffffff000075");
// End of system ranges
pub const UUID_DOES_NOT_EXIST: Uuid = uuid!("00000000-0000-0000-0000-fffffffffffe");

View file

@ -12,10 +12,9 @@ use std::str::FromStr;
use std::sync::Arc;
use std::time::Duration;
use base64::{engine::general_purpose, Engine as _};
use hashbrown::HashSet;
use ::base64::{engine::general_purpose, Engine as _};
pub use compact_jwt::{compact::JwkKeySet, OidcToken};
use compact_jwt::{
crypto::JwsRs256Signer, jws::JwsBuilder, JwsCompact, JwsEs256Signer, JwsSigner,
@ -26,21 +25,27 @@ use fernet::Fernet;
use hashbrown::HashMap;
use kanidm_proto::constants::*;
// #[cfg(feature = "dev-oauth2-device-flow")]
// use kanidm_proto::oauth2::OAUTH2_DEVICE_CODE_EXPIRY_SECONDS;
pub use kanidm_proto::oauth2::{
AccessTokenIntrospectRequest, AccessTokenIntrospectResponse, AccessTokenRequest,
AccessTokenResponse, AuthorisationRequest, CodeChallengeMethod, ErrorResponse, GrantTypeReq,
OAuth2RFC9068Token, OAuth2RFC9068TokenExtensions, Oauth2Rfc8414MetadataResponse,
OidcDiscoveryResponse, PkceAlg, TokenRevokeRequest,
};
use kanidm_proto::oauth2::{
AccessTokenType, ClaimType, DisplayValue, GrantType, IdTokenSignAlg, ResponseMode,
ResponseType, SubjectType, TokenEndpointAuthMethod,
AccessTokenType, ClaimType, DeviceAuthorizationResponse, DisplayValue, GrantType,
IdTokenSignAlg, ResponseMode, ResponseType, SubjectType, TokenEndpointAuthMethod,
};
use openssl::sha;
use serde::{Deserialize, Serialize};
use serde_with::{base64, formats, serde_as};
use serde_with::{formats, serde_as};
use time::OffsetDateTime;
use tracing::trace;
use uri::{OAUTH2_TOKEN_INTROSPECT_ENDPOINT, OAUTH2_TOKEN_REVOKE_ENDPOINT};
use url::{Origin, Url};
use crate::idm::account::Account;
@ -72,6 +77,25 @@ pub enum Oauth2Error {
InsufficientScope,
// from https://datatracker.ietf.org/doc/html/rfc7009#section-2.2.1
UnsupportedTokenType,
/// <https://datatracker.ietf.org/doc/html/rfc8628#section-3.5> A variant of "authorization_pending", the authorization request is
/// still pending and polling should continue, but the interval MUST
/// be increased by 5 seconds for this and all subsequent requests.
SlowDown,
/// The authorization request is still pending as the end user hasn't
/// yet completed the user-interaction steps (Section 3.3). The
/// client SHOULD repeat the access token request to the token
/// endpoint (a process known as polling). Before each new request,
/// the client MUST wait at least the number of seconds specified by
/// the "interval" parameter of the device authorization response (see
/// Section 3.2), or 5 seconds if none was provided, and respect any
/// increase in the polling interval required by the "slow_down"
/// error.
AuthorizationPending,
/// The "device_code" has expired, and the device authorization
/// session has concluded. The client MAY commence a new device
/// authorization request but SHOULD wait for user interaction before
/// restarting to avoid unnecessary polling.
ExpiredToken,
}
impl std::fmt::Display for Oauth2Error {
@ -91,6 +115,9 @@ impl std::fmt::Display for Oauth2Error {
Oauth2Error::InvalidToken => "invalid_token",
Oauth2Error::InsufficientScope => "insufficient_scope",
Oauth2Error::UnsupportedTokenType => "unsupported_token_type",
Oauth2Error::SlowDown => "slow_down",
Oauth2Error::AuthorizationPending => "authorization_pending",
Oauth2Error::ExpiredToken => "expired_token",
})
}
}
@ -108,7 +135,9 @@ struct ConsentToken {
// CSRF
pub state: String,
// The S256 code challenge.
#[serde_as(as = "Option<base64::Base64<base64::UrlSafe, formats::Unpadded>>")]
#[serde_as(
as = "Option<serde_with::base64::Base64<serde_with::base64::UrlSafe, formats::Unpadded>>"
)]
pub code_challenge: Option<Vec<u8>>,
// Where the RS wants us to go back to.
pub redirect_uri: Url,
@ -128,7 +157,9 @@ struct TokenExchangeCode {
pub session_id: Uuid,
// The S256 code challenge.
#[serde_as(as = "Option<base64::Base64<base64::UrlSafe, formats::Unpadded>>")]
#[serde_as(
as = "Option<serde_with::base64::Base64<serde_with::base64::UrlSafe, formats::Unpadded>>"
)]
pub code_challenge: Option<Vec<u8>>,
// The original redirect uri
pub redirect_uri: Url,
@ -305,6 +336,37 @@ pub struct Oauth2RS {
type_: OauthRSType,
/// Does the RS have a custom image set? If not, we use the default.
has_custom_image: bool,
device_authorization_endpoint: Option<Url>,
}
impl Oauth2RS {
pub fn is_basic(&self) -> bool {
match self.type_ {
OauthRSType::Basic { .. } => true,
OauthRSType::Public { .. } => false,
}
}
pub fn is_pkce(&self) -> bool {
match self.type_ {
OauthRSType::Basic { .. } => false,
OauthRSType::Public { .. } => true,
}
}
/// Does this client require PKCE?
pub fn require_pkce(&self) -> bool {
match &self.type_ {
OauthRSType::Basic { enable_pkce, .. } => *enable_pkce,
OauthRSType::Public { .. } => true,
}
}
/// Does this RS have device flow enabled?
pub fn device_flow_enabled(&self) -> bool {
self.device_authorization_endpoint.is_some()
}
}
impl std::fmt::Debug for Oauth2RS {
@ -628,13 +690,13 @@ impl<'a> Oauth2ResourceServersWriteTransaction<'a> {
authorization_endpoint.set_path("/ui/oauth2");
let mut token_endpoint = self.inner.origin.clone();
token_endpoint.set_path("/oauth2/token");
token_endpoint.set_path(uri::OAUTH2_TOKEN_ENDPOINT);
let mut revocation_endpoint = self.inner.origin.clone();
revocation_endpoint.set_path("/oauth2/token/revoke");
revocation_endpoint.set_path(OAUTH2_TOKEN_REVOKE_ENDPOINT);
let mut introspection_endpoint = self.inner.origin.clone();
introspection_endpoint.set_path("/oauth2/token/introspect");
introspection_endpoint.set_path(OAUTH2_TOKEN_INTROSPECT_ENDPOINT);
let mut userinfo_endpoint = self.inner.origin.clone();
userinfo_endpoint.set_path(&format!("/oauth2/openid/{name}/userinfo"));
@ -659,6 +721,20 @@ impl<'a> Oauth2ResourceServersWriteTransaction<'a> {
.cloned()
.collect();
let device_authorization_endpoint: Option<Url> = match cfg!(feature="dev-oauth2-device-flow") {
true => {
match ent.get_ava_single_bool(Attribute::OAuth2DeviceFlowEnable).unwrap_or(false) {
true => {
let mut device_authorization_endpoint = self.inner.origin.clone();
device_authorization_endpoint.set_path(uri::OAUTH2_AUTHORISE_DEVICE);
Some(device_authorization_endpoint)
},
false => None
}
},
false => {None}
};
let client_id = name.clone();
let rscfg = Oauth2RS {
name,
@ -687,6 +763,7 @@ impl<'a> Oauth2ResourceServersWriteTransaction<'a> {
prefer_short_username,
type_,
has_custom_image,
device_authorization_endpoint,
};
Ok((client_id, rscfg))
@ -819,7 +896,7 @@ impl<'a> IdmServerProxyWriteTransaction<'a> {
// 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(
let modlist: ModifyList<ModifyInvalid> = ModifyList::new_list(vec![Modify::Removed(
Attribute::OAuth2Session,
PartialValue::Refer(session_id),
)]);
@ -860,19 +937,7 @@ impl<'a> IdmServerProxyWriteTransaction<'a> {
}
};
// 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 _)
};
let o2rs = self.get_client(&client_id)?;
// check the secret.
let client_authentication_valid = match &o2rs.type_ {
@ -906,7 +971,7 @@ impl<'a> IdmServerProxyWriteTransaction<'a> {
redirect_uri,
code_verifier,
} => self.check_oauth2_token_exchange_authorization_code(
o2rs,
&o2rs,
code,
redirect_uri,
code_verifier.as_deref(),
@ -914,7 +979,7 @@ impl<'a> IdmServerProxyWriteTransaction<'a> {
),
GrantTypeReq::ClientCredentials { scope } => {
if client_authentication_valid {
self.check_oauth2_token_client_credentials(o2rs, scope.as_ref(), ct)
self.check_oauth2_token_client_credentials(&o2rs, scope.as_ref(), ct)
} else {
security_info!(
"Unable to proceed with client credentials grant unless client authentication is provided and valid"
@ -925,9 +990,91 @@ impl<'a> IdmServerProxyWriteTransaction<'a> {
GrantTypeReq::RefreshToken {
refresh_token,
scope,
} => self.check_oauth2_token_refresh(o2rs, refresh_token, scope.as_ref(), ct),
} => self.check_oauth2_token_refresh(&o2rs, refresh_token, scope.as_ref(), ct),
GrantTypeReq::DeviceCode { device_code, scope } => {
self.check_oauth2_device_code_status(device_code, scope)
}
}
}
fn get_client(&self, client_id: &str) -> Result<Oauth2RS, Oauth2Error> {
let s = self
.oauth2rs
.inner
.rs_set
.get(client_id)
.ok_or_else(|| {
admin_warn!("Invalid OAuth2 client_id {}", client_id);
Oauth2Error::AuthenticationRequired
})?
.clone();
Ok(s)
}
#[instrument(level = "info", skip(self))]
pub fn handle_oauth2_start_device_flow(
&mut self,
_client_auth_info: ClientAuthInfo,
_client_id: &str,
_scope: &Option<BTreeSet<String>>,
_eventid: Uuid,
) -> Result<DeviceAuthorizationResponse, Oauth2Error> {
// let o2rs = self.get_client(client_id)?;
// info!("Got Client: {:?}", o2rs);
// // TODO: change this to checking if it's got device flow enabled
// if !o2rs.require_pkce() {
// security_info!("Device flow is only available for PKCE-enabled clients");
// return Err(Oauth2Error::InvalidRequest);
// }
// info!(
// "Starting device flow for client_id={} scopes={} source={:?}",
// client_id,
// scope
// .as_ref()
// .map(|s| s.iter().cloned().collect::<Vec<_>>().into_iter().join(","))
// .unwrap_or("[]".to_string()),
// client_auth_info.source
// );
// let mut verification_uri = self.oauth2rs.inner.origin.clone();
// verification_uri.set_path(uri::OAUTH2_DEVICE_LOGIN);
// let (user_code_string, _user_code) = gen_user_code();
// let expiry =
// Duration::from_secs(OAUTH2_DEVICE_CODE_EXPIRY_SECONDS) + duration_from_epoch_now();
// let device_code = gen_device_code()
// .inspect_err(|err| error!("Failed to generate a device code! {:?}", err))?;
Err(Oauth2Error::InvalidGrant)
// TODO: store user_code / expiry / client_id / device_code in the backend, needs to be checked on the token exchange.
// Ok(DeviceAuthorizationResponse::new(
// verification_uri,
// device_code,
// user_code_string,
// ))
}
#[instrument(level = "info", skip(self))]
fn check_oauth2_device_code_status(
&mut self,
device_code: &str,
scope: &Option<BTreeSet<String>>,
) -> Result<AccessTokenResponse, Oauth2Error> {
// TODO: check the device code is valid, do the needful
error!(
"haven't done the device grant yet! Got device_code={} scope={:?}",
device_code, scope
);
Err(Oauth2Error::AuthorizationPending)
// if it's an expired code, then just delete it from the db and return an error.
// Err(Oauth2Error::ExpiredToken)
}
#[instrument(level = "debug", skip_all)]
pub fn check_oauth2_authorise_permit(
@ -1053,11 +1200,6 @@ impl<'a> IdmServerProxyWriteTransaction<'a> {
})
})?;
let require_pkce = match &o2rs.type_ {
OauthRSType::Basic { enable_pkce, .. } => *enable_pkce,
OauthRSType::Public { .. } => true,
};
// 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
@ -1078,7 +1220,7 @@ impl<'a> IdmServerProxyWriteTransaction<'a> {
);
return Err(Oauth2Error::InvalidRequest);
}
} else if require_pkce {
} else if o2rs.require_pkce() {
security_info!(
"PKCE code verification failed - no code challenge present in PKCE enforced mode"
);
@ -1607,16 +1749,12 @@ impl<'a> IdmServerProxyWriteTransaction<'a> {
})?;
// check the secret.
match &o2rs.type_ {
OauthRSType::Basic { authz_secret, .. } => {
if authz_secret != &secret {
security_info!("Invalid OAuth2 client_id secret");
if let OauthRSType::Basic { authz_secret, .. } = &o2rs.type_ {
if o2rs.is_basic() && authz_secret != &secret {
security_info!("Invalid OAuth2 secret for client_id={}", client_id);
return Err(OperationError::InvalidSessionState);
}
}
// Relies on the token to be valid.
OauthRSType::Public { .. } => {}
};
o2rs.token_fernet
.decrypt(token)
@ -1733,13 +1871,8 @@ impl<'a> IdmServerProxyReadTransaction<'a> {
return Err(Oauth2Error::InvalidOrigin);
}
let require_pkce = match &o2rs.type_ {
OauthRSType::Basic { enable_pkce, .. } => *enable_pkce,
OauthRSType::Public { .. } => true,
};
let code_challenge = if let Some(pkce_request) = &auth_req.pkce_request {
if !require_pkce {
if !o2rs.require_pkce() {
security_info!(?o2rs.name, "Insecure rs configuration - pkce is not enforced, but rs is requesting it!");
}
// CodeChallengeMethod must be S256
@ -1748,7 +1881,7 @@ impl<'a> IdmServerProxyReadTransaction<'a> {
return Err(Oauth2Error::InvalidRequest);
}
Some(pkce_request.code_challenge.clone())
} else if require_pkce {
} else if o2rs.require_pkce() {
security_error!(?o2rs.name, "No PKCE code challenge was provided with client in enforced PKCE mode.");
return Err(Oauth2Error::InvalidRequest);
} else {
@ -2387,12 +2520,7 @@ impl<'a> IdmServerProxyReadTransaction<'a> {
let service_documentation = Some(URL_SERVICE_DOCUMENTATION.clone());
let require_pkce = match &o2rs.type_ {
OauthRSType::Basic { enable_pkce, .. } => *enable_pkce,
OauthRSType::Public { .. } => true,
};
let code_challenge_methods_supported = if require_pkce {
let code_challenge_methods_supported = if o2rs.require_pkce() {
vec![PkceAlg::S256]
} else {
Vec::with_capacity(0)
@ -2444,7 +2572,11 @@ impl<'a> IdmServerProxyReadTransaction<'a> {
let scopes_supported = Some(o2rs.scopes_supported.iter().cloned().collect());
let response_types_supported = vec![ResponseType::Code];
let response_modes_supported = vec![ResponseMode::Query];
// TODO: add device code if the rs supports it per <https://www.rfc-editor.org/rfc/rfc8628#section-4>
// `urn:ietf:params:oauth:grant-type:device_code`
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 {
@ -2463,12 +2595,7 @@ impl<'a> IdmServerProxyReadTransaction<'a> {
let claims_supported = None;
let service_documentation = Some(URL_SERVICE_DOCUMENTATION.clone());
let require_pkce = match &o2rs.type_ {
OauthRSType::Basic { enable_pkce, .. } => *enable_pkce,
OauthRSType::Public { .. } => true,
};
let code_challenge_methods_supported = if require_pkce {
let code_challenge_methods_supported = if o2rs.require_pkce() {
vec![PkceAlg::S256]
} else {
Vec::with_capacity(0)
@ -2534,6 +2661,7 @@ impl<'a> IdmServerProxyReadTransaction<'a> {
introspection_endpoint,
introspection_endpoint_auth_methods_supported,
introspection_endpoint_auth_signing_alg_values_supported: None,
device_authorization_endpoint: o2rs.device_authorization_endpoint.clone(),
})
}
@ -2701,12 +2829,55 @@ fn validate_scopes(req_scopes: &BTreeSet<String>) -> Result<(), Oauth2Error> {
Ok(())
}
/// device code is a random bucket of bytes used in the device flow
#[inline]
#[cfg(any(feature = "dev-oauth2-device-flow", test))]
#[allow(dead_code)]
fn gen_device_code() -> Result<[u8; 16], Oauth2Error> {
let mut rng = rand::thread_rng();
let mut result = [0u8; 16];
// doing it here because of feature-shenanigans.
use rand::Rng;
if let Err(err) = rng.try_fill(&mut result) {
error!("Failed to generate device code! {:?}", err);
return Err(Oauth2Error::ServerError(OperationError::Backend));
}
Ok(result)
}
#[inline]
#[cfg(any(feature = "dev-oauth2-device-flow", test))]
#[allow(dead_code)]
/// Returns (xxx-yyy-zzz, digits) where one's the human-facing code, the other is what we store in the DB.
fn gen_user_code() -> (String, u32) {
use rand::Rng;
let mut rng = rand::thread_rng();
let num: u32 = rng.gen_range(0..=999999999);
let result = format!("{:09}", num);
(
format!("{}-{}-{}", &result[0..3], &result[3..6], &result[6..9]),
num,
)
}
/// Take the supplied user code and check it's a valid u32
#[allow(dead_code)]
fn parse_user_code(val: &str) -> Result<u32, Oauth2Error> {
let mut val = val.to_string();
val.retain(|c| c.is_ascii_digit());
val.parse().map_err(|err| {
debug!("Failed to parse value={} as u32: {:?}", val, err);
Oauth2Error::InvalidRequest
})
}
#[cfg(test)]
mod tests {
use base64::{engine::general_purpose, Engine as _};
use std::convert::TryFrom;
use std::str::FromStr;
use std::time::Duration;
use uri::{OAUTH2_TOKEN_INTROSPECT_ENDPOINT, OAUTH2_TOKEN_REVOKE_ENDPOINT};
use compact_jwt::{
compact::JwkUse, crypto::JwsRs256Verifier, dangernoverify::JwsDangerReleaseWithoutVerify,
@ -4271,7 +4442,12 @@ mod tests {
);
assert!(
discovery.token_endpoint == Url::parse("https://idm.example.com/oauth2/token").unwrap()
discovery.token_endpoint
== Url::parse(&format!(
"https://idm.example.com{}",
uri::OAUTH2_TOKEN_ENDPOINT
))
.unwrap()
);
assert!(
@ -4319,7 +4495,13 @@ mod tests {
assert!(
discovery.revocation_endpoint
== Some(Url::parse("https://idm.example.com/oauth2/token/revoke").unwrap())
== Some(
Url::parse(&format!(
"https://idm.example.com{}",
OAUTH2_TOKEN_REVOKE_ENDPOINT
))
.unwrap()
)
);
assert!(
discovery.revocation_endpoint_auth_methods_supported
@ -4331,7 +4513,13 @@ mod tests {
assert!(
discovery.introspection_endpoint
== Some(Url::parse("https://idm.example.com/oauth2/token/introspect").unwrap())
== Some(
Url::parse(&format!(
"https://idm.example.com{}",
kanidm_proto::constants::uri::OAUTH2_TOKEN_INTROSPECT_ENDPOINT
))
.unwrap()
)
);
assert!(
discovery.introspection_endpoint_auth_methods_supported
@ -4507,7 +4695,13 @@ mod tests {
// Extensions
assert!(
discovery.revocation_endpoint
== Some(Url::parse("https://idm.example.com/oauth2/token/revoke").unwrap())
== Some(
Url::parse(&format!(
"https://idm.example.com{}",
OAUTH2_TOKEN_REVOKE_ENDPOINT
))
.unwrap()
)
);
assert!(
discovery.revocation_endpoint_auth_methods_supported
@ -4519,7 +4713,13 @@ mod tests {
assert!(
discovery.introspection_endpoint
== Some(Url::parse("https://idm.example.com/oauth2/token/introspect").unwrap())
== Some(
Url::parse(&format!(
"https://idm.example.com{}",
OAUTH2_TOKEN_INTROSPECT_ENDPOINT
))
.unwrap()
)
);
assert!(
discovery.introspection_endpoint_auth_methods_supported
@ -6571,4 +6771,49 @@ mod tests {
assert!(idms_prox_write.commit().is_ok());
}
#[test]
fn test_get_code() {
use super::{gen_device_code, gen_user_code, parse_user_code};
assert!(gen_device_code().is_ok());
let (res_string, res_value) = gen_user_code();
assert!(res_string.split('-').count() == 3);
let res_string_clean = res_string.replace("-", "");
let res_string_as_num = res_string_clean
.parse::<u32>()
.expect("Failed to parse as number");
assert_eq!(res_string_as_num, res_value);
assert_eq!(
parse_user_code(&res_string).expect("Failed to parse code"),
res_value
);
}
#[idm_test]
async fn handle_oauth2_start_device_flow(
idms: &IdmServer,
_idms_delayed: &mut IdmServerDelayed,
) {
let ct = duration_from_epoch_now();
let client_auth_info = ClientAuthInfo::from(Source::Https(
"127.0.0.1"
.parse()
.expect("Failed to parse 127.0.0.1 as an IP!"),
));
let eventid = Uuid::new_v4();
let res = idms
.proxy_write(ct)
.await
.expect("Failed to get idmspwt")
.handle_oauth2_start_device_flow(client_auth_info, "test_rs_id", &None, eventid);
dbg!(&res);
assert!(res.is_err());
}
}

View file

@ -83,12 +83,20 @@ pub struct SchemaAttribute {
pub uuid: Uuid,
// Perhaps later add aliases?
pub description: String,
/// This is a vec, not a single value
pub multivalue: bool,
/// If the attribute must be unique amongst all other values of this attribute? Maybe?
pub unique: bool,
/// TODO: What does this do?
pub phantom: bool,
/// TODO: What does this do?
pub sync_allowed: bool,
/// If the value of this attribute get replicated to other servers
pub replicated: bool,
/// TODO: What does this do?
pub index: Vec<IndexType>,
/// THe type of data that this attribute may hold.
pub syntax: SyntaxType,
}
@ -1934,6 +1942,22 @@ impl<'a> SchemaWriteTransaction<'a> {
},
);
self.attributes.insert(
Attribute::OAuth2DeviceFlowEnable,
SchemaAttribute {
name: Attribute::OAuth2DeviceFlowEnable,
uuid: UUID_SCHEMA_ATTR_OAUTH2_DEVICE_FLOW_ENABLE,
description: String::from("Enable the OAuth2 Device Flow for this client."),
multivalue: false,
unique: true,
phantom: false,
sync_allowed: false,
replicated: true,
index: vec![],
syntax: SyntaxType::Boolean,
},
);
self.classes.insert(
EntryClass::AttributeType.into(),
SchemaClass {

View file

@ -634,6 +634,34 @@ impl<'a> QueryServerWriteTransaction<'a> {
// =========== Apply changes ==============
// Now update schema
let idm_schema_changes = [
SCHEMA_ATTR_OAUTH2_DEVICE_FLOW_ENABLE_DL9.clone().into(),
SCHEMA_CLASS_OAUTH2_RS_DL9.clone().into(),
];
idm_schema_changes
.into_iter()
.try_for_each(|entry| self.internal_migrate_or_create(entry))
.map_err(|err| {
error!(?err, "migrate_domain_8_to_9 -> Error");
err
})?;
self.reload()?;
let idm_data = [IDM_ACP_OAUTH2_MANAGE_DL9.clone().into()];
idm_data
.into_iter()
.try_for_each(|entry| self.internal_migrate_or_create(entry))
.map_err(|err| {
error!(?err, "migrate_domain_8_to_9 -> Error");
err
})?;
self.reload()?;
Ok(())
}

View file

@ -95,6 +95,10 @@ impl DomainInfo {
pub fn image(&self) -> Option<&ImageValue> {
self.d_image.as_ref()
}
pub fn has_custom_image(&self) -> bool {
self.d_image.is_some()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]

View file

@ -18,10 +18,12 @@ test = true
doctest = false
[features]
default = []
# default = ["dev-oauth2-device-flow"]
# Enables webdriver tests, you need to be running a webdriver server
webdriver = []
dev-oauth2-device-flow = []
[dependencies]
hyper-tls = { workspace = true }
http = { workspace = true }
@ -57,7 +59,9 @@ escargot = "0.5.12"
# used for webdriver testing
fantoccini = { version = "0.21.2" }
futures = { workspace = true }
oauth2_ext = { workspace = true, default-features = false }
oauth2_ext = { workspace = true, default-features = false, features = [
"reqwest",
] }
openssl = { workspace = true }
petgraph = { version = "0.6.4", features = ["serde", "serde-1"] }
serde_json = { workspace = true }

View file

@ -28,9 +28,16 @@ pub const IDM_ADMIN_TEST_PASSWORD: &str = "integration idm admin password";
pub const NOT_ADMIN_TEST_USERNAME: &str = "krab_test_user";
pub const NOT_ADMIN_TEST_PASSWORD: &str = "eicieY7ahchaoCh0eeTa";
pub const NOT_ADMIN_TEST_EMAIL: &str = "krab_test@example.com";
pub static PORT_ALLOC: AtomicU16 = AtomicU16::new(18080);
pub const TEST_INTEGRATION_RS_ID: &str = "test_integration";
pub const TEST_INTEGRATION_RS_GROUP_ALL: &str = "idm_all_accounts";
pub const TEST_INTEGRATION_RS_DISPLAY: &str = "Test Integration";
pub const TEST_INTEGRATION_RS_URL: &str = "https://demo.example.com";
pub const TEST_INTEGRATION_RS_REDIRECT_URL: &str = "https://demo.example.com/oauth2/flow";
pub use testkit_macros::test;
use tracing::trace;
@ -382,3 +389,28 @@ pub async fn login_put_admin_idm_admins(rsclient: &KanidmClient) {
.await
.expect("Failed to add admin user to idm_admins")
}
#[macro_export]
macro_rules! assert_no_cache {
($response:expr) => {{
// Check we have correct nocache headers.
let cache_header: &str = $response
.headers()
.get(http::header::CACHE_CONTROL)
.expect("missing cache-control header")
.to_str()
.expect("invalid cache-control header");
assert!(cache_header.contains("no-store"));
assert!(cache_header.contains("max-age=0"));
let pragma_header: &str = $response
.headers()
.get("pragma")
.expect("missing cache-control header")
.to_str()
.expect("invalid cache-control header");
assert!(pragma_header.contains("no-cache"));
}};
}

View file

@ -0,0 +1,303 @@
#![allow(unused_imports)]
use std::collections::BTreeMap;
use std::str::FromStr;
use compact_jwt::{JwkKeySet, JwsEs256Verifier, JwsVerifier, OidcToken, OidcUnverified};
use kanidm_client::KanidmClient;
use kanidm_proto::constants::uri::{OAUTH2_AUTHORISE, OAUTH2_AUTHORISE_PERMIT};
use kanidm_proto::internal::Oauth2ClaimMapJoin;
use kanidm_proto::oauth2::{
AccessTokenRequest, AccessTokenResponse, AuthorisationResponse, GrantTypeReq,
};
use kanidmd_lib::prelude::uri::{OAUTH2_AUTHORISE_DEVICE, OAUTH2_TOKEN_ENDPOINT};
use kanidmd_lib::prelude::{
Attribute, IDM_ALL_ACCOUNTS, OAUTH2_SCOPE_EMAIL, OAUTH2_SCOPE_OPENID, OAUTH2_SCOPE_READ,
};
use kanidmd_testkit::{
assert_no_cache, ADMIN_TEST_PASSWORD, ADMIN_TEST_USER, IDM_ADMIN_TEST_PASSWORD,
IDM_ADMIN_TEST_USER, NOT_ADMIN_TEST_EMAIL, NOT_ADMIN_TEST_PASSWORD, NOT_ADMIN_TEST_USERNAME,
TEST_INTEGRATION_RS_DISPLAY, TEST_INTEGRATION_RS_GROUP_ALL, TEST_INTEGRATION_RS_ID,
TEST_INTEGRATION_RS_REDIRECT_URL, TEST_INTEGRATION_RS_URL,
};
use oauth2_ext::basic::BasicClient;
use oauth2_ext::http::StatusCode;
use oauth2_ext::{
AuthUrl, ClientId, DeviceAuthorizationUrl, HttpRequest, HttpResponse, PkceCodeChallenge,
RequestTokenError, Scope, StandardDeviceAuthorizationResponse, StandardErrorResponse, TokenUrl,
};
use reqwest::Client;
use tracing::{debug, error, info};
use url::Url;
#[cfg(feature = "dev-oauth2-device-flow")]
async fn http_client(
request: HttpRequest,
) -> Result<HttpResponse, oauth2_ext::reqwest::Error<reqwest::Error>> {
// let ca_contents = std::fs::read("/tmp/kanidm/ca.pem")
// .map_err(|err| oauth2::reqwest::Error::Other(err.to_string()))?;
let client = Client::builder()
.danger_accept_invalid_certs(true)
// Following redirects opens the client up to SSRF vulnerabilities.
.redirect(reqwest::redirect::Policy::none())
// reqwest::Certificate::from_der(&ca_contents)
// .map_err(oauth2::reqwest::Error::Reqwest)?,
// )
.build()
.map_err(oauth2_ext::reqwest::Error::Reqwest)?;
let method = reqwest::Method::from_str(request.method.as_str())
.map_err(|err| oauth2_ext::reqwest::Error::Other(err.to_string()))?;
let mut request_builder = client
.request(method, request.url.as_str())
.body(request.body);
for (name, value) in &request.headers {
request_builder = request_builder.header(name.as_str(), value.as_bytes());
}
let response = client
.execute(request_builder.build().map_err(|err| {
error!("Failed to build request... {:?}", err);
oauth2_ext::reqwest::Error::Reqwest(err)
})?)
.await
.map_err(|err| {
error!("Failed to query url {} error={:?}", request.url, err);
oauth2_ext::reqwest::Error::Reqwest(err)
})?;
let status_code = StatusCode::from_u16(response.status().as_u16())
.map_err(|err| oauth2_ext::reqwest::Error::Other(err.to_string()))?;
let headers = response
.headers()
.into_iter()
.map(|(k, v)| {
debug!("header key={:?} value={:?}", k, v);
(
oauth2_ext::http::HeaderName::from_str(k.as_str()).expect("Failed to parse header"),
oauth2_ext::http::HeaderValue::from_str(
v.to_str().expect("Failed to parse header value"),
)
.expect("Failed to parse header value"),
)
})
.collect();
let body = response.bytes().await.map_err(|err| {
error!("Failed to parse body...? {:?}", err);
oauth2_ext::reqwest::Error::Reqwest(err)
})?;
info!("Response body: {:?}", String::from_utf8(body.to_vec()));
Ok(HttpResponse {
status_code,
headers,
body: body.to_vec(),
})
}
#[cfg(feature = "dev-oauth2-device-flow")]
#[kanidmd_testkit::test]
async fn oauth2_device_flow(rsclient: KanidmClient) {
let res = rsclient
.auth_simple_password(ADMIN_TEST_USER, ADMIN_TEST_PASSWORD)
.await;
assert!(res.is_ok());
// Create an oauth2 application integration.
rsclient
.idm_oauth2_rs_public_create(
TEST_INTEGRATION_RS_ID,
TEST_INTEGRATION_RS_DISPLAY,
TEST_INTEGRATION_RS_URL,
)
.await
.expect("Failed to create oauth2 config");
rsclient
.idm_oauth2_client_add_origin(
TEST_INTEGRATION_RS_ID,
&Url::parse(TEST_INTEGRATION_RS_REDIRECT_URL).expect("Invalid URL"),
)
.await
.expect("Failed to update oauth2 config");
// Extend the admin account with extended details for openid claims.
rsclient
.idm_person_account_create(NOT_ADMIN_TEST_USERNAME, NOT_ADMIN_TEST_USERNAME)
.await
.expect("Failed to create account details");
rsclient
.idm_person_account_set_attr(
NOT_ADMIN_TEST_USERNAME,
Attribute::Mail.as_ref(),
&[NOT_ADMIN_TEST_EMAIL],
)
.await
.expect("Failed to create account mail");
rsclient
.idm_person_account_primary_credential_set_password(
NOT_ADMIN_TEST_USERNAME,
NOT_ADMIN_TEST_PASSWORD,
)
.await
.expect("Failed to configure account password");
rsclient
.idm_oauth2_rs_update(TEST_INTEGRATION_RS_ID, None, None, None, true, true, true)
.await
.expect("Failed to update oauth2 config");
rsclient
.idm_oauth2_rs_update_scope_map(
TEST_INTEGRATION_RS_ID,
IDM_ALL_ACCOUNTS.name,
vec![OAUTH2_SCOPE_READ, OAUTH2_SCOPE_EMAIL, OAUTH2_SCOPE_OPENID],
)
.await
.expect("Failed to update oauth2 scopes");
rsclient
.idm_oauth2_rs_update_sup_scope_map(
TEST_INTEGRATION_RS_ID,
IDM_ALL_ACCOUNTS.name,
vec![ADMIN_TEST_USER],
)
.await
.expect("Failed to update oauth2 scopes");
// Add a custom claim map.
rsclient
.idm_oauth2_rs_update_claim_map(
TEST_INTEGRATION_RS_ID,
"test_claim",
IDM_ALL_ACCOUNTS.name,
&["claim_a".to_string(), "claim_b".to_string()],
)
.await
.expect("Failed to update oauth2 claims");
// Set an alternate join
rsclient
.idm_oauth2_rs_update_claim_map_join(
TEST_INTEGRATION_RS_ID,
"test_claim",
Oauth2ClaimMapJoin::Ssv,
)
.await
.expect("Failed to update oauth2 claims");
// Get our admin's auth token for our new client.
// We have to re-auth to update the mail field.
let res = rsclient
.auth_simple_password(IDM_ADMIN_TEST_USER, IDM_ADMIN_TEST_PASSWORD)
.await;
assert!(res.is_ok());
// set up the device flow values
let rsdata = rsclient
.idm_oauth2_rs_get(TEST_INTEGRATION_RS_ID)
.await
.expect("failed to query rs")
.expect("failed to get rsdata");
dbg!(&rsdata);
assert!(
!rsdata
.attrs
.contains_key(Attribute::OAuth2DeviceFlowEnable.as_str()),
"Found device flow enable attribute, shouldn't be there yet!"
);
rsclient
.idm_oauth2_client_device_flow_update(TEST_INTEGRATION_RS_ID, false)
.await
.expect("Failed to update oauth2 config to disable device flow");
rsclient
.idm_oauth2_client_device_flow_update(TEST_INTEGRATION_RS_ID, true)
.await
.expect("Failed to update oauth2 config to enable device flow");
let rsdata = rsclient
.idm_oauth2_rs_get(TEST_INTEGRATION_RS_ID)
.await
.expect("failed to query rs")
.expect("failed to get rsdata");
dbg!(&rsdata);
assert!(
rsdata
.attrs
.contains_key(Attribute::OAuth2DeviceFlowEnable.as_str()),
"Couldn't find device flow enable attribute"
);
assert_eq!(
rsdata
.attrs
.get(Attribute::OAuth2DeviceFlowEnable.as_str())
.expect("Couldn't find device flow enable attribute"),
&vec!["true".to_string()],
"Device flow enable attribute not set to true"
);
// ok we've checked that adding the thing works.
// now we need to test the device flow itself.
// first we need to get the device code.
// kanidm system oauth2 create-public device_flow device_flow 'https://deviceauth'
let client = BasicClient::new(
ClientId::new(TEST_INTEGRATION_RS_ID.to_string()),
None,
AuthUrl::new(rsclient.make_url(OAUTH2_AUTHORISE).to_string())
.expect("Failed to build authurl"),
Some(
TokenUrl::new(rsclient.make_url(OAUTH2_TOKEN_ENDPOINT).to_string())
.expect("Failed to build token url"),
),
)
.set_device_authorization_url(
DeviceAuthorizationUrl::new(rsclient.make_url(OAUTH2_AUTHORISE_DEVICE).to_string())
.expect("Failed to build DeviceAuthorizationUrl"),
);
let details: StandardDeviceAuthorizationResponse = client
.exchange_device_code()
.expect("Failed to exchange device code")
.add_scope(Scope::new("read".to_string()))
.request_async(http_client)
.await
.expect("Failed to get device code!");
debug!("{:?}", details);
dbg!(&details.device_code().secret());
assert!(details.device_code().secret().len() == 24);
// now take that device code and get the token... glhf!
let result = client
.exchange_device_access_token(&details)
.request_async(
http_client,
tokio::time::sleep,
Some(std::time::Duration::from_secs(1)),
)
.await;
assert!(result.is_err());
let err = result.err().expect("Failed to get error");
dbg!(&err.to_string());
assert!(err.to_string().contains("Server returned error response"));
}

View file

@ -16,45 +16,21 @@ use kanidmd_lib::prelude::{Attribute, IDM_ALL_ACCOUNTS};
use oauth2_ext::PkceCodeChallenge;
use reqwest::header::{HeaderValue, CONTENT_TYPE};
use reqwest::StatusCode;
use uri::{OAUTH2_TOKEN_ENDPOINT, OAUTH2_TOKEN_INTROSPECT_ENDPOINT, OAUTH2_TOKEN_REVOKE_ENDPOINT};
use url::Url;
use kanidm_client::KanidmClient;
use kanidmd_testkit::ADMIN_TEST_PASSWORD;
macro_rules! assert_no_cache {
($response:expr) => {{
// Check we have correct nocache headers.
let cache_header: &str = $response
.headers()
.get(http::header::CACHE_CONTROL)
.expect("missing cache-control header")
.to_str()
.expect("invalid cache-control header");
assert!(cache_header.contains("no-store"));
assert!(cache_header.contains("max-age=0"));
let pragma_header: &str = $response
.headers()
.get("pragma")
.expect("missing cache-control header")
.to_str()
.expect("invalid cache-control header");
assert!(pragma_header.contains("no-cache"));
}};
}
const TEST_INTEGRATION_RS_ID: &str = "test_integration";
const TEST_INTEGRATION_RS_GROUP_ALL: &str = "idm_all_accounts";
const TEST_INTEGRATION_RS_DISPLAY: &str = "Test Integration";
const TEST_INTEGRATION_RS_URL: &str = "https://demo.example.com";
const TEST_INTEGRATION_REDIRECT_URL: &str = "https://demo.example.com/oauth2/flow";
use kanidmd_testkit::{
assert_no_cache, ADMIN_TEST_PASSWORD, ADMIN_TEST_USER, NOT_ADMIN_TEST_EMAIL,
NOT_ADMIN_TEST_PASSWORD, NOT_ADMIN_TEST_USERNAME, TEST_INTEGRATION_RS_DISPLAY,
TEST_INTEGRATION_RS_GROUP_ALL, TEST_INTEGRATION_RS_ID, TEST_INTEGRATION_RS_REDIRECT_URL,
TEST_INTEGRATION_RS_URL,
};
#[kanidmd_testkit::test]
async fn test_oauth2_openid_basic_flow(rsclient: KanidmClient) {
let res = rsclient
.auth_simple_password("admin", ADMIN_TEST_PASSWORD)
.auth_simple_password(ADMIN_TEST_USER, ADMIN_TEST_PASSWORD)
.await;
assert!(res.is_ok());
@ -71,39 +47,42 @@ async fn test_oauth2_openid_basic_flow(rsclient: KanidmClient) {
rsclient
.idm_oauth2_client_add_origin(
TEST_INTEGRATION_RS_ID,
&Url::parse(TEST_INTEGRATION_REDIRECT_URL).expect("Invalid URL"),
&Url::parse(TEST_INTEGRATION_RS_REDIRECT_URL).expect("Invalid URL"),
)
.await
.expect("Failed to update oauth2 config");
// Extend the admin account with extended details for openid claims.
rsclient
.idm_person_account_create("oauth_test", "oauth_test")
.idm_person_account_create(NOT_ADMIN_TEST_USERNAME, NOT_ADMIN_TEST_USERNAME)
.await
.expect("Failed to create account details");
rsclient
.idm_person_account_set_attr(
"oauth_test",
NOT_ADMIN_TEST_USERNAME,
Attribute::Mail.as_ref(),
&["oauth_test@localhost"],
&[NOT_ADMIN_TEST_EMAIL],
)
.await
.expect("Failed to create account mail");
rsclient
.idm_person_account_primary_credential_set_password("oauth_test", ADMIN_TEST_PASSWORD)
.idm_person_account_primary_credential_set_password(
NOT_ADMIN_TEST_USERNAME,
NOT_ADMIN_TEST_PASSWORD,
)
.await
.expect("Failed to configure account password");
rsclient
.idm_oauth2_rs_update("test_integration", None, None, None, true, true, true)
.idm_oauth2_rs_update(TEST_INTEGRATION_RS_ID, None, None, None, true, true, true)
.await
.expect("Failed to update oauth2 config");
rsclient
.idm_oauth2_rs_update_scope_map(
"test_integration",
TEST_INTEGRATION_RS_ID,
IDM_ALL_ACCOUNTS.name,
vec![OAUTH2_SCOPE_READ, OAUTH2_SCOPE_EMAIL, OAUTH2_SCOPE_OPENID],
)
@ -112,15 +91,15 @@ async fn test_oauth2_openid_basic_flow(rsclient: KanidmClient) {
rsclient
.idm_oauth2_rs_update_sup_scope_map(
"test_integration",
TEST_INTEGRATION_RS_ID,
IDM_ALL_ACCOUNTS.name,
vec!["admin"],
vec![ADMIN_TEST_USER],
)
.await
.expect("Failed to update oauth2 scopes");
let client_secret = rsclient
.idm_oauth2_rs_get_basic_secret("test_integration")
.idm_oauth2_rs_get_basic_secret(TEST_INTEGRATION_RS_ID)
.await
.ok()
.flatten()
@ -129,7 +108,7 @@ async fn test_oauth2_openid_basic_flow(rsclient: KanidmClient) {
// Get our admin's auth token for our new client.
// We have to re-auth to update the mail field.
let res = rsclient
.auth_simple_password("oauth_test", ADMIN_TEST_PASSWORD)
.auth_simple_password(NOT_ADMIN_TEST_USERNAME, NOT_ADMIN_TEST_PASSWORD)
.await;
assert!(res.is_ok());
let oauth_test_uat = rsclient
@ -205,7 +184,10 @@ async fn test_oauth2_openid_basic_flow(rsclient: KanidmClient) {
rsclient.make_url("/ui/oauth2")
);
assert_eq!(discovery.token_endpoint, rsclient.make_url("/oauth2/token"));
assert_eq!(
discovery.token_endpoint,
rsclient.make_url(OAUTH2_TOKEN_ENDPOINT)
);
assert!(
discovery.userinfo_endpoint
@ -248,11 +230,11 @@ async fn test_oauth2_openid_basic_flow(rsclient: KanidmClient) {
.bearer_auth(oauth_test_uat.clone())
.query(&[
("response_type", "code"),
("client_id", "test_integration"),
("client_id", TEST_INTEGRATION_RS_ID),
("state", "YWJjZGVm"),
("code_challenge", pkce_code_challenge.as_str()),
("code_challenge_method", "S256"),
("redirect_uri", TEST_INTEGRATION_REDIRECT_URL),
("redirect_uri", TEST_INTEGRATION_RS_REDIRECT_URL),
("scope", "email read openid"),
])
.send()
@ -274,6 +256,7 @@ async fn test_oauth2_openid_basic_flow(rsclient: KanidmClient) {
} = consent_req
{
// Note the supplemental scope here (admin)
dbg!(&scopes);
assert!(scopes.contains("admin"));
consent_token
} else {
@ -319,14 +302,14 @@ async fn test_oauth2_openid_basic_flow(rsclient: KanidmClient) {
let form_req: AccessTokenRequest = GrantTypeReq::AuthorizationCode {
code: code.to_string(),
redirect_uri: Url::parse(TEST_INTEGRATION_REDIRECT_URL).expect("Invalid URL"),
redirect_uri: Url::parse(TEST_INTEGRATION_RS_REDIRECT_URL).expect("Invalid URL"),
code_verifier: Some(pkce_code_verifier.secret().clone()),
}
.into();
let response = client
.post(rsclient.make_url("/oauth2/token"))
.basic_auth("test_integration", Some(client_secret.clone()))
.post(rsclient.make_url(OAUTH2_TOKEN_ENDPOINT))
.basic_auth(TEST_INTEGRATION_RS_ID, Some(client_secret.clone()))
.form(&form_req)
.send()
.await
@ -361,8 +344,8 @@ async fn test_oauth2_openid_basic_flow(rsclient: KanidmClient) {
};
let response = client
.post(rsclient.make_url("/oauth2/token/introspect"))
.basic_auth("test_integration", Some(client_secret.clone()))
.post(rsclient.make_url(OAUTH2_TOKEN_INTROSPECT_ENDPOINT))
.basic_auth(TEST_INTEGRATION_RS_ID, Some(client_secret.clone()))
.form(&intr_request)
.send()
.await
@ -382,14 +365,17 @@ async fn test_oauth2_openid_basic_flow(rsclient: KanidmClient) {
assert!(tir.active);
assert!(tir.scope.is_some());
assert_eq!(tir.client_id.as_deref(), Some("test_integration"));
assert_eq!(tir.username.as_deref(), Some("oauth_test@localhost"));
assert_eq!(tir.client_id.as_deref(), Some(TEST_INTEGRATION_RS_ID));
assert_eq!(
tir.username.as_deref(),
Some(format!("{}@localhost", NOT_ADMIN_TEST_USERNAME).as_str())
);
assert_eq!(tir.token_type, Some(AccessTokenType::Bearer));
assert!(tir.exp.is_some());
assert!(tir.iat.is_some());
assert!(tir.nbf.is_some());
assert!(tir.sub.is_some());
assert_eq!(tir.aud.as_deref(), Some("test_integration"));
assert_eq!(tir.aud.as_deref(), Some(TEST_INTEGRATION_RS_ID));
assert!(tir.iss.is_none());
assert!(tir.jti.is_none());
@ -410,7 +396,7 @@ async fn test_oauth2_openid_basic_flow(rsclient: KanidmClient) {
rsclient.make_url("/oauth2/openid/test_integration")
);
eprintln!("{:?}", oidc.s_claims.email);
assert_eq!(oidc.s_claims.email.as_deref(), Some("oauth_test@localhost"));
assert_eq!(oidc.s_claims.email.as_deref(), Some(NOT_ADMIN_TEST_EMAIL));
assert_eq!(oidc.s_claims.email_verified, Some(true));
let response = client
@ -446,8 +432,8 @@ async fn test_oauth2_openid_basic_flow(rsclient: KanidmClient) {
.into();
let response = client
.post(rsclient.make_url("/oauth2/token"))
.basic_auth("test_integration", Some(client_secret.clone()))
.post(rsclient.make_url(OAUTH2_TOKEN_ENDPOINT))
.basic_auth(TEST_INTEGRATION_RS_ID, Some(client_secret.clone()))
.form(&form_req)
.send()
.await
@ -467,8 +453,8 @@ async fn test_oauth2_openid_basic_flow(rsclient: KanidmClient) {
};
let response = client
.post(rsclient.make_url("/oauth2/token/introspect"))
.basic_auth("test_integration", Some(client_secret))
.post(rsclient.make_url(OAUTH2_TOKEN_INTROSPECT_ENDPOINT))
.basic_auth(TEST_INTEGRATION_RS_ID, Some(client_secret))
.form(&intr_request)
.send()
.await
@ -483,17 +469,17 @@ async fn test_oauth2_openid_basic_flow(rsclient: KanidmClient) {
assert!(tir.active);
assert!(tir.scope.is_some());
assert_eq!(tir.client_id.as_deref(), Some("test_integration"));
assert_eq!(tir.client_id.as_deref(), Some(TEST_INTEGRATION_RS_ID));
assert_eq!(tir.username.as_deref(), Some("test_integration@localhost"));
assert_eq!(tir.token_type, Some(AccessTokenType::Bearer));
// auth back with admin so we can test deleting things
let res = rsclient
.auth_simple_password("admin", ADMIN_TEST_PASSWORD)
.auth_simple_password(ADMIN_TEST_USER, ADMIN_TEST_PASSWORD)
.await;
assert!(res.is_ok());
rsclient
.idm_oauth2_rs_delete_sup_scope_map("test_integration", TEST_INTEGRATION_RS_GROUP_ALL)
.idm_oauth2_rs_delete_sup_scope_map(TEST_INTEGRATION_RS_ID, TEST_INTEGRATION_RS_GROUP_ALL)
.await
.expect("Failed to update oauth2 scopes");
}
@ -501,7 +487,7 @@ async fn test_oauth2_openid_basic_flow(rsclient: KanidmClient) {
#[kanidmd_testkit::test]
async fn test_oauth2_openid_public_flow(rsclient: KanidmClient) {
let res = rsclient
.auth_simple_password("admin", ADMIN_TEST_PASSWORD)
.auth_simple_password(ADMIN_TEST_USER, ADMIN_TEST_PASSWORD)
.await;
assert!(res.is_ok());
@ -518,39 +504,42 @@ async fn test_oauth2_openid_public_flow(rsclient: KanidmClient) {
rsclient
.idm_oauth2_client_add_origin(
TEST_INTEGRATION_RS_ID,
&Url::parse(TEST_INTEGRATION_REDIRECT_URL).expect("Invalid URL"),
&Url::parse(TEST_INTEGRATION_RS_REDIRECT_URL).expect("Invalid URL"),
)
.await
.expect("Failed to update oauth2 config");
// Extend the admin account with extended details for openid claims.
rsclient
.idm_person_account_create("oauth_test", "oauth_test")
.idm_person_account_create(NOT_ADMIN_TEST_USERNAME, NOT_ADMIN_TEST_USERNAME)
.await
.expect("Failed to create account details");
rsclient
.idm_person_account_set_attr(
"oauth_test",
NOT_ADMIN_TEST_USERNAME,
Attribute::Mail.as_ref(),
&["oauth_test@localhost"],
&[NOT_ADMIN_TEST_EMAIL],
)
.await
.expect("Failed to create account mail");
rsclient
.idm_person_account_primary_credential_set_password("oauth_test", ADMIN_TEST_PASSWORD)
.idm_person_account_primary_credential_set_password(
NOT_ADMIN_TEST_USERNAME,
ADMIN_TEST_PASSWORD,
)
.await
.expect("Failed to configure account password");
rsclient
.idm_oauth2_rs_update("test_integration", None, None, None, true, true, true)
.idm_oauth2_rs_update(TEST_INTEGRATION_RS_ID, None, None, None, true, true, true)
.await
.expect("Failed to update oauth2 config");
rsclient
.idm_oauth2_rs_update_scope_map(
"test_integration",
TEST_INTEGRATION_RS_ID,
IDM_ALL_ACCOUNTS.name,
vec![OAUTH2_SCOPE_READ, OAUTH2_SCOPE_EMAIL, OAUTH2_SCOPE_OPENID],
)
@ -559,9 +548,9 @@ async fn test_oauth2_openid_public_flow(rsclient: KanidmClient) {
rsclient
.idm_oauth2_rs_update_sup_scope_map(
"test_integration",
TEST_INTEGRATION_RS_ID,
IDM_ALL_ACCOUNTS.name,
vec!["admin"],
vec![ADMIN_TEST_USER],
)
.await
.expect("Failed to update oauth2 scopes");
@ -569,7 +558,7 @@ async fn test_oauth2_openid_public_flow(rsclient: KanidmClient) {
// Add a custom claim map.
rsclient
.idm_oauth2_rs_update_claim_map(
"test_integration",
TEST_INTEGRATION_RS_ID,
"test_claim",
IDM_ALL_ACCOUNTS.name,
&["claim_a".to_string(), "claim_b".to_string()],
@ -580,7 +569,7 @@ async fn test_oauth2_openid_public_flow(rsclient: KanidmClient) {
// Set an alternate join
rsclient
.idm_oauth2_rs_update_claim_map_join(
"test_integration",
TEST_INTEGRATION_RS_ID,
"test_claim",
Oauth2ClaimMapJoin::Ssv,
)
@ -590,7 +579,7 @@ async fn test_oauth2_openid_public_flow(rsclient: KanidmClient) {
// Get our admin's auth token for our new client.
// We have to re-auth to update the mail field.
let res = rsclient
.auth_simple_password("oauth_test", ADMIN_TEST_PASSWORD)
.auth_simple_password(NOT_ADMIN_TEST_USERNAME, ADMIN_TEST_PASSWORD)
.await;
assert!(res.is_ok());
let oauth_test_uat = rsclient
@ -639,11 +628,11 @@ async fn test_oauth2_openid_public_flow(rsclient: KanidmClient) {
.bearer_auth(oauth_test_uat.clone())
.query(&[
("response_type", "code"),
("client_id", "test_integration"),
("client_id", TEST_INTEGRATION_RS_ID),
("state", "YWJjZGVm"),
("code_challenge", pkce_code_challenge.as_str()),
("code_challenge_method", "S256"),
("redirect_uri", TEST_INTEGRATION_REDIRECT_URL),
("redirect_uri", TEST_INTEGRATION_RS_REDIRECT_URL),
("scope", "email read openid"),
])
.send()
@ -665,7 +654,7 @@ async fn test_oauth2_openid_public_flow(rsclient: KanidmClient) {
} = consent_req
{
// Note the supplemental scope here (admin)
assert!(scopes.contains("admin"));
assert!(scopes.contains(ADMIN_TEST_USER));
consent_token
} else {
unreachable!();
@ -710,15 +699,15 @@ async fn test_oauth2_openid_public_flow(rsclient: KanidmClient) {
let form_req = AccessTokenRequest {
grant_type: GrantTypeReq::AuthorizationCode {
code: code.to_string(),
redirect_uri: Url::parse(TEST_INTEGRATION_REDIRECT_URL).expect("Invalid URL"),
redirect_uri: Url::parse(TEST_INTEGRATION_RS_REDIRECT_URL).expect("Invalid URL"),
code_verifier: Some(pkce_code_verifier.secret().clone()),
},
client_id: Some("test_integration".to_string()),
client_id: Some(TEST_INTEGRATION_RS_ID.to_string()),
client_secret: None,
};
let response = client
.post(rsclient.make_url("/oauth2/token"))
.post(rsclient.make_url(OAUTH2_TOKEN_ENDPOINT))
.form(&form_req)
.send()
.await
@ -750,7 +739,7 @@ async fn test_oauth2_openid_public_flow(rsclient: KanidmClient) {
rsclient.make_url("/oauth2/openid/test_integration")
);
eprintln!("{:?}", oidc.s_claims.email);
assert_eq!(oidc.s_claims.email.as_deref(), Some("oauth_test@localhost"));
assert_eq!(oidc.s_claims.email.as_deref(), Some(NOT_ADMIN_TEST_EMAIL));
assert_eq!(oidc.s_claims.email_verified, Some(true));
eprintln!("{:?}", oidc.claims);
@ -797,11 +786,11 @@ async fn test_oauth2_openid_public_flow(rsclient: KanidmClient) {
// auth back with admin so we can test deleting things
let res = rsclient
.auth_simple_password("admin", ADMIN_TEST_PASSWORD)
.auth_simple_password(ADMIN_TEST_USER, ADMIN_TEST_PASSWORD)
.await;
assert!(res.is_ok());
rsclient
.idm_oauth2_rs_delete_sup_scope_map("test_integration", TEST_INTEGRATION_RS_GROUP_ALL)
.idm_oauth2_rs_delete_sup_scope_map(TEST_INTEGRATION_RS_ID, TEST_INTEGRATION_RS_GROUP_ALL)
.await
.expect("Failed to update oauth2 scopes");
}
@ -809,7 +798,7 @@ async fn test_oauth2_openid_public_flow(rsclient: KanidmClient) {
#[kanidmd_testkit::test]
async fn test_oauth2_token_post_bad_bodies(rsclient: KanidmClient) {
let res = rsclient
.auth_simple_password("admin", ADMIN_TEST_PASSWORD)
.auth_simple_password(ADMIN_TEST_USER, ADMIN_TEST_PASSWORD)
.await;
assert!(res.is_ok());
@ -821,7 +810,7 @@ async fn test_oauth2_token_post_bad_bodies(rsclient: KanidmClient) {
// test for a bad-body request on token
let response = client
.post(rsclient.make_url("/oauth2/token"))
.post(rsclient.make_url(OAUTH2_TOKEN_ENDPOINT))
.form(&serde_json::json!({}))
// .bearer_auth(atr.access_token.clone())
.send()
@ -832,7 +821,7 @@ async fn test_oauth2_token_post_bad_bodies(rsclient: KanidmClient) {
// test for a bad-auth request
let response = client
.post(rsclient.make_url("/oauth2/token/introspect"))
.post(rsclient.make_url(OAUTH2_TOKEN_INTROSPECT_ENDPOINT))
.form(&serde_json::json!({ "token": "lol" }))
.send()
.await
@ -844,7 +833,7 @@ async fn test_oauth2_token_post_bad_bodies(rsclient: KanidmClient) {
#[kanidmd_testkit::test]
async fn test_oauth2_token_revoke_post(rsclient: KanidmClient) {
let res = rsclient
.auth_simple_password("admin", ADMIN_TEST_PASSWORD)
.auth_simple_password(ADMIN_TEST_USER, ADMIN_TEST_PASSWORD)
.await;
assert!(res.is_ok());
@ -856,7 +845,7 @@ async fn test_oauth2_token_revoke_post(rsclient: KanidmClient) {
// test for a bad-body request on token
let response = client
.post(rsclient.make_url("/oauth2/token/revoke"))
.post(rsclient.make_url(OAUTH2_TOKEN_REVOKE_ENDPOINT))
.form(&serde_json::json!({}))
.bearer_auth("lolol")
.send()
@ -867,7 +856,7 @@ async fn test_oauth2_token_revoke_post(rsclient: KanidmClient) {
// test for a invalid format request on token
let response = client
.post(rsclient.make_url("/oauth2/token/revoke"))
.post(rsclient.make_url(OAUTH2_TOKEN_REVOKE_ENDPOINT))
.json("")
.bearer_auth("lolol")
.send()
@ -879,7 +868,7 @@ async fn test_oauth2_token_revoke_post(rsclient: KanidmClient) {
// test for a bad-body request on token
let response = client
.post(rsclient.make_url("/oauth2/token/revoke"))
.post(rsclient.make_url(OAUTH2_TOKEN_REVOKE_ENDPOINT))
.form(&serde_json::json!({}))
.bearer_auth("Basic lolol")
.send()
@ -890,7 +879,7 @@ async fn test_oauth2_token_revoke_post(rsclient: KanidmClient) {
// test for a bad-body request on token
let response = client
.post(rsclient.make_url("/oauth2/token/revoke"))
.post(rsclient.make_url(OAUTH2_TOKEN_REVOKE_ENDPOINT))
.body(serde_json::json!({}).to_string())
.bearer_auth("Basic lolol")
.send()

View file

@ -12,8 +12,12 @@ homepage = { workspace = true }
repository = { workspace = true }
[features]
default = ["unix"]
default = [
"unix",
# "dev-oauth2-device-flow"
]
unix = []
dev-oauth2-device-flow = []
[lib]
name = "kanidm_cli"

View file

@ -31,6 +31,12 @@ impl Oauth2Opt {
Oauth2Opt::DisableLegacyCrypto(nopt) => nopt.copt.debug,
Oauth2Opt::PreferShortUsername(nopt) => nopt.copt.debug,
Oauth2Opt::PreferSPNUsername(nopt) => nopt.copt.debug,
#[cfg(feature = "dev-oauth2-device-flow")]
Oauth2Opt::DeviceFlowDisable(nopt) => nopt.copt.debug,
#[cfg(feature = "dev-oauth2-device-flow")]
Oauth2Opt::DeviceFlowEnable(nopt) => nopt.copt.debug,
Oauth2Opt::CreateBasic { copt, .. }
| Oauth2Opt::CreatePublic { copt, .. }
| Oauth2Opt::UpdateClaimMap { copt, .. }
@ -47,6 +53,30 @@ impl Oauth2Opt {
pub async fn exec(&self) {
match self {
#[cfg(feature = "dev-oauth2-device-flow")]
Oauth2Opt::DeviceFlowDisable(nopt) => {
// TODO: finish the CLI bits for DeviceFlowDisable
let client = nopt.copt.to_client(OpType::Write).await;
match client
.idm_oauth2_client_device_flow_update(&nopt.name, true)
.await
{
Ok(_) => println!("Success"),
Err(e) => handle_client_error(e, nopt.copt.output_mode),
}
}
#[cfg(feature = "dev-oauth2-device-flow")]
Oauth2Opt::DeviceFlowEnable(nopt) => {
// TODO: finish the CLI bits for DeviceFlowEnable
let client = nopt.copt.to_client(OpType::Write).await;
match client
.idm_oauth2_client_device_flow_update(&nopt.name, true)
.await
{
Ok(_) => println!("Success"),
Err(e) => handle_client_error(e, nopt.copt.output_mode),
}
}
Oauth2Opt::List(copt) => {
let client = copt.to_client(OpType::Read).await;
match client.idm_oauth2_rs_list().await {

View file

@ -1188,6 +1188,12 @@ pub enum Oauth2Opt {
/// Use the 'spn' attribute instead of 'name' for the preferred_username
#[clap(name = "prefer-spn-username")]
PreferSPNUsername(Named),
#[cfg(feature = "dev-oauth2-device-flow")]
/// Enable OAuth2 Device Flow authentication
DeviceFlowEnable(Named),
#[cfg(feature = "dev-oauth2-device-flow")]
/// Disable OAuth2 Device Flow authentication
DeviceFlowDisable(Named),
}
#[derive(Args, Debug)]

View file

@ -0,0 +1,32 @@
[package]
name = "kanidm_device_flow"
description = "Kanidm Device Flow Client"
documentation = "https://kanidm.github.io/kanidm/stable/"
version = { workspace = true }
authors = { workspace = true }
rust-version = { workspace = true }
edition = { workspace = true }
license = { workspace = true }
homepage = { workspace = true }
repository = { workspace = true }
[lib]
test = false
doctest = false
[features]
[dependencies]
kanidm_proto = { workspace = true }
anyhow = { workspace = true }
oauth2 = "4.4.2"
reqwest = { version = "0.12.8", default-features = false, features = [
"rustls-tls",
] }
tokio = { workspace = true, features = ["full"] }
url = { workspace = true }
tracing = { workspace = true }
sketching = { workspace = true }
base64.workspace = true

View file

@ -0,0 +1,136 @@
use std::str::FromStr;
use kanidm_proto::constants::uri::{
OAUTH2_AUTHORISE, OAUTH2_AUTHORISE_DEVICE, OAUTH2_TOKEN_ENDPOINT,
};
use oauth2::basic::BasicClient;
use oauth2::devicecode::StandardDeviceAuthorizationResponse;
use oauth2::http::StatusCode;
use oauth2::{
AuthUrl, ClientId, DeviceAuthorizationUrl, HttpRequest, HttpResponse, Scope, TokenUrl,
};
use reqwest::Client;
use sketching::tracing_subscriber::layer::SubscriberExt;
use sketching::tracing_subscriber::util::SubscriberInitExt;
use sketching::tracing_subscriber::{fmt, EnvFilter};
use tracing::level_filters::LevelFilter;
use tracing::{debug, error, info};
async fn http_client(
request: HttpRequest,
) -> Result<HttpResponse, oauth2::reqwest::Error<reqwest::Error>> {
let client = Client::builder()
.danger_accept_invalid_certs(true)
// Following redirects opens the client up to SSRF vulnerabilities.
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(oauth2::reqwest::Error::Reqwest)?;
let method = reqwest::Method::from_str(request.method.as_str())
.map_err(|err| oauth2::reqwest::Error::Other(err.to_string()))?;
let mut request_builder = client
.request(method, request.url.as_str())
.body(request.body);
for (name, value) in &request.headers {
request_builder = request_builder.header(name.as_str(), value.as_bytes());
}
let response = client
.execute(request_builder.build().map_err(|err| {
error!("Failed to build request... {:?}", err);
oauth2::reqwest::Error::Reqwest(err)
})?)
.await
.map_err(|err| {
error!("Failed to query url {} error={:?}", request.url, err);
oauth2::reqwest::Error::Reqwest(err)
})?;
let status_code = StatusCode::from_u16(response.status().as_u16())
.map_err(|err| oauth2::reqwest::Error::Other(err.to_string()))?;
let headers = response
.headers()
.into_iter()
.map(|(k, v)| {
debug!("header key={:?} value={:?}", k, v);
(
oauth2::http::HeaderName::from_str(k.as_str()).expect("Failed to parse header"),
oauth2::http::HeaderValue::from_str(
v.to_str().expect("Failed to parse header value"),
)
.expect("Failed to parse header value"),
)
})
.collect();
let body = response.bytes().await.map_err(|err| {
error!("Failed to parse body...? {:?}", err);
oauth2::reqwest::Error::Reqwest(err)
})?;
info!("Response body: {:?}", String::from_utf8(body.to_vec()));
Ok(HttpResponse {
status_code,
headers,
body: body.to_vec(),
})
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let fmt_layer = fmt::layer().with_writer(std::io::stderr);
let filter_layer = EnvFilter::builder()
.with_default_directive(LevelFilter::INFO.into())
.parse_lossy("info,kanidm_client=warn,kanidm_cli=info");
sketching::tracing_subscriber::registry()
.with(filter_layer)
.with(fmt_layer)
.init();
info!("building client...");
// kanidm system oauth2 create-public device_flow device_flow 'https://deviceauth'
let client = BasicClient::new(
ClientId::new("device_code".to_string()),
None,
AuthUrl::new(format!("https://localhost:8443{}", OAUTH2_AUTHORISE))?,
Some(TokenUrl::new(format!(
"https://localhost:8443{}",
OAUTH2_TOKEN_ENDPOINT
))?),
)
.set_device_authorization_url(DeviceAuthorizationUrl::new(format!(
"https://localhost:8443{}",
OAUTH2_AUTHORISE_DEVICE
))?);
info!("Getting details...");
let details: StandardDeviceAuthorizationResponse = client
.exchange_device_code()
.inspect_err(|err| error!("configuration error: {:?}", err))?
.add_scope(Scope::new("read".to_string()))
.request_async(http_client)
.await?;
println!(
"Open this URL in your browser: {}",
match details.verification_uri_complete() {
Some(uri) => uri.secret().as_str(),
None => details.verification_uri().as_str(),
}
);
println!("the code is {}", details.user_code().secret());
let token_result = client
.exchange_device_access_token(&details)
.request_async(http_client, tokio::time::sleep, None)
.await?;
println!("Result: {:?}", token_result);
Ok(())
}

View file

@ -0,0 +1 @@