mirror of
https://github.com/kanidm/kanidm.git
synced 2025-02-23 20:47:01 +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 = [
|
members = [
|
||||||
"proto",
|
"proto",
|
||||||
"tools/cli",
|
"tools/cli",
|
||||||
|
"tools/device_flow",
|
||||||
"tools/iam_migrations/freeipa",
|
"tools/iam_migrations/freeipa",
|
||||||
"tools/iam_migrations/ldap",
|
"tools/iam_migrations/ldap",
|
||||||
"tools/orca",
|
"tools/orca",
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
<!-- markdownlint-disable MD025 MD042 -->
|
||||||
# Kanidm
|
# Kanidm
|
||||||
|
|
||||||
- [Introduction to Kanidm](introduction_to_kanidm.md)
|
- [Introduction to Kanidm](introduction_to_kanidm.md)
|
||||||
|
@ -83,6 +84,7 @@
|
||||||
- [Cryptography Key Domains (2024)](developers/designs/cryptography_key_domains.md)
|
- [Cryptography Key Domains (2024)](developers/designs/cryptography_key_domains.md)
|
||||||
- [Domain Join - Machine Accounts](developers/designs/domain_join_machine_accounts.md)
|
- [Domain Join - Machine Accounts](developers/designs/domain_join_machine_accounts.md)
|
||||||
- [Elevated Priv Mode](developers/designs/elevated_priv_mode.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)
|
- [OAuth2 Refresh Tokens](developers/designs/oauth2_refresh_tokens.md)
|
||||||
- [Replication Coordinator](developers/designs/replication_coordinator.md)
|
- [Replication Coordinator](developers/designs/replication_coordinator.md)
|
||||||
- [Replication Design and Notes](developers/designs/replication_design_and_notes.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 crate::{ClientError, KanidmClient};
|
||||||
|
use kanidm_proto::attribute::Attribute;
|
||||||
use kanidm_proto::constants::{
|
use kanidm_proto::constants::{
|
||||||
ATTR_DISPLAYNAME, ATTR_ES256_PRIVATE_KEY_DER, ATTR_NAME,
|
ATTR_DISPLAYNAME, ATTR_ES256_PRIVATE_KEY_DER, ATTR_NAME,
|
||||||
ATTR_OAUTH2_ALLOW_INSECURE_CLIENT_DISABLE_PKCE, ATTR_OAUTH2_ALLOW_LOCALHOST_REDIRECT,
|
ATTR_OAUTH2_ALLOW_INSECURE_CLIENT_DISABLE_PKCE, ATTR_OAUTH2_ALLOW_LOCALHOST_REDIRECT,
|
||||||
|
@ -453,4 +454,32 @@ impl KanidmClient {
|
||||||
)
|
)
|
||||||
.await
|
.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
|
doctest = true
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
|
# default = ["dev-oauth2-device-flow"]
|
||||||
wasm = ["webauthn-rs-proto/wasm"]
|
wasm = ["webauthn-rs-proto/wasm"]
|
||||||
test = []
|
test = []
|
||||||
|
|
||||||
|
dev-oauth2-device-flow = []
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
base32 = { workspace = true }
|
base32 = { workspace = true }
|
||||||
|
base64 = { workspace = true }
|
||||||
clap = { workspace = true }
|
clap = { workspace = true }
|
||||||
num_enum = { workspace = true }
|
num_enum = { workspace = true }
|
||||||
scim_proto = { workspace = true }
|
scim_proto = { workspace = true }
|
||||||
|
|
|
@ -112,6 +112,7 @@ pub enum Attribute {
|
||||||
OAuth2AllowInsecureClientDisablePkce,
|
OAuth2AllowInsecureClientDisablePkce,
|
||||||
OAuth2AllowLocalhostRedirect,
|
OAuth2AllowLocalhostRedirect,
|
||||||
OAuth2ConsentScopeMap,
|
OAuth2ConsentScopeMap,
|
||||||
|
OAuth2DeviceFlowEnable,
|
||||||
OAuth2JwtLegacyCryptoEnable,
|
OAuth2JwtLegacyCryptoEnable,
|
||||||
OAuth2PreferShortUsername,
|
OAuth2PreferShortUsername,
|
||||||
OAuth2RsBasicSecret,
|
OAuth2RsBasicSecret,
|
||||||
|
@ -338,6 +339,7 @@ impl Attribute {
|
||||||
}
|
}
|
||||||
Attribute::OAuth2AllowLocalhostRedirect => ATTR_OAUTH2_ALLOW_LOCALHOST_REDIRECT,
|
Attribute::OAuth2AllowLocalhostRedirect => ATTR_OAUTH2_ALLOW_LOCALHOST_REDIRECT,
|
||||||
Attribute::OAuth2ConsentScopeMap => ATTR_OAUTH2_CONSENT_SCOPE_MAP,
|
Attribute::OAuth2ConsentScopeMap => ATTR_OAUTH2_CONSENT_SCOPE_MAP,
|
||||||
|
Attribute::OAuth2DeviceFlowEnable => ATTR_OAUTH2_DEVICE_FLOW_ENABLE,
|
||||||
Attribute::OAuth2JwtLegacyCryptoEnable => ATTR_OAUTH2_JWT_LEGACY_CRYPTO_ENABLE,
|
Attribute::OAuth2JwtLegacyCryptoEnable => ATTR_OAUTH2_JWT_LEGACY_CRYPTO_ENABLE,
|
||||||
Attribute::OAuth2PreferShortUsername => ATTR_OAUTH2_PREFER_SHORT_USERNAME,
|
Attribute::OAuth2PreferShortUsername => ATTR_OAUTH2_PREFER_SHORT_USERNAME,
|
||||||
Attribute::OAuth2RsBasicSecret => ATTR_OAUTH2_RS_BASIC_SECRET,
|
Attribute::OAuth2RsBasicSecret => ATTR_OAUTH2_RS_BASIC_SECRET,
|
||||||
|
@ -518,6 +520,7 @@ impl Attribute {
|
||||||
}
|
}
|
||||||
ATTR_OAUTH2_ALLOW_LOCALHOST_REDIRECT => Attribute::OAuth2AllowLocalhostRedirect,
|
ATTR_OAUTH2_ALLOW_LOCALHOST_REDIRECT => Attribute::OAuth2AllowLocalhostRedirect,
|
||||||
ATTR_OAUTH2_CONSENT_SCOPE_MAP => Attribute::OAuth2ConsentScopeMap,
|
ATTR_OAUTH2_CONSENT_SCOPE_MAP => Attribute::OAuth2ConsentScopeMap,
|
||||||
|
ATTR_OAUTH2_DEVICE_FLOW_ENABLE => Attribute::OAuth2DeviceFlowEnable,
|
||||||
ATTR_OAUTH2_JWT_LEGACY_CRYPTO_ENABLE => Attribute::OAuth2JwtLegacyCryptoEnable,
|
ATTR_OAUTH2_JWT_LEGACY_CRYPTO_ENABLE => Attribute::OAuth2JwtLegacyCryptoEnable,
|
||||||
ATTR_OAUTH2_PREFER_SHORT_USERNAME => Attribute::OAuth2PreferShortUsername,
|
ATTR_OAUTH2_PREFER_SHORT_USERNAME => Attribute::OAuth2PreferShortUsername,
|
||||||
ATTR_OAUTH2_RS_BASIC_SECRET => Attribute::OAuth2RsBasicSecret,
|
ATTR_OAUTH2_RS_BASIC_SECRET => Attribute::OAuth2RsBasicSecret,
|
||||||
|
@ -596,7 +599,10 @@ impl Attribute {
|
||||||
#[allow(clippy::unreachable)]
|
#[allow(clippy::unreachable)]
|
||||||
#[cfg(test)]
|
#[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)]
|
#[cfg(test)]
|
||||||
mod test {
|
mod test {
|
||||||
use super::Attribute;
|
use super::Attribute;
|
||||||
|
@ -632,7 +644,12 @@ mod test {
|
||||||
let the_list = all::<Attribute>().collect::<Vec<_>>();
|
let the_list = all::<Attribute>().collect::<Vec<_>>();
|
||||||
for attr in the_list {
|
for attr in the_list {
|
||||||
let attr2 = Attribute::from(attr.as_str());
|
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";
|
"oauth2_allow_insecure_client_disable_pkce";
|
||||||
pub const ATTR_OAUTH2_ALLOW_LOCALHOST_REDIRECT: &str = "oauth2_allow_localhost_redirect";
|
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_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_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_PREFER_SHORT_USERNAME: &str = "oauth2_prefer_short_username";
|
||||||
pub const ATTR_OAUTH2_RS_BASIC_SECRET: &str = "oauth2_rs_basic_secret";
|
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";
|
pub const X_FORWARDED_FOR: &str = "x-forwarded-for";
|
||||||
|
|
||||||
// OAuth
|
// 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: &str = "oauth2_resource_server";
|
||||||
pub const OAUTH2_RESOURCE_SERVER_BASIC: &str = "oauth2_resource_server_basic";
|
pub const OAUTH2_RESOURCE_SERVER_BASIC: &str = "oauth2_resource_server_basic";
|
||||||
pub const OAUTH2_RESOURCE_SERVER_PUBLIC: &str = "oauth2_resource_server_public";
|
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";
|
pub const OAUTH2_AUTHORISE_PERMIT: &str = "/oauth2/authorise/permit";
|
||||||
/// ⚠️ ⚠️ WARNING DO NOT CHANGE THIS ⚠️ ⚠️
|
/// ⚠️ ⚠️ WARNING DO NOT CHANGE THIS ⚠️ ⚠️
|
||||||
pub const OAUTH2_AUTHORISE_REJECT: &str = "/oauth2/authorise/reject";
|
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";
|
pub const V1_AUTH_VALID: &str = "/v1/auth/valid";
|
||||||
|
|
|
@ -2,12 +2,19 @@
|
||||||
|
|
||||||
use std::collections::{BTreeMap, BTreeSet};
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
|
||||||
|
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_with::base64::{Base64, UrlSafe};
|
||||||
use serde_with::formats::SpaceSeparator;
|
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 url::Url;
|
||||||
use uuid::Uuid;
|
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)]
|
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone, Copy)]
|
||||||
pub enum CodeChallengeMethod {
|
pub enum CodeChallengeMethod {
|
||||||
// default to plain if not requested as S256. Reject the auth?
|
// default to plain if not requested as S256. Reject the auth?
|
||||||
|
@ -19,7 +26,7 @@ pub enum CodeChallengeMethod {
|
||||||
#[serde_as]
|
#[serde_as]
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
pub struct PkceRequest {
|
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: Vec<u8>,
|
||||||
pub code_challenge_method: CodeChallengeMethod,
|
pub code_challenge_method: CodeChallengeMethod,
|
||||||
}
|
}
|
||||||
|
@ -102,6 +109,13 @@ pub enum GrantTypeReq {
|
||||||
#[serde_as(as = "Option<StringWithSeparator::<SpaceSeparator, String>>")]
|
#[serde_as(as = "Option<StringWithSeparator::<SpaceSeparator, String>>")]
|
||||||
scope: Option<BTreeSet<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.
|
/// 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: Option<Url>,
|
||||||
pub introspection_endpoint_auth_methods_supported: Vec<TokenEndpointAuthMethod>,
|
pub introspection_endpoint_auth_methods_supported: Vec<TokenEndpointAuthMethod>,
|
||||||
pub introspection_endpoint_auth_signing_alg_values_supported: Option<Vec<IdTokenSignAlg>>,
|
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
|
/// The response to an OAuth2 rfc8414 metadata request
|
||||||
|
@ -504,6 +521,39 @@ pub struct ErrorResponse {
|
||||||
pub error_uri: Option<Url>,
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{AccessTokenRequest, GrantTypeReq};
|
use super::{AccessTokenRequest, GrantTypeReq};
|
||||||
|
|
|
@ -18,10 +18,11 @@ doctest = false
|
||||||
[features]
|
[features]
|
||||||
default = ["ui_htmx"]
|
default = ["ui_htmx"]
|
||||||
ui_htmx = []
|
ui_htmx = []
|
||||||
|
dev-oauth2-device-flow = []
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
askama = { workspace = true }
|
askama = { workspace = true, features = ["with-axum"] }
|
||||||
askama_axum = { workspace = true }
|
askama_axum = { workspace = true }
|
||||||
axum = { workspace = true }
|
axum = { workspace = true }
|
||||||
axum-htmx = { workspace = true }
|
axum-htmx = { workspace = true }
|
||||||
|
|
|
@ -12,6 +12,7 @@ use time::OffsetDateTime;
|
||||||
use tracing::{info, instrument, trace};
|
use tracing::{info, instrument, trace};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
|
||||||
use kanidmd_lib::{
|
use kanidmd_lib::{
|
||||||
event::{CreateEvent, DeleteEvent, ModifyEvent, ReviveRecycledEvent},
|
event::{CreateEvent, DeleteEvent, ModifyEvent, ReviveRecycledEvent},
|
||||||
filter::{Filter, FilterInvalid},
|
filter::{Filter, FilterInvalid},
|
||||||
|
@ -33,6 +34,9 @@ use kanidmd_lib::{
|
||||||
|
|
||||||
use kanidmd_lib::prelude::*;
|
use kanidmd_lib::prelude::*;
|
||||||
|
|
||||||
|
#[cfg(feature = "dev-oauth2-device-flow")]
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
use super::QueryServerWriteV1;
|
use super::QueryServerWriteV1;
|
||||||
|
|
||||||
impl QueryServerWriteV1 {
|
impl QueryServerWriteV1 {
|
||||||
|
@ -1701,4 +1705,26 @@ impl QueryServerWriteV1 {
|
||||||
.oauth2_token_revoke(&client_auth_info, &intr_req, ct)
|
.oauth2_token_revoke(&client_auth_info, &intr_req, ct)
|
||||||
.and_then(|()| idms_prox_write.commit().map_err(Oauth2Error::ServerError))
|
.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::errors::WebError;
|
||||||
use super::middleware::KOpId;
|
use super::middleware::KOpId;
|
||||||
use super::ServerState;
|
use super::ServerState;
|
||||||
|
@ -5,11 +7,13 @@ use crate::https::extractors::VerifiedClientInformation;
|
||||||
use axum::{
|
use axum::{
|
||||||
body::Body,
|
body::Body,
|
||||||
extract::{Path, Query, State},
|
extract::{Path, Query, State},
|
||||||
http::header::{
|
http::{
|
||||||
|
header::{
|
||||||
ACCESS_CONTROL_ALLOW_HEADERS, ACCESS_CONTROL_ALLOW_ORIGIN, CONTENT_TYPE, LOCATION,
|
ACCESS_CONTROL_ALLOW_HEADERS, ACCESS_CONTROL_ALLOW_ORIGIN, CONTENT_TYPE, LOCATION,
|
||||||
WWW_AUTHENTICATE,
|
WWW_AUTHENTICATE,
|
||||||
},
|
},
|
||||||
http::{HeaderValue, StatusCode},
|
HeaderValue, StatusCode,
|
||||||
|
},
|
||||||
middleware::from_fn,
|
middleware::from_fn,
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
|
@ -22,6 +26,9 @@ use kanidm_proto::constants::uri::{
|
||||||
};
|
};
|
||||||
use kanidm_proto::constants::APPLICATION_JSON;
|
use kanidm_proto::constants::APPLICATION_JSON;
|
||||||
use kanidm_proto::oauth2::AuthorisationResponse;
|
use kanidm_proto::oauth2::AuthorisationResponse;
|
||||||
|
|
||||||
|
#[cfg(feature = "dev-oauth2-device-flow")]
|
||||||
|
use kanidm_proto::oauth2::DeviceAuthorizationResponse;
|
||||||
use kanidmd_lib::idm::oauth2::{
|
use kanidmd_lib::idm::oauth2::{
|
||||||
AccessTokenIntrospectRequest, AccessTokenRequest, AuthorisationRequest, AuthorisePermitSuccess,
|
AccessTokenIntrospectRequest, AccessTokenRequest, AuthorisationRequest, AuthorisePermitSuccess,
|
||||||
AuthoriseResponse, ErrorResponse, Oauth2Error, TokenRevokeRequest,
|
AuthoriseResponse, ErrorResponse, Oauth2Error, TokenRevokeRequest,
|
||||||
|
@ -30,6 +37,12 @@ use kanidmd_lib::prelude::f_eq;
|
||||||
use kanidmd_lib::prelude::*;
|
use kanidmd_lib::prelude::*;
|
||||||
use kanidmd_lib::value::PartialValue;
|
use kanidmd_lib::value::PartialValue;
|
||||||
use serde::{Deserialize, Serialize};
|
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
|
// TODO: merge this into a value in WebError later
|
||||||
pub struct HTTPOauth2Error(Oauth2Error);
|
pub struct HTTPOauth2Error(Oauth2Error);
|
||||||
|
@ -724,6 +737,38 @@ pub async fn oauth2_preflight_options() -> Response {
|
||||||
.into_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> {
|
pub fn route_setup(state: ServerState) -> Router<ServerState> {
|
||||||
// this has all the openid-related routes
|
// this has all the openid-related routes
|
||||||
let openid_router = Router::new()
|
let openid_router = Router::new()
|
||||||
|
@ -753,7 +798,7 @@ pub fn route_setup(state: ServerState) -> Router<ServerState> {
|
||||||
)
|
)
|
||||||
.with_state(state.clone());
|
.with_state(state.clone());
|
||||||
|
|
||||||
Router::new()
|
let mut router = Router::new()
|
||||||
.route("/oauth2", get(super::v1_oauth2::oauth2_get))
|
.route("/oauth2", get(super::v1_oauth2::oauth2_get))
|
||||||
// ⚠️ ⚠️ WARNING ⚠️ ⚠️
|
// ⚠️ ⚠️ WARNING ⚠️ ⚠️
|
||||||
// IF YOU CHANGE THESE VALUES YOU MUST UPDATE OIDC DISCOVERY URLS
|
// IF YOU CHANGE THESE VALUES YOU MUST UPDATE OIDC DISCOVERY URLS
|
||||||
|
@ -772,21 +817,30 @@ pub fn route_setup(state: ServerState) -> Router<ServerState> {
|
||||||
.route(
|
.route(
|
||||||
OAUTH2_AUTHORISE_REJECT,
|
OAUTH2_AUTHORISE_REJECT,
|
||||||
post(oauth2_authorise_reject_post).get(oauth2_authorise_reject_get),
|
post(oauth2_authorise_reject_post).get(oauth2_authorise_reject_get),
|
||||||
)
|
);
|
||||||
// ⚠️ ⚠️ WARNING ⚠️ ⚠️
|
// ⚠️ ⚠️ WARNING ⚠️ ⚠️
|
||||||
// IF YOU CHANGE THESE VALUES YOU MUST UPDATE OIDC DISCOVERY URLS
|
// 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(
|
.route(
|
||||||
"/oauth2/token",
|
OAUTH2_TOKEN_ENDPOINT,
|
||||||
post(oauth2_token_post).options(oauth2_preflight_options),
|
post(oauth2_token_post).options(oauth2_preflight_options),
|
||||||
)
|
)
|
||||||
// ⚠️ ⚠️ WARNING ⚠️ ⚠️
|
// ⚠️ ⚠️ WARNING ⚠️ ⚠️
|
||||||
// IF YOU CHANGE THESE VALUES YOU MUST UPDATE OIDC DISCOVERY URLS
|
// IF YOU CHANGE THESE VALUES YOU MUST UPDATE OIDC DISCOVERY URLS
|
||||||
.route(
|
.route(
|
||||||
"/oauth2/token/introspect",
|
OAUTH2_TOKEN_INTROSPECT_ENDPOINT,
|
||||||
post(oauth2_token_introspect_post),
|
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)
|
.merge(openid_router)
|
||||||
.with_state(state)
|
.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 constants::Urls;
|
||||||
use kanidmd_lib::prelude::{OperationError, Uuid};
|
use kanidmd_lib::prelude::{OperationError, Uuid};
|
||||||
|
|
||||||
use crate::https::{
|
use crate::https::ServerState;
|
||||||
// extractors::VerifiedClientInformation, middleware::KOpId, v1::SessionId,
|
|
||||||
ServerState,
|
|
||||||
};
|
|
||||||
|
|
||||||
mod apps;
|
mod apps;
|
||||||
mod constants;
|
mod constants;
|
||||||
|
@ -33,7 +30,7 @@ struct UnrecoverableErrorView {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn view_router() -> Router<ServerState> {
|
pub fn view_router() -> Router<ServerState> {
|
||||||
let unguarded_router = Router::new()
|
let mut unguarded_router = Router::new()
|
||||||
.route(
|
.route(
|
||||||
"/",
|
"/",
|
||||||
get(|| async { Redirect::permanent(Urls::Login.as_ref()) }),
|
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", get(profile::view_profile_get))
|
||||||
.route("/profile/unlock", get(profile::view_profile_unlock_get))
|
.route("/profile/unlock", get(profile::view_profile_unlock_get))
|
||||||
.route("/logout", get(login::view_logout_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/resume", get(oauth2::view_resume_get))
|
||||||
.route("/oauth2/consent", post(oauth2::view_consent_post))
|
.route("/oauth2/consent", post(oauth2::view_consent_post))
|
||||||
// The login routes are htmx-free to make them simpler, which means
|
// 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::{
|
use kanidmd_lib::idm::oauth2::{
|
||||||
AuthorisationRequest, AuthorisePermitSuccess, AuthoriseResponse, Oauth2Error,
|
AuthorisationRequest, AuthorisePermitSuccess, AuthoriseResponse, Oauth2Error,
|
||||||
};
|
};
|
||||||
use kanidmd_lib::prelude::*;
|
use kanidmd_lib::prelude::*;
|
||||||
|
|
||||||
use crate::https::{extractors::VerifiedClientInformation, middleware::KOpId, ServerState};
|
|
||||||
|
|
||||||
use kanidm_proto::internal::COOKIE_OAUTH2_REQ;
|
use kanidm_proto::internal::COOKIE_OAUTH2_REQ;
|
||||||
|
|
||||||
use std::collections::BTreeSet;
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
use askama::Template;
|
use askama::Template;
|
||||||
|
|
||||||
|
#[cfg(feature = "dev-oauth2-device-flow")]
|
||||||
|
use axum::http::StatusCode;
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Query, State},
|
extract::{Query, State},
|
||||||
http::header::ACCESS_CONTROL_ALLOW_ORIGIN,
|
http::header::ACCESS_CONTROL_ALLOW_ORIGIN,
|
||||||
|
@ -31,6 +32,7 @@ struct ConsentRequestView {
|
||||||
// scopes: BTreeSet<String>,
|
// scopes: BTreeSet<String>,
|
||||||
pii_scopes: BTreeSet<String>,
|
pii_scopes: BTreeSet<String>,
|
||||||
consent_token: String,
|
consent_token: String,
|
||||||
|
redirect: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
|
@ -54,19 +56,18 @@ pub async fn view_resume_get(
|
||||||
Extension(kopid): Extension<KOpId>,
|
Extension(kopid): Extension<KOpId>,
|
||||||
VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
|
VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
|
||||||
jar: CookieJar,
|
jar: CookieJar,
|
||||||
) -> Response {
|
) -> Result<Response, UnrecoverableErrorView> {
|
||||||
let maybe_auth_req =
|
let maybe_auth_req =
|
||||||
cookies::get_signed::<AuthorisationRequest>(&state, &jar, COOKIE_OAUTH2_REQ);
|
cookies::get_signed::<AuthorisationRequest>(&state, &jar, COOKIE_OAUTH2_REQ);
|
||||||
|
|
||||||
if let Some(auth_req) = maybe_auth_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 {
|
} else {
|
||||||
error!("unable to resume session, no auth_req was found in the cookie");
|
error!("unable to resume session, no auth_req was found in the cookie");
|
||||||
UnrecoverableErrorView {
|
Err(UnrecoverableErrorView {
|
||||||
err_code: OperationError::InvalidState,
|
err_code: OperationError::InvalidState,
|
||||||
operation_id: kopid.eventid,
|
operation_id: kopid.eventid,
|
||||||
}
|
})
|
||||||
.into_response()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -128,6 +129,7 @@ async fn oauth2_auth_req(
|
||||||
// scopes,
|
// scopes,
|
||||||
pii_scopes,
|
pii_scopes,
|
||||||
consent_token,
|
consent_token,
|
||||||
|
redirect: None,
|
||||||
}
|
}
|
||||||
.into_response()
|
.into_response()
|
||||||
}
|
}
|
||||||
|
@ -185,6 +187,9 @@ async fn oauth2_auth_req(
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
pub struct ConsentForm {
|
pub struct ConsentForm {
|
||||||
consent_token: String,
|
consent_token: String,
|
||||||
|
#[serde(default)]
|
||||||
|
#[allow(dead_code)] // TODO: do smoething with this
|
||||||
|
redirect: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn view_consent_post(
|
pub async fn view_consent_post(
|
||||||
|
@ -193,7 +198,7 @@ pub async fn view_consent_post(
|
||||||
VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
|
VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
|
||||||
jar: CookieJar,
|
jar: CookieJar,
|
||||||
Form(consent_form): Form<ConsentForm>,
|
Form(consent_form): Form<ConsentForm>,
|
||||||
) -> Response {
|
) -> Result<Response, UnrecoverableErrorView> {
|
||||||
let res = state
|
let res = state
|
||||||
.qe_w_ref
|
.qe_w_ref
|
||||||
.handle_oauth2_authorise_permit(client_auth_info, consent_form.consent_token, kopid.eventid)
|
.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);
|
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
|
redirect_uri
|
||||||
.query_pairs_mut()
|
.query_pairs_mut()
|
||||||
.clear()
|
.clear()
|
||||||
.append_pair("state", &state)
|
.append_pair("state", &state)
|
||||||
.append_pair("code", &code);
|
.append_pair("code", &code);
|
||||||
(
|
Ok((
|
||||||
jar,
|
jar,
|
||||||
[
|
[
|
||||||
(HX_REDIRECT, redirect_uri.as_str().to_string()),
|
(HX_REDIRECT, redirect_uri.as_str().to_string()),
|
||||||
|
@ -223,7 +242,8 @@ pub async fn view_consent_post(
|
||||||
],
|
],
|
||||||
Redirect::to(redirect_uri.as_str()),
|
Redirect::to(redirect_uri.as_str()),
|
||||||
)
|
)
|
||||||
.into_response()
|
.into_response())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(err_code) => {
|
Err(err_code) => {
|
||||||
error!(
|
error!(
|
||||||
|
@ -232,11 +252,64 @@ pub async fn view_consent_post(
|
||||||
&err_code.to_string()
|
&err_code.to_string()
|
||||||
);
|
);
|
||||||
|
|
||||||
UnrecoverableErrorView {
|
Err(UnrecoverableErrorView {
|
||||||
err_code: OperationError::InvalidState,
|
err_code: OperationError::InvalidState,
|
||||||
operation_id: kopid.eventid,
|
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(
|
pub(crate) async fn view_profile_unlock_get(
|
||||||
State(state): State<ServerState>,
|
State(state): State<ServerState>,
|
||||||
VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
|
VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
|
||||||
|
|
|
@ -25,6 +25,9 @@
|
||||||
</div>
|
</div>
|
||||||
(% endif %)
|
(% endif %)
|
||||||
<form id="login" action="/ui/oauth2/consent" method="post">
|
<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 ))" />
|
<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>
|
<button autofocus=true class="w-100 btn btn-lg btn-primary" type="submit">Proceed</button>
|
||||||
</form>
|
</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
|
harness = false
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
# default = [ "libsqlite3-sys/bundled", "openssl/vendored" ]
|
default = []
|
||||||
dhat-heap = ["dep:dhat"]
|
dhat-heap = ["dep:dhat"]
|
||||||
dhat-ad-hoc = ["dep:dhat"]
|
dhat-ad-hoc = ["dep:dhat"]
|
||||||
|
dev-oauth2-device-flow = [] # still-in-development oauth2 device flow support
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
base64 = { workspace = true }
|
base64 = { workspace = true }
|
||||||
|
@ -105,7 +106,11 @@ svg = { workspace = true }
|
||||||
whoami = { workspace = true }
|
whoami = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[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"] }
|
criterion = { workspace = true, features = ["html_reports"] }
|
||||||
futures = { workspace = true }
|
futures = { workspace = true }
|
||||||
kanidmd_lib_macros = { 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! {
|
lazy_static! {
|
||||||
pub static ref IDM_ACP_DOMAIN_ADMIN_DL6: BuiltinAcp = BuiltinAcp {
|
pub static ref IDM_ACP_DOMAIN_ADMIN_DL6: BuiltinAcp = BuiltinAcp {
|
||||||
classes: vec![
|
classes: vec![
|
||||||
|
|
|
@ -52,6 +52,7 @@ pub enum EntryClass {
|
||||||
OAuth2ResourceServer,
|
OAuth2ResourceServer,
|
||||||
OAuth2ResourceServerBasic,
|
OAuth2ResourceServerBasic,
|
||||||
OAuth2ResourceServerPublic,
|
OAuth2ResourceServerPublic,
|
||||||
|
OAuth2DeviceCodeSession,
|
||||||
Object,
|
Object,
|
||||||
OrgPerson,
|
OrgPerson,
|
||||||
Person,
|
Person,
|
||||||
|
@ -102,6 +103,7 @@ impl From<EntryClass> for &'static str {
|
||||||
EntryClass::KeyObjectJweA128GCM => ENTRYCLASS_KEY_OBJECT_JWE_A128GCM,
|
EntryClass::KeyObjectJweA128GCM => ENTRYCLASS_KEY_OBJECT_JWE_A128GCM,
|
||||||
EntryClass::KeyObjectInternal => ENTRYCLASS_KEY_OBJECT_INTERNAL,
|
EntryClass::KeyObjectInternal => ENTRYCLASS_KEY_OBJECT_INTERNAL,
|
||||||
EntryClass::MemberOf => ENTRYCLASS_MEMBER_OF,
|
EntryClass::MemberOf => ENTRYCLASS_MEMBER_OF,
|
||||||
|
EntryClass::OAuth2DeviceCodeSession => OAUTH2_DEVICE_CODE_SESSION,
|
||||||
EntryClass::OAuth2ResourceServer => OAUTH2_RESOURCE_SERVER,
|
EntryClass::OAuth2ResourceServer => OAUTH2_RESOURCE_SERVER,
|
||||||
EntryClass::OAuth2ResourceServerBasic => OAUTH2_RESOURCE_SERVER_BASIC,
|
EntryClass::OAuth2ResourceServerBasic => OAUTH2_RESOURCE_SERVER_BASIC,
|
||||||
EntryClass::OAuth2ResourceServerPublic => OAUTH2_RESOURCE_SERVER_PUBLIC,
|
EntryClass::OAuth2ResourceServerPublic => OAUTH2_RESOURCE_SERVER_PUBLIC,
|
||||||
|
|
|
@ -54,24 +54,24 @@ pub type DomainVersion = u32;
|
||||||
/// previously.
|
/// previously.
|
||||||
pub const DOMAIN_LEVEL_0: DomainVersion = 0;
|
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;
|
pub const DOMAIN_LEVEL_5: DomainVersion = 5;
|
||||||
|
|
||||||
/// Domain Level introduced with 1.2.0.
|
/// 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 DOMAIN_LEVEL_6: DomainVersion = 6;
|
||||||
pub const PATCH_LEVEL_1: u32 = 1;
|
pub const PATCH_LEVEL_1: u32 = 1;
|
||||||
|
|
||||||
/// Domain Level introduced with 1.3.0.
|
/// 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;
|
pub const DOMAIN_LEVEL_7: DomainVersion = 7;
|
||||||
|
|
||||||
/// Domain Level introduced with 1.4.0.
|
/// 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;
|
pub const DOMAIN_LEVEL_8: DomainVersion = 8;
|
||||||
|
|
||||||
/// Domain Level introduced with 1.5.0.
|
/// 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;
|
pub const DOMAIN_LEVEL_9: DomainVersion = 9;
|
||||||
|
|
||||||
// The minimum level that we can re-migrate from.
|
// 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()
|
..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 {
|
pub static ref SCHEMA_ATTR_ES256_PRIVATE_KEY_DER: SchemaAttribute = SchemaAttribute {
|
||||||
uuid: UUID_SCHEMA_ATTR_ES256_PRIVATE_KEY_DER,
|
uuid: UUID_SCHEMA_ATTR_ES256_PRIVATE_KEY_DER,
|
||||||
name: Attribute::Es256PrivateKeyDer,
|
name: Attribute::Es256PrivateKeyDer,
|
||||||
|
@ -1280,6 +1290,33 @@ pub static ref SCHEMA_CLASS_OAUTH2_RS_DL7: SchemaClass = SchemaClass {
|
||||||
..Default::default()
|
..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 {
|
pub static ref SCHEMA_CLASS_OAUTH2_RS_BASIC_DL5: SchemaClass = SchemaClass {
|
||||||
uuid: UUID_SCHEMA_CLASS_OAUTH2_RS_BASIC,
|
uuid: UUID_SCHEMA_CLASS_OAUTH2_RS_BASIC,
|
||||||
name: EntryClass::OAuth2ResourceServerBasic.into(),
|
name: EntryClass::OAuth2ResourceServerBasic.into(),
|
||||||
|
|
|
@ -441,6 +441,8 @@ pub const UUID_IDM_ACP_APPLICATION_ENTRY_MANAGER: Uuid =
|
||||||
uuid!("00000000-0000-0000-0000-ffffff000072");
|
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_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_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
|
// End of system ranges
|
||||||
pub const UUID_DOES_NOT_EXIST: Uuid = uuid!("00000000-0000-0000-0000-fffffffffffe");
|
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::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use base64::{engine::general_purpose, Engine as _};
|
||||||
use hashbrown::HashSet;
|
use hashbrown::HashSet;
|
||||||
|
|
||||||
use ::base64::{engine::general_purpose, Engine as _};
|
|
||||||
|
|
||||||
pub use compact_jwt::{compact::JwkKeySet, OidcToken};
|
pub use compact_jwt::{compact::JwkKeySet, OidcToken};
|
||||||
use compact_jwt::{
|
use compact_jwt::{
|
||||||
crypto::JwsRs256Signer, jws::JwsBuilder, JwsCompact, JwsEs256Signer, JwsSigner,
|
crypto::JwsRs256Signer, jws::JwsBuilder, JwsCompact, JwsEs256Signer, JwsSigner,
|
||||||
|
@ -26,21 +25,27 @@ use fernet::Fernet;
|
||||||
use hashbrown::HashMap;
|
use hashbrown::HashMap;
|
||||||
use kanidm_proto::constants::*;
|
use kanidm_proto::constants::*;
|
||||||
|
|
||||||
|
// #[cfg(feature = "dev-oauth2-device-flow")]
|
||||||
|
// use kanidm_proto::oauth2::OAUTH2_DEVICE_CODE_EXPIRY_SECONDS;
|
||||||
|
|
||||||
pub use kanidm_proto::oauth2::{
|
pub use kanidm_proto::oauth2::{
|
||||||
AccessTokenIntrospectRequest, AccessTokenIntrospectResponse, AccessTokenRequest,
|
AccessTokenIntrospectRequest, AccessTokenIntrospectResponse, AccessTokenRequest,
|
||||||
AccessTokenResponse, AuthorisationRequest, CodeChallengeMethod, ErrorResponse, GrantTypeReq,
|
AccessTokenResponse, AuthorisationRequest, CodeChallengeMethod, ErrorResponse, GrantTypeReq,
|
||||||
OAuth2RFC9068Token, OAuth2RFC9068TokenExtensions, Oauth2Rfc8414MetadataResponse,
|
OAuth2RFC9068Token, OAuth2RFC9068TokenExtensions, Oauth2Rfc8414MetadataResponse,
|
||||||
OidcDiscoveryResponse, PkceAlg, TokenRevokeRequest,
|
OidcDiscoveryResponse, PkceAlg, TokenRevokeRequest,
|
||||||
};
|
};
|
||||||
|
|
||||||
use kanidm_proto::oauth2::{
|
use kanidm_proto::oauth2::{
|
||||||
AccessTokenType, ClaimType, DisplayValue, GrantType, IdTokenSignAlg, ResponseMode,
|
AccessTokenType, ClaimType, DeviceAuthorizationResponse, DisplayValue, GrantType,
|
||||||
ResponseType, SubjectType, TokenEndpointAuthMethod,
|
IdTokenSignAlg, ResponseMode, ResponseType, SubjectType, TokenEndpointAuthMethod,
|
||||||
};
|
};
|
||||||
use openssl::sha;
|
use openssl::sha;
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_with::{base64, formats, serde_as};
|
use serde_with::{formats, serde_as};
|
||||||
use time::OffsetDateTime;
|
use time::OffsetDateTime;
|
||||||
use tracing::trace;
|
use tracing::trace;
|
||||||
|
use uri::{OAUTH2_TOKEN_INTROSPECT_ENDPOINT, OAUTH2_TOKEN_REVOKE_ENDPOINT};
|
||||||
use url::{Origin, Url};
|
use url::{Origin, Url};
|
||||||
|
|
||||||
use crate::idm::account::Account;
|
use crate::idm::account::Account;
|
||||||
|
@ -72,6 +77,25 @@ pub enum Oauth2Error {
|
||||||
InsufficientScope,
|
InsufficientScope,
|
||||||
// from https://datatracker.ietf.org/doc/html/rfc7009#section-2.2.1
|
// from https://datatracker.ietf.org/doc/html/rfc7009#section-2.2.1
|
||||||
UnsupportedTokenType,
|
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 {
|
impl std::fmt::Display for Oauth2Error {
|
||||||
|
@ -91,6 +115,9 @@ impl std::fmt::Display for Oauth2Error {
|
||||||
Oauth2Error::InvalidToken => "invalid_token",
|
Oauth2Error::InvalidToken => "invalid_token",
|
||||||
Oauth2Error::InsufficientScope => "insufficient_scope",
|
Oauth2Error::InsufficientScope => "insufficient_scope",
|
||||||
Oauth2Error::UnsupportedTokenType => "unsupported_token_type",
|
Oauth2Error::UnsupportedTokenType => "unsupported_token_type",
|
||||||
|
Oauth2Error::SlowDown => "slow_down",
|
||||||
|
Oauth2Error::AuthorizationPending => "authorization_pending",
|
||||||
|
Oauth2Error::ExpiredToken => "expired_token",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -108,7 +135,9 @@ struct ConsentToken {
|
||||||
// CSRF
|
// CSRF
|
||||||
pub state: String,
|
pub state: String,
|
||||||
// The S256 code challenge.
|
// 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>>,
|
pub code_challenge: Option<Vec<u8>>,
|
||||||
// Where the RS wants us to go back to.
|
// Where the RS wants us to go back to.
|
||||||
pub redirect_uri: Url,
|
pub redirect_uri: Url,
|
||||||
|
@ -128,7 +157,9 @@ struct TokenExchangeCode {
|
||||||
pub session_id: Uuid,
|
pub session_id: Uuid,
|
||||||
|
|
||||||
// The S256 code challenge.
|
// 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>>,
|
pub code_challenge: Option<Vec<u8>>,
|
||||||
// The original redirect uri
|
// The original redirect uri
|
||||||
pub redirect_uri: Url,
|
pub redirect_uri: Url,
|
||||||
|
@ -305,6 +336,37 @@ pub struct Oauth2RS {
|
||||||
type_: OauthRSType,
|
type_: OauthRSType,
|
||||||
/// Does the RS have a custom image set? If not, we use the default.
|
/// Does the RS have a custom image set? If not, we use the default.
|
||||||
has_custom_image: bool,
|
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 {
|
impl std::fmt::Debug for Oauth2RS {
|
||||||
|
@ -628,13 +690,13 @@ impl<'a> Oauth2ResourceServersWriteTransaction<'a> {
|
||||||
authorization_endpoint.set_path("/ui/oauth2");
|
authorization_endpoint.set_path("/ui/oauth2");
|
||||||
|
|
||||||
let mut token_endpoint = self.inner.origin.clone();
|
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();
|
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();
|
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();
|
let mut userinfo_endpoint = self.inner.origin.clone();
|
||||||
userinfo_endpoint.set_path(&format!("/oauth2/openid/{name}/userinfo"));
|
userinfo_endpoint.set_path(&format!("/oauth2/openid/{name}/userinfo"));
|
||||||
|
@ -659,6 +721,20 @@ impl<'a> Oauth2ResourceServersWriteTransaction<'a> {
|
||||||
.cloned()
|
.cloned()
|
||||||
.collect();
|
.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 client_id = name.clone();
|
||||||
let rscfg = Oauth2RS {
|
let rscfg = Oauth2RS {
|
||||||
name,
|
name,
|
||||||
|
@ -687,6 +763,7 @@ impl<'a> Oauth2ResourceServersWriteTransaction<'a> {
|
||||||
prefer_short_username,
|
prefer_short_username,
|
||||||
type_,
|
type_,
|
||||||
has_custom_image,
|
has_custom_image,
|
||||||
|
device_authorization_endpoint,
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok((client_id, rscfg))
|
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
|
// submit the Modify::Remove. This way it's inserted into the entry changelog
|
||||||
// and when replication converges the session is actually removed.
|
// 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,
|
Attribute::OAuth2Session,
|
||||||
PartialValue::Refer(session_id),
|
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
|
let o2rs = self.get_client(&client_id)?;
|
||||||
// and other operations we need qs to be mut. But when we borrow oauth2rs here we
|
|
||||||
// cause multiple borrows to occur on struct members that freaks rust out. This *IS*
|
|
||||||
// safe however because no element of the search or write process calls the oauth2rs
|
|
||||||
// excepting for this idm layer within a single thread, meaning that stripping the
|
|
||||||
// lifetime here is safe since we are the sole accessor.
|
|
||||||
let o2rs: &Oauth2RS = unsafe {
|
|
||||||
let s = self.oauth2rs.inner.rs_set.get(&client_id).ok_or_else(|| {
|
|
||||||
admin_warn!("Invalid OAuth2 client_id");
|
|
||||||
Oauth2Error::AuthenticationRequired
|
|
||||||
})?;
|
|
||||||
&*(s as *const _)
|
|
||||||
};
|
|
||||||
|
|
||||||
// check the secret.
|
// check the secret.
|
||||||
let client_authentication_valid = match &o2rs.type_ {
|
let client_authentication_valid = match &o2rs.type_ {
|
||||||
|
@ -906,7 +971,7 @@ impl<'a> IdmServerProxyWriteTransaction<'a> {
|
||||||
redirect_uri,
|
redirect_uri,
|
||||||
code_verifier,
|
code_verifier,
|
||||||
} => self.check_oauth2_token_exchange_authorization_code(
|
} => self.check_oauth2_token_exchange_authorization_code(
|
||||||
o2rs,
|
&o2rs,
|
||||||
code,
|
code,
|
||||||
redirect_uri,
|
redirect_uri,
|
||||||
code_verifier.as_deref(),
|
code_verifier.as_deref(),
|
||||||
|
@ -914,7 +979,7 @@ impl<'a> IdmServerProxyWriteTransaction<'a> {
|
||||||
),
|
),
|
||||||
GrantTypeReq::ClientCredentials { scope } => {
|
GrantTypeReq::ClientCredentials { scope } => {
|
||||||
if client_authentication_valid {
|
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 {
|
} else {
|
||||||
security_info!(
|
security_info!(
|
||||||
"Unable to proceed with client credentials grant unless client authentication is provided and valid"
|
"Unable to proceed with client credentials grant unless client authentication is provided and valid"
|
||||||
|
@ -925,9 +990,91 @@ impl<'a> IdmServerProxyWriteTransaction<'a> {
|
||||||
GrantTypeReq::RefreshToken {
|
GrantTypeReq::RefreshToken {
|
||||||
refresh_token,
|
refresh_token,
|
||||||
scope,
|
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)]
|
#[instrument(level = "debug", skip_all)]
|
||||||
pub fn check_oauth2_authorise_permit(
|
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!
|
// 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
|
// 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
|
// 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);
|
return Err(Oauth2Error::InvalidRequest);
|
||||||
}
|
}
|
||||||
} else if require_pkce {
|
} else if o2rs.require_pkce() {
|
||||||
security_info!(
|
security_info!(
|
||||||
"PKCE code verification failed - no code challenge present in PKCE enforced mode"
|
"PKCE code verification failed - no code challenge present in PKCE enforced mode"
|
||||||
);
|
);
|
||||||
|
@ -1607,16 +1749,12 @@ impl<'a> IdmServerProxyWriteTransaction<'a> {
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// check the secret.
|
// check the secret.
|
||||||
match &o2rs.type_ {
|
if let OauthRSType::Basic { authz_secret, .. } = &o2rs.type_ {
|
||||||
OauthRSType::Basic { authz_secret, .. } => {
|
if o2rs.is_basic() && authz_secret != &secret {
|
||||||
if authz_secret != &secret {
|
security_info!("Invalid OAuth2 secret for client_id={}", client_id);
|
||||||
security_info!("Invalid OAuth2 client_id secret");
|
|
||||||
return Err(OperationError::InvalidSessionState);
|
return Err(OperationError::InvalidSessionState);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Relies on the token to be valid.
|
|
||||||
OauthRSType::Public { .. } => {}
|
|
||||||
};
|
|
||||||
|
|
||||||
o2rs.token_fernet
|
o2rs.token_fernet
|
||||||
.decrypt(token)
|
.decrypt(token)
|
||||||
|
@ -1733,13 +1871,8 @@ impl<'a> IdmServerProxyReadTransaction<'a> {
|
||||||
return Err(Oauth2Error::InvalidOrigin);
|
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 {
|
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!");
|
security_info!(?o2rs.name, "Insecure rs configuration - pkce is not enforced, but rs is requesting it!");
|
||||||
}
|
}
|
||||||
// CodeChallengeMethod must be S256
|
// CodeChallengeMethod must be S256
|
||||||
|
@ -1748,7 +1881,7 @@ impl<'a> IdmServerProxyReadTransaction<'a> {
|
||||||
return Err(Oauth2Error::InvalidRequest);
|
return Err(Oauth2Error::InvalidRequest);
|
||||||
}
|
}
|
||||||
Some(pkce_request.code_challenge.clone())
|
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.");
|
security_error!(?o2rs.name, "No PKCE code challenge was provided with client in enforced PKCE mode.");
|
||||||
return Err(Oauth2Error::InvalidRequest);
|
return Err(Oauth2Error::InvalidRequest);
|
||||||
} else {
|
} else {
|
||||||
|
@ -2387,12 +2520,7 @@ impl<'a> IdmServerProxyReadTransaction<'a> {
|
||||||
|
|
||||||
let service_documentation = Some(URL_SERVICE_DOCUMENTATION.clone());
|
let service_documentation = Some(URL_SERVICE_DOCUMENTATION.clone());
|
||||||
|
|
||||||
let require_pkce = match &o2rs.type_ {
|
let code_challenge_methods_supported = if o2rs.require_pkce() {
|
||||||
OauthRSType::Basic { enable_pkce, .. } => *enable_pkce,
|
|
||||||
OauthRSType::Public { .. } => true,
|
|
||||||
};
|
|
||||||
|
|
||||||
let code_challenge_methods_supported = if require_pkce {
|
|
||||||
vec![PkceAlg::S256]
|
vec![PkceAlg::S256]
|
||||||
} else {
|
} else {
|
||||||
Vec::with_capacity(0)
|
Vec::with_capacity(0)
|
||||||
|
@ -2444,7 +2572,11 @@ impl<'a> IdmServerProxyReadTransaction<'a> {
|
||||||
let scopes_supported = Some(o2rs.scopes_supported.iter().cloned().collect());
|
let scopes_supported = Some(o2rs.scopes_supported.iter().cloned().collect());
|
||||||
let response_types_supported = vec![ResponseType::Code];
|
let response_types_supported = vec![ResponseType::Code];
|
||||||
let response_modes_supported = vec![ResponseMode::Query];
|
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 grant_types_supported = vec![GrantType::AuthorisationCode];
|
||||||
|
|
||||||
let subject_types_supported = vec![SubjectType::Public];
|
let subject_types_supported = vec![SubjectType::Public];
|
||||||
|
|
||||||
let id_token_signing_alg_values_supported = match &o2rs.jws_signer {
|
let id_token_signing_alg_values_supported = match &o2rs.jws_signer {
|
||||||
|
@ -2463,12 +2595,7 @@ impl<'a> IdmServerProxyReadTransaction<'a> {
|
||||||
let claims_supported = None;
|
let claims_supported = None;
|
||||||
let service_documentation = Some(URL_SERVICE_DOCUMENTATION.clone());
|
let service_documentation = Some(URL_SERVICE_DOCUMENTATION.clone());
|
||||||
|
|
||||||
let require_pkce = match &o2rs.type_ {
|
let code_challenge_methods_supported = if o2rs.require_pkce() {
|
||||||
OauthRSType::Basic { enable_pkce, .. } => *enable_pkce,
|
|
||||||
OauthRSType::Public { .. } => true,
|
|
||||||
};
|
|
||||||
|
|
||||||
let code_challenge_methods_supported = if require_pkce {
|
|
||||||
vec![PkceAlg::S256]
|
vec![PkceAlg::S256]
|
||||||
} else {
|
} else {
|
||||||
Vec::with_capacity(0)
|
Vec::with_capacity(0)
|
||||||
|
@ -2534,6 +2661,7 @@ impl<'a> IdmServerProxyReadTransaction<'a> {
|
||||||
introspection_endpoint,
|
introspection_endpoint,
|
||||||
introspection_endpoint_auth_methods_supported,
|
introspection_endpoint_auth_methods_supported,
|
||||||
introspection_endpoint_auth_signing_alg_values_supported: None,
|
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(())
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use base64::{engine::general_purpose, Engine as _};
|
use base64::{engine::general_purpose, Engine as _};
|
||||||
use std::convert::TryFrom;
|
use std::convert::TryFrom;
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
use uri::{OAUTH2_TOKEN_INTROSPECT_ENDPOINT, OAUTH2_TOKEN_REVOKE_ENDPOINT};
|
||||||
|
|
||||||
use compact_jwt::{
|
use compact_jwt::{
|
||||||
compact::JwkUse, crypto::JwsRs256Verifier, dangernoverify::JwsDangerReleaseWithoutVerify,
|
compact::JwkUse, crypto::JwsRs256Verifier, dangernoverify::JwsDangerReleaseWithoutVerify,
|
||||||
|
@ -4271,7 +4442,12 @@ mod tests {
|
||||||
);
|
);
|
||||||
|
|
||||||
assert!(
|
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!(
|
assert!(
|
||||||
|
@ -4319,7 +4495,13 @@ mod tests {
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
discovery.revocation_endpoint
|
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!(
|
assert!(
|
||||||
discovery.revocation_endpoint_auth_methods_supported
|
discovery.revocation_endpoint_auth_methods_supported
|
||||||
|
@ -4331,7 +4513,13 @@ mod tests {
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
discovery.introspection_endpoint
|
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!(
|
assert!(
|
||||||
discovery.introspection_endpoint_auth_methods_supported
|
discovery.introspection_endpoint_auth_methods_supported
|
||||||
|
@ -4507,7 +4695,13 @@ mod tests {
|
||||||
// Extensions
|
// Extensions
|
||||||
assert!(
|
assert!(
|
||||||
discovery.revocation_endpoint
|
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!(
|
assert!(
|
||||||
discovery.revocation_endpoint_auth_methods_supported
|
discovery.revocation_endpoint_auth_methods_supported
|
||||||
|
@ -4519,7 +4713,13 @@ mod tests {
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
discovery.introspection_endpoint
|
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!(
|
assert!(
|
||||||
discovery.introspection_endpoint_auth_methods_supported
|
discovery.introspection_endpoint_auth_methods_supported
|
||||||
|
@ -6571,4 +6771,49 @@ mod tests {
|
||||||
|
|
||||||
assert!(idms_prox_write.commit().is_ok());
|
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,
|
pub uuid: Uuid,
|
||||||
// Perhaps later add aliases?
|
// Perhaps later add aliases?
|
||||||
pub description: String,
|
pub description: String,
|
||||||
|
/// This is a vec, not a single value
|
||||||
pub multivalue: bool,
|
pub multivalue: bool,
|
||||||
|
/// If the attribute must be unique amongst all other values of this attribute? Maybe?
|
||||||
pub unique: bool,
|
pub unique: bool,
|
||||||
|
/// TODO: What does this do?
|
||||||
pub phantom: bool,
|
pub phantom: bool,
|
||||||
|
/// TODO: What does this do?
|
||||||
pub sync_allowed: bool,
|
pub sync_allowed: bool,
|
||||||
|
|
||||||
|
/// If the value of this attribute get replicated to other servers
|
||||||
pub replicated: bool,
|
pub replicated: bool,
|
||||||
|
/// TODO: What does this do?
|
||||||
pub index: Vec<IndexType>,
|
pub index: Vec<IndexType>,
|
||||||
|
/// THe type of data that this attribute may hold.
|
||||||
pub syntax: SyntaxType,
|
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(
|
self.classes.insert(
|
||||||
EntryClass::AttributeType.into(),
|
EntryClass::AttributeType.into(),
|
||||||
SchemaClass {
|
SchemaClass {
|
||||||
|
|
|
@ -634,6 +634,34 @@ impl<'a> QueryServerWriteTransaction<'a> {
|
||||||
|
|
||||||
// =========== Apply changes ==============
|
// =========== 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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -95,6 +95,10 @@ impl DomainInfo {
|
||||||
pub fn image(&self) -> Option<&ImageValue> {
|
pub fn image(&self) -> Option<&ImageValue> {
|
||||||
self.d_image.as_ref()
|
self.d_image.as_ref()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn has_custom_image(&self) -> bool {
|
||||||
|
self.d_image.is_some()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||||
|
|
|
@ -18,10 +18,12 @@ test = true
|
||||||
doctest = false
|
doctest = false
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = []
|
# default = ["dev-oauth2-device-flow"]
|
||||||
# Enables webdriver tests, you need to be running a webdriver server
|
# Enables webdriver tests, you need to be running a webdriver server
|
||||||
webdriver = []
|
webdriver = []
|
||||||
|
|
||||||
|
dev-oauth2-device-flow = []
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
hyper-tls = { workspace = true }
|
hyper-tls = { workspace = true }
|
||||||
http = { workspace = true }
|
http = { workspace = true }
|
||||||
|
@ -57,7 +59,9 @@ escargot = "0.5.12"
|
||||||
# used for webdriver testing
|
# used for webdriver testing
|
||||||
fantoccini = { version = "0.21.2" }
|
fantoccini = { version = "0.21.2" }
|
||||||
futures = { workspace = true }
|
futures = { workspace = true }
|
||||||
oauth2_ext = { workspace = true, default-features = false }
|
oauth2_ext = { workspace = true, default-features = false, features = [
|
||||||
|
"reqwest",
|
||||||
|
] }
|
||||||
openssl = { workspace = true }
|
openssl = { workspace = true }
|
||||||
petgraph = { version = "0.6.4", features = ["serde", "serde-1"] }
|
petgraph = { version = "0.6.4", features = ["serde", "serde-1"] }
|
||||||
serde_json = { workspace = true }
|
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_USERNAME: &str = "krab_test_user";
|
||||||
pub const NOT_ADMIN_TEST_PASSWORD: &str = "eicieY7ahchaoCh0eeTa";
|
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 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;
|
pub use testkit_macros::test;
|
||||||
use tracing::trace;
|
use tracing::trace;
|
||||||
|
|
||||||
|
@ -382,3 +389,28 @@ pub async fn login_put_admin_idm_admins(rsclient: &KanidmClient) {
|
||||||
.await
|
.await
|
||||||
.expect("Failed to add admin user to idm_admins")
|
.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 oauth2_ext::PkceCodeChallenge;
|
||||||
use reqwest::header::{HeaderValue, CONTENT_TYPE};
|
use reqwest::header::{HeaderValue, CONTENT_TYPE};
|
||||||
use reqwest::StatusCode;
|
use reqwest::StatusCode;
|
||||||
|
use uri::{OAUTH2_TOKEN_ENDPOINT, OAUTH2_TOKEN_INTROSPECT_ENDPOINT, OAUTH2_TOKEN_REVOKE_ENDPOINT};
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
use kanidm_client::KanidmClient;
|
use kanidm_client::KanidmClient;
|
||||||
use kanidmd_testkit::ADMIN_TEST_PASSWORD;
|
use kanidmd_testkit::{
|
||||||
|
assert_no_cache, ADMIN_TEST_PASSWORD, ADMIN_TEST_USER, NOT_ADMIN_TEST_EMAIL,
|
||||||
macro_rules! assert_no_cache {
|
NOT_ADMIN_TEST_PASSWORD, NOT_ADMIN_TEST_USERNAME, TEST_INTEGRATION_RS_DISPLAY,
|
||||||
($response:expr) => {{
|
TEST_INTEGRATION_RS_GROUP_ALL, TEST_INTEGRATION_RS_ID, TEST_INTEGRATION_RS_REDIRECT_URL,
|
||||||
// Check we have correct nocache headers.
|
TEST_INTEGRATION_RS_URL,
|
||||||
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";
|
|
||||||
|
|
||||||
#[kanidmd_testkit::test]
|
#[kanidmd_testkit::test]
|
||||||
async fn test_oauth2_openid_basic_flow(rsclient: KanidmClient) {
|
async fn test_oauth2_openid_basic_flow(rsclient: KanidmClient) {
|
||||||
let res = rsclient
|
let res = rsclient
|
||||||
.auth_simple_password("admin", ADMIN_TEST_PASSWORD)
|
.auth_simple_password(ADMIN_TEST_USER, ADMIN_TEST_PASSWORD)
|
||||||
.await;
|
.await;
|
||||||
assert!(res.is_ok());
|
assert!(res.is_ok());
|
||||||
|
|
||||||
|
@ -71,39 +47,42 @@ async fn test_oauth2_openid_basic_flow(rsclient: KanidmClient) {
|
||||||
rsclient
|
rsclient
|
||||||
.idm_oauth2_client_add_origin(
|
.idm_oauth2_client_add_origin(
|
||||||
TEST_INTEGRATION_RS_ID,
|
TEST_INTEGRATION_RS_ID,
|
||||||
&Url::parse(TEST_INTEGRATION_REDIRECT_URL).expect("Invalid URL"),
|
&Url::parse(TEST_INTEGRATION_RS_REDIRECT_URL).expect("Invalid URL"),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect("Failed to update oauth2 config");
|
.expect("Failed to update oauth2 config");
|
||||||
|
|
||||||
// Extend the admin account with extended details for openid claims.
|
// Extend the admin account with extended details for openid claims.
|
||||||
rsclient
|
rsclient
|
||||||
.idm_person_account_create("oauth_test", "oauth_test")
|
.idm_person_account_create(NOT_ADMIN_TEST_USERNAME, NOT_ADMIN_TEST_USERNAME)
|
||||||
.await
|
.await
|
||||||
.expect("Failed to create account details");
|
.expect("Failed to create account details");
|
||||||
|
|
||||||
rsclient
|
rsclient
|
||||||
.idm_person_account_set_attr(
|
.idm_person_account_set_attr(
|
||||||
"oauth_test",
|
NOT_ADMIN_TEST_USERNAME,
|
||||||
Attribute::Mail.as_ref(),
|
Attribute::Mail.as_ref(),
|
||||||
&["oauth_test@localhost"],
|
&[NOT_ADMIN_TEST_EMAIL],
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect("Failed to create account mail");
|
.expect("Failed to create account mail");
|
||||||
|
|
||||||
rsclient
|
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
|
.await
|
||||||
.expect("Failed to configure account password");
|
.expect("Failed to configure account password");
|
||||||
|
|
||||||
rsclient
|
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
|
.await
|
||||||
.expect("Failed to update oauth2 config");
|
.expect("Failed to update oauth2 config");
|
||||||
|
|
||||||
rsclient
|
rsclient
|
||||||
.idm_oauth2_rs_update_scope_map(
|
.idm_oauth2_rs_update_scope_map(
|
||||||
"test_integration",
|
TEST_INTEGRATION_RS_ID,
|
||||||
IDM_ALL_ACCOUNTS.name,
|
IDM_ALL_ACCOUNTS.name,
|
||||||
vec![OAUTH2_SCOPE_READ, OAUTH2_SCOPE_EMAIL, OAUTH2_SCOPE_OPENID],
|
vec![OAUTH2_SCOPE_READ, OAUTH2_SCOPE_EMAIL, OAUTH2_SCOPE_OPENID],
|
||||||
)
|
)
|
||||||
|
@ -112,15 +91,15 @@ async fn test_oauth2_openid_basic_flow(rsclient: KanidmClient) {
|
||||||
|
|
||||||
rsclient
|
rsclient
|
||||||
.idm_oauth2_rs_update_sup_scope_map(
|
.idm_oauth2_rs_update_sup_scope_map(
|
||||||
"test_integration",
|
TEST_INTEGRATION_RS_ID,
|
||||||
IDM_ALL_ACCOUNTS.name,
|
IDM_ALL_ACCOUNTS.name,
|
||||||
vec!["admin"],
|
vec![ADMIN_TEST_USER],
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect("Failed to update oauth2 scopes");
|
.expect("Failed to update oauth2 scopes");
|
||||||
|
|
||||||
let client_secret = rsclient
|
let client_secret = rsclient
|
||||||
.idm_oauth2_rs_get_basic_secret("test_integration")
|
.idm_oauth2_rs_get_basic_secret(TEST_INTEGRATION_RS_ID)
|
||||||
.await
|
.await
|
||||||
.ok()
|
.ok()
|
||||||
.flatten()
|
.flatten()
|
||||||
|
@ -129,7 +108,7 @@ async fn test_oauth2_openid_basic_flow(rsclient: KanidmClient) {
|
||||||
// Get our admin's auth token for our new client.
|
// Get our admin's auth token for our new client.
|
||||||
// We have to re-auth to update the mail field.
|
// We have to re-auth to update the mail field.
|
||||||
let res = rsclient
|
let res = rsclient
|
||||||
.auth_simple_password("oauth_test", ADMIN_TEST_PASSWORD)
|
.auth_simple_password(NOT_ADMIN_TEST_USERNAME, NOT_ADMIN_TEST_PASSWORD)
|
||||||
.await;
|
.await;
|
||||||
assert!(res.is_ok());
|
assert!(res.is_ok());
|
||||||
let oauth_test_uat = rsclient
|
let oauth_test_uat = rsclient
|
||||||
|
@ -205,7 +184,10 @@ async fn test_oauth2_openid_basic_flow(rsclient: KanidmClient) {
|
||||||
rsclient.make_url("/ui/oauth2")
|
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!(
|
assert!(
|
||||||
discovery.userinfo_endpoint
|
discovery.userinfo_endpoint
|
||||||
|
@ -248,11 +230,11 @@ async fn test_oauth2_openid_basic_flow(rsclient: KanidmClient) {
|
||||||
.bearer_auth(oauth_test_uat.clone())
|
.bearer_auth(oauth_test_uat.clone())
|
||||||
.query(&[
|
.query(&[
|
||||||
("response_type", "code"),
|
("response_type", "code"),
|
||||||
("client_id", "test_integration"),
|
("client_id", TEST_INTEGRATION_RS_ID),
|
||||||
("state", "YWJjZGVm"),
|
("state", "YWJjZGVm"),
|
||||||
("code_challenge", pkce_code_challenge.as_str()),
|
("code_challenge", pkce_code_challenge.as_str()),
|
||||||
("code_challenge_method", "S256"),
|
("code_challenge_method", "S256"),
|
||||||
("redirect_uri", TEST_INTEGRATION_REDIRECT_URL),
|
("redirect_uri", TEST_INTEGRATION_RS_REDIRECT_URL),
|
||||||
("scope", "email read openid"),
|
("scope", "email read openid"),
|
||||||
])
|
])
|
||||||
.send()
|
.send()
|
||||||
|
@ -274,6 +256,7 @@ async fn test_oauth2_openid_basic_flow(rsclient: KanidmClient) {
|
||||||
} = consent_req
|
} = consent_req
|
||||||
{
|
{
|
||||||
// Note the supplemental scope here (admin)
|
// Note the supplemental scope here (admin)
|
||||||
|
dbg!(&scopes);
|
||||||
assert!(scopes.contains("admin"));
|
assert!(scopes.contains("admin"));
|
||||||
consent_token
|
consent_token
|
||||||
} else {
|
} else {
|
||||||
|
@ -319,14 +302,14 @@ async fn test_oauth2_openid_basic_flow(rsclient: KanidmClient) {
|
||||||
|
|
||||||
let form_req: AccessTokenRequest = GrantTypeReq::AuthorizationCode {
|
let form_req: AccessTokenRequest = GrantTypeReq::AuthorizationCode {
|
||||||
code: code.to_string(),
|
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()),
|
code_verifier: Some(pkce_code_verifier.secret().clone()),
|
||||||
}
|
}
|
||||||
.into();
|
.into();
|
||||||
|
|
||||||
let response = client
|
let response = client
|
||||||
.post(rsclient.make_url("/oauth2/token"))
|
.post(rsclient.make_url(OAUTH2_TOKEN_ENDPOINT))
|
||||||
.basic_auth("test_integration", Some(client_secret.clone()))
|
.basic_auth(TEST_INTEGRATION_RS_ID, Some(client_secret.clone()))
|
||||||
.form(&form_req)
|
.form(&form_req)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
|
@ -361,8 +344,8 @@ async fn test_oauth2_openid_basic_flow(rsclient: KanidmClient) {
|
||||||
};
|
};
|
||||||
|
|
||||||
let response = client
|
let response = client
|
||||||
.post(rsclient.make_url("/oauth2/token/introspect"))
|
.post(rsclient.make_url(OAUTH2_TOKEN_INTROSPECT_ENDPOINT))
|
||||||
.basic_auth("test_integration", Some(client_secret.clone()))
|
.basic_auth(TEST_INTEGRATION_RS_ID, Some(client_secret.clone()))
|
||||||
.form(&intr_request)
|
.form(&intr_request)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
|
@ -382,14 +365,17 @@ async fn test_oauth2_openid_basic_flow(rsclient: KanidmClient) {
|
||||||
|
|
||||||
assert!(tir.active);
|
assert!(tir.active);
|
||||||
assert!(tir.scope.is_some());
|
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("oauth_test@localhost"));
|
assert_eq!(
|
||||||
|
tir.username.as_deref(),
|
||||||
|
Some(format!("{}@localhost", NOT_ADMIN_TEST_USERNAME).as_str())
|
||||||
|
);
|
||||||
assert_eq!(tir.token_type, Some(AccessTokenType::Bearer));
|
assert_eq!(tir.token_type, Some(AccessTokenType::Bearer));
|
||||||
assert!(tir.exp.is_some());
|
assert!(tir.exp.is_some());
|
||||||
assert!(tir.iat.is_some());
|
assert!(tir.iat.is_some());
|
||||||
assert!(tir.nbf.is_some());
|
assert!(tir.nbf.is_some());
|
||||||
assert!(tir.sub.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.iss.is_none());
|
||||||
assert!(tir.jti.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")
|
rsclient.make_url("/oauth2/openid/test_integration")
|
||||||
);
|
);
|
||||||
eprintln!("{:?}", oidc.s_claims.email);
|
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));
|
assert_eq!(oidc.s_claims.email_verified, Some(true));
|
||||||
|
|
||||||
let response = client
|
let response = client
|
||||||
|
@ -446,8 +432,8 @@ async fn test_oauth2_openid_basic_flow(rsclient: KanidmClient) {
|
||||||
.into();
|
.into();
|
||||||
|
|
||||||
let response = client
|
let response = client
|
||||||
.post(rsclient.make_url("/oauth2/token"))
|
.post(rsclient.make_url(OAUTH2_TOKEN_ENDPOINT))
|
||||||
.basic_auth("test_integration", Some(client_secret.clone()))
|
.basic_auth(TEST_INTEGRATION_RS_ID, Some(client_secret.clone()))
|
||||||
.form(&form_req)
|
.form(&form_req)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
|
@ -467,8 +453,8 @@ async fn test_oauth2_openid_basic_flow(rsclient: KanidmClient) {
|
||||||
};
|
};
|
||||||
|
|
||||||
let response = client
|
let response = client
|
||||||
.post(rsclient.make_url("/oauth2/token/introspect"))
|
.post(rsclient.make_url(OAUTH2_TOKEN_INTROSPECT_ENDPOINT))
|
||||||
.basic_auth("test_integration", Some(client_secret))
|
.basic_auth(TEST_INTEGRATION_RS_ID, Some(client_secret))
|
||||||
.form(&intr_request)
|
.form(&intr_request)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
|
@ -483,17 +469,17 @@ async fn test_oauth2_openid_basic_flow(rsclient: KanidmClient) {
|
||||||
|
|
||||||
assert!(tir.active);
|
assert!(tir.active);
|
||||||
assert!(tir.scope.is_some());
|
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.username.as_deref(), Some("test_integration@localhost"));
|
||||||
assert_eq!(tir.token_type, Some(AccessTokenType::Bearer));
|
assert_eq!(tir.token_type, Some(AccessTokenType::Bearer));
|
||||||
|
|
||||||
// auth back with admin so we can test deleting things
|
// auth back with admin so we can test deleting things
|
||||||
let res = rsclient
|
let res = rsclient
|
||||||
.auth_simple_password("admin", ADMIN_TEST_PASSWORD)
|
.auth_simple_password(ADMIN_TEST_USER, ADMIN_TEST_PASSWORD)
|
||||||
.await;
|
.await;
|
||||||
assert!(res.is_ok());
|
assert!(res.is_ok());
|
||||||
rsclient
|
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
|
.await
|
||||||
.expect("Failed to update oauth2 scopes");
|
.expect("Failed to update oauth2 scopes");
|
||||||
}
|
}
|
||||||
|
@ -501,7 +487,7 @@ async fn test_oauth2_openid_basic_flow(rsclient: KanidmClient) {
|
||||||
#[kanidmd_testkit::test]
|
#[kanidmd_testkit::test]
|
||||||
async fn test_oauth2_openid_public_flow(rsclient: KanidmClient) {
|
async fn test_oauth2_openid_public_flow(rsclient: KanidmClient) {
|
||||||
let res = rsclient
|
let res = rsclient
|
||||||
.auth_simple_password("admin", ADMIN_TEST_PASSWORD)
|
.auth_simple_password(ADMIN_TEST_USER, ADMIN_TEST_PASSWORD)
|
||||||
.await;
|
.await;
|
||||||
assert!(res.is_ok());
|
assert!(res.is_ok());
|
||||||
|
|
||||||
|
@ -518,39 +504,42 @@ async fn test_oauth2_openid_public_flow(rsclient: KanidmClient) {
|
||||||
rsclient
|
rsclient
|
||||||
.idm_oauth2_client_add_origin(
|
.idm_oauth2_client_add_origin(
|
||||||
TEST_INTEGRATION_RS_ID,
|
TEST_INTEGRATION_RS_ID,
|
||||||
&Url::parse(TEST_INTEGRATION_REDIRECT_URL).expect("Invalid URL"),
|
&Url::parse(TEST_INTEGRATION_RS_REDIRECT_URL).expect("Invalid URL"),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect("Failed to update oauth2 config");
|
.expect("Failed to update oauth2 config");
|
||||||
|
|
||||||
// Extend the admin account with extended details for openid claims.
|
// Extend the admin account with extended details for openid claims.
|
||||||
rsclient
|
rsclient
|
||||||
.idm_person_account_create("oauth_test", "oauth_test")
|
.idm_person_account_create(NOT_ADMIN_TEST_USERNAME, NOT_ADMIN_TEST_USERNAME)
|
||||||
.await
|
.await
|
||||||
.expect("Failed to create account details");
|
.expect("Failed to create account details");
|
||||||
|
|
||||||
rsclient
|
rsclient
|
||||||
.idm_person_account_set_attr(
|
.idm_person_account_set_attr(
|
||||||
"oauth_test",
|
NOT_ADMIN_TEST_USERNAME,
|
||||||
Attribute::Mail.as_ref(),
|
Attribute::Mail.as_ref(),
|
||||||
&["oauth_test@localhost"],
|
&[NOT_ADMIN_TEST_EMAIL],
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect("Failed to create account mail");
|
.expect("Failed to create account mail");
|
||||||
|
|
||||||
rsclient
|
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
|
.await
|
||||||
.expect("Failed to configure account password");
|
.expect("Failed to configure account password");
|
||||||
|
|
||||||
rsclient
|
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
|
.await
|
||||||
.expect("Failed to update oauth2 config");
|
.expect("Failed to update oauth2 config");
|
||||||
|
|
||||||
rsclient
|
rsclient
|
||||||
.idm_oauth2_rs_update_scope_map(
|
.idm_oauth2_rs_update_scope_map(
|
||||||
"test_integration",
|
TEST_INTEGRATION_RS_ID,
|
||||||
IDM_ALL_ACCOUNTS.name,
|
IDM_ALL_ACCOUNTS.name,
|
||||||
vec![OAUTH2_SCOPE_READ, OAUTH2_SCOPE_EMAIL, OAUTH2_SCOPE_OPENID],
|
vec![OAUTH2_SCOPE_READ, OAUTH2_SCOPE_EMAIL, OAUTH2_SCOPE_OPENID],
|
||||||
)
|
)
|
||||||
|
@ -559,9 +548,9 @@ async fn test_oauth2_openid_public_flow(rsclient: KanidmClient) {
|
||||||
|
|
||||||
rsclient
|
rsclient
|
||||||
.idm_oauth2_rs_update_sup_scope_map(
|
.idm_oauth2_rs_update_sup_scope_map(
|
||||||
"test_integration",
|
TEST_INTEGRATION_RS_ID,
|
||||||
IDM_ALL_ACCOUNTS.name,
|
IDM_ALL_ACCOUNTS.name,
|
||||||
vec!["admin"],
|
vec![ADMIN_TEST_USER],
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect("Failed to update oauth2 scopes");
|
.expect("Failed to update oauth2 scopes");
|
||||||
|
@ -569,7 +558,7 @@ async fn test_oauth2_openid_public_flow(rsclient: KanidmClient) {
|
||||||
// Add a custom claim map.
|
// Add a custom claim map.
|
||||||
rsclient
|
rsclient
|
||||||
.idm_oauth2_rs_update_claim_map(
|
.idm_oauth2_rs_update_claim_map(
|
||||||
"test_integration",
|
TEST_INTEGRATION_RS_ID,
|
||||||
"test_claim",
|
"test_claim",
|
||||||
IDM_ALL_ACCOUNTS.name,
|
IDM_ALL_ACCOUNTS.name,
|
||||||
&["claim_a".to_string(), "claim_b".to_string()],
|
&["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
|
// Set an alternate join
|
||||||
rsclient
|
rsclient
|
||||||
.idm_oauth2_rs_update_claim_map_join(
|
.idm_oauth2_rs_update_claim_map_join(
|
||||||
"test_integration",
|
TEST_INTEGRATION_RS_ID,
|
||||||
"test_claim",
|
"test_claim",
|
||||||
Oauth2ClaimMapJoin::Ssv,
|
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.
|
// Get our admin's auth token for our new client.
|
||||||
// We have to re-auth to update the mail field.
|
// We have to re-auth to update the mail field.
|
||||||
let res = rsclient
|
let res = rsclient
|
||||||
.auth_simple_password("oauth_test", ADMIN_TEST_PASSWORD)
|
.auth_simple_password(NOT_ADMIN_TEST_USERNAME, ADMIN_TEST_PASSWORD)
|
||||||
.await;
|
.await;
|
||||||
assert!(res.is_ok());
|
assert!(res.is_ok());
|
||||||
let oauth_test_uat = rsclient
|
let oauth_test_uat = rsclient
|
||||||
|
@ -639,11 +628,11 @@ async fn test_oauth2_openid_public_flow(rsclient: KanidmClient) {
|
||||||
.bearer_auth(oauth_test_uat.clone())
|
.bearer_auth(oauth_test_uat.clone())
|
||||||
.query(&[
|
.query(&[
|
||||||
("response_type", "code"),
|
("response_type", "code"),
|
||||||
("client_id", "test_integration"),
|
("client_id", TEST_INTEGRATION_RS_ID),
|
||||||
("state", "YWJjZGVm"),
|
("state", "YWJjZGVm"),
|
||||||
("code_challenge", pkce_code_challenge.as_str()),
|
("code_challenge", pkce_code_challenge.as_str()),
|
||||||
("code_challenge_method", "S256"),
|
("code_challenge_method", "S256"),
|
||||||
("redirect_uri", TEST_INTEGRATION_REDIRECT_URL),
|
("redirect_uri", TEST_INTEGRATION_RS_REDIRECT_URL),
|
||||||
("scope", "email read openid"),
|
("scope", "email read openid"),
|
||||||
])
|
])
|
||||||
.send()
|
.send()
|
||||||
|
@ -665,7 +654,7 @@ async fn test_oauth2_openid_public_flow(rsclient: KanidmClient) {
|
||||||
} = consent_req
|
} = consent_req
|
||||||
{
|
{
|
||||||
// Note the supplemental scope here (admin)
|
// Note the supplemental scope here (admin)
|
||||||
assert!(scopes.contains("admin"));
|
assert!(scopes.contains(ADMIN_TEST_USER));
|
||||||
consent_token
|
consent_token
|
||||||
} else {
|
} else {
|
||||||
unreachable!();
|
unreachable!();
|
||||||
|
@ -710,15 +699,15 @@ async fn test_oauth2_openid_public_flow(rsclient: KanidmClient) {
|
||||||
let form_req = AccessTokenRequest {
|
let form_req = AccessTokenRequest {
|
||||||
grant_type: GrantTypeReq::AuthorizationCode {
|
grant_type: GrantTypeReq::AuthorizationCode {
|
||||||
code: code.to_string(),
|
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()),
|
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,
|
client_secret: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let response = client
|
let response = client
|
||||||
.post(rsclient.make_url("/oauth2/token"))
|
.post(rsclient.make_url(OAUTH2_TOKEN_ENDPOINT))
|
||||||
.form(&form_req)
|
.form(&form_req)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
|
@ -750,7 +739,7 @@ async fn test_oauth2_openid_public_flow(rsclient: KanidmClient) {
|
||||||
rsclient.make_url("/oauth2/openid/test_integration")
|
rsclient.make_url("/oauth2/openid/test_integration")
|
||||||
);
|
);
|
||||||
eprintln!("{:?}", oidc.s_claims.email);
|
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));
|
assert_eq!(oidc.s_claims.email_verified, Some(true));
|
||||||
|
|
||||||
eprintln!("{:?}", oidc.claims);
|
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
|
// auth back with admin so we can test deleting things
|
||||||
let res = rsclient
|
let res = rsclient
|
||||||
.auth_simple_password("admin", ADMIN_TEST_PASSWORD)
|
.auth_simple_password(ADMIN_TEST_USER, ADMIN_TEST_PASSWORD)
|
||||||
.await;
|
.await;
|
||||||
assert!(res.is_ok());
|
assert!(res.is_ok());
|
||||||
rsclient
|
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
|
.await
|
||||||
.expect("Failed to update oauth2 scopes");
|
.expect("Failed to update oauth2 scopes");
|
||||||
}
|
}
|
||||||
|
@ -809,7 +798,7 @@ async fn test_oauth2_openid_public_flow(rsclient: KanidmClient) {
|
||||||
#[kanidmd_testkit::test]
|
#[kanidmd_testkit::test]
|
||||||
async fn test_oauth2_token_post_bad_bodies(rsclient: KanidmClient) {
|
async fn test_oauth2_token_post_bad_bodies(rsclient: KanidmClient) {
|
||||||
let res = rsclient
|
let res = rsclient
|
||||||
.auth_simple_password("admin", ADMIN_TEST_PASSWORD)
|
.auth_simple_password(ADMIN_TEST_USER, ADMIN_TEST_PASSWORD)
|
||||||
.await;
|
.await;
|
||||||
assert!(res.is_ok());
|
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
|
// test for a bad-body request on token
|
||||||
let response = client
|
let response = client
|
||||||
.post(rsclient.make_url("/oauth2/token"))
|
.post(rsclient.make_url(OAUTH2_TOKEN_ENDPOINT))
|
||||||
.form(&serde_json::json!({}))
|
.form(&serde_json::json!({}))
|
||||||
// .bearer_auth(atr.access_token.clone())
|
// .bearer_auth(atr.access_token.clone())
|
||||||
.send()
|
.send()
|
||||||
|
@ -832,7 +821,7 @@ async fn test_oauth2_token_post_bad_bodies(rsclient: KanidmClient) {
|
||||||
|
|
||||||
// test for a bad-auth request
|
// test for a bad-auth request
|
||||||
let response = client
|
let response = client
|
||||||
.post(rsclient.make_url("/oauth2/token/introspect"))
|
.post(rsclient.make_url(OAUTH2_TOKEN_INTROSPECT_ENDPOINT))
|
||||||
.form(&serde_json::json!({ "token": "lol" }))
|
.form(&serde_json::json!({ "token": "lol" }))
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
|
@ -844,7 +833,7 @@ async fn test_oauth2_token_post_bad_bodies(rsclient: KanidmClient) {
|
||||||
#[kanidmd_testkit::test]
|
#[kanidmd_testkit::test]
|
||||||
async fn test_oauth2_token_revoke_post(rsclient: KanidmClient) {
|
async fn test_oauth2_token_revoke_post(rsclient: KanidmClient) {
|
||||||
let res = rsclient
|
let res = rsclient
|
||||||
.auth_simple_password("admin", ADMIN_TEST_PASSWORD)
|
.auth_simple_password(ADMIN_TEST_USER, ADMIN_TEST_PASSWORD)
|
||||||
.await;
|
.await;
|
||||||
assert!(res.is_ok());
|
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
|
// test for a bad-body request on token
|
||||||
let response = client
|
let response = client
|
||||||
.post(rsclient.make_url("/oauth2/token/revoke"))
|
.post(rsclient.make_url(OAUTH2_TOKEN_REVOKE_ENDPOINT))
|
||||||
.form(&serde_json::json!({}))
|
.form(&serde_json::json!({}))
|
||||||
.bearer_auth("lolol")
|
.bearer_auth("lolol")
|
||||||
.send()
|
.send()
|
||||||
|
@ -867,7 +856,7 @@ async fn test_oauth2_token_revoke_post(rsclient: KanidmClient) {
|
||||||
|
|
||||||
// test for a invalid format request on token
|
// test for a invalid format request on token
|
||||||
let response = client
|
let response = client
|
||||||
.post(rsclient.make_url("/oauth2/token/revoke"))
|
.post(rsclient.make_url(OAUTH2_TOKEN_REVOKE_ENDPOINT))
|
||||||
.json("")
|
.json("")
|
||||||
.bearer_auth("lolol")
|
.bearer_auth("lolol")
|
||||||
.send()
|
.send()
|
||||||
|
@ -879,7 +868,7 @@ async fn test_oauth2_token_revoke_post(rsclient: KanidmClient) {
|
||||||
|
|
||||||
// test for a bad-body request on token
|
// test for a bad-body request on token
|
||||||
let response = client
|
let response = client
|
||||||
.post(rsclient.make_url("/oauth2/token/revoke"))
|
.post(rsclient.make_url(OAUTH2_TOKEN_REVOKE_ENDPOINT))
|
||||||
.form(&serde_json::json!({}))
|
.form(&serde_json::json!({}))
|
||||||
.bearer_auth("Basic lolol")
|
.bearer_auth("Basic lolol")
|
||||||
.send()
|
.send()
|
||||||
|
@ -890,7 +879,7 @@ async fn test_oauth2_token_revoke_post(rsclient: KanidmClient) {
|
||||||
|
|
||||||
// test for a bad-body request on token
|
// test for a bad-body request on token
|
||||||
let response = client
|
let response = client
|
||||||
.post(rsclient.make_url("/oauth2/token/revoke"))
|
.post(rsclient.make_url(OAUTH2_TOKEN_REVOKE_ENDPOINT))
|
||||||
.body(serde_json::json!({}).to_string())
|
.body(serde_json::json!({}).to_string())
|
||||||
.bearer_auth("Basic lolol")
|
.bearer_auth("Basic lolol")
|
||||||
.send()
|
.send()
|
||||||
|
|
|
@ -12,8 +12,12 @@ homepage = { workspace = true }
|
||||||
repository = { workspace = true }
|
repository = { workspace = true }
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["unix"]
|
default = [
|
||||||
|
"unix",
|
||||||
|
# "dev-oauth2-device-flow"
|
||||||
|
]
|
||||||
unix = []
|
unix = []
|
||||||
|
dev-oauth2-device-flow = []
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
name = "kanidm_cli"
|
name = "kanidm_cli"
|
||||||
|
|
|
@ -31,6 +31,12 @@ impl Oauth2Opt {
|
||||||
Oauth2Opt::DisableLegacyCrypto(nopt) => nopt.copt.debug,
|
Oauth2Opt::DisableLegacyCrypto(nopt) => nopt.copt.debug,
|
||||||
Oauth2Opt::PreferShortUsername(nopt) => nopt.copt.debug,
|
Oauth2Opt::PreferShortUsername(nopt) => nopt.copt.debug,
|
||||||
Oauth2Opt::PreferSPNUsername(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::CreateBasic { copt, .. }
|
||||||
| Oauth2Opt::CreatePublic { copt, .. }
|
| Oauth2Opt::CreatePublic { copt, .. }
|
||||||
| Oauth2Opt::UpdateClaimMap { copt, .. }
|
| Oauth2Opt::UpdateClaimMap { copt, .. }
|
||||||
|
@ -47,6 +53,30 @@ impl Oauth2Opt {
|
||||||
|
|
||||||
pub async fn exec(&self) {
|
pub async fn exec(&self) {
|
||||||
match 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) => {
|
Oauth2Opt::List(copt) => {
|
||||||
let client = copt.to_client(OpType::Read).await;
|
let client = copt.to_client(OpType::Read).await;
|
||||||
match client.idm_oauth2_rs_list().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
|
/// Use the 'spn' attribute instead of 'name' for the preferred_username
|
||||||
#[clap(name = "prefer-spn-username")]
|
#[clap(name = "prefer-spn-username")]
|
||||||
PreferSPNUsername(Named),
|
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)]
|
#[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