mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-10-02 07:34:45 +08:00
test(tmachine): migrate Keycloak provider refresh coverage (#3404)
* test(tmachine): add Keycloak provider refresh suite Signed-off-by: Evan Lezar <elezar@nvidia.com> * refactor(tmachine): share container runtime detection Signed-off-by: Evan Lezar <elezar@nvidia.com> * ci(tmachine): run feature suites in GitHub Actions Signed-off-by: Evan Lezar <elezar@nvidia.com> * ci(tmachine): run conformance with Podman tests Signed-off-by: Evan Lezar <elezar@nvidia.com> * ci(tmachine): cover provider refresh with Podman Signed-off-by: Evan Lezar <elezar@nvidia.com> * ci(integration): split input preparation from runners Signed-off-by: Evan Lezar <elezar@nvidia.com> --------- Signed-off-by: Evan Lezar <elezar@nvidia.com>
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
---
|
||||
- name: Run Keycloak provider refresh tests
|
||||
hosts: all
|
||||
gather_facts: false
|
||||
roles:
|
||||
- keycloak
|
||||
tasks:
|
||||
- name: Wait for SSH
|
||||
ansible.builtin.wait_for_connection:
|
||||
|
||||
- name: Create Keycloak provider refresh test directory
|
||||
become: true
|
||||
ansible.builtin.file:
|
||||
path: /var/lib/openshell-provider-refresh/tests
|
||||
state: directory
|
||||
owner: tmachine
|
||||
group: tmachine
|
||||
mode: "0700"
|
||||
|
||||
- name: Install Keycloak provider refresh test bundle
|
||||
become: true
|
||||
ansible.builtin.copy:
|
||||
src: "{{ provider_refresh_keycloak_test_bundle }}"
|
||||
dest: /var/lib/openshell-provider-refresh/tests/bundle.tar
|
||||
owner: tmachine
|
||||
group: tmachine
|
||||
mode: "0600"
|
||||
|
||||
- name: Extract Keycloak provider refresh test bundle
|
||||
become: true
|
||||
ansible.builtin.unarchive:
|
||||
src: /var/lib/openshell-provider-refresh/tests/bundle.tar
|
||||
dest: /var/lib/openshell-provider-refresh/tests
|
||||
owner: tmachine
|
||||
group: tmachine
|
||||
remote_src: true
|
||||
|
||||
- name: Check Keycloak provider refresh test archive
|
||||
ansible.builtin.stat:
|
||||
path: /var/lib/openshell-provider-refresh/tests/tests.tar.zst
|
||||
register: provider_refresh_keycloak_test_archive
|
||||
|
||||
- name: Require Keycloak provider refresh test archive
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- provider_refresh_keycloak_test_archive.stat.isreg | default(false)
|
||||
fail_msg: Keycloak provider refresh test bundle did not contain tests.tar.zst
|
||||
|
||||
- name: Run Keycloak provider refresh archive
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- cargo-nextest
|
||||
- nextest
|
||||
- run
|
||||
- --archive-file
|
||||
- /var/lib/openshell-provider-refresh/tests/tests.tar.zst
|
||||
- --workspace-remap
|
||||
- /var/lib/openshell-provider-refresh/tests
|
||||
- --no-capture
|
||||
environment:
|
||||
OPENSHELL_BIN: /usr/local/bin/openshell
|
||||
OPENSHELL_E2E_OIDC_ISSUER: http://127.0.0.1:8180/realms/openshell
|
||||
OPENSHELL_E2E_OIDC_USERNAME: admin@test
|
||||
OPENSHELL_E2E_OIDC_PASSWORD: admin
|
||||
register: provider_refresh_result
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: Show Keycloak provider refresh result
|
||||
ansible.builtin.debug:
|
||||
var: provider_refresh_result
|
||||
when: provider_refresh_result.rc != 0
|
||||
|
||||
- name: Read OpenShell gateway logs
|
||||
become: true
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- journalctl
|
||||
- --unit
|
||||
- openshell-gateway.service
|
||||
- --no-pager
|
||||
- --lines
|
||||
- "500"
|
||||
register: openshell_gateway_logs
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
when: provider_refresh_result.rc != 0
|
||||
|
||||
- name: Show OpenShell gateway logs
|
||||
ansible.builtin.debug:
|
||||
var: openshell_gateway_logs.stdout_lines
|
||||
when: provider_refresh_result.rc != 0
|
||||
|
||||
- name: Require Keycloak provider refresh success
|
||||
ansible.builtin.assert:
|
||||
that: provider_refresh_result.rc == 0
|
||||
fail_msg: Keycloak provider refresh test failed
|
||||
@@ -0,0 +1,15 @@
|
||||
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
---
|
||||
# Keep these values aligned with scripts/keycloak-dev.sh. The role provisions
|
||||
# an isolated guest fixture, while the script manages a developer-host fixture.
|
||||
keycloak_container_name: openshell-keycloak
|
||||
keycloak_image: quay.io/keycloak/keycloak:24.0
|
||||
keycloak_port: 8180
|
||||
keycloak_realm: openshell
|
||||
keycloak_admin_username: admin
|
||||
keycloak_admin_password: admin
|
||||
keycloak_state_directory: /var/lib/openshell-keycloak
|
||||
keycloak_health_retries: 45
|
||||
keycloak_health_delay: 2
|
||||
@@ -0,0 +1,126 @@
|
||||
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
---
|
||||
- name: Wait for SSH
|
||||
ansible.builtin.wait_for_connection:
|
||||
|
||||
- name: Detect tmachine container runtime
|
||||
ansible.builtin.include_role:
|
||||
name: tmachine_container_runtime
|
||||
|
||||
- name: Set Keycloak runtime values
|
||||
ansible.builtin.set_fact:
|
||||
keycloak_runtime_user: >-
|
||||
{{ 'tmachine' if tmachine_container_runtime_is_rootless else 'root' }}
|
||||
keycloak_runtime_home: >-
|
||||
{{ '/home/tmachine' if tmachine_container_runtime_is_rootless else '/root' }}
|
||||
keycloak_runtime_state_directory: >-
|
||||
{{ '/home/tmachine/.local/share/openshell-keycloak'
|
||||
if tmachine_container_runtime_is_rootless else keycloak_state_directory }}
|
||||
|
||||
- name: Require Keycloak container runtime
|
||||
become: true
|
||||
become_user: "{{ keycloak_runtime_user }}"
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- "{{ tmachine_container_runtime_name }}"
|
||||
- --version
|
||||
environment:
|
||||
HOME: "{{ keycloak_runtime_home }}"
|
||||
changed_when: false
|
||||
|
||||
- name: Create Keycloak state directory
|
||||
become: true
|
||||
ansible.builtin.file:
|
||||
path: "{{ keycloak_runtime_state_directory }}"
|
||||
state: directory
|
||||
owner: "{{ keycloak_runtime_user }}"
|
||||
group: "{{ keycloak_runtime_user }}"
|
||||
mode: "0755"
|
||||
|
||||
- name: Install Keycloak realm fixture
|
||||
become: true
|
||||
ansible.builtin.copy:
|
||||
src: "{{ keycloak_realm_file }}"
|
||||
dest: "{{ keycloak_runtime_state_directory }}/realm.json"
|
||||
owner: "{{ keycloak_runtime_user }}"
|
||||
group: "{{ keycloak_runtime_user }}"
|
||||
mode: "0644"
|
||||
|
||||
- name: Remove an existing Keycloak fixture
|
||||
become: true
|
||||
become_user: "{{ keycloak_runtime_user }}"
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- "{{ tmachine_container_runtime_name }}"
|
||||
- rm
|
||||
- --force
|
||||
- "{{ keycloak_container_name }}"
|
||||
environment:
|
||||
HOME: "{{ keycloak_runtime_home }}"
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: Start Keycloak fixture
|
||||
become: true
|
||||
become_user: "{{ keycloak_runtime_user }}"
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- "{{ tmachine_container_runtime_name }}"
|
||||
- run
|
||||
- --detach
|
||||
- --name
|
||||
- "{{ keycloak_container_name }}"
|
||||
- --publish
|
||||
- "127.0.0.1:{{ keycloak_port }}:8080"
|
||||
- --env
|
||||
- "KEYCLOAK_ADMIN={{ keycloak_admin_username }}"
|
||||
- --env
|
||||
- "KEYCLOAK_ADMIN_PASSWORD={{ keycloak_admin_password }}"
|
||||
- --volume
|
||||
- "{{ keycloak_runtime_state_directory }}/realm.json:/opt/keycloak/data/import/realm.json:ro,z"
|
||||
- "{{ keycloak_image }}"
|
||||
- start-dev
|
||||
- --import-realm
|
||||
environment:
|
||||
HOME: "{{ keycloak_runtime_home }}"
|
||||
changed_when: true
|
||||
|
||||
- name: Wait for Keycloak discovery endpoint
|
||||
ansible.builtin.uri:
|
||||
url: "http://127.0.0.1:{{ keycloak_port }}/realms/{{ keycloak_realm }}/.well-known/openid-configuration"
|
||||
status_code: 200
|
||||
return_content: false
|
||||
register: keycloak_discovery
|
||||
retries: "{{ keycloak_health_retries }}"
|
||||
delay: "{{ keycloak_health_delay }}"
|
||||
until: keycloak_discovery.status | default(0) == 200
|
||||
failed_when: false
|
||||
|
||||
- name: Read Keycloak logs after failed health check
|
||||
become: true
|
||||
become_user: "{{ keycloak_runtime_user }}"
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- "{{ tmachine_container_runtime_name }}"
|
||||
- logs
|
||||
- --tail
|
||||
- "30"
|
||||
- "{{ keycloak_container_name }}"
|
||||
environment:
|
||||
HOME: "{{ keycloak_runtime_home }}"
|
||||
register: keycloak_logs
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
when: keycloak_discovery.status | default(0) != 200
|
||||
|
||||
- name: Show Keycloak logs after failed health check
|
||||
ansible.builtin.debug:
|
||||
var: keycloak_logs.stdout_lines
|
||||
when: keycloak_discovery.status | default(0) != 200
|
||||
|
||||
- name: Require Keycloak discovery endpoint
|
||||
ansible.builtin.assert:
|
||||
that: keycloak_discovery.status | default(0) == 200
|
||||
fail_msg: Keycloak did not become healthy within the configured timeout
|
||||
@@ -2,50 +2,21 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
---
|
||||
- name: Resolve tmachine UID
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- id
|
||||
- -u
|
||||
- tmachine
|
||||
changed_when: false
|
||||
register: openshell_tmachine_uid
|
||||
- name: Detect tmachine container runtime
|
||||
ansible.builtin.include_role:
|
||||
name: tmachine_container_runtime
|
||||
|
||||
- name: Check Docker socket
|
||||
become: true
|
||||
ansible.builtin.stat:
|
||||
path: /var/run/docker.sock
|
||||
register: openshell_docker_socket
|
||||
|
||||
- name: Check rootful Podman socket
|
||||
become: true
|
||||
ansible.builtin.stat:
|
||||
path: /run/podman/podman.sock
|
||||
register: openshell_rootful_podman_socket
|
||||
|
||||
- name: Check rootless Podman socket
|
||||
become: true
|
||||
ansible.builtin.stat:
|
||||
path: "/run/user/{{ openshell_tmachine_uid.stdout }}/podman/podman.sock"
|
||||
register: openshell_rootless_podman_socket
|
||||
|
||||
- name: Require exactly one container runtime socket
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- >-
|
||||
(openshell_docker_socket.stat.exists | int)
|
||||
+ (openshell_rootful_podman_socket.stat.exists | int)
|
||||
+ (openshell_rootless_podman_socket.stat.exists | int) == 1
|
||||
fail_msg: Expected exactly one Docker or Podman socket
|
||||
|
||||
- name: Select container runtime
|
||||
- name: Set OpenShell gateway runtime values
|
||||
ansible.builtin.set_fact:
|
||||
openshell_gateway_driver: "{{ 'docker' if openshell_docker_socket.stat.exists else 'podman' }}"
|
||||
openshell_gateway_user: "{{ 'root' if openshell_rootful_podman_socket.stat.exists else 'tmachine' }}"
|
||||
openshell_gateway_home: "{{ '/root' if openshell_rootful_podman_socket.stat.exists else '/home/tmachine' }}"
|
||||
openshell_gateway_uid: "{{ '0' if openshell_rootful_podman_socket.stat.exists else openshell_tmachine_uid.stdout }}"
|
||||
openshell_gateway_bind_address: "{{ '127.0.0.1:17670' if openshell_docker_socket.stat.exists else '0.0.0.0:17670' }}"
|
||||
openshell_runtime_socket: >-
|
||||
{{ '/var/run/docker.sock' if openshell_docker_socket.stat.exists
|
||||
else '/run/podman/podman.sock' if openshell_rootful_podman_socket.stat.exists
|
||||
else '/run/user/' ~ openshell_tmachine_uid.stdout ~ '/podman/podman.sock' }}
|
||||
openshell_gateway_driver: "{{ tmachine_container_runtime_name }}"
|
||||
openshell_gateway_user: >-
|
||||
{{ 'root' if tmachine_container_runtime_name == 'podman'
|
||||
and not tmachine_container_runtime_is_rootless else 'tmachine' }}
|
||||
openshell_gateway_home: >-
|
||||
{{ '/root' if tmachine_container_runtime_name == 'podman'
|
||||
and not tmachine_container_runtime_is_rootless else '/home/tmachine' }}
|
||||
openshell_gateway_uid: >-
|
||||
{{ '0' if tmachine_container_runtime_name == 'podman'
|
||||
and not tmachine_container_runtime_is_rootless else tmachine_container_runtime_tmachine_uid.stdout }}
|
||||
openshell_gateway_bind_address: "{{ '127.0.0.1:17670' if tmachine_container_runtime_name == 'docker' else '0.0.0.0:17670' }}"
|
||||
openshell_runtime_socket: "{{ tmachine_container_runtime_socket }}"
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
---
|
||||
- name: Resolve tmachine UID
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- id
|
||||
- -u
|
||||
- tmachine
|
||||
changed_when: false
|
||||
register: tmachine_container_runtime_tmachine_uid
|
||||
|
||||
- name: Check Docker socket
|
||||
become: true
|
||||
ansible.builtin.stat:
|
||||
path: /var/run/docker.sock
|
||||
register: tmachine_container_runtime_docker_socket
|
||||
|
||||
- name: Check rootful Podman socket
|
||||
become: true
|
||||
ansible.builtin.stat:
|
||||
path: /run/podman/podman.sock
|
||||
register: tmachine_container_runtime_rootful_podman_socket
|
||||
|
||||
- name: Check rootless Podman socket
|
||||
become: true
|
||||
ansible.builtin.stat:
|
||||
path: "/run/user/{{ tmachine_container_runtime_tmachine_uid.stdout }}/podman/podman.sock"
|
||||
register: tmachine_container_runtime_rootless_podman_socket
|
||||
|
||||
- name: Require exactly one container runtime socket
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- >-
|
||||
(tmachine_container_runtime_docker_socket.stat.exists | int)
|
||||
+ (tmachine_container_runtime_rootful_podman_socket.stat.exists | int)
|
||||
+ (tmachine_container_runtime_rootless_podman_socket.stat.exists | int) == 1
|
||||
fail_msg: Expected exactly one Docker or Podman socket
|
||||
|
||||
- name: Select tmachine container runtime
|
||||
ansible.builtin.set_fact:
|
||||
tmachine_container_runtime_name: >-
|
||||
{{ 'docker' if tmachine_container_runtime_docker_socket.stat.exists else 'podman' }}
|
||||
tmachine_container_runtime_is_rootless: >-
|
||||
{{ tmachine_container_runtime_rootless_podman_socket.stat.exists }}
|
||||
tmachine_container_runtime_socket: >-
|
||||
{{ '/var/run/docker.sock' if tmachine_container_runtime_docker_socket.stat.exists
|
||||
else '/run/podman/podman.sock' if tmachine_container_runtime_rootful_podman_socket.stat.exists
|
||||
else '/run/user/' ~ tmachine_container_runtime_tmachine_uid.stdout ~ '/podman/podman.sock' }}
|
||||
+23
-3
@@ -84,9 +84,17 @@ let
|
||||
target = muslToolchain.target;
|
||||
output = "artifacts/test-archives/${muslToolchain.target}/openshell-conformance-tests.tar";
|
||||
};
|
||||
providerRefreshKeycloakArchive = mkTestArchive {
|
||||
name = "provider-refresh-keycloak";
|
||||
workspacePath = "tests/suites/features";
|
||||
manifestPath = "tests/suites/features/Cargo.toml";
|
||||
package = "openshell-test-feature-provider-refresh-keycloak";
|
||||
target = muslToolchain.target;
|
||||
output = "artifacts/test-archives/${muslToolchain.target}/provider-refresh-keycloak-tests.tar";
|
||||
};
|
||||
in
|
||||
rec {
|
||||
inherit conformanceCliArchive;
|
||||
inherit conformanceCliArchive providerRefreshKeycloakArchive;
|
||||
|
||||
binaries = pkgs.writeShellApplication {
|
||||
name = "build-artifacts-binaries";
|
||||
@@ -126,6 +134,18 @@ rec {
|
||||
'';
|
||||
};
|
||||
|
||||
testArchives = pkgs.writeShellApplication {
|
||||
name = "build-artifacts-test-archives";
|
||||
runtimeInputs = [
|
||||
conformanceCliArchive
|
||||
providerRefreshKeycloakArchive
|
||||
];
|
||||
text = ''
|
||||
build-openshell-conformance-test-archive
|
||||
build-provider-refresh-keycloak-test-archive
|
||||
'';
|
||||
};
|
||||
|
||||
images = pkgs.writeShellApplication {
|
||||
name = "build-artifacts-images";
|
||||
runtimeInputs = [
|
||||
@@ -204,13 +224,13 @@ rec {
|
||||
name = "build-artifacts";
|
||||
runtimeInputs = [
|
||||
binaries
|
||||
conformanceCliArchive
|
||||
testArchives
|
||||
images
|
||||
helm
|
||||
];
|
||||
text = ''
|
||||
build-artifacts-binaries
|
||||
build-openshell-conformance-test-archive
|
||||
build-artifacts-test-archives
|
||||
build-artifacts-images
|
||||
build-artifacts-helm
|
||||
'';
|
||||
|
||||
@@ -114,6 +114,14 @@ let
|
||||
openshell_conformance_test_bundle = "../artifacts/test-archives/${muslTarget}/openshell-conformance-tests.tar";
|
||||
};
|
||||
}
|
||||
{
|
||||
name = "provider-refresh";
|
||||
playbooks = [ "ansible/playbooks/features/provider-refresh/keycloak.yaml" ];
|
||||
inputs = {
|
||||
keycloak_realm_file = "../scripts/keycloak-realm.json";
|
||||
provider_refresh_keycloak_test_bundle = "../artifacts/test-archives/${muslTarget}/provider-refresh-keycloak-tests.tar";
|
||||
};
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
|
||||
Generated
+1868
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,6 @@
|
||||
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["provider-refresh/keycloak"]
|
||||
@@ -0,0 +1,14 @@
|
||||
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
[package]
|
||||
name = "openshell-test-feature-provider-refresh-keycloak"
|
||||
version = "0.0.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
openshell-conformance = { path = "../../../../../crates/openshell-conformance" }
|
||||
openshell-e2e = { path = "../../../../../e2e/rust" }
|
||||
serde_json = "1"
|
||||
tempfile = "3"
|
||||
tokio = { version = "1.43", features = ["macros", "process", "io-util", "rt"] }
|
||||
@@ -0,0 +1,340 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
#![cfg(target_os = "linux")]
|
||||
|
||||
//! Provider OAuth refresh recovery against Keycloak.
|
||||
//!
|
||||
//! OpenShell itself uses the local gateway's mTLS authentication. Keycloak is
|
||||
//! only the provider token issuer: the test refreshes a valid grant, revokes
|
||||
//! its Keycloak session, and verifies that the gateway reports the next
|
||||
//! refresh as requiring user reauthorization.
|
||||
|
||||
use std::io::Write as _;
|
||||
use std::process::{Output, Stdio};
|
||||
|
||||
use openshell_e2e::harness::binary::openshell_cmd;
|
||||
use serde_json::Value;
|
||||
use tempfile::{Builder as TempFileBuilder, NamedTempFile};
|
||||
use tokio::io::AsyncWriteExt as _;
|
||||
use tokio::process::Command;
|
||||
|
||||
const PROVIDER_NAME: &str = "e2e-keycloak-refresh";
|
||||
const PROFILE_ID: &str = "e2e-keycloak-refresh";
|
||||
const CREDENTIAL_KEY: &str = "KEYCLOAK_ACCESS_TOKEN";
|
||||
|
||||
fn combined_output(output: &Output) -> String {
|
||||
format!(
|
||||
"{}{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
)
|
||||
}
|
||||
|
||||
async fn run_cli(args: &[&str], env: &[(&str, &str)]) -> Result<Output, String> {
|
||||
openshell_cmd()
|
||||
.args(args)
|
||||
.env("NO_COLOR", "1")
|
||||
.envs(env.iter().copied())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.await
|
||||
.map_err(|error| format!("run openshell command: {error}"))
|
||||
}
|
||||
|
||||
async fn run_cli_success(args: &[&str], env: &[(&str, &str)]) -> Result<String, String> {
|
||||
let output = run_cli(args, env).await?;
|
||||
let combined = combined_output(&output);
|
||||
if !output.status.success() {
|
||||
return Err(format!(
|
||||
"openshell command failed (exit {:?}):\n{combined}",
|
||||
output.status.code()
|
||||
));
|
||||
}
|
||||
Ok(combined)
|
||||
}
|
||||
|
||||
async fn acquire_keycloak_grant(
|
||||
issuer: &str,
|
||||
username: &str,
|
||||
password: &str,
|
||||
) -> Result<(String, String), String> {
|
||||
let token_endpoint = format!("{issuer}/protocol/openid-connect/token");
|
||||
let username_form = format!("username={username}");
|
||||
let password_form = format!("password={password}");
|
||||
let output = Command::new("curl")
|
||||
.args([
|
||||
"--fail",
|
||||
"--silent",
|
||||
"--show-error",
|
||||
"--request",
|
||||
"POST",
|
||||
&token_endpoint,
|
||||
"--data-urlencode",
|
||||
"grant_type=password",
|
||||
"--data-urlencode",
|
||||
"client_id=openshell-cli",
|
||||
"--data-urlencode",
|
||||
&username_form,
|
||||
"--data-urlencode",
|
||||
&password_form,
|
||||
"--data-urlencode",
|
||||
"scope=openid",
|
||||
])
|
||||
.output()
|
||||
.await
|
||||
.map_err(|error| format!("request Keycloak grant: {error}"))?;
|
||||
if !output.status.success() {
|
||||
return Err(format!(
|
||||
"Keycloak grant request failed (exit {:?}): {}",
|
||||
output.status.code(),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
));
|
||||
}
|
||||
|
||||
let response: Value = serde_json::from_slice(&output.stdout)
|
||||
.map_err(|error| format!("decode Keycloak grant response: {error}"))?;
|
||||
let access_token = response
|
||||
.get("access_token")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| "Keycloak grant response omitted access_token".to_string())?;
|
||||
let refresh_token = response
|
||||
.get("refresh_token")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| "Keycloak grant response omitted refresh_token".to_string())?;
|
||||
Ok((access_token.to_string(), refresh_token.to_string()))
|
||||
}
|
||||
|
||||
async fn revoke_keycloak_grant(issuer: &str, refresh_token: &str) -> Result<(), String> {
|
||||
let logout_endpoint = format!("{issuer}/protocol/openid-connect/logout");
|
||||
let mut child = Command::new("curl")
|
||||
.args([
|
||||
"--fail",
|
||||
"--silent",
|
||||
"--show-error",
|
||||
"--output",
|
||||
"/dev/null",
|
||||
"--request",
|
||||
"POST",
|
||||
&logout_endpoint,
|
||||
"--data-urlencode",
|
||||
"client_id=openshell-cli",
|
||||
"--data-urlencode",
|
||||
"refresh_token@-",
|
||||
])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|error| format!("start Keycloak logout request: {error}"))?;
|
||||
child
|
||||
.stdin
|
||||
.take()
|
||||
.ok_or_else(|| "Keycloak logout stdin was not piped".to_string())?
|
||||
.write_all(refresh_token.as_bytes())
|
||||
.await
|
||||
.map_err(|error| format!("write Keycloak logout request: {error}"))?;
|
||||
let output = child
|
||||
.wait_with_output()
|
||||
.await
|
||||
.map_err(|error| format!("wait for Keycloak logout request: {error}"))?;
|
||||
if !output.status.success() {
|
||||
return Err(format!(
|
||||
"Keycloak logout failed (exit {:?}): {}",
|
||||
output.status.code(),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_profile(issuer: &str) -> Result<NamedTempFile, String> {
|
||||
let mut file = TempFileBuilder::new()
|
||||
.suffix(".yaml")
|
||||
.tempfile()
|
||||
.map_err(|error| format!("create provider profile: {error}"))?;
|
||||
let profile = format!(
|
||||
r#"id: {PROFILE_ID}
|
||||
display_name: Keycloak provider refresh E2E
|
||||
category: other
|
||||
credentials:
|
||||
- name: access_token
|
||||
env_vars: [{CREDENTIAL_KEY}]
|
||||
required: true
|
||||
auth_style: bearer
|
||||
header_name: authorization
|
||||
refresh:
|
||||
strategy: oauth2_refresh_token
|
||||
token_url: {issuer}/protocol/openid-connect/token
|
||||
scopes: [openid]
|
||||
refresh_before_seconds: 60
|
||||
max_lifetime_seconds: 3600
|
||||
material:
|
||||
- name: client_id
|
||||
required: true
|
||||
- name: refresh_token
|
||||
required: true
|
||||
secret: true
|
||||
endpoints:
|
||||
- host: keycloak.test.invalid
|
||||
port: 443
|
||||
protocol: rest
|
||||
access: read-only
|
||||
enforcement: enforce
|
||||
binaries:
|
||||
- /usr/bin/curl
|
||||
"#
|
||||
);
|
||||
file.write_all(profile.as_bytes())
|
||||
.map_err(|error| format!("write provider profile: {error}"))?;
|
||||
file.flush()
|
||||
.map_err(|error| format!("flush provider profile: {error}"))?;
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
async fn delete_provider_resources() {
|
||||
let _ = run_cli(&["provider", "delete", PROVIDER_NAME], &[]).await;
|
||||
let _ = run_cli(&["provider", "profile", "delete", PROFILE_ID], &[]).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn revoked_refresh_grant_requires_user_reauthorization() -> Result<(), String> {
|
||||
let issuer = std::env::var("OPENSHELL_E2E_OIDC_ISSUER")
|
||||
.map_err(|_| "OPENSHELL_E2E_OIDC_ISSUER is required".to_string())?;
|
||||
let username = std::env::var("OPENSHELL_E2E_OIDC_USERNAME")
|
||||
.map_err(|_| "OPENSHELL_E2E_OIDC_USERNAME is required".to_string())?;
|
||||
let password = std::env::var("OPENSHELL_E2E_OIDC_PASSWORD")
|
||||
.map_err(|_| "OPENSHELL_E2E_OIDC_PASSWORD is required".to_string())?;
|
||||
let (access_token, refresh_token) =
|
||||
acquire_keycloak_grant(&issuer, &username, &password).await?;
|
||||
let profile = write_profile(&issuer)?;
|
||||
let profile_path = profile.path().to_string_lossy().into_owned();
|
||||
|
||||
delete_provider_resources().await;
|
||||
let result = async {
|
||||
run_cli_success(
|
||||
&["provider", "profile", "import", "--file", &profile_path],
|
||||
&[],
|
||||
)
|
||||
.await?;
|
||||
run_cli_success(
|
||||
&[
|
||||
"provider",
|
||||
"create",
|
||||
"--name",
|
||||
PROVIDER_NAME,
|
||||
"--type",
|
||||
PROFILE_ID,
|
||||
"--credential",
|
||||
CREDENTIAL_KEY,
|
||||
],
|
||||
&[(CREDENTIAL_KEY, &access_token)],
|
||||
)
|
||||
.await?;
|
||||
run_cli_success(
|
||||
&[
|
||||
"provider",
|
||||
"refresh",
|
||||
"configure",
|
||||
PROVIDER_NAME,
|
||||
"--credential-key",
|
||||
CREDENTIAL_KEY,
|
||||
"--strategy",
|
||||
"oauth2-refresh-token",
|
||||
"--material",
|
||||
"client_id=openshell-cli",
|
||||
"--secret-material-env",
|
||||
"refresh_token=KEYCLOAK_REFRESH_TOKEN",
|
||||
],
|
||||
&[("KEYCLOAK_REFRESH_TOKEN", &refresh_token)],
|
||||
)
|
||||
.await?;
|
||||
|
||||
run_cli_success(
|
||||
&[
|
||||
"provider",
|
||||
"refresh",
|
||||
"rotate",
|
||||
PROVIDER_NAME,
|
||||
"--credential-key",
|
||||
CREDENTIAL_KEY,
|
||||
],
|
||||
&[],
|
||||
)
|
||||
.await?;
|
||||
let valid_status = run_cli_success(
|
||||
&[
|
||||
"provider",
|
||||
"refresh",
|
||||
"status",
|
||||
PROVIDER_NAME,
|
||||
"--credential-key",
|
||||
CREDENTIAL_KEY,
|
||||
],
|
||||
&[],
|
||||
)
|
||||
.await?;
|
||||
if !valid_status.contains("refreshed") {
|
||||
return Err(format!(
|
||||
"valid Keycloak refresh did not reach refreshed state:\n{valid_status}"
|
||||
));
|
||||
}
|
||||
|
||||
revoke_keycloak_grant(&issuer, &refresh_token).await?;
|
||||
let failed_rotation = run_cli(
|
||||
&[
|
||||
"provider",
|
||||
"refresh",
|
||||
"rotate",
|
||||
PROVIDER_NAME,
|
||||
"--credential-key",
|
||||
CREDENTIAL_KEY,
|
||||
],
|
||||
&[],
|
||||
)
|
||||
.await?;
|
||||
let failed_rotation_output = combined_output(&failed_rotation);
|
||||
if failed_rotation.status.success() || !failed_rotation_output.contains("invalid_grant") {
|
||||
return Err(format!(
|
||||
"revoked Keycloak refresh did not fail with invalid_grant:\n{failed_rotation_output}"
|
||||
));
|
||||
}
|
||||
|
||||
let revoked_status = run_cli_success(
|
||||
&[
|
||||
"provider",
|
||||
"refresh",
|
||||
"status",
|
||||
PROVIDER_NAME,
|
||||
"--credential-key",
|
||||
CREDENTIAL_KEY,
|
||||
],
|
||||
&[],
|
||||
)
|
||||
.await?;
|
||||
for expected in [
|
||||
"reauthorization_required",
|
||||
"reauthorize",
|
||||
"oauth_invalid_grant",
|
||||
] {
|
||||
if !revoked_status.contains(expected) {
|
||||
return Err(format!(
|
||||
"revoked refresh status omitted {expected}:\n{revoked_status}"
|
||||
));
|
||||
}
|
||||
}
|
||||
if revoked_status.contains("292278994") {
|
||||
return Err(format!(
|
||||
"parked refresh rendered the i64::MAX scheduling sentinel as a date:\n{revoked_status}"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
|
||||
delete_provider_resources().await;
|
||||
result
|
||||
}
|
||||
Reference in New Issue
Block a user