|
| 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) |
0 commit comments