Skip to content

Commit e26bdb9

Browse files
committed
feat: Implement JWT login
implement JWT login gated behind the keystone_ng feature.
1 parent 49f6494 commit e26bdb9

File tree

7 files changed

+257
-11
lines changed

7 files changed

+257
-11
lines changed

openstack_cli/src/output.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ use comfy_table::{
2020
use itertools::Itertools;
2121
use openstack_sdk::types::EntryStatus;
2222
use owo_colors::{OwoColorize, Stream::Stderr};
23-
use rand::prelude::*; //seq::IndexedRandom;
23+
use rand::prelude::*;
2424
use serde::de::DeserializeOwned;
2525
use std::collections::BTreeSet;
2626
use std::io::{self, Write};

openstack_sdk/src/auth.rs

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,15 +38,15 @@ pub mod v3totp;
3838
pub mod v3websso;
3939
#[cfg(feature = "keystone_ng")]
4040
pub mod v4federation;
41+
#[cfg(feature = "keystone_ng")]
42+
pub mod v4jwt;
4143
#[cfg(feature = "passkey")]
4244
pub mod v4passkey;
4345

4446
use authtoken::{AuthToken, AuthTokenError};
4547
use authtoken_scope::AuthTokenScopeError;
4648
use v3oidcaccesstoken::OidcAccessTokenError;
4749
use v3websso::WebSsoError;
48-
#[cfg(feature = "keystone_ng")]
49-
use v4federation::FederationError;
5050

5151
/// Authentication error
5252
#[derive(Debug, Error)]
@@ -97,14 +97,23 @@ impl From<WebSsoError> for AuthError {
9797
}
9898

9999
#[cfg(feature = "keystone_ng")]
100-
impl From<FederationError> for AuthError {
100+
impl From<v4federation::FederationError> for AuthError {
101101
fn from(source: v4federation::FederationError) -> Self {
102102
Self::AuthToken {
103103
source: source.into(),
104104
}
105105
}
106106
}
107107

108+
#[cfg(feature = "keystone_ng")]
109+
impl From<v4jwt::JwtError> for AuthError {
110+
fn from(source: v4jwt::JwtError) -> Self {
111+
Self::AuthToken {
112+
source: source.into(),
113+
}
114+
}
115+
}
116+
108117
#[cfg(feature = "passkey")]
109118
impl From<v4passkey::PasskeyError> for AuthError {
110119
fn from(source: v4passkey::PasskeyError) -> Self {

openstack_sdk/src/auth/authtoken.rs

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,14 @@ use tracing::{debug, error, trace};
2626
use crate::api::identity::v3::auth::token::get as token_v3_info;
2727
use crate::api::RestEndpoint;
2828
use crate::auth::auth_token_endpoint as token_v3;
29-
#[cfg(feature = "keystone_ng")]
30-
use crate::auth::v4federation;
3129
#[cfg(feature = "passkey")]
3230
use crate::auth::v4passkey;
3331
use crate::auth::{
3432
auth_helper::AuthHelper, authtoken_scope, v3applicationcredential, v3oidcaccesstoken,
3533
v3password, v3token, v3totp, v3websso, AuthState,
3634
};
35+
#[cfg(feature = "keystone_ng")]
36+
use crate::auth::{v4federation, v4jwt};
3737
use crate::config;
3838
use crate::types::identity::v3::{AuthReceiptResponse, AuthResponse};
3939

@@ -152,15 +152,15 @@ pub enum AuthTokenError {
152152
source: v3totp::TotpError,
153153
},
154154

155-
/// WebSSO Identity error
155+
/// WebSSO Identity error.
156156
#[error("SSO based authentication error: {}", source)]
157157
WebSso {
158158
/// The error source
159159
#[from]
160160
source: v3websso::WebSsoError,
161161
},
162162

163-
/// Federation Identity error
163+
/// Federation Identity error.
164164
#[cfg(feature = "keystone_ng")]
165165
#[error("Federation based authentication error: {}", source)]
166166
Federation {
@@ -169,7 +169,16 @@ pub enum AuthTokenError {
169169
source: v4federation::FederationError,
170170
},
171171

172-
/// Passkey error
172+
/// JWT error.
173+
#[cfg(feature = "keystone_ng")]
174+
#[error("Jwt based authentication error: {}", source)]
175+
Jwt {
176+
/// The error source
177+
#[from]
178+
source: v4jwt::JwtError,
179+
},
180+
181+
/// Passkey error.
173182
#[cfg(feature = "passkey")]
174183
#[error("Passkey based authentication error: {}", source)]
175184
Passkey {
@@ -275,6 +284,9 @@ pub enum AuthType {
275284
#[cfg(feature = "keystone_ng")]
276285
/// Federation.
277286
V4Federation,
287+
#[cfg(feature = "keystone_ng")]
288+
/// JWT.
289+
V4Jwt,
278290
#[cfg(feature = "passkey")]
279291
/// Passkey.
280292
V4Passkey,
@@ -296,6 +308,8 @@ impl FromStr for AuthType {
296308
"v3websso" => Ok(Self::V3WebSso),
297309
#[cfg(feature = "keystone_ng")]
298310
"v4federation" | "federation" => Ok(Self::V4Federation),
311+
#[cfg(feature = "keystone_ng")]
312+
"v4jwt" | "jwt" => Ok(Self::V4Jwt),
299313
#[cfg(feature = "passkey")]
300314
"v4passkey" | "passkey" => Ok(Self::V4Passkey),
301315
other => Err(Self::Err::IdentityMethod {
@@ -327,6 +341,8 @@ impl AuthType {
327341
Self::V3WebSso => "v3websso",
328342
#[cfg(feature = "keystone_ng")]
329343
Self::V4Federation => "v4federation",
344+
#[cfg(feature = "keystone_ng")]
345+
Self::V4Jwt => "v4jwt",
330346
#[cfg(feature = "passkey")]
331347
Self::V4Passkey => "v4passkey",
332348
}

openstack_sdk/src/auth/v4jwt.rs

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
// Licensed under the Apache License, Version 2.0 (the "License");
2+
// you may not use this file except in compliance with the License.
3+
// You may obtain a copy of the License at
4+
//
5+
// http://www.apache.org/licenses/LICENSE-2.0
6+
//
7+
// Unless required by applicable law or agreed to in writing, software
8+
// distributed under the License is distributed on an "AS IS" BASIS,
9+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10+
// See the License for the specific language governing permissions and
11+
// limitations under the License.
12+
//
13+
// SPDX-License-Identifier: Apache-2.0
14+
15+
//! JWT login handling
16+
//!
17+
//! This module implements login using the JWT token by exchanging it for a regular Keystone token.
18+
19+
use derive_builder::Builder;
20+
use http::{header, HeaderMap, HeaderName, HeaderValue};
21+
use secrecy::{ExposeSecret, SecretString};
22+
use std::borrow::Cow;
23+
use thiserror::Error;
24+
use tracing::error;
25+
26+
use crate::api::rest_endpoint_prelude::*;
27+
use crate::api::RestEndpoint;
28+
use crate::auth::auth_helper::AuthHelper;
29+
use crate::config;
30+
use crate::types::{ApiVersion, ServiceType};
31+
32+
/// JWT related errors
33+
#[derive(Debug, Error)]
34+
#[non_exhaustive]
35+
pub enum JwtError {
36+
/// Auth data is missing.
37+
#[error("auth data is missing")]
38+
MissingAuthData,
39+
40+
/// Identity provider id is missing.
41+
#[error("identity provider id is missing")]
42+
MissingIdentityProvider,
43+
44+
/// Attribute mapping name is missing.
45+
#[error("attribute mapping name is missing")]
46+
MissingAttributeMapping,
47+
48+
/// JWT is missing.
49+
#[error("JWT is missing")]
50+
MissingJwt,
51+
52+
/// Jwt Auth builder.
53+
#[error("error preparing auth request: {}", source)]
54+
JwtBuilder {
55+
/// The error source.
56+
#[from]
57+
source: JwtRequestBuilderError,
58+
},
59+
60+
/// HeaderValue error.
61+
#[error("invalid value for the header: {}", source)]
62+
HeaderValue {
63+
/// The error source.
64+
#[from]
65+
source: http::header::InvalidHeaderValue,
66+
},
67+
}
68+
69+
/// Endpoint for the JWT authorization
70+
#[derive(Builder, Debug, Clone)]
71+
#[builder(setter(into, strip_option))]
72+
pub struct JwtRequest<'a> {
73+
/// idp_id that issued the JWT.
74+
#[builder(setter(into))]
75+
idp_id: Cow<'a, str>,
76+
/// Attribute mapping name.
77+
78+
#[builder(default, private)]
79+
_headers: Option<HeaderMap>,
80+
}
81+
82+
impl<'a> JwtRequest<'a> {
83+
/// Create a builder for the endpoint.
84+
pub fn builder() -> JwtRequestBuilder<'a> {
85+
JwtRequestBuilder::default()
86+
}
87+
}
88+
89+
impl<'a> JwtRequestBuilder<'a> {
90+
/// Set attribute mapping name.
91+
pub fn mapping_name<S: AsRef<str>>(&mut self, mapping_name: S) -> Result<(), JwtError> {
92+
let val = HeaderValue::from_str(mapping_name.as_ref())?;
93+
self._headers
94+
.get_or_insert(None)
95+
.get_or_insert_with(HeaderMap::new)
96+
.insert(HeaderName::from_static("openstack-mapping"), val);
97+
Ok(())
98+
}
99+
100+
/// Set the JWT token.
101+
pub fn token(&mut self, token: &SecretString) -> Result<(), JwtError> {
102+
let mut val = HeaderValue::from_str(format!("bearer {}", token.expose_secret()).as_str())?;
103+
val.set_sensitive(true);
104+
self._headers
105+
.get_or_insert(None)
106+
.get_or_insert_with(HeaderMap::new)
107+
.insert(header::AUTHORIZATION, val);
108+
Ok(())
109+
}
110+
}
111+
112+
impl RestEndpoint for JwtRequest<'_> {
113+
fn method(&self) -> http::Method {
114+
http::Method::POST
115+
}
116+
117+
fn endpoint(&self) -> Cow<'static, str> {
118+
format!(
119+
"federation/identity_providers/{idp_id}/jwt",
120+
idp_id = self.idp_id.as_ref(),
121+
)
122+
.into()
123+
}
124+
125+
fn body(&self) -> Result<Option<(&'static str, Vec<u8>)>, BodyError> {
126+
JsonBodyParams::default().into_body()
127+
}
128+
129+
fn service_type(&self) -> ServiceType {
130+
ServiceType::Identity
131+
}
132+
133+
/// Returns headers to be set into the request
134+
fn request_headers(&self) -> Option<&HeaderMap> {
135+
self._headers.as_ref()
136+
}
137+
138+
/// Returns required API version
139+
fn api_version(&self) -> Option<ApiVersion> {
140+
Some(ApiVersion::new(4, 0))
141+
}
142+
}
143+
144+
/// Get [`RestEndpoint`] for initializing the JWT authentication.
145+
pub async fn get_auth_ep<A>(
146+
config: &config::CloudConfig,
147+
auth_helper: &mut A,
148+
) -> Result<impl RestEndpoint, JwtError>
149+
where
150+
A: AuthHelper,
151+
{
152+
if let Some(auth_data) = &config.auth {
153+
let connection_name = config.name.as_ref();
154+
let mut ep = JwtRequest::builder();
155+
if let Some(val) = &auth_data.identity_provider {
156+
ep.idp_id(val.clone());
157+
} else {
158+
// Or ask user for idp_id in interactive mode
159+
let idp = auth_helper
160+
.get("identity_provider".into(), connection_name.cloned())
161+
.await
162+
.map_err(|_| JwtError::MissingIdentityProvider)?
163+
.to_owned();
164+
ep.idp_id(idp);
165+
}
166+
if let Some(val) = &auth_data.attribute_mapping_name {
167+
ep.mapping_name(val)?;
168+
} else {
169+
// Or ask user for mapping name in interactive mode
170+
ep.mapping_name(
171+
auth_helper
172+
.get("mapping name".into(), connection_name.cloned())
173+
.await
174+
.map_err(|_| JwtError::MissingAttributeMapping)?,
175+
)?;
176+
}
177+
if let Some(val) = &auth_data.jwt {
178+
ep.token(val)?;
179+
} else {
180+
// Or ask user for token in interactive mode
181+
ep.token(
182+
&auth_helper
183+
.get_secret("JWT".into(), connection_name.cloned())
184+
.await
185+
.map_err(|_| JwtError::MissingJwt)?,
186+
)?;
187+
}
188+
return Ok(ep.build()?);
189+
}
190+
Err(JwtError::MissingAuthData)
191+
}

openstack_sdk/src/config.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,10 +261,14 @@ pub struct Auth {
261261
pub protocol: Option<String>,
262262
/// `Federation` identity provider.
263263
pub identity_provider: Option<String>,
264+
/// `Federation` attribute mapping name to be applied.
265+
pub attribute_mapping_name: Option<String>,
264266
/// OIDC access token.
265267
pub access_token: Option<SecretString>,
266268
/// OIDC access token type (when not access_token).
267269
pub access_token_type: Option<String>,
270+
/// JWT token.
271+
pub jwt: Option<SecretString>,
268272

269273
/// `Application Credential` ID.
270274
pub application_credential_id: Option<String>,
@@ -292,6 +296,7 @@ impl fmt::Debug for Auth {
292296
.field("user_domain_name", &self.user_domain_name)
293297
.field("protocol", &self.protocol)
294298
.field("identity_provider", &self.identity_provider)
299+
.field("mapping_name", &self.attribute_mapping_name)
295300
.field("access_token_type", &self.access_token_type)
296301
.field("application_credential_id", &self.application_credential_id)
297302
.field(
@@ -371,6 +376,9 @@ pub fn get_config_identity_hash(config: &CloudConfig) -> u64 {
371376
if let Some(data) = &auth.protocol {
372377
data.hash(&mut s);
373378
}
379+
if let Some(data) = &auth.attribute_mapping_name {
380+
data.hash(&mut s);
381+
}
374382
if let Some(data) = &auth.access_token_type {
375383
data.hash(&mut s);
376384
}
@@ -479,13 +487,21 @@ impl CloudConfig {
479487
auth.identity_provider
480488
.clone_from(&update_auth.identity_provider);
481489
}
490+
if auth.attribute_mapping_name.is_none() && update_auth.attribute_mapping_name.is_some()
491+
{
492+
auth.attribute_mapping_name
493+
.clone_from(&update_auth.attribute_mapping_name);
494+
}
482495
if auth.access_token_type.is_none() && update_auth.access_token_type.is_some() {
483496
auth.access_token_type
484497
.clone_from(&update_auth.access_token_type);
485498
}
486499
if auth.access_token.is_none() && update_auth.access_token.is_some() {
487500
auth.access_token.clone_from(&update_auth.access_token);
488501
}
502+
if auth.jwt.is_none() && update_auth.jwt.is_some() {
503+
auth.jwt.clone_from(&update_auth.jwt);
504+
}
489505
if auth.application_credential_id.is_none()
490506
&& update_auth.application_credential_id.is_some()
491507
{

0 commit comments

Comments
 (0)