Skip to content

Commit d319d8b

Browse files
committed
fix(auth): stop reading an unreachable Keycloak as "no registered redirect URIs"
The redirect_uri check is fail-closed on an empty allowlist, and the cache handed it an empty allowlist for two very different reasons: Keycloak said the client has no URIs, or Keycloak could not be asked at all. fetchClientConfig caught every error and returned `{ redirectUris: [] }`, so an admin-API failure came back out of /auth/authorize as 400 redirect_uri does not match a registered redirect URI for this client blaming the app's configuration for an outage on ours. Both call sites already had the right branch for this — authorize and the callback handler answer a THROWN lookup with `Unable to validate redirect_uri` and an error-level log — but the cache swallowed the throw, so neither branch could ever fire. This is the same shape as the WAF incident recorded in keycloak-stack.ts. Worse, the empty result was cached for the full 5-minute TTL, so a single hiccup rejected every launch for five minutes. The lookup now reports found / absent / unavailable. Only found and absent are cached, absent on a short 30s TTL because absence usually means "not created yet" (the compliance workflow deletes and recreates its client mid-run). getRegisteredRedirectUris throws on unavailable; getSmartClientConfig stays lenient because token-time enrichment only reads patientFacing and must not fail a token issue over an unreachable admin API. Also drops a duplicated KcAdminClient construction in favour of the existing getAdminClient factory, which exists precisely to be a test seam, and moves the cache behind createClientConfigCache so the new test drives it by injection — a sibling test mock.modules this whole module, and bun's module mocks are process-global. Backend 1389 pass / 31 pre-existing fail (same 31 without this diff), typecheck, lint.
1 parent 21052c9 commit d319d8b

3 files changed

Lines changed: 215 additions & 51 deletions

File tree

.github/workflows/smart-compliance-tests.yml

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
# SPDX-FileCopyrightText: Max Health Inc.
2+
# SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Commercial
3+
14
name: SMART Compliance Tests (Inferno)
25

36
on:
@@ -473,20 +476,30 @@ jobs:
473476
if: steps.config.outputs.target != 'deployed'
474477
run: |
475478
cd backend
476-
bun run start &
479+
# Log to a file: a backgrounded process's stdout stops being captured
480+
# when this step ends, which left every later failure undiagnosable.
481+
bun run start > /tmp/backend.log 2>&1 &
477482
APP_PID=$!
478483
echo "app_pid=$APP_PID" >> $GITHUB_ENV
479484
480485
# Wait for app to be ready
481486
echo "Waiting for application..."
487+
READY=false
482488
for i in {1..30}; do
483489
if curl -sf http://localhost:8445/health; then
484490
echo "Application is ready!"
491+
READY=true
485492
break
486493
fi
487494
echo "Attempt $i/30..."
488495
sleep 2
489496
done
497+
498+
if [ "$READY" != "true" ]; then
499+
echo "ERROR: application never became healthy — backend log follows"
500+
cat /tmp/backend.log || true
501+
exit 1
502+
fi
490503
env:
491504
NODE_ENV: production
492505
KEYCLOAK_BASE_URL: http://localhost:8080
@@ -1371,12 +1384,12 @@ jobs:
13711384
echo "(Deployed mode — container logs not available from CI runner.)"
13721385
echo "Check VPS logs: docker logs proxy-smart-backend-beta --tail 200 2>&1 | grep -iE 'token|auth|error|warn'"
13731386
else
1374-
# Local CI: dump from Docker containers
1387+
# Local CI: the backend runs as a bare process, not a container.
13751388
echo "--- Backend logs (auth-related) ---"
1376-
docker logs backend 2>&1 | grep -iE 'token endpoint|keycloak token|forwarding to keycloak|error response|warn|400|401|422|500' | tail -100 || echo "(no backend container)"
1389+
grep -iE 'redirect_uri|registered redirect|token endpoint|keycloak|error|warn|400|401|422|500' /tmp/backend.log | tail -100 || echo "(nothing matched)"
13771390
echo ""
1378-
echo "--- Backend logs (last 50 lines) ---"
1379-
docker logs backend 2>&1 | tail -50 || echo "(no backend container)"
1391+
echo "--- Backend logs (last 80 lines) ---"
1392+
tail -80 /tmp/backend.log || echo "(no /tmp/backend.log)"
13801393
echo ""
13811394
echo "--- Keycloak logs (auth-related, last 50 lines) ---"
13821395
docker logs keycloak 2>&1 | grep -iE 'token|grant|client|error|warn' | tail -50 || echo "(no keycloak container)"
@@ -1422,6 +1435,7 @@ jobs:
14221435
14231436
# Also save service logs (only exists for local targets)
14241437
docker logs keycloak > test-results/keycloak.log 2>&1 || true
1438+
cp /tmp/backend.log test-results/backend.log 2>/dev/null || true
14251439
14261440
- name: Upload Artifacts
14271441
if: always()

backend/src/lib/smart-client-config-cache.ts

Lines changed: 106 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,8 @@
1111
* Avoids hitting Keycloak admin API on every token exchange.
1212
*/
1313

14-
import KcAdminClient from '@keycloak/keycloak-admin-client'
1514
import { isCimdClientId, resolveCimdRedirectUris, type SmartProxyLogger } from '@proxy-smart/auth'
16-
import { config } from '@/config'
15+
import { getAdminClient } from '@/lib/kc-admin-factory'
1716
import { logger } from '@/lib/logger'
1817

1918
/** The lib takes a flat logger; adapt our structured one once, here. */
@@ -36,31 +35,101 @@ export interface SmartClientConfig {
3635
redirectUris: string[]
3736
}
3837

38+
/**
39+
* The three distinguishable outcomes of asking Keycloak about a client.
40+
*
41+
* `unavailable` exists because collapsing it into `absent` is what turns a
42+
* Keycloak hiccup into "this client has no registered redirect URIs", and the
43+
* redirect_uri check is fail-closed on an empty allowlist — so an infrastructure
44+
* failure came out as a client-configuration error and rejected every launch.
45+
*/
46+
export type ClientLookup =
47+
| { status: 'found'; config: SmartClientConfig }
48+
| { status: 'absent' }
49+
| { status: 'unavailable'; reason: string }
50+
51+
/** How the cache reaches the client directory. Injectable so tests need no module mocking. */
52+
export type ClientLookupSource = (clientId: string) => Promise<ClientLookup>
53+
3954
interface CacheEntry {
4055
config: SmartClientConfig
4156
expiresAt: number
4257
}
4358

4459
const DEFAULT_TTL_MS = 5 * 60 * 1000 // 5 minutes
60+
/** Absence is usually "not created yet", so re-ask soon (admin create/recreate races). */
61+
const ABSENT_TTL_MS = 30 * 1000
4562

46-
const cache = new Map<string, CacheEntry>()
63+
const EMPTY_CONFIG: SmartClientConfig = { redirectUris: [] }
4764

4865
/**
49-
* Get the SMART client config for a given clientId.
50-
* Returns cached value if available; otherwise fetches from Keycloak.
66+
* Build a caching client-config reader over a lookup source.
67+
*
68+
* Exported so tests can drive the caching and failure semantics through a fake
69+
* source. `mock.module` is process-global in bun, so a sibling test that mocks
70+
* this whole module would otherwise make these paths untestable.
5171
*/
52-
export async function getSmartClientConfig(clientId: string): Promise<SmartClientConfig> {
53-
const now = Date.now()
54-
const cached = cache.get(clientId)
72+
export function createClientConfigCache(source: ClientLookupSource) {
73+
const cache = new Map<string, CacheEntry>()
74+
75+
/** A failed lookup is NEVER cached — that would stretch one hiccup across the whole TTL. */
76+
async function lookup(clientId: string): Promise<ClientLookup> {
77+
const now = Date.now()
78+
const cached = cache.get(clientId)
79+
80+
if (cached && now < cached.expiresAt) {
81+
return { status: 'found', config: cached.config }
82+
}
83+
84+
const result = await source(clientId)
85+
86+
if (result.status === 'found') {
87+
cache.set(clientId, { config: result.config, expiresAt: now + DEFAULT_TTL_MS })
88+
} else if (result.status === 'absent') {
89+
cache.set(clientId, { config: EMPTY_CONFIG, expiresAt: now + ABSENT_TTL_MS })
90+
}
91+
92+
return result
93+
}
94+
95+
/** Lenient reader: see getSmartClientConfig. */
96+
async function getSmartClientConfig(clientId: string): Promise<SmartClientConfig> {
97+
const result = await lookup(clientId)
98+
return result.status === 'found' ? result.config : EMPTY_CONFIG
99+
}
55100

56-
if (cached && now < cached.expiresAt) {
57-
return cached.config
101+
/** Strict reader: see getRegisteredRedirectUris. */
102+
async function getRegisteredRedirectUris(clientId: string): Promise<string[]> {
103+
if (!clientId) return []
104+
if (isCimdClientId(clientId)) {
105+
return resolveCimdRedirectUris(clientId, { logger: smartLogger })
106+
}
107+
const result = await lookup(clientId)
108+
if (result.status === 'unavailable') {
109+
throw new Error(`Cannot read registered redirect URIs for "${clientId}": ${result.reason}`)
110+
}
111+
return result.status === 'found' ? result.config.redirectUris : []
58112
}
59113

60-
// Fetch from Keycloak
61-
const fetched = await fetchClientConfig(clientId)
62-
cache.set(clientId, { config: fetched, expiresAt: now + DEFAULT_TTL_MS })
63-
return fetched
114+
return {
115+
getSmartClientConfig,
116+
getRegisteredRedirectUris,
117+
invalidate: (clientId: string) => cache.delete(clientId),
118+
clear: () => cache.clear(),
119+
}
120+
}
121+
122+
const defaultCache = createClientConfigCache(fetchClientConfig)
123+
124+
/**
125+
* Get the SMART client config for a given clientId.
126+
*
127+
* Lenient by design: token-time enrichment only reads `patientFacing`, and an
128+
* unreachable Keycloak must not stop a token being issued. Callers that need a
129+
* trustworthy allowlist use `getRegisteredRedirectUris`, which fails loudly.
130+
*/
131+
export async function getSmartClientConfig(clientId: string): Promise<SmartClientConfig> {
132+
return defaultCache.getSmartClientConfig(clientId)
64133
}
65134

66135
/**
@@ -81,54 +150,47 @@ export async function getSmartClientConfig(clientId: string): Promise<SmartClien
81150
* client registered. An earlier attempt special-cased CIMD at the interception
82151
* site instead, which silently delegated an authorization-server MUST to Keycloak.
83152
*
84-
* Returns an empty array for unknown clients, an unverifiable metadata document,
85-
* or an unavailable Keycloak — the caller treats an empty allowlist as "reject
86-
* every redirect_uri" (fail-closed).
153+
* Returns an empty array for an unknown client or an unverifiable metadata
154+
* document — the caller treats an empty allowlist as "reject every redirect_uri"
155+
* (fail-closed).
156+
*
157+
* THROWS when Keycloak could not be asked at all. That is not the same as "this
158+
* client has no registered URIs", and the callers act on the difference: both
159+
* the authorize interceptor and the callback handler already answer a thrown
160+
* lookup with `Unable to validate redirect_uri` and an error-level log, instead
161+
* of blaming the client's configuration for an outage on our side.
87162
*
88163
* Wired into `@proxy-smart/auth`'s `getRegisteredRedirectUris` dependency.
89164
*/
90165
export async function getRegisteredRedirectUris(clientId: string): Promise<string[]> {
91-
if (!clientId) return []
92-
if (isCimdClientId(clientId)) {
93-
return resolveCimdRedirectUris(clientId, { logger: smartLogger })
94-
}
95-
const { redirectUris } = await getSmartClientConfig(clientId)
96-
return redirectUris
166+
return defaultCache.getRegisteredRedirectUris(clientId)
97167
}
98168

99169
/**
100170
* Invalidate cache for a specific client (call after admin updates).
101171
*/
102172
export function invalidateClientConfig(clientId: string): void {
103-
cache.delete(clientId)
173+
defaultCache.invalidate(clientId)
104174
}
105175

106176
/**
107177
* Clear the entire client config cache.
108178
*/
109179
export function clearClientConfigCache(): void {
110-
cache.clear()
180+
defaultCache.clear()
111181
}
112182

113-
async function fetchClientConfig(clientId: string): Promise<SmartClientConfig> {
114-
if (!config.keycloak.isConfigured || !config.keycloak.adminClientId || !config.keycloak.adminClientSecret) {
115-
return { redirectUris: [] }
116-
}
117-
183+
async function fetchClientConfig(clientId: string): Promise<ClientLookup> {
118184
try {
119-
const admin = new KcAdminClient({
120-
baseUrl: config.keycloak.baseUrl!,
121-
realmName: config.keycloak.realm!,
122-
})
123-
await admin.auth({
124-
grantType: 'client_credentials',
125-
clientId: config.keycloak.adminClientId,
126-
clientSecret: config.keycloak.adminClientSecret,
127-
})
185+
const admin = await getAdminClient()
186+
if (!admin) {
187+
return { status: 'unavailable', reason: 'Keycloak admin credentials are not configured' }
188+
}
128189

129190
const clients = await admin.clients.find({ clientId, max: 1 })
130191
if (!clients || clients.length === 0) {
131-
return { redirectUris: [] }
192+
logger.auth.warn('Keycloak has no such client', { clientId })
193+
return { status: 'absent' }
132194
}
133195

134196
const attrs = clients[0].attributes || {}
@@ -139,12 +201,10 @@ async function fetchClientConfig(clientId: string): Promise<SmartClientConfig> {
139201

140202
const redirectUris = Array.isArray(clients[0].redirectUris) ? clients[0].redirectUris : []
141203

142-
return { patientFacing, redirectUris }
204+
return { status: 'found', config: { patientFacing, redirectUris } }
143205
} catch (error) {
144-
logger.auth.warn('Failed to fetch client config from Keycloak', {
145-
clientId,
146-
error: error instanceof Error ? error.message : 'Unknown error',
147-
})
148-
return { redirectUris: [] }
206+
const reason = error instanceof Error ? error.message : 'Unknown error'
207+
logger.auth.error('Cannot reach Keycloak to read client config', { clientId, error: reason })
208+
return { status: 'unavailable', reason }
149209
}
150210
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
// SPDX-FileCopyrightText: Max Health Inc.
2+
// SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Commercial
3+
4+
/**
5+
* The client-config cache must distinguish "this client has no registered
6+
* redirect URIs" from "Keycloak could not be asked".
7+
*
8+
* The redirect_uri check is fail-closed on an empty allowlist, so flattening an
9+
* unreachable Keycloak into `[]` made every SMART launch fail with
10+
* "redirect_uri does not match a registered redirect URI for this client" —
11+
* blaming the app's configuration for an outage on our side. Worse, the empty
12+
* result was cached, so one hiccup rejected every launch for the whole TTL.
13+
*
14+
* Driven through createClientConfigCache rather than mock.module: bun's module
15+
* mocks are process-global, and a sibling test mocks this whole module.
16+
*/
17+
import { describe, it, expect, beforeEach } from 'bun:test'
18+
import { createClientConfigCache, type ClientLookup } from '../src/lib/smart-client-config-cache'
19+
20+
const CLIENT = 'inferno-test-client'
21+
const REDIRECT = 'http://localhost:4567/custom/smart_stu2_2/redirect'
22+
23+
let mode: 'found' | 'absent' | 'unavailable' = 'found'
24+
let calls = 0
25+
26+
function source(_clientId: string): Promise<ClientLookup> {
27+
calls++
28+
if (mode === 'unavailable') {
29+
return Promise.resolve({ status: 'unavailable', reason: 'connect ECONNREFUSED 127.0.0.1:8080' })
30+
}
31+
if (mode === 'absent') return Promise.resolve({ status: 'absent' })
32+
return Promise.resolve({
33+
status: 'found',
34+
config: { redirectUris: [REDIRECT], patientFacing: true },
35+
})
36+
}
37+
38+
let cache = createClientConfigCache(source)
39+
40+
describe('client config cache — allowlist vs unavailable', () => {
41+
beforeEach(() => {
42+
cache = createClientConfigCache(source)
43+
calls = 0
44+
mode = 'found'
45+
})
46+
47+
it('returns the URIs registered for a known client', async () => {
48+
expect(await cache.getRegisteredRedirectUris(CLIENT)).toEqual([REDIRECT])
49+
})
50+
51+
it('returns an empty allowlist for a client Keycloak does not have', async () => {
52+
mode = 'absent'
53+
expect(await cache.getRegisteredRedirectUris('no-such-client')).toEqual([])
54+
})
55+
56+
it('throws instead of returning an empty allowlist when Keycloak is unreachable', async () => {
57+
mode = 'unavailable'
58+
await expect(cache.getRegisteredRedirectUris(CLIENT)).rejects.toThrow(
59+
/Cannot read registered redirect URIs/,
60+
)
61+
})
62+
63+
it('does not cache an unreachable lookup, so recovery is immediate', async () => {
64+
mode = 'unavailable'
65+
await expect(cache.getRegisteredRedirectUris(CLIENT)).rejects.toThrow()
66+
67+
mode = 'found'
68+
expect(await cache.getRegisteredRedirectUris(CLIENT)).toEqual([REDIRECT])
69+
})
70+
71+
it('caches a successful lookup', async () => {
72+
await cache.getRegisteredRedirectUris(CLIENT)
73+
await cache.getRegisteredRedirectUris(CLIENT)
74+
expect(calls).toBe(1)
75+
})
76+
77+
it('re-asks after an absent client is invalidated (admin recreate)', async () => {
78+
mode = 'absent'
79+
expect(await cache.getRegisteredRedirectUris(CLIENT)).toEqual([])
80+
81+
cache.invalidate(CLIENT)
82+
mode = 'found'
83+
expect(await cache.getRegisteredRedirectUris(CLIENT)).toEqual([REDIRECT])
84+
})
85+
86+
it('keeps token-time config lenient — no throw when Keycloak is unreachable', async () => {
87+
mode = 'unavailable'
88+
expect(await cache.getSmartClientConfig(CLIENT)).toEqual({ redirectUris: [] })
89+
})
90+
})

0 commit comments

Comments
 (0)