Skip to content

Commit d7fc922

Browse files
committed
fix(keycloak): reconcile the admin-ui device grant so the CLI works on prod
`proxy-smart login --url https://api.proxy-smart.com` fails at the first step: 400 {"error":"unauthorized_client", "error_description":"Client is not allowed to initiate OAuth 2.0 Device Authorization Grant."} The CLI has no browser and no client secret, so it authenticates with the RFC 8628 device flow against admin-ui. With the grant disabled there is no way in at all — and Keycloak's own admin console is not reachable on production either, because the WAF blocks /admin. So prod had no CLI and no console. All three realm exports already declare `oauth2.device.authorization.grant.enabled: "true"` on admin-ui. It was still unset on production, for the same reason as every other drift in kc-system-provisioning.ts: --import-realm is IGNORE_EXISTING, so a realm that already exists never picks up anything added to the export afterwards. Beta has it only because .github/scripts/deploy-beta-remote.sh reconciles it at deploy time (lines 546-591) and production does not run that script. Reconciling it at startup alongside the other system clients makes it true in every environment by construction, rather than in whichever one happened to run a shell script. Idempotent and non-fatal, like its neighbours. The tests assert the reconcile rather than the export, because an export declaring something is not evidence that it is set anywhere — that assumption is precisely what hid this.
1 parent e60a5fc commit d7fc922

3 files changed

Lines changed: 175 additions & 0 deletions

File tree

backend/src/init.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
ensureIntrospectionClientConfig,
1414
ensureResourceServerClients,
1515
ensureResourceIndicatorsScope,
16+
ensureAdminUiDeviceGrant,
1617
} from './lib/kc-system-provisioning'
1718
import KcAdminClient from '@keycloak/keycloak-admin-client'
1819

@@ -959,6 +960,8 @@ async function ensureSystemClients(): Promise<void> {
959960
await ensureResourceServerClients(admin)
960961
// After the resource clients — the scope's mappers name them as audiences.
961962
await ensureResourceIndicatorsScope(admin)
963+
// Lets work without a browser or a client secret.
964+
await ensureAdminUiDeviceGrant(admin)
962965
}
963966

964967
export async function initializeServer(): Promise<void> {

backend/src/lib/kc-system-provisioning.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ export const RESOURCE_AUDIENCE_CLIENT_IDS = ['fhir-resource-server', 'mcp-resour
2929
*/
3030
const RESOURCE_URL_ATTR = 'resource_url'
3131

32+
/** Keycloak client attribute gating RFC 8628 device authorization. */
33+
const DEVICE_GRANT_ATTR = 'oauth2.device.authorization.grant.enabled'
34+
3235
/**
3336
* Non-login "resource server" client that exists only to hold a `resource_url`.
3437
*
@@ -353,3 +356,53 @@ export async function ensureShlExchangeClient(admin: KcAdminClient): Promise<voi
353356
})
354357
}
355358
}
359+
360+
/**
361+
* Ensure the admin webapp client may start the RFC 8628 device authorization grant.
362+
*
363+
* This is how `proxy-smart login` works: the CLI has no browser and no client
364+
* secret, so it starts a device flow against the admin-ui client and polls. With
365+
* the grant disabled Keycloak refuses at the first step:
366+
*
367+
* HTTP 400 {"error":"unauthorized_client",
368+
* "error_description":"Client is not allowed to initiate OAuth 2.0
369+
* Device Authorization Grant."}
370+
*
371+
* ALL THREE realm exports already declare the attribute — and it still was not
372+
* set on production. Same reason as every other drift in this file:
373+
* `--import-realm` is IGNORE_EXISTING, so a realm that already exists never picks
374+
* up anything added to the export afterwards. Beta only had it because
375+
* .github/scripts/deploy-beta-remote.sh reconciles it at deploy time, and
376+
* production does not run that script. Reconciling here makes it true in every
377+
* environment by construction rather than in whichever one happened to run a
378+
* shell script.
379+
*/
380+
export async function ensureAdminUiDeviceGrant(admin: KcAdminClient): Promise<void> {
381+
const clientId = config.keycloak.adminUiClientId
382+
if (!clientId) return
383+
384+
try {
385+
const existing = await admin.clients.find({ clientId, max: 1 })
386+
if (existing.length === 0) {
387+
logger.keycloak.debug('Admin UI client not found — skipping device-grant reconcile', { clientId })
388+
return
389+
}
390+
391+
const client = existing[0]
392+
if (client.attributes?.[DEVICE_GRANT_ATTR] === 'true') return
393+
394+
await admin.clients.update(
395+
{ id: client.id! },
396+
{ clientId, attributes: { ...(client.attributes ?? {}), [DEVICE_GRANT_ATTR]: 'true' } },
397+
)
398+
logger.keycloak.info('Enabled device-authorization grant on the admin UI client', {
399+
clientId,
400+
previous: client.attributes?.[DEVICE_GRANT_ATTR] ?? '(unset)',
401+
})
402+
} catch (error) {
403+
logger.keycloak.warn('Failed to reconcile admin-ui device-authorization grant', {
404+
clientId,
405+
error: error instanceof Error ? error.message : String(error),
406+
})
407+
}
408+
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
// SPDX-FileCopyrightText: Max Health Inc.
2+
// SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Commercial
3+
4+
/**
5+
* `proxy-smart login` needs the device grant on the admin UI client.
6+
*
7+
* THE BUG THIS GUARDS. All three realm exports declare
8+
* `oauth2.device.authorization.grant.enabled: "true"` on admin-ui, and it still
9+
* was not set on production, so the CLI could not authenticate there at all:
10+
*
11+
* HTTP 400 {"error":"unauthorized_client",
12+
* "error_description":"Client is not allowed to initiate OAuth 2.0
13+
* Device Authorization Grant."}
14+
*
15+
* `--import-realm` is IGNORE_EXISTING: a realm that already exists never picks up
16+
* anything added to the export afterwards. Beta had it only because
17+
* .github/scripts/deploy-beta-remote.sh reconciles it at deploy time, and
18+
* production does not run that script — the same shape as the resource-indicators
19+
* drift in resource-server-clients.test.ts.
20+
*
21+
* Declaring it in an export is therefore not evidence that it is set anywhere.
22+
* These tests assert the runtime reconcile, which is what actually makes it true.
23+
*/
24+
import { describe, it, expect } from 'bun:test'
25+
import { readFileSync } from 'fs'
26+
import { join } from 'path'
27+
import { ensureAdminUiDeviceGrant } from '@/lib/kc-system-provisioning'
28+
29+
const DEVICE_GRANT_ATTR = 'oauth2.device.authorization.grant.enabled'
30+
const REPO = join(import.meta.dir, '..', '..')
31+
32+
interface RealmExport {
33+
clients?: { clientId?: string; attributes?: Record<string, string> }[]
34+
}
35+
36+
/** A stand-in for the Keycloak admin client, recording what would be written. */
37+
function fakeAdmin(existing: Record<string, string> | null) {
38+
const updates: { id: string; attributes?: Record<string, string> }[] = []
39+
return {
40+
updates,
41+
clients: {
42+
find: async () =>
43+
existing === null ? [] : [{ id: 'internal-uuid', clientId: 'admin-ui', attributes: existing }],
44+
update: async (
45+
where: { id: string },
46+
body: { attributes?: Record<string, string> },
47+
) => {
48+
updates.push({ id: where.id, attributes: body.attributes })
49+
},
50+
},
51+
}
52+
}
53+
54+
describe('every realm export declares the attribute', () => {
55+
// Not the thing that makes it true, but if an export ever drops it the runtime
56+
// reconcile becomes the only source — worth knowing.
57+
for (const path of [
58+
'keycloak/realm-export.json',
59+
'deploy/beta/realm-export.json',
60+
'deploy/prod/realm-export.json',
61+
]) {
62+
it(`${path} sets it on admin-ui`, () => {
63+
const realm = JSON.parse(readFileSync(join(REPO, path), 'utf8')) as RealmExport
64+
const adminUi = (realm.clients ?? []).find((c) => c.clientId === 'admin-ui')
65+
expect(adminUi?.attributes?.[DEVICE_GRANT_ATTR]).toBe('true')
66+
})
67+
}
68+
})
69+
70+
describe('ensureAdminUiDeviceGrant', () => {
71+
it('enables the grant when the live client has it disabled', async () => {
72+
// Production's actual state: the attribute present and false.
73+
const admin = fakeAdmin({ [DEVICE_GRANT_ATTR]: 'false', 'pkce.code.challenge.method': 'S256' })
74+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
75+
await ensureAdminUiDeviceGrant(admin as any)
76+
77+
expect(admin.updates).toHaveLength(1)
78+
expect(admin.updates[0].attributes?.[DEVICE_GRANT_ATTR]).toBe('true')
79+
// Unrelated attributes must survive the merge.
80+
expect(admin.updates[0].attributes?.['pkce.code.challenge.method']).toBe('S256')
81+
})
82+
83+
it('enables the grant when the attribute is absent entirely', async () => {
84+
const admin = fakeAdmin({})
85+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
86+
await ensureAdminUiDeviceGrant(admin as any)
87+
88+
expect(admin.updates).toHaveLength(1)
89+
expect(admin.updates[0].attributes?.[DEVICE_GRANT_ATTR]).toBe('true')
90+
})
91+
92+
it('writes nothing when it is already enabled', async () => {
93+
// Idempotent: this runs on every boot.
94+
const admin = fakeAdmin({ [DEVICE_GRANT_ATTR]: 'true' })
95+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
96+
await ensureAdminUiDeviceGrant(admin as any)
97+
98+
expect(admin.updates).toHaveLength(0)
99+
})
100+
101+
it('does nothing when the client does not exist', async () => {
102+
const admin = fakeAdmin(null)
103+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
104+
await ensureAdminUiDeviceGrant(admin as any)
105+
106+
expect(admin.updates).toHaveLength(0)
107+
})
108+
109+
it('never throws — a failed reconcile must not stop the server booting', async () => {
110+
const admin = {
111+
clients: {
112+
find: async () => { throw new Error('Keycloak unreachable') },
113+
update: async () => {},
114+
},
115+
}
116+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
117+
expect(ensureAdminUiDeviceGrant(admin as any)).resolves.toBeUndefined()
118+
})
119+
})

0 commit comments

Comments
 (0)