Skip to content

Commit 3502d00

Browse files
committed
feat: Implement JWT login
implement JWT login gated behind the keystone_ng feature.
1 parent e81abc8 commit 3502d00

11 files changed

Lines changed: 322 additions & 13 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ example when certain resource is not currently implemented natively.
6969
## Terminal User Interface (TUI)
7070

7171
Exploring the cloud resources using CLI is not something very comfortable. A
72-
terminal user interface imroves user experience when a quick look at currently
72+
terminal user interface improves user experience when a quick look at currently
7373
present resources is required.
7474

7575
openstack_tui (`ostui` as a binary name) is such TUI built upon

‎openstack_cli/.config/config.yaml‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,22 @@ views:
126126
width: 34
127127
- name: domain_id
128128
width: 34
129+
identity.federation/identity_provider:
130+
default_fields: [id, name, domain_id, oidc_discovery_url, oidc_client_id, oidc_response_mode, oidc_response_types, default_mapping_name, jwks_url, jwt_validation_pubkeys, bound_issuer, provider_config]
131+
fields:
132+
- name: id
133+
min_width: 34
134+
- name: domain_id
135+
min_width: 34
136+
identity.federation/mapping:
137+
default_fields: [id, name, idp_id, domain_id, domain_id_claim, type, oidc_scopes, allowed_redirect_uris, user_id_claim, user_name_claim, groups_claim, bound_audiences, bound_subject, bound_claims, token_user_id, token_project_id, token_role_ids]
138+
fields:
139+
- name: id
140+
width: 38
141+
- name: idp_id
142+
min_width: 34
143+
- name: domain_id
144+
min_width: 34
129145
identity.project:
130146
default_fields: [id, name, domain_id, enabled, parent_id, description, options, tags]
131147
fields:

‎openstack_cli/src/identity/v4/user/passkey/register.rs‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -381,8 +381,12 @@ fn convert_user(
381381
fn get_finish_registration_endpoint(
382382
user_id: String,
383383
register_cred: webauthn_authenticator_rs::prelude::RegisterPublicKeyCredential,
384+
description: Option<String>
384385
) -> Result<register_finish::Request<'static>, OpenStackCliError> {
385386
let mut builder = register_finish::Request::builder();
387+
if let Some(description) = description {
388+
builder.description(description);
389+
}
386390
builder.id(register_cred.id);
387391
builder.raw_id(URL_SAFE.encode(register_cred.raw_id));
388392
builder.type_(register_cred.type_);
@@ -525,7 +529,7 @@ impl PasskeyCommand {
525529
convert_api_parameters_to_webauthn(pk_request)?,
526530
) {
527531
Ok(rsp) => {
528-
let ep = get_finish_registration_endpoint(user_id, rsp)?;
532+
let ep = get_finish_registration_endpoint(user_id, rsp, self.passkey.description.clone())?;
529533
let data = ep.query_async(client).await?;
530534
op.output_single::<passkey::RegisterFinishResponse>(data)?;
531535
}

‎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/api/identity/v4/user/passkey/register_finish.rs‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,10 @@ pub struct Response<'a> {
121121
#[derive(Builder, Debug, Clone)]
122122
#[builder(setter(strip_option))]
123123
pub struct Request<'a> {
124+
/// Credential description.
125+
#[builder(default, setter(into))]
126+
pub(crate) description: Option<Cow<'a, str>>,
127+
124128
/// https://w3c.github.io/webauthn/#dictdef-authenticationextensionsclientoutputs
125129
/// The default option here for Options are None, so it can be derived
126130
#[builder(setter(into))]
@@ -210,6 +214,7 @@ impl RestEndpoint for Request<'_> {
210214
fn body(&self) -> Result<Option<(&'static str, Vec<u8>)>, BodyError> {
211215
let mut params = JsonBodyParams::default();
212216

217+
params.push_opt("description", self.description.as_ref());
213218
params.push("extensions", serde_json::to_value(&self.extensions)?);
214219
params.push("id", serde_json::to_value(&self.id)?);
215220
params.push("raw_id", serde_json::to_value(&self.raw_id)?);

‎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+
}

0 commit comments

Comments
 (0)