mirror of
https://github.com/kanidm/kanidm.git
synced 2025-02-23 12:37:00 +01:00
OAuth2 Device flow foundations (#3098)
This commit is contained in:
parent
b0824fef18
commit
5a709520dc
31
.devcontainer/devcontainer.json
Normal file
31
.devcontainer/devcontainer.json
Normal 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
596
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
@ -15,6 +15,7 @@ resolver = "2"
|
|||
members = [
|
||||
"proto",
|
||||
"tools/cli",
|
||||
"tools/device_flow",
|
||||
"tools/iam_migrations/freeipa",
|
||||
"tools/iam_migrations/ldap",
|
||||
"tools/orca",
|
||||
|
|
|
@ -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)
|
||||
|
|
35
book/src/developers/designs/oauth2_device_flow.md
Normal file
35
book/src/developers/designs/oauth2_device_flow.md
Normal 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!)
|
||||
|
||||
```
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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};
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"))
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
|
|
35
server/core/templates/oauth2_device_login.html
Normal file
35
server/core/templates/oauth2_device_login.html
Normal 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 %)
|
|
@ -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 }
|
||||
|
|
|
@ -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![
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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");
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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(())
|
||||
}
|
||||
|
||||
|
|
|
@ -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)]
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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"));
|
||||
}};
|
||||
}
|
||||
|
|
303
server/testkit/tests/oauth2_device_flow.rs
Normal file
303
server/testkit/tests/oauth2_device_flow.rs
Normal 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"));
|
||||
}
|
|
@ -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()
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)]
|
||||
|
|
32
tools/device_flow/Cargo.toml
Normal file
32
tools/device_flow/Cargo.toml
Normal 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
|
136
tools/device_flow/examples/device_flow.rs
Normal file
136
tools/device_flow/examples/device_flow.rs
Normal 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(())
|
||||
}
|
1
tools/device_flow/src/lib.rs
Normal file
1
tools/device_flow/src/lib.rs
Normal file
|
@ -0,0 +1 @@
|
|||
|
Loading…
Reference in a new issue