Skip to content

Commit 178b501

Browse files
Merge pull request #967 from Max-Health-Inc/develop
🧪 Auto-PR: Merge `develop` → `test`
2 parents e60a5fc + 192fecd commit 178b501

16 files changed

Lines changed: 188 additions & 13 deletions

File tree

backend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "proxy-smart-backend",
33
"displayName": "Proxy Smart Backend",
4-
"version": "0.3.4-beta.202608081329.9f7c97362",
4+
"version": "0.3.4-alpha.202608081354.8d12f64df",
55
"type": "module",
66
"scripts": {
77
"test": "bun test --isolate",

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+
})

config/eslint/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@proxy-smart/eslint-config",
3-
"version": "0.3.4-beta.202608081258.d360e9831",
3+
"version": "0.3.4-beta.202608081329.9f7c97362",
44
"private": true,
55
"type": "module",
66
"exports": {

deploy/infra/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "proxy-smart-infra",
33
"displayName": "Proxy Smart Infrastructure",
44
"description": "AWS CDK infrastructure for Proxy Smart production deployment",
5-
"version": "0.3.4-beta.202608081329.9f7c97362",
5+
"version": "0.3.4-alpha.202608081354.8d12f64df",
66
"private": true,
77
"type": "module",
88
"scripts": {

frontend/smart-dicom-template/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"displayName": "SMART DICOM Algorithm Template",
44
"description": "Starter kit for building SMART on FHIR imaging algorithm apps. Clone, implement your algorithm in src/algorithm.ts, and deploy as a SMART app.",
55
"private": true,
6-
"version": "0.3.4-beta.202608081258.d360e9831",
6+
"version": "0.3.4-beta.202608081329.9f7c97362",
77
"type": "module",
88
"scripts": {
99
"dev": "vite --port 5180",

frontend/ui/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"displayName": "Proxy Smart Admin UI",
44
"description": "A web-based administration interface for managing healthcare applications and resources via Proxy Smart.",
55
"private": true,
6-
"version": "0.3.4-beta.202608081258.d360e9831",
6+
"version": "0.3.4-beta.202608081329.9f7c97362",
77
"type": "module",
88
"scripts": {
99
"dev": "vite",

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "proxy-smart",
3-
"version": "0.3.4-beta.202608081329.9f7c97362",
3+
"version": "0.3.4-alpha.202608081354.8d12f64df",
44
"repository": {
55
"type": "git",
66
"url": "git+https://github.com/Max-Health-Inc/proxy-smart.git"

packages/app-store/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@proxy-smart/app-store",
3-
"version": "0.3.4-beta.202608081258.d360e9831",
3+
"version": "0.3.4-beta.202608081329.9f7c97362",
44
"private": false,
55
"type": "module",
66
"description": "SMART on FHIR app store — manifest discovery, visibility configuration, and registry CRUD. Framework-agnostic.",

0 commit comments

Comments
 (0)