Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .github/workflows/functional.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/linters.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ concurrency:

env:
CARGO_TERM_COLOR: always
rust_min: 1.85.0
rust_min: 1.89.0

jobs:
rustfmt:
Expand Down
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 6 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand All @@ -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
113 changes: 112 additions & 1 deletion doc/src/federation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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": <IDP_ID>,
"domain_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": <UID>
}
```

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

Expand All @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion src/api/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ pub async fn get_domain<I: AsRef<str>, N: AsRef<str>>(
identifier: name.as_ref().to_string(),
})
} else {
return Err(KeystoneApiError::DomainIdOrName);
Err(KeystoneApiError::DomainIdOrName)
}
}

Expand Down
2 changes: 2 additions & 0 deletions src/api/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@ impl KeystoneApiError {
resource: "mapping provider".into(),
identifier: x,
},
FederationProviderError::Conflict(x) => Self::Conflict(x),
_ => Self::Federation { source },
}
}
Expand Down Expand Up @@ -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 {
Expand Down
13 changes: 12 additions & 1 deletion src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion src/api/v4/federation/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
35 changes: 35 additions & 0 deletions src/api/v4/federation/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 },

Expand Down Expand Up @@ -69,29 +72,50 @@ 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,
expected: String,
found: String,
},

/// Error building user data.
#[error(transparent)]
MappedUserDataBuilder {
#[from]
#[allow(private_interfaces)]
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 {
Expand Down Expand Up @@ -122,6 +146,9 @@ impl From<OidcError> 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}"))
}
Expand Down Expand Up @@ -167,6 +194,14 @@ impl From<OidcError> 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
}
}
}
}
2 changes: 2 additions & 0 deletions src/api/v4/federation/identity_provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
Expand Down Expand Up @@ -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()),
Expand Down
Loading