From 4e0ef6f8744d8557dea0a161e8768bc3d319c43b Mon Sep 17 00:00:00 2001 From: Artem Goncharov Date: Tue, 19 Aug 2025 20:04:33 +0200 Subject: [PATCH] feat: Add JWT login functionality --- .github/workflows/functional.yml | 19 + .github/workflows/linters.yml | 2 +- Cargo.lock | 2 + Cargo.toml | 7 +- doc/src/federation.md | 113 +++- src/api/common.rs | 2 +- src/api/error.rs | 2 + src/api/mod.rs | 13 +- src/api/v4/federation/auth.rs | 2 +- src/api/v4/federation/error.rs | 35 ++ src/api/v4/federation/identity_provider.rs | 2 + src/api/v4/federation/jwt.rs | 493 ++++++++++++++++++ src/api/v4/federation/mapping.rs | 4 + src/api/v4/federation/mod.rs | 2 + src/api/v4/federation/types/auth.rs | 13 + .../v4/federation/types/identity_provider.rs | 19 + src/api/v4/federation/types/mapping.rs | 142 ++++- src/db/entity.rs | 1 + src/db/entity/federated_identity_provider.rs | 1 + src/db/entity/federated_mapping.rs | 2 + src/db/entity/sea_orm_active_enums.rs | 13 + src/db_migration/m20250414_000001_idp.rs | 59 ++- src/federation/backends/error.rs | 27 +- src/federation/backends/sql.rs | 16 +- .../backends/sql/identity_provider.rs | 38 +- src/federation/backends/sql/mapping.rs | 57 +- src/federation/error.rs | 32 +- src/federation/mod.rs | 70 ++- src/federation/types/identity_provider.rs | 6 + src/federation/types/mapping.rs | 57 +- src/token/federation_unscoped.rs | 1 - tests/github/keystone_utils.rs | 150 ++++++ tests/github/main.rs | 49 ++ tests/keycloak/keycloak_utils.rs | 54 +- tests/keycloak/keystone_utils.rs | 95 +++- tests/keycloak/main.rs | 46 +- 36 files changed, 1545 insertions(+), 101 deletions(-) create mode 100644 src/api/v4/federation/jwt.rs create mode 100644 tests/github/keystone_utils.rs create mode 100644 tests/github/main.rs diff --git a/.github/workflows/functional.yml b/.github/workflows/functional.yml index 8f467ced..b72436b7 100644 --- a/.github/workflows/functional.yml +++ b/.github/workflows/functional.yml @@ -214,6 +214,10 @@ jobs: runs-on: ubuntu-latest needs: - build + permissions: + id-token: write + contents: read + packages: read env: KEYCLOAK_URL: http://localhost:8082 services: @@ -316,6 +320,21 @@ jobs: BROWSERDRIVER_PORT: 4444 run: cargo test --test keycloak + - name: Get GitHub JWT token + id: get_token + run: | + TOKEN_JSON=$(curl -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ + "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=https://github.com") + + TOKEN=$(echo $TOKEN_JSON | jq -r .value) + echo "token=$TOKEN" >> $GITHUB_OUTPUT + + - name: Run github tests + env: + GITHUB_JWT: ${{ steps.get_token.outputs.token }} + GITHUB_SUB: "repo:gtema/keystone:pull_request" + run: cargo test --test github -- --nocapture + - name: Dump OPA log if: failure() run: docker logs opa diff --git a/.github/workflows/linters.yml b/.github/workflows/linters.yml index 0b7c5a07..dec94bc9 100644 --- a/.github/workflows/linters.yml +++ b/.github/workflows/linters.yml @@ -20,7 +20,7 @@ concurrency: env: CARGO_TERM_COLOR: always - rust_min: 1.85.0 + rust_min: 1.89.0 jobs: rustfmt: diff --git a/Cargo.lock b/Cargo.lock index 4c437c58..7483a61f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3808,6 +3808,7 @@ dependencies = [ "bytes", "encoding_rs", "futures-core", + "futures-util", "h2", "http", "http-body", @@ -3819,6 +3820,7 @@ dependencies = [ "js-sys", "log", "mime", + "mime_guess", "native-tls", "percent-encoding", "pin-project-lite", diff --git a/Cargo.toml b/Cargo.toml index bed92348..9556ebc4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -69,7 +69,7 @@ hyper = { version = "1.7", features = ["http1"] } hyper-util = { version = "0.1", features = ["tokio", "http1"] } keycloak = { version = "26.2" } mockall = { version = "0.13" } -reqwest = { version = "0.12", features = ["json"] } +reqwest = { version = "0.12", features = ["json", "multipart"] } sea-orm = { version = "1.1", features = ["mock"]} serde_urlencoded = { version = "0.7" } tempfile = { version = "3.21" } @@ -96,3 +96,8 @@ test = false name = "keycloak" path = "tests/keycloak/main.rs" test = false + +[[test]] +name = "github" +path = "tests/github/main.rs" +test = false diff --git a/doc/src/federation.md b/doc/src/federation.md index 4891cc9f..e4969698 100644 --- a/doc/src/federation.md +++ b/doc/src/federation.md @@ -58,7 +58,116 @@ sequenceDiagram ## Authenticating with the JWT -This is a work in progress and is not implemented yet +It is possible to authenticate with the JWT token issued by the federated IdP. +More precisely it is possible to exchange a valid JWT for the Keystone token. +There are few different use scenarios that are covered. + +Since the JWT was issued without any knowledge of the Keystone scopes it +becomes hard to control scope. In the case of real human login the Keystone may +issue unscoped token allowing user to further rescope it. In the case of the +workflow federation that introduces a potential security vulnerability. As such +in this scenario the attribute mapping is responsible to fix the scope. + +Login request looks following: + +```console + + curl https://keystone/v4/federation/identity_providers/${IDP}/jwt -X POST -H "Authorization: bearer ${JWT}" -H "openstack-mapping: ${MAPPING_NAME}" +``` + +### Regular user obtains JWT (ID token) at the IdP and presents it to Keystone + +In this scenario a real user (human) is obtaining the valid JWT from the IDP +using any available method without any communication with Keystone. This may +use authorization code grant, password grant, device grant or any other enabled +method. This JWT is then presented to the Keystone and an explicitly requested +attribute mapping converts the JWT claims to the Keystone internal +representation after verifying the JWT signature, expiration and further +restricted bound claims. + +### Workflow federation + +Automated workflows (Zuul job, GitHub workflows, GitLab CI, etc) are typical +workloads not being bound to any specific user and are more regularly +considered being triggered by certain services. Such workflows are usually in +possession of a JWT token issued by the service owned IdP. Keystone allows +exchange of such tokens to the regular Keystone token after validating token +issuer signature, expiration and applying the configured attribute mapping. +Since in such case there is no real human the mapping also need to be +configured slightly different. + +- It is strongly advised the attribute mapping must fill `token_user_id`, + `token_project_id` (and soon `token_role_ids`). This allows strong control of + which technical account (soon a concept of service accounts will be introduced + in Keystone) is being used and which project such request can access. + +- Attribute mapping should use `bound_audiences`, `bound_claims`, + `bound_subject`, etc to control the tokens issued by which workflows are + allowed to access OpenStack resources. + +### GitHub workflow federation + +In order for the GitHub workflow to be able to access OpenStack resources it is +necessary to register GitHub as a federated IdP and establish a corresponding +attribute mapping of the `jwt` type. + +IdP: + +```json +"identity_provider": { + "name": "github", + "bound_issuer": "https://token.actions.githubusercontent.com", + "jwks_url": "https://token.actions.githubusercontent.com/.well-known/jwks" +} +``` + + +Mapping: + +```json +"mapping": { + "type": "jwt", + "name": "gtema_keystone_main", + "idp_id": , + "domain_id": , + "bound_audiences": ["https://github.com"], + "bound_subject": "repo:gtema/keystone:pull_request", + "bound_claims": { + "base_ref": "main" + }, + "user_id_claim": "actor_id", + "user_name_claim": "actor", + "token_user_id": +} +``` + +TODO: add more claims according to [docs](https://docs.github.com/en/actions/reference/security/oidc#oidc-token-claims) + +A way for the workflow to obtain the JWT [is described here](https://docs.github.com/en/actions/reference/security/oidc#methods-for-requesting-the-oidc-token). + +```yaml +... +permissions: + token: write + contents: read + +job: + ... + - name: Get GitHub JWT token + id: get_token + run: | + TOKEN_JSON=$(curl -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ + "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=https://github.com") + + TOKEN=$(echo $TOKEN_JSON | jq -r .value) + echo "token=$TOKEN" >> $GITHUB_OUTPUT + ... + # TODO: build a proper command for capturing the actual token and/or write a dedicated action for that. + - name: Exchange GitHub JWT for Keystone token + run: | + KEYSTONE_TOKEN=$(curl -H "Authorization: bearer ${{ steps.get_token.outputs.token }}" -H "openstack-mapping: gtmema_keystone_main" https://keystone_url/v4/federation/identity_providers/IDP/jwt) + +``` ## API changes @@ -72,6 +181,8 @@ A series of brand new API endpoints have been added to the Keystone API. - /v3/federation/oidc/callback (exchange the authorization code for the Keystone token) +- /v3/federation/identity_providers/{idp_id}/jwt (exchange the JWT token issued by the referred IdP for the Keystone token) + ## DB changes Following tables are added: diff --git a/src/api/common.rs b/src/api/common.rs index 920ee46e..b0641d2e 100644 --- a/src/api/common.rs +++ b/src/api/common.rs @@ -56,7 +56,7 @@ pub async fn get_domain, N: AsRef>( identifier: name.as_ref().to_string(), }) } else { - return Err(KeystoneApiError::DomainIdOrName); + Err(KeystoneApiError::DomainIdOrName) } } diff --git a/src/api/error.rs b/src/api/error.rs index f8caff5e..1de440f3 100644 --- a/src/api/error.rs +++ b/src/api/error.rs @@ -218,6 +218,7 @@ impl KeystoneApiError { resource: "mapping provider".into(), identifier: x, }, + FederationProviderError::Conflict(x) => Self::Conflict(x), _ => Self::Federation { source }, } } @@ -300,6 +301,7 @@ pub enum WebauthnError { #[error("User Has No Credentials")] UserHasNoCredentials, } + impl IntoResponse for WebauthnError { fn into_response(self) -> Response { let body = match self { diff --git a/src/api/mod.rs b/src/api/mod.rs index 7a77c56c..967e1f00 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -20,7 +20,8 @@ use axum::{ use utoipa::{ Modify, OpenApi, openapi::security::{ - ApiKey, ApiKeyValue, AuthorizationCode, Flow, OAuth2, Scopes, SecurityScheme, + ApiKey, ApiKeyValue, AuthorizationCode, Flow, HttpAuthScheme, HttpBuilder, OAuth2, Scopes, + SecurityScheme, }, }; use utoipa_axum::{router::OpenApiRouter, routes}; @@ -57,6 +58,16 @@ impl Modify for SecurityAddon { "x-auth", SecurityScheme::ApiKey(ApiKey::Header(ApiKeyValue::new("x-auth-token"))), ); + components.add_security_scheme( + "jwt", + SecurityScheme::Http( + HttpBuilder::new() + .scheme(HttpAuthScheme::Bearer) + .bearer_format("JWT") + .description(Some("JWT (ID) Token issued by the federated IDP")) + .build(), + ), + ); // TODO: This must be dynamic components.add_security_scheme( "oauth2", diff --git a/src/api/v4/federation/auth.rs b/src/api/v4/federation/auth.rs index 00518de8..8889cf96 100644 --- a/src/api/v4/federation/auth.rs +++ b/src/api/v4/federation/auth.rs @@ -117,7 +117,7 @@ pub async fn post( &ProviderMappingListParameters { idp_id: Some(idp.id.clone()), name: Some(mapping_name.clone()), - domain_id: None, + ..Default::default() }, ) .await? diff --git a/src/api/v4/federation/error.rs b/src/api/v4/federation/error.rs index 79f3f604..fa7452fe 100644 --- a/src/api/v4/federation/error.rs +++ b/src/api/v4/federation/error.rs @@ -31,6 +31,9 @@ pub enum OidcError { )] MappingRequired, + #[error("mapping id or mapping name with idp id must be specified")] + MappingIdOrNameWithIdp, + #[error("request token error")] RequestToken { msg: String }, @@ -69,13 +72,20 @@ pub enum OidcError { #[error("ID token does not contain user id claim {0}")] UserNameClaimRequired(String), + + /// Domain_id for the user cannot be identified. #[error("can not identify resulting domain_id for the user")] UserDomainUnbound, + /// Bound subject mismatch. #[error("bound subject mismatches {expected} != {found}")] BoundSubjectMismatch { expected: String, found: String }, + + /// Bound audiences mismatch. #[error("bound audiences mismatch {expected} != {found}")] BoundAudiencesMismatch { expected: String, found: String }, + + /// Bound claims mismatch. #[error("bound claims mismatch")] BoundClaimsMismatch { claim: String, @@ -83,6 +93,7 @@ pub enum OidcError { found: String, }, + /// Error building user data. #[error(transparent)] MappedUserDataBuilder { #[from] @@ -90,8 +101,21 @@ pub enum OidcError { source: MappedUserDataBuilderError, }, + /// Authentication expired. #[error("Authentication expired")] AuthStateExpired, + + /// Cannot use OIDC attribute mapping for JWT login. + #[error("non jwt mapping requested for jwt login")] + NonJwtMapping, + + /// No JWT issuer can be identified for the mapping. + #[error("no jwt issuer can be determined")] + NoJwtIssuer, + + /// User not found + #[error("token user not found")] + UserNotFound(String), } impl OidcError { @@ -122,6 +146,9 @@ impl From for KeystoneApiError { OidcError::MappingRequired => { KeystoneApiError::BadRequest("Federated authentication requires mapping being specified in the payload or default set on the identity provider.".to_string()) } + OidcError::MappingIdOrNameWithIdp => { + KeystoneApiError::BadRequest("Federated authentication requires mapping being specified in the payload either with ID or name with identity provider id.".to_string()) + } OidcError::RequestToken { msg } => { KeystoneApiError::BadRequest(format!("Error exchanging authorization code for the authorization token: {msg}")) } @@ -167,6 +194,14 @@ impl From for KeystoneApiError { OidcError::AuthStateExpired => { KeystoneApiError::BadRequest("Authentication has expired. Please start again.".to_string()) } + OidcError::NonJwtMapping | OidcError::NoJwtIssuer => { + // Not exposing info about mapping and idp existence. + KeystoneApiError::Unauthorized + } + OidcError::UserNotFound(_) => { + // Not exposing info about mapping and idp existence. + KeystoneApiError::Unauthorized + } } } } diff --git a/src/api/v4/federation/identity_provider.rs b/src/api/v4/federation/identity_provider.rs index 8df87e43..b0e2700d 100644 --- a/src/api/v4/federation/identity_provider.rs +++ b/src/api/v4/federation/identity_provider.rs @@ -433,6 +433,7 @@ mod tests { oidc_client_id: None, oidc_response_mode: None, oidc_response_types: None, + jwks_url: None, jwt_validation_pubkeys: None, bound_issuer: None, default_mapping_name: Some("dummy".into()), @@ -595,6 +596,7 @@ mod tests { oidc_client_id: None, oidc_response_mode: None, oidc_response_types: None, + jwks_url: None, jwt_validation_pubkeys: None, bound_issuer: None, default_mapping_name: Some("dummy".into()), diff --git a/src/api/v4/federation/jwt.rs b/src/api/v4/federation/jwt.rs new file mode 100644 index 00000000..e2dea5ee --- /dev/null +++ b/src/api/v4/federation/jwt.rs @@ -0,0 +1,493 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +//! JWT based authentication API + +use axum::{ + Json, debug_handler, + extract::{Path, State}, + http::HeaderMap, + http::StatusCode, + http::header::AUTHORIZATION, + response::IntoResponse, +}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::HashMap; +use std::str::FromStr; +use tracing::{debug, warn}; +use utoipa_axum::{router::OpenApiRouter, routes}; + +use openidconnect::core::{ + CoreClient, CoreGenderClaim, CoreJsonWebKey, CoreJweContentEncryptionAlgorithm, + CoreJwsSigningAlgorithm, CoreProviderMetadata, +}; +use openidconnect::reqwest; +use openidconnect::{ + AdditionalClaims, Client, ClientId, IdToken, IdTokenClaims, IssuerUrl, JsonWebKeySet, + JsonWebKeySetUrl, Nonce, +}; + +use crate::api::common::find_project_from_scope; +use crate::api::v4::auth::token::types::{ + Token as ApiResponseToken, TokenResponse as KeystoneTokenResponse, +}; +use crate::api::v4::federation::error::OidcError; +use crate::api::v4::federation::types::*; +use crate::api::{Catalog, error::KeystoneApiError}; +use crate::auth::{AuthenticatedInfo, AuthenticationError, AuthzInfo}; +use crate::catalog::CatalogApi; +use crate::federation::FederationApi; +use crate::federation::types::{ + MappingListParameters as ProviderMappingListParameters, MappingType as ProviderMappingType, + Project as ProviderProject, Scope as ProviderScope, + identity_provider::IdentityProvider as ProviderIdentityProvider, + mapping::Mapping as ProviderMapping, +}; +use crate::identity::IdentityApi; +use crate::identity::error::IdentityProviderError; +use crate::identity::types::{FederationBuilder, FederationProtocol, UserCreateBuilder}; +use crate::keystone::ServiceState; +use crate::token::TokenApi; + +pub(super) fn openapi_router() -> OpenApiRouter { + OpenApiRouter::new().routes(routes!(login)) +} + +#[derive(Debug, Deserialize, Serialize)] +struct AllOtherClaims(HashMap); +impl AdditionalClaims for AllOtherClaims {} + +type FullIdToken = IdToken< + AllOtherClaims, + CoreGenderClaim, + CoreJweContentEncryptionAlgorithm, + CoreJwsSigningAlgorithm, +>; + +/// Prepare the proper scope. +/// +/// # Arguments +/// * `state`: The service state +/// * `scope`: The scope to extract the AuthZ information from +/// +/// # Returns +/// * `AuthzInfo`: The AuthZ information +/// * `KeystoneApiError`: An error if the scope is not valid +async fn get_authz_info( + state: &ServiceState, + scope: Option, +) -> Result { + let authz_info = match scope { + Some(ProviderScope::Project(scope)) => { + if let Some(project) = find_project_from_scope(state, &scope.into()).await? { + AuthzInfo::Project(project) + } else { + return Err(KeystoneApiError::Unauthorized); + } + } + _ => AuthzInfo::Unscoped, + }; + authz_info.validate()?; + Ok(authz_info) +} + +/// Authentication using the JWT. +/// +/// This operation allows user to exchange the JWT issued by the trusted identity provider for the +/// regular Keystone session token. Request specifies the necessary authentication mapping, which +/// is also used to validate expected claims. +#[utoipa::path( + post, + //path = "/jwt/login", + path = "/identity_providers/{idp_id}/jwt", + params( + ("openstack-mapping" = String, Header, description = "Federated attribute mapping"), + + ), + responses( + (status = OK, description = "Authentication Token object", body = KeystoneTokenResponse, + headers( + ("x-subject-token" = String, description = "Keystone token"), + ), + ), + ), + security(("jwt" = [])), + tag="identity_providers" +)] +#[tracing::instrument( + name = "api::identity_provider_jwt_login", + level = "debug", + skip(state), + err(Debug) +)] +#[debug_handler] +pub async fn login( + State(state): State, + headers: HeaderMap, + Path(idp_id): Path, +) -> Result { + state + .config + .auth + .methods + .iter() + // TODO: is it how it should be hardcoded? + // TODO: should be better to use jwt, but it is not available in py-keystone + .find(|m| *m == "openid") + .ok_or(KeystoneApiError::AuthMethodNotSupported)?; + + let jwt: String = headers + .get(AUTHORIZATION) + .ok_or(KeystoneApiError::SubjectTokenMissing)? + .to_str() + .map_err(|_| KeystoneApiError::InvalidHeader)? + .to_string(); + + let mapping: String = headers + .get("openstack-mapping") + .ok_or(KeystoneApiError::SubjectTokenMissing)? + .to_str() + .map_err(|_| KeystoneApiError::InvalidHeader)? + .to_string(); + + let idp = state + .provider + .get_federation_provider() + .get_identity_provider(&state.db, &idp_id) + .await + .map(|x| { + x.ok_or_else(|| KeystoneApiError::NotFound { + resource: "identity provider".into(), + identifier: idp_id.clone(), + }) + })??; + + let mapping = state + .provider + .get_federation_provider() + .list_mappings( + &state.db, + &ProviderMappingListParameters { + idp_id: Some(idp_id.clone()), + name: Some(mapping.clone()), + r#type: Some(ProviderMappingType::Jwt), + ..Default::default() + }, + ) + .await? + .first() + .ok_or(KeystoneApiError::NotFound { + resource: "mapping".into(), + identifier: mapping.clone(), + })? + .to_owned(); + + //if !matches!(mapping.r#type, ProviderMappingType::Jwt) { + // // need to log helping message, since the error is wrapped + // // to prevent existence exposure. + // warn!("Not JWT mapping used for the JWT login"); + // return Err(OidcError::NonJwtMapping)?; + //} + + let http_client = reqwest::ClientBuilder::new() + // Following redirects opens the client up to SSRF vulnerabilities. + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(OidcError::from)?; + + // Discover metadata when issuer or jwks_url is not known + let provider_metadata: Option = if let Some(discovery_url) = + &idp.oidc_discovery_url + && (idp.bound_issuer.is_none() || idp.jwks_url.is_none()) + { + Some( + CoreProviderMetadata::discover_async( + IssuerUrl::new(discovery_url.to_string()).map_err(OidcError::from)?, + &http_client, + ) + .await + .map_err(|err| OidcError::discovery(&err))?, + ) + } else { + None + }; + + let issuer_url = if let Some(bound_issuer) = &idp.bound_issuer { + IssuerUrl::new(bound_issuer.clone()).map_err(OidcError::from)? + } else if let Some(metadata) = &provider_metadata { + metadata.issuer().clone() + } else { + warn!("No issuer_url can be determined for {:?}", idp); + return Err(OidcError::NoJwtIssuer)?; + }; + + let jwks_url = if let Some(jwks_url) = &idp.jwks_url { + JsonWebKeySetUrl::new(jwks_url.clone()).map_err(OidcError::from)? + } else if let Some(metadata) = &provider_metadata { + metadata.jwks_uri().clone() + } else { + warn!("No jwks_url can be determined for {:?}", idp); + return Err(OidcError::NoJwtIssuer)?; + }; + + let jwks: JsonWebKeySet = JsonWebKeySet::fetch_async(&jwks_url, &http_client) + .await + .map_err(|err| OidcError::discovery(&err))?; + + // TODO: client_id should match the audience. How to get that? + let audience = "keystone"; + let client: CoreClient = Client::new(ClientId::new(audience.to_string()), issuer_url, jwks); + + let id_token = FullIdToken::from_str(&jwt)?; + + let id_token_verifier = client.id_token_verifier().require_audience_match(false); + // The nonce is not used in the JWT flow, so we can ignore it. + let nonce_verifier = |_nonce: Option<&Nonce>| Ok(()); + let claims = id_token + .into_claims(&id_token_verifier, &nonce_verifier) + .map_err(OidcError::from)?; + + let claims_as_json = serde_json::to_value(&claims)?; + + validate_bound_claims(&mapping, &claims, &claims_as_json)?; + let mapped_user_data = map_user_data(&state, &idp, &mapping, &claims_as_json).await?; + + let user = if let Some(existing_user) = state + .provider + .get_identity_provider() + .find_federated_user(&state.db, &idp.id, &mapped_user_data.unique_id) + .await? + { + // The user exists already + existing_user + + // TODO: update user? + } else { + // New user + let mut federated_user: FederationBuilder = FederationBuilder::default(); + federated_user.idp_id(idp.id.clone()); + federated_user.unique_id(mapped_user_data.unique_id.clone()); + federated_user.protocols(vec![FederationProtocol { + protocol_id: "oidc".into(), + unique_id: mapped_user_data.unique_id.clone(), + }]); + let mut user_builder: UserCreateBuilder = UserCreateBuilder::default(); + user_builder.id(String::new()); + user_builder.domain_id(mapped_user_data.domain_id); + user_builder.enabled(true); + user_builder.name(mapped_user_data.user_name); + user_builder.federated(Vec::from([federated_user + .build() + .map_err(IdentityProviderError::from)?])); + + state + .provider + .get_identity_provider() + .create_user( + &state.db, + user_builder.build().map_err(IdentityProviderError::from)?, + ) + .await? + }; + let authed_info = AuthenticatedInfo::builder() + .user_id(user.id.clone()) + .user(user.clone()) + .methods(vec!["openid".into()]) + .idp_id(idp.id.clone()) + .protocol_id("jwt".to_string()) + .build() + .map_err(AuthenticationError::from)?; + authed_info.validate()?; + + // TODO: detect scope from the mapping or claims + let authz_info = get_authz_info( + &state, + mapping.token_project_id.as_ref().map(|token_project_id| { + ProviderScope::Project(ProviderProject { + id: Some(token_project_id.to_string()), + ..Default::default() + }) + }), + ) + .await?; + + let mut token = state + .provider + .get_token_provider() + .issue_token(authed_info, authz_info)?; + + // TODO: roles should be granted for the jwt login already + + token = state + .provider + .get_token_provider() + .expand_token_information(&token, &state.db, &state.provider) + .await + .map_err(|_| KeystoneApiError::Forbidden)?; + + let mut api_token = KeystoneTokenResponse { + token: ApiResponseToken::from_provider_token(&state, &token).await?, + }; + let catalog: Catalog = state + .provider + .get_catalog_provider() + .get_catalog(&state.db, true) + .await? + .into(); + api_token.token.catalog = Some(catalog); + + debug!("response is {:?}", api_token); + Ok(( + StatusCode::OK, + [( + "X-Subject-Token", + state.provider.get_token_provider().encode_token(&token)?, + )], + Json(api_token), + ) + .into_response()) +} + +/// Validate bound claims in the token +/// +/// # Arguments +/// +/// * `mapping` - The mapping to validate against +/// * `claims` - The claims to validate +/// * `claims_as_json` - The claims as json to validate +/// +/// # Returns +/// +/// * `Result<(), OidcError>` +fn validate_bound_claims( + mapping: &ProviderMapping, + claims: &IdTokenClaims, + claims_as_json: &Value, +) -> Result<(), OidcError> { + if let Some(bound_subject) = &mapping.bound_subject { + if bound_subject != claims.subject().as_str() { + return Err(OidcError::BoundSubjectMismatch { + expected: bound_subject.to_string(), + found: claims.subject().as_str().into(), + }); + } + } + if let Some(bound_audiences) = &mapping.bound_audiences { + let mut bound_audiences_match: bool = false; + for claim_audience in claims.audiences() { + if bound_audiences.iter().any(|x| x == claim_audience.as_str()) { + bound_audiences_match = true; + } + } + if !bound_audiences_match { + return Err(OidcError::BoundAudiencesMismatch { + expected: bound_audiences.join(","), + found: claims + .audiences() + .iter() + .map(|x| x.as_str()) + .collect::>() + .join(","), + }); + } + } + if let Some(bound_claims) = &mapping.bound_claims { + if let Some(required_claims) = bound_claims.as_object() { + for (claim, value) in required_claims.iter() { + if !claims_as_json + .get(claim) + .map(|x| x == value) + .is_some_and(|val| val) + { + return Err(OidcError::BoundClaimsMismatch { + claim: claim.to_string(), + expected: value.to_string(), + found: claims_as_json + .get(claim) + .map(|x| x.to_string()) + .unwrap_or_default(), + }); + } + } + } + } + Ok(()) +} + +/// Map the user data using the referred mapping +/// +/// # Arguments +/// * `idp` - The identity provider +/// * `mapping` - The mapping to use +/// * `claims_as_json` - The claims as json +/// +/// # Returns +/// The mapped user data +async fn map_user_data( + state: &ServiceState, + idp: &ProviderIdentityProvider, + mapping: &ProviderMapping, + claims_as_json: &Value, +) -> Result { + let mut builder = MappedUserDataBuilder::default(); + if let Some(token_user_id) = &mapping.token_user_id { + // TODO: How to check that the user belongs to the right domain) + if let Ok(Some(user)) = state + .provider + .get_identity_provider() + .get_user(&state.db, token_user_id) + .await + { + builder.unique_id(token_user_id.clone()); + builder.user_name(user.name.clone()); + } else { + return Err(OidcError::UserNotFound(token_user_id.clone()))?; + } + } else { + builder.unique_id( + claims_as_json + .get(&mapping.user_id_claim) + .and_then(|x| x.as_str()) + .ok_or_else(|| OidcError::UserIdClaimRequired(mapping.user_id_claim.clone()))? + .to_string(), + ); + + builder.user_name( + claims_as_json + .get(&mapping.user_name_claim) + .and_then(|x| x.as_str()) + .ok_or_else(|| OidcError::UserNameClaimRequired(mapping.user_name_claim.clone()))?, + ); + } + + builder.domain_id( + mapping + .domain_id + .as_ref() + .or(idp.domain_id.as_ref()) + .or(mapping + .domain_id_claim + .as_ref() + .and_then(|claim| { + claims_as_json + .get(claim) + .and_then(|x| x.as_str().map(|v| v.to_string())) + }) + .as_ref()) + .ok_or(OidcError::UserDomainUnbound)?, + ); + + Ok(builder.build()?) +} diff --git a/src/api/v4/federation/mapping.rs b/src/api/v4/federation/mapping.rs index e4d1a50e..92495148 100644 --- a/src/api/v4/federation/mapping.rs +++ b/src/api/v4/federation/mapping.rs @@ -409,6 +409,7 @@ mod tests { name: "name".into(), domain_id: Some("did".into()), idp_id: "idp_id".into(), + r#type: MappingType::default(), allowed_redirect_uris: None, user_id_claim: "sub".into(), user_name_claim: "preferred_username".into(), @@ -438,6 +439,7 @@ mod tests { name: Some("name".into()), domain_id: Some("did".into()), idp_id: Some("idp".into()), + ..Default::default() } == *qp }, ) @@ -447,6 +449,7 @@ mod tests { name: "name".into(), domain_id: Some("did".into()), idp_id: "idp".into(), + r#type: MappingType::default().into(), allowed_redirect_uris: None, user_id_claim: "sub".into(), user_name_claim: "preferred_username".into(), @@ -553,6 +556,7 @@ mod tests { name: "name".into(), domain_id: Some("did".into()), idp_id: "idp_id".into(), + r#type: MappingType::default(), allowed_redirect_uris: None, user_id_claim: "sub".into(), user_name_claim: "preferred_username".into(), diff --git a/src/api/v4/federation/mod.rs b/src/api/v4/federation/mod.rs index 9ea2a90a..0cf179c1 100644 --- a/src/api/v4/federation/mod.rs +++ b/src/api/v4/federation/mod.rs @@ -24,6 +24,7 @@ use crate::keystone::ServiceState; pub mod auth; pub mod error; pub mod identity_provider; +pub mod jwt; pub mod mapping; pub mod oidc; pub mod types; @@ -33,5 +34,6 @@ pub(super) fn openapi_router() -> OpenApiRouter { .nest("/identity_providers", identity_provider::openapi_router()) .nest("/mappings", mapping::openapi_router()) .merge(auth::openapi_router()) + .merge(jwt::openapi_router()) .merge(oidc::openapi_router()) } diff --git a/src/api/v4/federation/types/auth.rs b/src/api/v4/federation/types/auth.rs index cf97b5b1..19f61dc7 100644 --- a/src/api/v4/federation/types/auth.rs +++ b/src/api/v4/federation/types/auth.rs @@ -54,3 +54,16 @@ impl IntoResponse for IdentityProviderAuthResponse { (StatusCode::OK, Json(self)).into_response() } } + +/// JWT authentication request. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, ToSchema)] +pub struct IdentityProviderJwtAuthRequest { + /// JWT token. + pub jwt: String, + /// Identity provider ID. + pub idp_id: Option, + /// Authentication mapping id. + pub mapping_id: Option, + /// Authentication mapping name. + pub mapping_name: Option, +} diff --git a/src/api/v4/federation/types/identity_provider.rs b/src/api/v4/federation/types/identity_provider.rs index abb08798..2f0b4cfc 100644 --- a/src/api/v4/federation/types/identity_provider.rs +++ b/src/api/v4/federation/types/identity_provider.rs @@ -52,6 +52,11 @@ pub struct IdentityProvider { #[builder(default)] pub oidc_response_types: Option>, + /// URL to fetch JsonWebKeySet. + #[serde(skip_serializing_if = "Option::is_none")] + #[builder(default)] + pub jwks_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] #[builder(default)] pub jwt_validation_pubkeys: Option>, @@ -113,6 +118,12 @@ pub struct IdentityProviderCreate { #[builder(default)] pub oidc_response_types: Option>, + /// Optional URL to fetch JsonWebKeySet. Must be specified for JWT authentication when + /// discovery for the provider is not available or not standard compliant. + #[serde(skip_serializing_if = "Option::is_none")] + #[builder(default)] + pub jwks_url: Option, + /// JWT validation public keys #[serde(skip_serializing_if = "Option::is_none")] #[builder(default)] @@ -155,6 +166,11 @@ pub struct IdentityProviderUpdate { #[builder(default)] pub oidc_response_types: Option>>, + /// Optional URL to fetch JsonWebKeySet. Must be specified for JWT authentication when + /// discovery for the provider is not available or not standard compliant. + #[builder(default)] + pub jwks_url: Option>, + #[builder(default)] pub jwt_validation_pubkeys: Option>>, @@ -195,6 +211,7 @@ impl From for IdentityProvider { oidc_client_id: value.oidc_client_id, oidc_response_mode: value.oidc_response_mode, oidc_response_types: value.oidc_response_types, + jwks_url: value.jwks_url, jwt_validation_pubkeys: value.jwt_validation_pubkeys, bound_issuer: value.bound_issuer, default_mapping_name: value.default_mapping_name, @@ -214,6 +231,7 @@ impl From for types::IdentityProvider { oidc_client_secret: value.identity_provider.oidc_client_secret, oidc_response_mode: value.identity_provider.oidc_response_mode, oidc_response_types: value.identity_provider.oidc_response_types, + jwks_url: value.identity_provider.jwks_url, jwt_validation_pubkeys: value.identity_provider.jwt_validation_pubkeys, bound_issuer: value.identity_provider.bound_issuer, default_mapping_name: value.identity_provider.default_mapping_name, @@ -231,6 +249,7 @@ impl From for types::IdentityProviderUpdate { oidc_client_secret: value.identity_provider.oidc_client_secret, oidc_response_mode: value.identity_provider.oidc_response_mode, oidc_response_types: value.identity_provider.oidc_response_types, + jwks_url: value.identity_provider.jwks_url, jwt_validation_pubkeys: value.identity_provider.jwt_validation_pubkeys, bound_issuer: value.identity_provider.bound_issuer, default_mapping_name: value.identity_provider.default_mapping_name, diff --git a/src/api/v4/federation/types/mapping.rs b/src/api/v4/federation/types/mapping.rs index 390c29fa..d296a8a5 100644 --- a/src/api/v4/federation/types/mapping.rs +++ b/src/api/v4/federation/types/mapping.rs @@ -11,7 +11,7 @@ // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 - +/// Federated login - attribute mapping types. use axum::{ Json, http::StatusCode, @@ -21,67 +21,89 @@ use derive_builder::Builder; use serde::{Deserialize, Serialize}; use serde_json::Value; use utoipa::{IntoParams, ToSchema}; +use uuid::Uuid; use crate::api::error::KeystoneApiError; use crate::federation::types; -/// OIDC/JWT mapping data +/// OIDC/JWT mapping data. #[derive(Builder, Clone, Debug, Deserialize, PartialEq, Serialize, ToSchema)] #[builder(setter(strip_option, into))] pub struct Mapping { - /// Federation mapping ID + /// Attribute mapping ID for federated logins. pub id: String, - /// Mapping name + /// Attribute mapping name for federated logins. pub name: String, - /// domain_id of the mapping + /// `domain_id` owning the attribute mapping. + /// + /// Unset `domain_id` means the attribute mapping is shared and can be used by different + /// domains. This requires `domain_id_claim` to be present. Attribute mapping can be only + /// shared when the referred identity provider is also shared (does not set the `domain_id` + /// attribute). #[builder(default)] #[serde(skip_serializing_if = "Option::is_none")] pub domain_id: Option, - /// IDP ID + /// ID of the federated identity provider for which this attribute mapping can be used. pub idp_id: String, + /// Attribute mapping type ([oidc, jwt]). + pub r#type: MappingType, + + /// List of allowed redirect urls (only for `oidc` type). #[builder(default)] #[serde(skip_serializing_if = "Option::is_none")] pub allowed_redirect_uris: Option>, + /// `user_id` claim name. pub user_id_claim: String, + + /// `user_name` claim name. pub user_name_claim: String, + /// `domain_id` claim name. #[builder(default)] #[serde(skip_serializing_if = "Option::is_none")] pub domain_id_claim: Option, + /// `groups` claim name. #[builder(default)] #[serde(skip_serializing_if = "Option::is_none")] pub groups_claim: Option, + /// List of audiences that must be present in the token. #[builder(default)] #[serde(skip_serializing_if = "Option::is_none")] pub bound_audiences: Option>, + /// Token subject value that must be set in the token. #[builder(default)] #[serde(skip_serializing_if = "Option::is_none")] pub bound_subject: Option, + /// Additional claims that must be present in the token. #[builder(default)] #[serde(skip_serializing_if = "Option::is_none")] pub bound_claims: Option, + /// List of OIDC scopes. #[builder(default)] #[serde(skip_serializing_if = "Option::is_none")] pub oidc_scopes: Option>, + /// Fixed user_id for which the keystone token would be issued. #[builder(default)] #[serde(skip_serializing_if = "Option::is_none")] pub token_user_id: Option, + /// List of fixed roles that would be included in the token. #[builder(default)] #[serde(skip_serializing_if = "Option::is_none")] pub token_role_ids: Option>, + /// Fixed project_id for the token. #[builder(default)] #[serde(skip_serializing_if = "Option::is_none")] pub token_project_id: Option, @@ -93,90 +115,128 @@ pub struct MappingResponse { pub mapping: Mapping, } -/// OIDC/JWT mapping data +/// OIDC/JWT attribute mapping create data. #[derive(Builder, Clone, Default, Debug, Deserialize, PartialEq, Serialize, ToSchema)] #[builder(setter(strip_option, into))] pub struct MappingCreate { - /// Mapping name + /// Attribute mapping ID for federated logins. + pub id: Option, + + /// Attribute mapping name for federated logins. pub name: String, - /// domain_id of the mapping + /// `domain_id` owning the attribute mapping. + /// + /// Unset `domain_id` means the attribute mapping is shared and can be used by different + /// domains. This requires `domain_id_claim` to be present. Attribute mapping can be only + /// shared when the referred identity provider is also shared (does not set the `domain_id` + /// attribute). #[builder(default)] #[serde(skip_serializing_if = "Option::is_none")] pub domain_id: Option, - /// IDP ID + /// ID of the federated identity provider for which this attribute mapping can be used. pub idp_id: String, + /// Attribute mapping type ([oidc, jwt]). + #[builder(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub r#type: Option, + + /// List of allowed redirect urls (only for `oidc` type). #[builder(default)] #[serde(skip_serializing_if = "Option::is_none")] pub allowed_redirect_uris: Option>, + /// `user_id` claim name. pub user_id_claim: String, + + /// `user_name` claim name. pub user_name_claim: String, + /// `domain_id` claim name. #[builder(default)] #[serde(skip_serializing_if = "Option::is_none")] pub domain_id_claim: Option, + /// `groups` claim name. #[builder(default)] #[serde(skip_serializing_if = "Option::is_none")] pub groups_claim: Option, + /// List of audiences that must be present in the token. #[builder(default)] #[serde(skip_serializing_if = "Option::is_none")] pub bound_audiences: Option>, + /// Token subject value that must be set in the token. #[builder(default)] #[serde(skip_serializing_if = "Option::is_none")] pub bound_subject: Option, + /// Additional claims that must be present in the token. #[builder(default)] #[serde(skip_serializing_if = "Option::is_none")] pub bound_claims: Option, + /// List of OIDC scopes. #[builder(default)] #[serde(skip_serializing_if = "Option::is_none")] pub oidc_scopes: Option>, + /// Fixed user_id for which the keystone token would be issued. #[builder(default)] #[serde(skip_serializing_if = "Option::is_none")] pub token_user_id: Option, + /// List of fixed roles that would be included in the token. #[builder(default)] #[serde(skip_serializing_if = "Option::is_none")] pub token_role_ids: Option>, + /// Fixed project_id for the token. #[builder(default)] #[serde(skip_serializing_if = "Option::is_none")] pub token_project_id: Option, } -/// OIDC/JWT mapping data +/// OIDC/JWT attribute mapping update data. #[derive(Builder, Clone, Default, Debug, Deserialize, PartialEq, Serialize, ToSchema)] #[builder(setter(into))] pub struct MappingUpdate { - /// Mapping name + /// Attribute mapping name for federated logins. #[serde(skip_serializing_if = "Option::is_none")] pub name: Option, - /// domain_id of the mapping + /// `domain_id` owning the attribute mapping. + /// + /// Unset `domain_id` means the attribute mapping is shared and can be used by different + /// domains. This requires `domain_id_claim` to be present. Attribute mapping can be only + /// shared when the referred identity provider is also shared (does not set the `domain_id` + /// attribute). #[builder(default)] #[serde(skip_serializing_if = "Option::is_none")] pub domain_id: Option>, - /// IDP ID + /// ID of the federated identity provider for which this attribute mapping can be used. #[serde(skip_serializing_if = "Option::is_none")] pub idp_id: Option, + /// Attribute mapping type ([oidc, jwt]). + #[builder(default)] + pub r#type: Option, + + /// List of allowed redirect urls (only for `oidc` type). #[builder(default)] #[serde(skip_serializing_if = "Option::is_none")] pub allowed_redirect_uris: Option>>, + /// `user_id` claim name. #[builder(default)] #[serde(skip_serializing_if = "Option::is_none")] pub user_id_claim: Option, + /// `user_name` claim name. #[builder(default)] #[serde(skip_serializing_if = "Option::is_none")] pub user_name_claim: Option, @@ -185,40 +245,48 @@ pub struct MappingUpdate { #[serde(skip_serializing_if = "Option::is_none")] pub domain_id_claim: Option, + /// `groups` claim name. #[builder(default)] #[serde(skip_serializing_if = "Option::is_none")] pub groups_claim: Option>, + /// List of audiences that must be present in the token. #[builder(default)] #[serde(skip_serializing_if = "Option::is_none")] pub bound_audiences: Option>>, + /// Token subject value that must be set in the token. #[builder(default)] #[serde(skip_serializing_if = "Option::is_none")] pub bound_subject: Option>, + /// Additional claims that must be present in the token. #[builder(default)] #[serde(skip_serializing_if = "Option::is_none")] pub bound_claims: Option, + /// List of OIDC scopes. #[builder(default)] #[serde(skip_serializing_if = "Option::is_none")] pub oidc_scopes: Option>>, + /// Fixed user_id for which the keystone token would be issued. #[builder(default)] #[serde(skip_serializing_if = "Option::is_none")] pub token_user_id: Option>, + /// List of fixed roles that would be included in the token. #[builder(default)] #[serde(skip_serializing_if = "Option::is_none")] pub token_role_ids: Option>>, + /// Fixed project_id for the token. #[builder(default)] #[serde(skip_serializing_if = "Option::is_none")] pub token_project_id: Option>, } -/// OIDC/JWT mapping create request +/// OIDC/JWT attribute mapping create request. #[derive(Builder, Clone, Debug, Deserialize, PartialEq, Serialize, ToSchema)] #[builder(setter(strip_option, into))] pub struct MappingCreateRequest { @@ -226,7 +294,7 @@ pub struct MappingCreateRequest { pub mapping: MappingCreate, } -/// OIDC/JWT mapping update request +/// OIDC/JWT attribute mapping update request. #[derive(Builder, Clone, Debug, Deserialize, PartialEq, Serialize, ToSchema)] #[builder(setter(strip_option, into))] pub struct MappingUpdateRequest { @@ -241,6 +309,7 @@ impl From for Mapping { name: value.name, domain_id: value.domain_id, idp_id: value.idp_id, + r#type: value.r#type.into(), allowed_redirect_uris: value.allowed_redirect_uris, user_id_claim: value.user_id_claim, user_name_claim: value.user_name_claim, @@ -260,10 +329,11 @@ impl From for Mapping { impl From for types::Mapping { fn from(value: MappingCreateRequest) -> Self { Self { - id: String::new(), + id: value.mapping.id.unwrap_or_else(|| Uuid::new_v4().into()), name: value.mapping.name, domain_id: value.mapping.domain_id, idp_id: value.mapping.idp_id, + r#type: value.mapping.r#type.unwrap_or_default().into(), allowed_redirect_uris: value.mapping.allowed_redirect_uris, user_id_claim: value.mapping.user_id_claim, user_name_claim: value.mapping.user_name_claim, @@ -285,6 +355,7 @@ impl From for types::MappingUpdate { Self { name: value.mapping.name, idp_id: value.mapping.idp_id, + r#type: value.mapping.r#type.map(Into::into), allowed_redirect_uris: value.mapping.allowed_redirect_uris, user_id_claim: value.mapping.user_id_claim, user_name_claim: value.mapping.user_name_claim, @@ -319,7 +390,36 @@ impl From for KeystoneApiError { } } -/// List of OIDC/JWT mappings +/// Attribute mapping type. +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, ToSchema)] +#[serde(rename_all = "lowercase")] +pub enum MappingType { + #[default] + /// OIDC + Oidc, + /// JWT + Jwt, +} + +impl From for MappingType { + fn from(value: types::MappingType) -> MappingType { + match value { + types::MappingType::Oidc => MappingType::Oidc, + types::MappingType::Jwt => MappingType::Jwt, + } + } +} + +impl From for types::MappingType { + fn from(value: MappingType) -> types::MappingType { + match value { + MappingType::Oidc => types::MappingType::Oidc, + MappingType::Jwt => types::MappingType::Jwt, + } + } +} + +/// List of OIDC/JWT attribute mappings. #[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, ToSchema)] pub struct MappingList { /// Collection of identity provider objects @@ -332,7 +432,7 @@ impl IntoResponse for MappingList { } } -/// Query parameters for listing OIDC/JWT mappings. +/// Query parameters for listing OIDC/JWT attribute mappings. #[derive(Clone, Debug, Default, Deserialize, Serialize, IntoParams)] pub struct MappingListParameters { /// Filters the response by IDP name. @@ -343,6 +443,9 @@ pub struct MappingListParameters { /// Filters the response by a idp ID. pub idp_id: Option, + + /// Filters the response by a mapping type. + pub r#type: Option, } impl From for KeystoneApiError { @@ -359,6 +462,7 @@ impl TryFrom for types::MappingListParameters { name: value.name, domain_id: value.domain_id, idp_id: value.idp_id, + r#type: value.r#type.map(Into::into), }) } } diff --git a/src/db/entity.rs b/src/db/entity.rs index fcdac9d4..b141d3f9 100644 --- a/src/db/entity.rs +++ b/src/db/entity.rs @@ -148,6 +148,7 @@ impl Default for federated_identity_provider::Model { oidc_client_secret: None, oidc_response_mode: None, oidc_response_types: None, + jwks_url: None, jwt_validation_pubkeys: None, bound_issuer: None, default_mapping_name: None, diff --git a/src/db/entity/federated_identity_provider.rs b/src/db/entity/federated_identity_provider.rs index 06e2b941..97965e4e 100644 --- a/src/db/entity/federated_identity_provider.rs +++ b/src/db/entity/federated_identity_provider.rs @@ -14,6 +14,7 @@ pub struct Model { pub oidc_client_secret: Option, pub oidc_response_mode: Option, pub oidc_response_types: Option, + pub jwks_url: Option, #[sea_orm(column_type = "Text", nullable)] pub jwt_validation_pubkeys: Option, pub bound_issuer: Option, diff --git a/src/db/entity/federated_mapping.rs b/src/db/entity/federated_mapping.rs index 2bd33309..5b5f5aa8 100644 --- a/src/db/entity/federated_mapping.rs +++ b/src/db/entity/federated_mapping.rs @@ -1,5 +1,6 @@ //! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.7 +use super::sea_orm_active_enums::MappingType; use sea_orm::entity::prelude::*; #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] @@ -10,6 +11,7 @@ pub struct Model { pub name: String, pub idp_id: String, pub domain_id: Option, + pub r#type: MappingType, pub allowed_redirect_uris: Option, pub user_id_claim: String, pub user_name_claim: String, diff --git a/src/db/entity/sea_orm_active_enums.rs b/src/db/entity/sea_orm_active_enums.rs index 3098d38e..837dffea 100644 --- a/src/db/entity/sea_orm_active_enums.rs +++ b/src/db/entity/sea_orm_active_enums.rs @@ -36,3 +36,16 @@ pub enum Type { #[sea_orm(string_value = "UserProject")] UserProject, } + +#[derive(Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum)] +#[sea_orm( + rs_type = "String", + db_type = "Enum", + enum_name = "federated_mapping_type" +)] +pub enum MappingType { + #[sea_orm(string_value = "oidc")] + Oidc, + #[sea_orm(string_value = "jwt")] + Jwt, +} diff --git a/src/db_migration/m20250414_000001_idp.rs b/src/db_migration/m20250414_000001_idp.rs index d7412671..cbb524b5 100644 --- a/src/db_migration/m20250414_000001_idp.rs +++ b/src/db_migration/m20250414_000001_idp.rs @@ -11,8 +11,11 @@ // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 - -use sea_orm_migration::{prelude::*, schema::*}; +#![allow(clippy::enum_variant_names)] +use sea_orm_migration::{ + prelude::{extension::postgres::Type, *}, + schema::*, +}; use crate::db::entity::prelude::Project; use crate::db::entity::project; @@ -51,6 +54,7 @@ impl MigrationTrait for Migration { FederatedIdentityProvider::OidcResponseTypes, 255, )) + .col(text_null(FederatedIdentityProvider::JwksUrl)) .col(text_null(FederatedIdentityProvider::JwtValidationPubkeys)) .col(string_len_null(FederatedIdentityProvider::BoundIssuer, 255)) .col(json_null(FederatedIdentityProvider::ProviderConfig)) @@ -71,14 +75,25 @@ impl MigrationTrait for Migration { .index( Index::create() .unique() - .name("idx-idp-name-domain") + .nulls_not_distinct() + .name("idx-idp-name-domain-discovery") .col(FederatedIdentityProvider::DomainId) - .col(FederatedIdentityProvider::Name), + .col(FederatedIdentityProvider::Name) + .col(FederatedIdentityProvider::OidcDiscoveryUrl), ) .to_owned(), ) .await?; + manager + .create_type( + Type::create() + .as_enum(FederatedMappingType::FederatedMappingType) + .values([FederatedMappingType::Oidc, FederatedMappingType::Jwt]) + .to_owned(), + ) + .await?; + manager .create_table( Table::create() @@ -88,6 +103,11 @@ impl MigrationTrait for Migration { .col(string_len(FederatedMapping::Name, 255)) .col(string_len(FederatedMapping::IdpId, 64)) .col(string_len_null(FederatedMapping::DomainId, 64)) + .col(enumeration( + FederatedMapping::Type, + FederatedMappingType::FederatedMappingType, + [FederatedMappingType::Oidc, FederatedMappingType::Jwt], + )) .col(string_len_null(FederatedMapping::AllowedRedirectUris, 1024)) .col(string_len(FederatedMapping::UserIdClaim, 64)) .col(string_len(FederatedMapping::UserNameClaim, 64)) @@ -172,11 +192,30 @@ impl MigrationTrait for Migration { async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { manager - .drop_table(Table::drop().table(FederatedAuthState::Table).to_owned()) + .drop_table( + Table::drop() + .if_exists() + .table(FederatedAuthState::Table) + .to_owned(), + ) + .await?; + + manager + .drop_table( + Table::drop() + .if_exists() + .table(FederatedMapping::Table) + .to_owned(), + ) .await?; manager - .drop_table(Table::drop().table(FederatedMapping::Table).to_owned()) + .drop_type( + Type::drop() + .if_exists() + .name(FederatedMappingType::FederatedMappingType) + .to_owned(), + ) .await?; manager @@ -203,6 +242,7 @@ enum FederatedIdentityProvider { OidcResponseMode, OidcResponseTypes, BoundIssuer, + JwksUrl, JwtValidationPubkeys, ProviderConfig, DefaultMappingName, @@ -215,6 +255,7 @@ enum FederatedMapping { DomainId, Name, IdpId, + Type, AllowedRedirectUris, UserIdClaim, UserNameClaim, @@ -228,6 +269,12 @@ enum FederatedMapping { TokenRoleIds, TokenProjectId, } +#[derive(DeriveIden)] +enum FederatedMappingType { + FederatedMappingType, + Oidc, + Jwt, +} #[derive(DeriveIden)] enum FederatedAuthState { diff --git a/src/federation/backends/error.rs b/src/federation/backends/error.rs index d40ce072..75b90349 100644 --- a/src/federation/backends/error.rs +++ b/src/federation/backends/error.rs @@ -12,6 +12,7 @@ // // SPDX-License-Identifier: Apache-2.0 +use sea_orm::SqlErr; use thiserror::Error; use crate::federation::types::*; @@ -25,10 +26,7 @@ pub enum FederationDatabaseError { }, #[error("database error")] - Database { - #[from] - source: sea_orm::DbErr, - }, + Database { source: sea_orm::DbErr }, #[error("identity provider {0} not found")] IdentityProviderNotFound(String), @@ -39,6 +37,14 @@ pub enum FederationDatabaseError { #[error("auth state {0} not found")] AuthStateNotFound(String), + /// Conflict + #[error("conflict: {0}")] + Conflict(String), + + /// SqlError + #[error("sql error: {0}")] + Sql(String), + #[error(transparent)] AuthStateBuilder { #[from] @@ -57,3 +63,16 @@ pub enum FederationDatabaseError { source: MappingBuilderError, }, } + +impl From for FederationDatabaseError { + fn from(err: sea_orm::DbErr) -> Self { + match err.sql_err() { + Some(err) => match err { + SqlErr::UniqueConstraintViolation(descr) => Self::Conflict(descr), + SqlErr::ForeignKeyConstraintViolation(descr) => Self::Conflict(descr), + other => Self::Sql(other.to_string()), + }, + None => Self::Database { source: err }, + } + } +} diff --git a/src/federation/backends/sql.rs b/src/federation/backends/sql.rs index b12b7868..72d37315 100644 --- a/src/federation/backends/sql.rs +++ b/src/federation/backends/sql.rs @@ -83,9 +83,7 @@ impl FederationBackend for SqlBackend { db: &DatabaseConnection, id: &'a str, ) -> Result<(), FederationProviderError> { - identity_provider::delete(&self.config, db, id) - .await - .map_err(FederationProviderError::database) + Ok(identity_provider::delete(&self.config, db, id).await?) } /// List Mapping @@ -136,9 +134,7 @@ impl FederationBackend for SqlBackend { db: &DatabaseConnection, id: &'a str, ) -> Result<(), FederationProviderError> { - mapping::delete(&self.config, db, id) - .await - .map_err(FederationProviderError::database) + Ok(mapping::delete(&self.config, db, id).await?) } /// Get auth state by ID @@ -168,17 +164,13 @@ impl FederationBackend for SqlBackend { db: &DatabaseConnection, id: &'a str, ) -> Result<(), FederationProviderError> { - auth_state::delete(&self.config, db, id) - .await - .map_err(FederationProviderError::database) + Ok(auth_state::delete(&self.config, db, id).await?) } /// Cleanup expired resources #[tracing::instrument(level = "debug", skip(self, db))] async fn cleanup(&self, db: &DatabaseConnection) -> Result<(), FederationProviderError> { - auth_state::delete_expired(&self.config, db) - .await - .map_err(FederationProviderError::database) + Ok(auth_state::delete_expired(&self.config, db).await?) } } diff --git a/src/federation/backends/sql/identity_provider.rs b/src/federation/backends/sql/identity_provider.rs index f289dff2..f4af3152 100644 --- a/src/federation/backends/sql/identity_provider.rs +++ b/src/federation/backends/sql/identity_provider.rs @@ -95,6 +95,7 @@ pub async fn create( .map(|x| Set(x.join(","))) .unwrap_or(NotSet) .into(), + jwks_url: idp.jwks_url.clone().map(Set).unwrap_or(NotSet).into(), jwt_validation_pubkeys: idp .jwt_validation_pubkeys .clone() @@ -138,6 +139,15 @@ pub async fn create( .insert(db) .await?; + db_old_federation_protocol::ActiveModel { + id: Set("jwt".into()), + idp_id: Set(idp.id.clone()), + mapping_id: Set("<>".into()), + remote_id_attribute: NotSet, + } + .insert(db) + .await?; + db_entry.try_into() } @@ -170,6 +180,9 @@ pub async fn update>( if let Some(val) = idp.oidc_response_types { entry.oidc_response_types = Set(val.clone().map(|x| x.join(","))); } + if let Some(val) = idp.jwks_url { + entry.jwks_url = Set(val.to_owned()); + } if let Some(val) = idp.jwt_validation_pubkeys { entry.jwt_validation_pubkeys = Set(val.clone().map(|x| x.join(","))); } @@ -233,6 +246,9 @@ impl TryFrom for IdentityProvider { builder.oidc_response_types(Vec::from_iter(val.split(",").map(Into::into))); } } + if let Some(val) = &value.jwks_url { + builder.jwks_url(val); + } if let Some(val) = &value.jwt_validation_pubkeys { if !val.is_empty() { builder.jwt_validation_pubkeys(Vec::from_iter(val.split(",").map(Into::into))); @@ -313,7 +329,7 @@ mod tests { db.into_transaction_log(), [Transaction::from_sql_and_values( DatabaseBackend::Postgres, - r#"SELECT "federated_identity_provider"."id", "federated_identity_provider"."name", "federated_identity_provider"."domain_id", "federated_identity_provider"."oidc_discovery_url", "federated_identity_provider"."oidc_client_id", "federated_identity_provider"."oidc_client_secret", "federated_identity_provider"."oidc_response_mode", "federated_identity_provider"."oidc_response_types", "federated_identity_provider"."jwt_validation_pubkeys", "federated_identity_provider"."bound_issuer", "federated_identity_provider"."default_mapping_name", "federated_identity_provider"."provider_config" FROM "federated_identity_provider" WHERE "federated_identity_provider"."id" = $1 LIMIT $2"#, + r#"SELECT "federated_identity_provider"."id", "federated_identity_provider"."name", "federated_identity_provider"."domain_id", "federated_identity_provider"."oidc_discovery_url", "federated_identity_provider"."oidc_client_id", "federated_identity_provider"."oidc_client_secret", "federated_identity_provider"."oidc_response_mode", "federated_identity_provider"."oidc_response_types", "federated_identity_provider"."jwks_url", "federated_identity_provider"."jwt_validation_pubkeys", "federated_identity_provider"."bound_issuer", "federated_identity_provider"."default_mapping_name", "federated_identity_provider"."provider_config" FROM "federated_identity_provider" WHERE "federated_identity_provider"."id" = $1 LIMIT $2"#, ["1".into(), 1u64.into()] ),] ); @@ -356,12 +372,12 @@ mod tests { [ Transaction::from_sql_and_values( DatabaseBackend::Postgres, - r#"SELECT "federated_identity_provider"."id", "federated_identity_provider"."name", "federated_identity_provider"."domain_id", "federated_identity_provider"."oidc_discovery_url", "federated_identity_provider"."oidc_client_id", "federated_identity_provider"."oidc_client_secret", "federated_identity_provider"."oidc_response_mode", "federated_identity_provider"."oidc_response_types", "federated_identity_provider"."jwt_validation_pubkeys", "federated_identity_provider"."bound_issuer", "federated_identity_provider"."default_mapping_name", "federated_identity_provider"."provider_config" FROM "federated_identity_provider""#, + r#"SELECT "federated_identity_provider"."id", "federated_identity_provider"."name", "federated_identity_provider"."domain_id", "federated_identity_provider"."oidc_discovery_url", "federated_identity_provider"."oidc_client_id", "federated_identity_provider"."oidc_client_secret", "federated_identity_provider"."oidc_response_mode", "federated_identity_provider"."oidc_response_types", "federated_identity_provider"."jwks_url", "federated_identity_provider"."jwt_validation_pubkeys", "federated_identity_provider"."bound_issuer", "federated_identity_provider"."default_mapping_name", "federated_identity_provider"."provider_config" FROM "federated_identity_provider""#, [] ), Transaction::from_sql_and_values( DatabaseBackend::Postgres, - r#"SELECT "federated_identity_provider"."id", "federated_identity_provider"."name", "federated_identity_provider"."domain_id", "federated_identity_provider"."oidc_discovery_url", "federated_identity_provider"."oidc_client_id", "federated_identity_provider"."oidc_client_secret", "federated_identity_provider"."oidc_response_mode", "federated_identity_provider"."oidc_response_types", "federated_identity_provider"."jwt_validation_pubkeys", "federated_identity_provider"."bound_issuer", "federated_identity_provider"."default_mapping_name", "federated_identity_provider"."provider_config" FROM "federated_identity_provider" WHERE "federated_identity_provider"."name" = $1 AND "federated_identity_provider"."domain_id" = $2"#, + r#"SELECT "federated_identity_provider"."id", "federated_identity_provider"."name", "federated_identity_provider"."domain_id", "federated_identity_provider"."oidc_discovery_url", "federated_identity_provider"."oidc_client_id", "federated_identity_provider"."oidc_client_secret", "federated_identity_provider"."oidc_response_mode", "federated_identity_provider"."oidc_response_types", "federated_identity_provider"."jwks_url", "federated_identity_provider"."jwt_validation_pubkeys", "federated_identity_provider"."bound_issuer", "federated_identity_provider"."default_mapping_name", "federated_identity_provider"."provider_config" FROM "federated_identity_provider" WHERE "federated_identity_provider"."name" = $1 AND "federated_identity_provider"."domain_id" = $2"#, ["idp_name".into(), "did".into()] ), ] @@ -375,6 +391,7 @@ mod tests { .append_query_results([vec![get_idp_mock("1")]]) .append_query_results([vec![get_old_idp_mock("1")]]) .append_query_results([vec![get_old_proto_mock("1")]]) + .append_query_results([vec![get_old_proto_mock("2")]]) .into_connection(); let config = Config::default(); @@ -387,6 +404,7 @@ mod tests { oidc_client_secret: Some("oidccs".into()), oidc_response_mode: Some("oidcrm".into()), oidc_response_types: Some(vec!["t1".into(), "t2".into()]), + jwks_url: Some("http://jwks".into()), jwt_validation_pubkeys: Some(vec!["jt1".into(), "jt2".into()]), bound_issuer: Some("bi".into()), default_mapping_name: Some("dummy".into()), @@ -403,7 +421,7 @@ mod tests { [ Transaction::from_sql_and_values( DatabaseBackend::Postgres, - r#"INSERT INTO "federated_identity_provider" ("id", "name", "domain_id", "oidc_discovery_url", "oidc_client_id", "oidc_client_secret", "oidc_response_mode", "oidc_response_types", "jwt_validation_pubkeys", "bound_issuer", "default_mapping_name", "provider_config") VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING "id", "name", "domain_id", "oidc_discovery_url", "oidc_client_id", "oidc_client_secret", "oidc_response_mode", "oidc_response_types", "jwt_validation_pubkeys", "bound_issuer", "default_mapping_name", "provider_config""#, + r#"INSERT INTO "federated_identity_provider" ("id", "name", "domain_id", "oidc_discovery_url", "oidc_client_id", "oidc_client_secret", "oidc_response_mode", "oidc_response_types", "jwks_url", "jwt_validation_pubkeys", "bound_issuer", "default_mapping_name", "provider_config") VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING "id", "name", "domain_id", "oidc_discovery_url", "oidc_client_id", "oidc_client_secret", "oidc_response_mode", "oidc_response_types", "jwks_url", "jwt_validation_pubkeys", "bound_issuer", "default_mapping_name", "provider_config""#, [ "1".into(), "idp".into(), @@ -413,6 +431,7 @@ mod tests { "oidccs".into(), "oidcrm".into(), "t1,t2".into(), + "http://jwks".into(), "jt1,jt2".into(), "bi".into(), "dummy".into(), @@ -429,6 +448,11 @@ mod tests { r#"INSERT INTO "federation_protocol" ("id", "idp_id", "mapping_id") VALUES ($1, $2, $3) RETURNING "id", "idp_id", "mapping_id", "remote_id_attribute""#, ["oidc".into(), "1".into(), "<>".into()] ), + Transaction::from_sql_and_values( + DatabaseBackend::Postgres, + r#"INSERT INTO "federation_protocol" ("id", "idp_id", "mapping_id") VALUES ($1, $2, $3) RETURNING "id", "idp_id", "mapping_id", "remote_id_attribute""#, + ["jwt".into(), "1".into(), "<>".into()] + ), ] ); } @@ -452,6 +476,7 @@ mod tests { oidc_client_secret: Some(Some("oidccs".into())), oidc_response_mode: Some(Some("oidcrm".into())), oidc_response_types: Some(Some(vec!["t1".into(), "t2".into()])), + jwks_url: Some(Some("http://jwks".into())), jwt_validation_pubkeys: Some(Some(vec!["jt1".into(), "jt2".into()])), bound_issuer: Some(Some("bi".into())), default_mapping_name: Some(Some("dummy".into())), @@ -468,12 +493,12 @@ mod tests { [ Transaction::from_sql_and_values( DatabaseBackend::Postgres, - r#"SELECT "federated_identity_provider"."id", "federated_identity_provider"."name", "federated_identity_provider"."domain_id", "federated_identity_provider"."oidc_discovery_url", "federated_identity_provider"."oidc_client_id", "federated_identity_provider"."oidc_client_secret", "federated_identity_provider"."oidc_response_mode", "federated_identity_provider"."oidc_response_types", "federated_identity_provider"."jwt_validation_pubkeys", "federated_identity_provider"."bound_issuer", "federated_identity_provider"."default_mapping_name", "federated_identity_provider"."provider_config" FROM "federated_identity_provider" WHERE "federated_identity_provider"."id" = $1 LIMIT $2"#, + r#"SELECT "federated_identity_provider"."id", "federated_identity_provider"."name", "federated_identity_provider"."domain_id", "federated_identity_provider"."oidc_discovery_url", "federated_identity_provider"."oidc_client_id", "federated_identity_provider"."oidc_client_secret", "federated_identity_provider"."oidc_response_mode", "federated_identity_provider"."oidc_response_types", "federated_identity_provider"."jwks_url", "federated_identity_provider"."jwt_validation_pubkeys", "federated_identity_provider"."bound_issuer", "federated_identity_provider"."default_mapping_name", "federated_identity_provider"."provider_config" FROM "federated_identity_provider" WHERE "federated_identity_provider"."id" = $1 LIMIT $2"#, ["1".into(), 1u64.into()] ), Transaction::from_sql_and_values( DatabaseBackend::Postgres, - r#"UPDATE "federated_identity_provider" SET "name" = $1, "oidc_discovery_url" = $2, "oidc_client_id" = $3, "oidc_client_secret" = $4, "oidc_response_mode" = $5, "oidc_response_types" = $6, "jwt_validation_pubkeys" = $7, "bound_issuer" = $8, "provider_config" = $9 WHERE "federated_identity_provider"."id" = $10 RETURNING "id", "name", "domain_id", "oidc_discovery_url", "oidc_client_id", "oidc_client_secret", "oidc_response_mode", "oidc_response_types", "jwt_validation_pubkeys", "bound_issuer", "default_mapping_name", "provider_config""#, + r#"UPDATE "federated_identity_provider" SET "name" = $1, "oidc_discovery_url" = $2, "oidc_client_id" = $3, "oidc_client_secret" = $4, "oidc_response_mode" = $5, "oidc_response_types" = $6, "jwks_url" = $7, "jwt_validation_pubkeys" = $8, "bound_issuer" = $9, "provider_config" = $10 WHERE "federated_identity_provider"."id" = $11 RETURNING "id", "name", "domain_id", "oidc_discovery_url", "oidc_client_id", "oidc_client_secret", "oidc_response_mode", "oidc_response_types", "jwks_url", "jwt_validation_pubkeys", "bound_issuer", "default_mapping_name", "provider_config""#, [ "idp".into(), "url".into(), @@ -481,6 +506,7 @@ mod tests { "oidccs".into(), "oidcrm".into(), "t1,t2".into(), + "http://jwks".into(), "jt1,jt2".into(), "bi".into(), json!({"foo": "bar"}).into(), diff --git a/src/federation/backends/sql/mapping.rs b/src/federation/backends/sql/mapping.rs index 65d27c1c..c3db072f 100644 --- a/src/federation/backends/sql/mapping.rs +++ b/src/federation/backends/sql/mapping.rs @@ -19,6 +19,7 @@ use sea_orm::query::*; use crate::config::Config; use crate::db::entity::{ federated_mapping as db_federated_mapping, prelude::FederatedMapping as DbFederatedMapping, + sea_orm_active_enums::MappingType as db_mapping_type, }; use crate::federation::backends::error::FederationDatabaseError; use crate::federation::types::*; @@ -53,6 +54,10 @@ pub async fn list( select = select.filter(db_federated_mapping::Column::IdpId.eq(val)); } + if let Some(val) = ¶ms.r#type { + select = select.filter(db_federated_mapping::Column::r#Type.eq(db_mapping_type::from(val))); + } + let db_entities: Vec = select.all(db).await?; let results: Result, _> = db_entities .into_iter() @@ -62,6 +67,23 @@ pub async fn list( results } +impl From for db_mapping_type { + fn from(value: MappingType) -> db_mapping_type { + match value { + MappingType::Oidc => db_mapping_type::Oidc, + MappingType::Jwt => db_mapping_type::Jwt, + } + } +} +impl From<&MappingType> for db_mapping_type { + fn from(value: &MappingType) -> db_mapping_type { + match value { + MappingType::Oidc => db_mapping_type::Oidc, + MappingType::Jwt => db_mapping_type::Jwt, + } + } +} + pub async fn create( _conf: &Config, db: &DatabaseConnection, @@ -72,6 +94,7 @@ pub async fn create( domain_id: Set(mapping.domain_id.clone()), name: Set(mapping.name.clone()), idp_id: Set(mapping.idp_id.clone()), + r#type: Set(mapping.r#type.into()), allowed_redirect_uris: mapping .allowed_redirect_uris .clone() @@ -154,6 +177,9 @@ pub async fn update>( if let Some(val) = mapping.idp_id { entry.idp_id = Set(val.to_owned()); } + if let Some(val) = mapping.r#type { + entry.r#type = Set(val.into()); + } if let Some(val) = mapping.allowed_redirect_uris { entry.allowed_redirect_uris = Set(val.clone().map(|x| x.join(","))); } @@ -228,6 +254,10 @@ impl TryFrom for Mapping { if let Some(val) = &value.domain_id { builder.domain_id(val); } + builder.r#type(match value.r#type { + db_mapping_type::Oidc => MappingType::Oidc, + db_mapping_type::Jwt => MappingType::Jwt, + }); if let Some(val) = &value.allowed_redirect_uris { if !val.is_empty() { builder.allowed_redirect_uris(Vec::from_iter(val.split(",").map(Into::into))); @@ -288,6 +318,7 @@ mod tests { name: "name".into(), domain_id: Some("did".into()), idp_id: "idp".into(), + r#type: MappingType::default().into(), allowed_redirect_uris: None, user_id_claim: "sub".into(), user_name_claim: "preferred_username".into(), @@ -327,7 +358,7 @@ mod tests { db.into_transaction_log(), [Transaction::from_sql_and_values( DatabaseBackend::Postgres, - r#"SELECT "federated_mapping"."id", "federated_mapping"."name", "federated_mapping"."idp_id", "federated_mapping"."domain_id", "federated_mapping"."allowed_redirect_uris", "federated_mapping"."user_id_claim", "federated_mapping"."user_name_claim", "federated_mapping"."domain_id_claim", "federated_mapping"."groups_claim", "federated_mapping"."bound_audiences", "federated_mapping"."bound_subject", "federated_mapping"."bound_claims", "federated_mapping"."oidc_scopes", "federated_mapping"."token_user_id", "federated_mapping"."token_role_ids", "federated_mapping"."token_project_id" FROM "federated_mapping" WHERE "federated_mapping"."id" = $1 LIMIT $2"#, + r#"SELECT "federated_mapping"."id", "federated_mapping"."name", "federated_mapping"."idp_id", "federated_mapping"."domain_id", CAST("federated_mapping"."type" AS "text"), "federated_mapping"."allowed_redirect_uris", "federated_mapping"."user_id_claim", "federated_mapping"."user_name_claim", "federated_mapping"."domain_id_claim", "federated_mapping"."groups_claim", "federated_mapping"."bound_audiences", "federated_mapping"."bound_subject", "federated_mapping"."bound_claims", "federated_mapping"."oidc_scopes", "federated_mapping"."token_user_id", "federated_mapping"."token_role_ids", "federated_mapping"."token_project_id" FROM "federated_mapping" WHERE "federated_mapping"."id" = $1 LIMIT $2"#, ["1".into(), 1u64.into()] ),] ); @@ -352,7 +383,8 @@ mod tests { &MappingListParameters { name: Some("mapping_name".into()), domain_id: Some("did".into()), - idp_id: Some("idp".into()) + idp_id: Some("idp".into()), + r#type: Some(MappingType::Jwt) } ) .await @@ -374,13 +406,18 @@ mod tests { [ Transaction::from_sql_and_values( DatabaseBackend::Postgres, - r#"SELECT "federated_mapping"."id", "federated_mapping"."name", "federated_mapping"."idp_id", "federated_mapping"."domain_id", "federated_mapping"."allowed_redirect_uris", "federated_mapping"."user_id_claim", "federated_mapping"."user_name_claim", "federated_mapping"."domain_id_claim", "federated_mapping"."groups_claim", "federated_mapping"."bound_audiences", "federated_mapping"."bound_subject", "federated_mapping"."bound_claims", "federated_mapping"."oidc_scopes", "federated_mapping"."token_user_id", "federated_mapping"."token_role_ids", "federated_mapping"."token_project_id" FROM "federated_mapping""#, + r#"SELECT "federated_mapping"."id", "federated_mapping"."name", "federated_mapping"."idp_id", "federated_mapping"."domain_id", CAST("federated_mapping"."type" AS "text"), "federated_mapping"."allowed_redirect_uris", "federated_mapping"."user_id_claim", "federated_mapping"."user_name_claim", "federated_mapping"."domain_id_claim", "federated_mapping"."groups_claim", "federated_mapping"."bound_audiences", "federated_mapping"."bound_subject", "federated_mapping"."bound_claims", "federated_mapping"."oidc_scopes", "federated_mapping"."token_user_id", "federated_mapping"."token_role_ids", "federated_mapping"."token_project_id" FROM "federated_mapping""#, [] ), Transaction::from_sql_and_values( DatabaseBackend::Postgres, - r#"SELECT "federated_mapping"."id", "federated_mapping"."name", "federated_mapping"."idp_id", "federated_mapping"."domain_id", "federated_mapping"."allowed_redirect_uris", "federated_mapping"."user_id_claim", "federated_mapping"."user_name_claim", "federated_mapping"."domain_id_claim", "federated_mapping"."groups_claim", "federated_mapping"."bound_audiences", "federated_mapping"."bound_subject", "federated_mapping"."bound_claims", "federated_mapping"."oidc_scopes", "federated_mapping"."token_user_id", "federated_mapping"."token_role_ids", "federated_mapping"."token_project_id" FROM "federated_mapping" WHERE "federated_mapping"."name" = $1 AND "federated_mapping"."domain_id" = $2 AND "federated_mapping"."idp_id" = $3"#, - ["mapping_name".into(), "did".into(), "idp".into()] + r#"SELECT "federated_mapping"."id", "federated_mapping"."name", "federated_mapping"."idp_id", "federated_mapping"."domain_id", CAST("federated_mapping"."type" AS "text"), "federated_mapping"."allowed_redirect_uris", "federated_mapping"."user_id_claim", "federated_mapping"."user_name_claim", "federated_mapping"."domain_id_claim", "federated_mapping"."groups_claim", "federated_mapping"."bound_audiences", "federated_mapping"."bound_subject", "federated_mapping"."bound_claims", "federated_mapping"."oidc_scopes", "federated_mapping"."token_user_id", "federated_mapping"."token_role_ids", "federated_mapping"."token_project_id" FROM "federated_mapping" WHERE "federated_mapping"."name" = $1 AND "federated_mapping"."domain_id" = $2 AND "federated_mapping"."idp_id" = $3 AND "federated_mapping"."type" = (CAST($4 AS "federated_mapping_type"))"#, + [ + "mapping_name".into(), + "did".into(), + "idp".into(), + "jwt".into() + ] ), ] ); @@ -397,6 +434,7 @@ mod tests { id: "1".into(), name: "mapping".into(), domain_id: Some("foo_domain".into()), + r#type: MappingType::default(), idp_id: "idp".into(), allowed_redirect_uris: Some(vec!["url".into()]), user_id_claim: "sub".into(), @@ -421,12 +459,13 @@ mod tests { db.into_transaction_log(), [Transaction::from_sql_and_values( DatabaseBackend::Postgres, - r#"INSERT INTO "federated_mapping" ("id", "name", "idp_id", "domain_id", "allowed_redirect_uris", "user_id_claim", "user_name_claim", "domain_id_claim", "groups_claim", "bound_audiences", "bound_subject", "bound_claims", "oidc_scopes", "token_user_id", "token_role_ids", "token_project_id") VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) RETURNING "id", "name", "idp_id", "domain_id", "allowed_redirect_uris", "user_id_claim", "user_name_claim", "domain_id_claim", "groups_claim", "bound_audiences", "bound_subject", "bound_claims", "oidc_scopes", "token_user_id", "token_role_ids", "token_project_id""#, + r#"INSERT INTO "federated_mapping" ("id", "name", "idp_id", "domain_id", "type", "allowed_redirect_uris", "user_id_claim", "user_name_claim", "domain_id_claim", "groups_claim", "bound_audiences", "bound_subject", "bound_claims", "oidc_scopes", "token_user_id", "token_role_ids", "token_project_id") VALUES ($1, $2, $3, $4, CAST($5 AS "federated_mapping_type"), $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) RETURNING "id", "name", "idp_id", "domain_id", CAST("type" AS "text"), "allowed_redirect_uris", "user_id_claim", "user_name_claim", "domain_id_claim", "groups_claim", "bound_audiences", "bound_subject", "bound_claims", "oidc_scopes", "token_user_id", "token_role_ids", "token_project_id""#, [ "1".into(), "mapping".into(), "idp".into(), "foo_domain".into(), + "oidc".into(), "url".into(), "sub".into(), "preferred_username".into(), @@ -458,6 +497,7 @@ mod tests { let req = MappingUpdate { name: Some("name".into()), idp_id: Some("idp".into()), + r#type: MappingType::default().into(), allowed_redirect_uris: Some(Some(vec!["url".into()])), user_id_claim: Some("sub".into()), user_name_claim: Some("preferred_username".into()), @@ -482,15 +522,16 @@ mod tests { [ Transaction::from_sql_and_values( DatabaseBackend::Postgres, - r#"SELECT "federated_mapping"."id", "federated_mapping"."name", "federated_mapping"."idp_id", "federated_mapping"."domain_id", "federated_mapping"."allowed_redirect_uris", "federated_mapping"."user_id_claim", "federated_mapping"."user_name_claim", "federated_mapping"."domain_id_claim", "federated_mapping"."groups_claim", "federated_mapping"."bound_audiences", "federated_mapping"."bound_subject", "federated_mapping"."bound_claims", "federated_mapping"."oidc_scopes", "federated_mapping"."token_user_id", "federated_mapping"."token_role_ids", "federated_mapping"."token_project_id" FROM "federated_mapping" WHERE "federated_mapping"."id" = $1 LIMIT $2"#, + r#"SELECT "federated_mapping"."id", "federated_mapping"."name", "federated_mapping"."idp_id", "federated_mapping"."domain_id", CAST("federated_mapping"."type" AS "text"), "federated_mapping"."allowed_redirect_uris", "federated_mapping"."user_id_claim", "federated_mapping"."user_name_claim", "federated_mapping"."domain_id_claim", "federated_mapping"."groups_claim", "federated_mapping"."bound_audiences", "federated_mapping"."bound_subject", "federated_mapping"."bound_claims", "federated_mapping"."oidc_scopes", "federated_mapping"."token_user_id", "federated_mapping"."token_role_ids", "federated_mapping"."token_project_id" FROM "federated_mapping" WHERE "federated_mapping"."id" = $1 LIMIT $2"#, ["1".into(), 1u64.into()] ), Transaction::from_sql_and_values( DatabaseBackend::Postgres, - r#"UPDATE "federated_mapping" SET "name" = $1, "idp_id" = $2, "allowed_redirect_uris" = $3, "user_id_claim" = $4, "user_name_claim" = $5, "domain_id_claim" = $6, "groups_claim" = $7, "bound_audiences" = $8, "bound_subject" = $9, "bound_claims" = $10, "oidc_scopes" = $11, "token_user_id" = $12, "token_role_ids" = $13, "token_project_id" = $14 WHERE "federated_mapping"."id" = $15 RETURNING "id", "name", "idp_id", "domain_id", "allowed_redirect_uris", "user_id_claim", "user_name_claim", "domain_id_claim", "groups_claim", "bound_audiences", "bound_subject", "bound_claims", "oidc_scopes", "token_user_id", "token_role_ids", "token_project_id""#, + r#"UPDATE "federated_mapping" SET "name" = $1, "idp_id" = $2, "type" = CAST($3 AS "federated_mapping_type"), "allowed_redirect_uris" = $4, "user_id_claim" = $5, "user_name_claim" = $6, "domain_id_claim" = $7, "groups_claim" = $8, "bound_audiences" = $9, "bound_subject" = $10, "bound_claims" = $11, "oidc_scopes" = $12, "token_user_id" = $13, "token_role_ids" = $14, "token_project_id" = $15 WHERE "federated_mapping"."id" = $16 RETURNING "id", "name", "idp_id", "domain_id", CAST("type" AS "text"), "allowed_redirect_uris", "user_id_claim", "user_name_claim", "domain_id_claim", "groups_claim", "bound_audiences", "bound_subject", "bound_claims", "oidc_scopes", "token_user_id", "token_role_ids", "token_project_id""#, [ "name".into(), "idp".into(), + "oidc".into(), "url".into(), "sub".into(), "preferred_username".into(), diff --git a/src/federation/error.rs b/src/federation/error.rs index eb9c8488..882e3bc6 100644 --- a/src/federation/error.rs +++ b/src/federation/error.rs @@ -18,40 +18,50 @@ use crate::federation::backends::error::*; #[derive(Error, Debug)] pub enum FederationProviderError { - /// Unsupported driver + /// Unsupported driver. #[error("unsupported driver {0}")] UnsupportedDriver(String), - /// Identity provider error + /// Identity provider error. #[error("data serialization error")] Serde { #[from] source: serde_json::Error, }, - /// IDP not found + /// IDP not found. #[error("identity provider {0} not found")] IdentityProviderNotFound(String), - /// IDP mapping not found + /// IDP mapping not found. #[error("mapping {0} not found")] MappingNotFound(String), - /// Identity provider error + /// Use of token_user_id requires domain_id to be set. + #[error("`mapping.token_user_id` must be set")] + MappingTokenUserDomainUnset, + + /// Use of token_project_id requires domain_id to be set. + #[error("`mapping.token_project_id` must be set")] + MappingTokenProjectDomainUnset, + + /// Conflict. + #[error("oha {0}")] + Conflict(String), + + /// Identity provider error. #[error(transparent)] - FederationDatabase { - #[from] - source: FederationDatabaseError, - }, + FederationDatabase { source: FederationDatabaseError }, } -impl FederationProviderError { - pub fn database(source: FederationDatabaseError) -> Self { +impl From for FederationProviderError { + fn from(source: FederationDatabaseError) -> Self { match source { FederationDatabaseError::IdentityProviderNotFound(x) => { Self::IdentityProviderNotFound(x) } FederationDatabaseError::MappingNotFound(x) => Self::MappingNotFound(x), + FederationDatabaseError::Conflict(x) => Self::Conflict(x), _ => Self::FederationDatabase { source }, } } diff --git a/src/federation/mod.rs b/src/federation/mod.rs index 052be5c8..a7152670 100644 --- a/src/federation/mod.rs +++ b/src/federation/mod.rs @@ -81,14 +81,14 @@ pub trait FederationApi: Send + Sync + Clone { async fn create_mapping( &self, db: &DatabaseConnection, - idp: Mapping, + mapping: Mapping, ) -> Result; async fn update_mapping<'a>( &self, db: &DatabaseConnection, id: &'a str, - idp: MappingUpdate, + mapping: MappingUpdate, ) -> Result; async fn delete_mapping<'a>( @@ -175,7 +175,7 @@ mock! { async fn create_mapping( &self, db: &DatabaseConnection, - idp: Mapping, + mapping: Mapping, ) -> Result; /// Update mapping @@ -183,7 +183,7 @@ mock! { &self, db: &DatabaseConnection, id: &'a str, - idp: MappingUpdate, + mapping: MappingUpdate, ) -> Result; /// Delete mapping @@ -279,7 +279,9 @@ impl FederationApi for FederationProvider { idp: IdentityProvider, ) -> Result { let mut mod_idp = idp; - mod_idp.id = Uuid::new_v4().into(); + if mod_idp.id.is_empty() { + mod_idp.id = Uuid::new_v4().into(); + } self.backend_driver .create_identity_provider(db, mod_idp) @@ -334,12 +336,29 @@ impl FederationApi for FederationProvider { async fn create_mapping( &self, db: &DatabaseConnection, - idp: Mapping, + mapping: Mapping, ) -> Result { - let mut mod_idp = idp; - mod_idp.id = Uuid::new_v4().into(); + let mut mod_mapping = mapping; + mod_mapping.id = Uuid::new_v4().into(); + if let Some(_uid) = &mod_mapping.token_user_id { + // ensure domain_id is set and matches the user_id. + if let Some(_did) = &mod_mapping.domain_id { + // TODO: Get the user_id and compare the domain_id + } else { + return Err(FederationProviderError::MappingTokenUserDomainUnset); + } + } + if let Some(_pid) = &mod_mapping.token_project_id { + // ensure domain_id is set and matches the one of the project_id. + if let Some(_did) = &mod_mapping.domain_id { + // TODO: Get the project_id and compare the domain_id + } else { + return Err(FederationProviderError::MappingTokenProjectDomainUnset); + } + // TODO: ensure current user has access to the project + } - self.backend_driver.create_mapping(db, mod_idp).await + self.backend_driver.create_mapping(db, mod_mapping).await } /// Update mapping @@ -348,10 +367,37 @@ impl FederationApi for FederationProvider { &self, db: &DatabaseConnection, id: &'a str, - idp: MappingUpdate, + mapping: MappingUpdate, ) -> Result { - // TODO: Check update of idp_id to ensure it belongs to the same domain - self.backend_driver.update_mapping(db, id, idp).await + let current = self + .backend_driver + .get_mapping(db, id) + .await? + .ok_or_else(|| FederationProviderError::MappingNotFound(id.to_string()))?; + + if let Some(_new_idp_id) = &mapping.idp_id { + // TODO: Check the new idp_id domain escaping + } + + if let Some(_uid) = &mapping.token_user_id { + // ensure domain_id is set and matches the user_id. + if let Some(_did) = ¤t.domain_id { + // TODO: Get the user_id and compare the domain_id + } else { + return Err(FederationProviderError::MappingTokenUserDomainUnset); + } + } + if let Some(_pid) = &mapping.token_project_id { + // ensure domain_id is set and matches the one of the project_id. + if let Some(_did) = ¤t.domain_id { + // TODO: Get the project_id and compare the domain_id + } else { + return Err(FederationProviderError::MappingTokenProjectDomainUnset); + } + // TODO: ensure current user has access to the project + } + // TODO: Pass current to the backend to skip re-fetching + self.backend_driver.update_mapping(db, id, mapping).await } /// Delete identity provider diff --git a/src/federation/types/identity_provider.rs b/src/federation/types/identity_provider.rs index 3a8921f1..4f150f1d 100644 --- a/src/federation/types/identity_provider.rs +++ b/src/federation/types/identity_provider.rs @@ -43,6 +43,9 @@ pub struct IdentityProvider { #[builder(default)] pub oidc_response_types: Option>, + #[builder(default)] + pub jwks_url: Option, + #[builder(default)] pub jwt_validation_pubkeys: Option>, @@ -77,6 +80,9 @@ pub struct IdentityProviderUpdate { #[builder(default)] pub oidc_response_types: Option>>, + #[builder(default)] + pub jwks_url: Option>, + #[builder(default)] pub jwt_validation_pubkeys: Option>>, diff --git a/src/federation/types/mapping.rs b/src/federation/types/mapping.rs index 72687ce9..391683ee 100644 --- a/src/federation/types/mapping.rs +++ b/src/federation/types/mapping.rs @@ -16,107 +16,152 @@ use derive_builder::Builder; use serde::{Deserialize, Serialize}; use serde_json::Value; +/// Attribute mapping data. #[derive(Builder, Clone, Debug, Default, Deserialize, Serialize, PartialEq)] #[builder(setter(strip_option, into))] pub struct Mapping { - /// Federation IDP mapping ID + /// Federation IDP attribute mapping ID. pub id: String, - /// Mapping name + /// Attribute mapping name. pub name: String, + /// ID of the domain for the attribute mapping. #[builder(default)] pub domain_id: Option, - /// IDP ID + /// Identity provider for the attribute mapping. pub idp_id: String, + /// Mapping type. + pub r#type: MappingType, + + /// List of allowed redirect_uri for the oidc mapping. #[builder(default)] pub allowed_redirect_uris: Option>, + /// Claim attribute name to extract `user_id`. #[builder(default)] pub user_id_claim: String, + /// Claim attribute name to extract `user_name`. #[builder(default)] pub user_name_claim: String, + /// Claim attribute name to extract `domain_id`. #[builder(default)] pub domain_id_claim: Option, + /// Claim attribute name to extract list of groups. #[builder(default)] pub groups_claim: Option, + /// Fixed (JWT) audiences that the assertion must be issued for. #[builder(default)] pub bound_audiences: Option>, + /// Fixed subject that the assertion (jwt) must be issued for. #[builder(default)] pub bound_subject: Option, + /// Additional claims to further restrict the attribute mapping. #[builder(default)] pub bound_claims: Option, + /// List of the oidc scopes to request in the oidc flow. #[builder(default)] pub oidc_scopes: Option>, //#[builder(default)] //pub claim_mappings: Option, + /// Fixed `user_id` of the token to issue for successful authentication. #[builder(default)] pub token_user_id: Option, + /// List of fixed role ids of the token to issue for successful authentication. #[builder(default)] pub token_role_ids: Option>, + /// Fixed `project_id` scope of the token to issue for successful authentication. #[builder(default)] pub token_project_id: Option, } +/// Update attribute mapping data. #[derive(Builder, Clone, Debug, Default, Deserialize, Serialize, PartialEq)] #[builder(setter(into))] pub struct MappingUpdate { - /// Mapping name + /// Attribute mapping name. pub name: Option, - // TODO: on update must check that domain_id match + /// Identity provider for the attribute mapping. #[builder(default)] pub idp_id: Option, + /// Mapping type. + #[builder(default)] + pub r#type: Option, + + /// List of allowed redirect_uri for the oidc mapping. #[builder(default)] pub allowed_redirect_uris: Option>>, + /// Claim attribute name to extract `user_id`. #[builder(default)] pub user_id_claim: Option, + /// Claim attribute name to extract `user_name`. #[builder(default)] pub user_name_claim: Option, + /// Claim attribute name to extract `domain_id`. #[builder(default)] pub domain_id_claim: Option, + /// claim attribute name to extract list of groups. #[builder(default)] pub groups_claim: Option>, + /// Fixed (JWT) audiences that the assertion must be issued for. #[builder(default)] pub bound_audiences: Option>>, + /// Fixed subject that the assertion (jwt) must be issued for. #[builder(default)] pub bound_subject: Option>, + /// Additional claims to further restrict the attribute mapping. #[builder(default)] pub bound_claims: Option, + /// List of the oidc scopes to request in the oidc flow. #[builder(default)] pub oidc_scopes: Option>>, + /// Fixed `user_id` of the token to issue for successful authentication. #[builder(default)] pub token_user_id: Option>, + /// List of fixed role ids of the token to issue for successful authentication. #[builder(default)] pub token_role_ids: Option>>, + /// Fixed `project_id` scope of the token to issue for successful authentication. #[builder(default)] pub token_project_id: Option>, } +/// Attribute mapping type. +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +pub enum MappingType { + #[default] + /// OIDC + Oidc, + /// JWT + Jwt, +} + +/// List attribute mappings request. #[derive(Builder, Clone, Debug, Default, Deserialize, PartialEq, Serialize)] #[builder(setter(strip_option, into))] pub struct MappingListParameters { @@ -126,4 +171,6 @@ pub struct MappingListParameters { pub domain_id: Option, /// Filters the response by IDP ID. pub idp_id: Option, + /// Filters mappings by the type + pub r#type: Option, } diff --git a/src/token/federation_unscoped.rs b/src/token/federation_unscoped.rs index fe82a805..f23540b3 100644 --- a/src/token/federation_unscoped.rs +++ b/src/token/federation_unscoped.rs @@ -104,7 +104,6 @@ impl MsgPackToken for FederationUnscopedPayload { ) -> Result { // Order of reading is important let user_id = fernet_utils::read_uuid(rd)?; - println!("u: {user_id:?}"); let methods: Vec = fernet::decode_auth_methods(read_pfix(rd)?.into(), auth_map)? .into_iter() .collect(); diff --git a/tests/github/keystone_utils.rs b/tests/github/keystone_utils.rs new file mode 100644 index 00000000..cbb684c6 --- /dev/null +++ b/tests/github/keystone_utils.rs @@ -0,0 +1,150 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +use eyre::Report; +use reqwest::Client; +use serde_json::json; +use std::env; + +use openstack_keystone::api::v4::federation::types::*; +use openstack_keystone::api::v4::user::types::*; + +pub async fn auth() -> String { + let keystone_url = env::var("KEYSTONE_URL").expect("KEYSTONE_URL is set"); + let client = Client::new(); + client + .post(format!("{}/v3/auth/tokens", keystone_url,)) + .json(&json!({"auth": {"identity": { + "methods": [ + "password" + ], + "password": { + "user": { + "name": "admin", + "password": "password", + "domain": { + "id": "default" + }, + } + } + }, + "scope": { + "project": { + "name": "admin", + "domain": {"id": "default"} + } + }}})) + .send() + .await + .unwrap() + .headers() + .get("X-Subject-Token") + .unwrap() + .to_str() + .unwrap() + .to_string() +} + +pub async fn setup_github_idp>( + token: T, + user: &User, +) -> Result<(IdentityProviderResponse, MappingResponse), Report> { + let keystone_url = env::var("KEYSTONE_URL").expect("KEYSTONE_URL is set"); + let github_sub = env::var("GITHUB_SUB").expect("GITHUB_SUB is set"); + let client = Client::new(); + + let idp: IdentityProviderResponse = client + .post(format!("{}/v4/federation/identity_providers", keystone_url)) + .header("x-auth-token", token.as_ref()) + .json(&json!({ + "identity_provider": { + "id": "github", + "name": "github", + "bound_issuer": "https://token.actions.githubusercontent.com", + "jwks_url": "https://token.actions.githubusercontent.com/.well-known/jwks", + } + })) + .send() + .await? + .json() + .await?; + + let mapping: MappingResponse = client + .post(format!("{}/v4/federation/mappings", keystone_url,)) + .header("x-auth-token", token.as_ref()) + .json(&json!({ + "mapping": { + "id": "github", + "name": "github", + "type": "jwt", + "idp_id": idp.identity_provider.id.clone(), + "domain_id": user.domain_id, + "bound_audiences": vec!["https://github.com"], + "bound_claims": { + "base_ref": "main" + }, + "bound_subject": github_sub, + "user_id_claim": "actor_id", + "user_name_claim": "actor", + "token_user_id": user.id + } + })) + .send() + .await? + .json() + .await?; + + Ok((idp, mapping)) +} + +pub async fn ensure_user, U: AsRef, D: AsRef>( + token: T, + user_name: U, + domain_id: D, +) -> Result { + let keystone_url = env::var("KEYSTONE_URL").expect("KEYSTONE_URL is set"); + let client = Client::new(); + + let user_rsp = client + .post(format!("{}/v4/users", keystone_url)) + .header("x-auth-token", token.as_ref()) + .json(&json!({ + "user": { + "name": user_name.as_ref(), + "domain_id": domain_id.as_ref() + } + })) + .send() + .await?; + if !user_rsp.status().is_success() { + return Ok(client + .get(format!("{}/v4/users", keystone_url)) + .query(&[ + ("domain_id", domain_id.as_ref()), + ("name", user_name.as_ref()), + ]) + .header("x-auth-token", token.as_ref()) + .send() + .await? + .json::() + .await? + .users + .first() + .expect("cannot find user") + .clone()); + } + let user: UserResponse = user_rsp.json().await?; + + Ok(user.user) +} diff --git a/tests/github/main.rs b/tests/github/main.rs new file mode 100644 index 00000000..ff9e638b --- /dev/null +++ b/tests/github/main.rs @@ -0,0 +1,49 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +use reqwest::Client; +use reqwest::header::AUTHORIZATION; +use std::env; + +mod keystone_utils; + +use keystone_utils::*; + +use openstack_keystone::api::v4::auth::token::types::TokenResponse; + +#[tokio::test] +async fn test_login_jwt() { + let keystone_url = env::var("KEYSTONE_URL").expect("KEYSTONE_URL is set"); + let jwt = env::var("GITHUB_JWT").expect("GITHUB_JWT is set"); + let client = Client::new(); + + let token = auth().await; + let user = ensure_user(&token, "jwt_user", "default").await.unwrap(); + let (idp, mapping) = setup_github_idp(&token, &user).await.unwrap(); + + let auth_rsp: TokenResponse = client + .post(format!( + "{}/v4/federation/identity_providers/{}/jwt", + keystone_url, idp.identity_provider.id + )) + .header(AUTHORIZATION, jwt) + .header("openstack-mapping", mapping.mapping.name) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + println!("Token: {:?}", auth_rsp); +} diff --git a/tests/keycloak/keycloak_utils.rs b/tests/keycloak/keycloak_utils.rs index 5e6de8c5..9615f566 100644 --- a/tests/keycloak/keycloak_utils.rs +++ b/tests/keycloak/keycloak_utils.rs @@ -13,11 +13,9 @@ // SPDX-License-Identifier: Apache-2.0 use eyre::Report; -use keycloak::{ - types::*, - {KeycloakAdmin, KeycloakAdminToken}, -}; +use keycloak::{KeycloakAdmin, KeycloakAdminToken, KeycloakError, types::*}; use reqwest::Client; +use serde::Deserialize; use std::collections::HashMap; pub async fn get_keycloak_admin(client: &Client) -> Result { @@ -56,11 +54,14 @@ pub async fn create_keycloak_client, S2: AsRef>( ..Default::default() }] .into(), + // allow generating JWT directly + direct_access_grants_enabled: Some(true), ..Default::default() }; - admin.realm_clients_post(realm, keystone_client_req).await?; - - Ok(()) + match admin.realm_clients_post(realm, keystone_client_req).await { + Ok(_) | Err(KeycloakError::HttpFailure { status: 409, .. }) => Ok(()), + Err(err) => Err(err)?, + } } pub async fn create_keycloak_user, P: AsRef>( @@ -81,7 +82,42 @@ pub async fn create_keycloak_user, P: AsRef>( enabled: Some(true), ..Default::default() }; - admin.realm_users_post(realm, user_req).await?; + match admin.realm_users_post(realm, user_req).await { + Ok(_) | Err(KeycloakError::HttpFailure { status: 409, .. }) => Ok(()), + Err(err) => Err(err)?, + } +} + +#[derive(Debug, Deserialize)] +pub struct AuthResponse { + pub id_token: String, +} - Ok(()) +pub async fn generate_user_jwt( + client_id: &'static str, + client_secret: &'static str, + user: &'static str, + password: &'static str, +) -> Result { + let client = Client::new(); + let url = std::env::var("KEYCLOAK_URL").unwrap_or_else(|_| "http://localhost:8082".into()); + let realm = "master"; + let response: AuthResponse = client + .post(format!( + "{}/realms/{}/protocol/openid-connect/token", + url, realm + )) + .form(&[ + ("client_id", client_id), + ("client_secret", client_secret), + ("username", user), + ("password", password), + ("scope", "openid"), + ("grant_type", "password"), + ]) + .send() + .await? + .json() + .await?; + Ok(response.id_token) } diff --git a/tests/keycloak/keystone_utils.rs b/tests/keycloak/keystone_utils.rs index 18b51d2f..13f30ace 100644 --- a/tests/keycloak/keystone_utils.rs +++ b/tests/keycloak/keystone_utils.rs @@ -13,7 +13,7 @@ // SPDX-License-Identifier: Apache-2.0 use bytes::Bytes; -use eyre::Report; +use eyre::{Report, eyre}; use http_body_util::{BodyExt, Empty, combinators::BoxBody}; use hyper::server::conn::http1; use hyper::service::service_fn; @@ -31,6 +31,7 @@ use tokio::net::TcpListener; use tokio_util::sync::CancellationToken; use openstack_keystone::api::v4::federation::types::*; +use openstack_keystone::api::v4::user::types::*; pub async fn auth() -> String { let keystone_url = env::var("KEYSTONE_URL").expect("KEYSTONE_URL is set"); @@ -117,6 +118,98 @@ pub async fn setup_kecloak_idp, K: AsRef, S: AsRef>( Ok((idp, mapping)) } +pub async fn setup_kecloak_idp_jwt, K: AsRef, S: AsRef>( + token: T, + _client_id: K, + _client_secret: S, +) -> Result<(IdentityProviderResponse, MappingResponse), Report> { + let keystone_url = env::var("KEYSTONE_URL").expect("KEYSTONE_URL is set"); + let keycloak_url = env::var("KEYCLOAK_URL").expect("KEYCLOAK_URL is set"); + let client = Client::new(); + + let idp_rsp = client + .post(format!("{}/v4/federation/identity_providers", keystone_url)) + .header("x-auth-token", token.as_ref()) + .json(&json!({ + "identity_provider": { + "id": "kc_jwt", + "name": "keycloak_jwt", + "oidc_discovery_url": format!("{}/realms/master", keycloak_url), + "jwks_url": format!("{}/realms/master/protocol/openid-connect/certs", keycloak_url), + "bound_issuer": format!("{}/realms/master", keycloak_url) + } + })) + .send() + .await?; + if !idp_rsp.status().is_success() { + return Err(eyre!("{:?}", idp_rsp.text().await?)); + } + + let idp: IdentityProviderResponse = idp_rsp.json().await?; + + let mapping: MappingResponse = client + .post(format!("{}/v4/federation/mappings", keystone_url,)) + .header("x-auth-token", token.as_ref()) + .json(&json!({ + "mapping": { + "id": "kc_jwt", + "name": "keycloak_jwt", + "idp_id": idp.identity_provider.id.clone(), + "type": "jwt", + "user_id_claim": "sub", + "user_name_claim": "preferred_username", + "domain_id_claim": "domain_id" + } + })) + .send() + .await? + .json() + .await?; + + Ok((idp, mapping)) +} + +pub async fn ensure_user, U: AsRef, D: AsRef>( + token: T, + user_name: U, + domain_id: D, +) -> Result { + let keystone_url = env::var("KEYSTONE_URL").expect("KEYSTONE_URL is set"); + let client = Client::new(); + + let user_rsp = client + .post(format!("{}/v4/users", keystone_url)) + .header("x-auth-token", token.as_ref()) + .json(&json!({ + "user": { + "name": user_name.as_ref(), + "domain_id": domain_id.as_ref() + } + })) + .send() + .await?; + if !user_rsp.status().is_success() { + return Ok(client + .get(format!("{}/v4/users", keystone_url)) + .query(&[ + ("domain_id", domain_id.as_ref()), + ("name", user_name.as_ref()), + ]) + .header("x-auth-token", token.as_ref()) + .send() + .await? + .json::() + .await? + .users + .first() + .expect("cannot find user") + .clone()); + } + let user: UserResponse = user_rsp.json().await?; + + Ok(user.user) +} + /// Information for finishing the authorization request (received as a callback from `/authorize` /// call) #[derive(Clone, Debug, Deserialize, PartialEq)] diff --git a/tests/keycloak/main.rs b/tests/keycloak/main.rs index 76a83344..a68d1861 100644 --- a/tests/keycloak/main.rs +++ b/tests/keycloak/main.rs @@ -13,6 +13,7 @@ // SPDX-License-Identifier: Apache-2.0 use reqwest::Client; +use reqwest::header::AUTHORIZATION; use serde_json::json; use std::env; use std::sync::{Arc, Mutex}; @@ -30,7 +31,7 @@ use openstack_keystone::api::v4::auth::token::types::TokenResponse; use openstack_keystone::api::v4::federation::types::*; #[tokio::test] -async fn test_login_keycloak() { +async fn test_login_oidc_keycloak() { let keystone_url = env::var("KEYSTONE_URL").expect("KEYSTONE_URL is set"); let client = Client::new(); let user_name = "test"; @@ -138,3 +139,46 @@ async fn test_login_keycloak() { // TODO: Add checks for the response } + +#[tokio::test] +async fn test_login_jwt_keycloak() { + let keystone_url = env::var("KEYSTONE_URL").expect("KEYSTONE_URL is set"); + let client = Client::new(); + let user_name = "test"; + let user_password = "pass"; + let client_id = "keystone_test"; + let client_secret = "keystone_test_secret"; + + let keycloak = get_keycloak_admin(&client).await.unwrap(); + + create_keycloak_client(&keycloak, client_id, client_secret) + .await + .unwrap(); + create_keycloak_user(&keycloak, user_name, user_password) + .await + .unwrap(); + let jwt = generate_user_jwt(client_id, client_secret, user_name, user_password) + .await + .unwrap(); + println!("jwt is {:?}", jwt); + + let token = auth().await; + let user = ensure_user(&token, "jwt_user", "default").await.unwrap(); + let (idp, mapping) = setup_kecloak_idp_jwt(&token, client_id, client_secret) + .await + .unwrap(); + + let _auth_rsp: TokenResponse = client + .post(format!( + "{}/v4/federation/identity_providers/{}/jwt", + keystone_url, idp.identity_provider.id + )) + .header(AUTHORIZATION, jwt) + .header("openstack-mapping", mapping.mapping.name) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); +}