Skip to content

Commit b629766

Browse files
maxdubrinskymax
authored andcommitted
test(sdk): manual OIDC smoke against local Keycloak
Provides a manual end-to-end check for the SDK's OIDC paths against a live OIDC issuer. The existing Python e2e suite covers OIDC enforcement on the gateway side; this covers the SDK as a client. - scripts/openshell-sdk-oidc-smoke.sh — orchestrates: verifies the Keycloak realm is reachable, mints an initial refresh token via the password grant, then runs the Rust and TS smoke binaries in sequence. - crates/openshell-sdk/examples/oidc_smoke.rs — exercises openshell_sdk::oidc::{discover, refresh_token} against the live issuer. - crates/openshell-sdk-node/test/oidc_smoke.mjs — drives the napi OidcRefresher with a JS callback that hits the live token endpoint, validates single-flight under load (5 concurrent calls → 1 hit) and callback rejection mapping. - tasks/keycloak.toml — `mise run oidc:smoke` runs the script after `mise run keycloak` has started a local instance. Run order: mise run keycloak # docker container, port 8180 mise run oidc:smoke # builds the napi binding, runs both smokes
1 parent 5bc1d4c commit b629766

4 files changed

Lines changed: 278 additions & 0 deletions

File tree

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
// Manual smoke test: drive the napi OidcRefresher against a live Keycloak.
5+
//
6+
// The Rust SDK's refresh_token function isn't exposed through napi — the
7+
// JS-side callback is responsible for hitting the OIDC token endpoint. This
8+
// script exercises that callback bridge under real network conditions and
9+
// validates single-flight coalescing against the live server.
10+
//
11+
// Driven by scripts/openshell-sdk-oidc-smoke.sh.
12+
//
13+
// Env:
14+
// OPENSHELL_OIDC_ISSUER, OPENSHELL_OIDC_CLIENT_ID, OPENSHELL_OIDC_REFRESH_TOKEN
15+
16+
import { OidcRefresher, errorCode } from '../lib.mjs'
17+
18+
function mustEnv(name) {
19+
const value = process.env[name]
20+
if (!value) {
21+
console.error(`${name} is required`)
22+
process.exit(2)
23+
}
24+
return value
25+
}
26+
27+
const issuer = mustEnv('OPENSHELL_OIDC_ISSUER')
28+
const clientId = mustEnv('OPENSHELL_OIDC_CLIENT_ID')
29+
let currentRefreshToken = mustEnv('OPENSHELL_OIDC_REFRESH_TOKEN')
30+
31+
console.log(` issuer = ${issuer}`)
32+
console.log(` client_id = ${clientId}`)
33+
34+
const failures = []
35+
function ok(cond, msg) {
36+
console.log(cond ? ' ok' : 'FAIL', msg)
37+
if (!cond) failures.push(msg)
38+
}
39+
40+
async function discoverTokenEndpoint() {
41+
const url = `${issuer.replace(/\/$/, '')}/.well-known/openid-configuration`
42+
const res = await fetch(url)
43+
if (!res.ok) throw new Error(`discovery ${res.status}`)
44+
const doc = await res.json()
45+
return doc.token_endpoint
46+
}
47+
48+
async function callTokenEndpoint(tokenEndpoint) {
49+
const body = new URLSearchParams({
50+
grant_type: 'refresh_token',
51+
client_id: clientId,
52+
refresh_token: currentRefreshToken,
53+
})
54+
const res = await fetch(tokenEndpoint, {
55+
method: 'POST',
56+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
57+
body,
58+
})
59+
if (!res.ok) throw new Error(`token endpoint ${res.status}: ${await res.text()}`)
60+
const payload = await res.json()
61+
if (payload.refresh_token) currentRefreshToken = payload.refresh_token
62+
return {
63+
accessToken: payload.access_token,
64+
expiresAt: payload.expires_in
65+
? Math.floor(Date.now() / 1000) + payload.expires_in
66+
: undefined,
67+
}
68+
}
69+
70+
console.log('==> discovering token endpoint')
71+
const tokenEndpoint = await discoverTokenEndpoint()
72+
console.log(` token_endpoint = ${tokenEndpoint}`)
73+
74+
console.log('==> 5 concurrent refresh() calls on an expired token (single-flight)')
75+
let callbackInvocations = 0
76+
const refresher = new OidcRefresher('', 1, async () => {
77+
callbackInvocations += 1
78+
return callTokenEndpoint(tokenEndpoint)
79+
})
80+
const tokens = await Promise.all(Array.from({ length: 5 }, () => refresher.refresh()))
81+
ok(tokens.every((t) => typeof t === 'string' && t.length > 50), `all 5 promises returned access tokens`)
82+
ok(tokens[0].split('.').length === 3, 'access token looks like a JWT')
83+
ok(
84+
callbackInvocations === 1,
85+
`5 concurrent calls collapsed to ${callbackInvocations} token endpoint hit (expected 1)`,
86+
)
87+
88+
console.log('==> follow-up refresh() short-circuits on a non-expired cached token')
89+
const beforeFollowup = callbackInvocations
90+
await refresher.refresh()
91+
ok(
92+
callbackInvocations === beforeFollowup,
93+
`cached non-expired token did not trigger another callback (${callbackInvocations - beforeFollowup} new hits)`,
94+
)
95+
96+
console.log('==> callback rejection surfaces as auth error')
97+
const broken = new OidcRefresher('', 1, async () => {
98+
throw new Error('simulated failure')
99+
})
100+
try {
101+
await broken.refresh()
102+
ok(false, 'expected refresh() to reject')
103+
} catch (e) {
104+
ok(errorCode(e) === 'auth', `error code = ${errorCode(e)}`)
105+
}
106+
107+
if (failures.length) {
108+
console.error(`\n${failures.length} assertion(s) failed`)
109+
process.exit(1)
110+
}
111+
console.log('==> ok')
112+
// napi-rs holds the libuv event loop open; exit explicitly. Same workaround
113+
// the unit smoke (test/smoke.mjs) uses.
114+
process.exit(0)
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
//! Manual smoke test: exercise `openshell_sdk::oidc::{discover, refresh_token}`
5+
//! against a live OIDC issuer (Keycloak in our case).
6+
//!
7+
//! Driven by `scripts/openshell-sdk-oidc-smoke.sh`, which fetches an initial
8+
//! refresh token via the password grant and then runs this example.
9+
//!
10+
//! Env:
11+
//! OPENSHELL_OIDC_ISSUER — e.g. http://localhost:8180/realms/openshell
12+
//! OPENSHELL_OIDC_CLIENT_ID — e.g. openshell-cli
13+
//! OPENSHELL_OIDC_REFRESH_TOKEN — refresh token from the issuer
14+
15+
use openshell_sdk::oidc::{RefreshTokenInput, discover, refresh_token};
16+
use std::env;
17+
18+
#[tokio::main(flavor = "current_thread")]
19+
async fn main() -> Result<(), String> {
20+
let issuer = env("OPENSHELL_OIDC_ISSUER")?;
21+
let client_id = env("OPENSHELL_OIDC_CLIENT_ID")?;
22+
let refresh = env("OPENSHELL_OIDC_REFRESH_TOKEN")?;
23+
24+
println!(" issuer = {issuer}");
25+
println!(" client_id = {client_id}");
26+
27+
println!("==> discover({issuer})");
28+
let discovery = discover(&issuer, false)
29+
.await
30+
.map_err(|e| format!("discover failed: {e}"))?;
31+
require(
32+
!discovery.token_endpoint.is_empty(),
33+
"token_endpoint should be non-empty",
34+
)?;
35+
require(
36+
!discovery.authorization_endpoint.is_empty(),
37+
"authorization_endpoint should be non-empty",
38+
)?;
39+
println!(" authorization_endpoint = {}", discovery.authorization_endpoint);
40+
println!(" token_endpoint = {}", discovery.token_endpoint);
41+
42+
println!("==> discover() accepts a trailing slash on the issuer");
43+
let trailing = format!("{}/", issuer.trim_end_matches('/'));
44+
discover(&trailing, false)
45+
.await
46+
.map_err(|e| format!("trailing-slash issuer should normalize: {e}"))?;
47+
48+
println!("==> refresh_token() against the live token endpoint");
49+
let input = RefreshTokenInput::new(refresh.clone(), &issuer, &client_id);
50+
let output = refresh_token(&input)
51+
.await
52+
.map_err(|e| format!("refresh_token failed: {e}"))?;
53+
54+
require(!output.access_token.is_empty(), "access_token should be non-empty")?;
55+
require(
56+
output.access_token.split('.').count() == 3,
57+
"access_token should look like a JWT (header.payload.signature)",
58+
)?;
59+
let preview: String = output.access_token.chars().take(24).collect();
60+
println!(
61+
" access_token = {preview}... ({} bytes)",
62+
output.access_token.len()
63+
);
64+
println!(
65+
" refresh_token = {}",
66+
if output.refresh_token.is_some() {
67+
"<rotated by server>"
68+
} else {
69+
"<unchanged>"
70+
}
71+
);
72+
println!(" expires_at = {:?}", output.expires_at);
73+
74+
println!("==> ok");
75+
Ok(())
76+
}
77+
78+
fn env(name: &str) -> Result<String, String> {
79+
env::var(name).map_err(|_| format!("{name} is required"))
80+
}
81+
82+
fn require(cond: bool, msg: &str) -> Result<(), String> {
83+
if cond { Ok(()) } else { Err(msg.to_string()) }
84+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
#!/usr/bin/env bash
2+
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
3+
# SPDX-License-Identifier: Apache-2.0
4+
#
5+
# Manual smoke test: drive openshell-sdk and @openshell/sdk's OIDC paths
6+
# against a local Keycloak (the same realm used by the Python OIDC e2e suite).
7+
#
8+
# Prerequisites:
9+
# mise run keycloak # start Keycloak on http://localhost:8180
10+
# mise run sdk-node:build # build the napi binding
11+
#
12+
# Usage:
13+
# scripts/openshell-sdk-oidc-smoke.sh
14+
#
15+
# Overrides (env):
16+
# KEYCLOAK_URL, REALM, CLIENT_ID, USERNAME, PASSWORD
17+
18+
set -euo pipefail
19+
20+
KEYCLOAK_URL="${KEYCLOAK_URL:-http://localhost:8180}"
21+
REALM="${REALM:-openshell}"
22+
CLIENT_ID="${CLIENT_ID:-openshell-cli}"
23+
USERNAME="${USERNAME:-admin@test}"
24+
PASSWORD="${PASSWORD:-admin}"
25+
26+
ISSUER="${KEYCLOAK_URL}/realms/${REALM}"
27+
TOKEN_URL="${ISSUER}/protocol/openid-connect/token"
28+
29+
repo_root="$(cd "$(dirname "$0")/.." && pwd)"
30+
31+
mint_refresh_token() {
32+
curl -sf -X POST "$TOKEN_URL" \
33+
--data-urlencode "grant_type=password" \
34+
--data-urlencode "client_id=${CLIENT_ID}" \
35+
--data-urlencode "username=${USERNAME}" \
36+
--data-urlencode "password=${PASSWORD}" \
37+
--data-urlencode "scope=openid" \
38+
| jq -r .refresh_token
39+
}
40+
41+
echo "==> verifying Keycloak realm is reachable: ${ISSUER}"
42+
if ! curl -sfo /dev/null "${ISSUER}/.well-known/openid-configuration"; then
43+
echo "ERR: ${ISSUER}/.well-known/openid-configuration is not reachable." >&2
44+
echo " Start Keycloak first: mise run keycloak" >&2
45+
exit 1
46+
fi
47+
48+
echo "==> minting initial refresh token via the password grant"
49+
refresh_token="$(mint_refresh_token)"
50+
if [ -z "$refresh_token" ] || [ "$refresh_token" = "null" ]; then
51+
echo "ERR: Keycloak did not return a refresh_token." >&2
52+
exit 1
53+
fi
54+
echo " refresh_token: ${refresh_token:0:24}..."
55+
56+
export OPENSHELL_OIDC_ISSUER="$ISSUER"
57+
export OPENSHELL_OIDC_CLIENT_ID="$CLIENT_ID"
58+
export OPENSHELL_OIDC_REFRESH_TOKEN="$refresh_token"
59+
60+
echo
61+
echo "============================================================"
62+
echo "== Rust: openshell_sdk::oidc::{discover, refresh_token}"
63+
echo "============================================================"
64+
cargo run --quiet --example oidc_smoke -p openshell-sdk
65+
66+
echo
67+
echo "============================================================"
68+
echo "== TypeScript: @openshell/sdk OidcRefresher"
69+
echo "============================================================"
70+
# The Rust run rotated the refresh token server-side; mint a fresh one.
71+
export OPENSHELL_OIDC_REFRESH_TOKEN="$(mint_refresh_token)"
72+
node "${repo_root}/crates/openshell-sdk-node/test/oidc_smoke.mjs"
73+
74+
echo
75+
echo "==> all OIDC smoke checks passed"

tasks/keycloak.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,8 @@ run = "tasks/scripts/keycloak-k8s-setup.sh"
2222
["keycloak:k8s:teardown"]
2323
description = "Remove Keycloak from the local k3s cluster"
2424
run = "kubectl delete namespace keycloak --ignore-not-found"
25+
26+
["oidc:smoke"]
27+
description = "Manual OIDC smoke against a local Keycloak (run `mise run keycloak` first)"
28+
depends = ["sdk-node:build"]
29+
run = "scripts/openshell-sdk-oidc-smoke.sh"

0 commit comments

Comments
 (0)