Skip to content

Commit 4f2f1ee

Browse files
Merge pull request #966 from Max-Health-Inc/develop
🧪 Auto-PR: Merge `develop` → `test`
2 parents 6fa2811 + 9cd074b commit 4f2f1ee

15 files changed

Lines changed: 76 additions & 27 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.202608081258.d360e9831",
4+
"version": "0.3.4-alpha.202608081329.9f7c97362",
55
"type": "module",
66
"scripts": {
77
"test": "bun test --isolate",

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.202608081217.1edcad1f3",
3+
"version": "0.3.4-beta.202608081258.d360e9831",
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.202608081258.d360e9831",
5+
"version": "0.3.4-alpha.202608081329.9f7c97362",
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.202608081217.1edcad1f3",
6+
"version": "0.3.4-beta.202608081258.d360e9831",
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.202608081217.1edcad1f3",
6+
"version": "0.3.4-beta.202608081258.d360e9831",
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.202608081258.d360e9831",
3+
"version": "0.3.4-alpha.202608081329.9f7c97362",
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.202608081217.1edcad1f3",
3+
"version": "0.3.4-beta.202608081258.d360e9831",
44
"private": false,
55
"type": "module",
66
"description": "SMART on FHIR app store — manifest discovery, visibility configuration, and registry CRUD. Framework-agnostic.",

packages/auth/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/auth",
3-
"version": "0.3.4-beta.202608081217.1edcad1f3",
3+
"version": "0.3.4-beta.202608081258.d360e9831",
44
"private": false,
55
"type": "module",
66
"description": "SMART on FHIR STU 2.2.0 server-side authorization proxy — launch context, session management, token enrichment. Framework-agnostic, IdP-pluggable.",

packages/auth/src/authorize-interceptor.ts

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import type { IdPAdapter } from './idp/interface'
2424
import { isSmartLaunch, isStandaloneLaunch, parseScopes } from './smart-scopes'
2525
import { verifyLaunchCode, type LaunchCodeServiceOptions } from './launch-code'
2626
import { isRedirectUriRegistered, type GetRegisteredRedirectUris } from './redirect-uri'
27+
import { isCimdClientId } from './cimd'
2728

2829
export interface AuthorizeInterceptorDeps {
2930
config: SmartProxyConfig
@@ -136,16 +137,39 @@ export async function handleAuthorize(
136137
// SmartProxyConfig.interceptedResourceUrls.
137138
const targetsInterceptedResource = !!aud && (config.interceptedResourceUrls ?? []).includes(aud)
138139

139-
// NB: this is uniform across registration methods on purpose. A CIMD client
140-
// (`client_id` is an https URL) has no record in the IdP, so an earlier version
141-
// excluded it here — which took the beta MCP connector down, because
142-
// `getRegisteredRedirectUris` found nothing and the fail-closed check below
143-
// rejected every authorize with 400 before a login page rendered. Excluding it
144-
// the other way (skip interception for CIMD) merely hid the same gap and handed
145-
// an authorization-server MUST back to the IdP. The resolver behind
146-
// `getRegisteredRedirectUris` now understands both registration methods, so this
147-
// does not need to know which one produced the client.
148-
const shouldIntercept = (smartLaunch || targetsInterceptedResource) && !!params.redirect_uri
140+
let shouldIntercept = (smartLaunch || targetsInterceptedResource) && !!params.redirect_uri
141+
142+
// ── Only intercept what we can validate ───────────────────────────────
143+
// Interception rewrites redirect_uri to the proxy callback, which takes the
144+
// RFC 6749 §10.6 check away from the IdP and gives it to us. We may only do
145+
// that when we can actually perform it.
146+
//
147+
// For a CIMD client (`client_id` is an https URL) the allowlist lives in a
148+
// document on the CLIENT'S OWN HOST, and fetching it can fail for reasons that
149+
// say nothing about the request: a bot-protection interstitial in front of that
150+
// host, egress restrictions, an outage. Treating an unreadable document as "no
151+
// registered URIs" would reject a legitimate authorize request that, before any
152+
// of this existed, passed through to the IdP and worked — the IdP resolves CIMD
153+
// itself and can validate what we could not read.
154+
//
155+
// So: resolve first, and only take over when the document actually answered.
156+
// Otherwise stand aside. The cost is that such clients keep the IdP's `iss`
157+
// instead of ours, which is a conformance gap; rejecting them outright would be
158+
// an outage, and an outage is worse.
159+
if (shouldIntercept && isCimdClientId(params.client_id) && deps.getRegisteredRedirectUris) {
160+
let resolved: string[]
161+
try {
162+
resolved = await deps.getRegisteredRedirectUris(params.client_id!)
163+
} catch {
164+
resolved = []
165+
}
166+
if (resolved.length === 0) {
167+
logger?.warn('CIMD metadata document unavailable — passing through to the IdP unintercepted', {
168+
clientId: params.client_id,
169+
})
170+
shouldIntercept = false
171+
}
172+
}
149173

150174
// ── Validate redirect_uri against the client's registered URIs ────────
151175
// RFC 6749 §3.1.2.3 / §10.6: reject any redirect_uri that is not an EXACT

packages/auth/src/issuer-identification.test.ts

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -129,13 +129,14 @@ describe('callback interception for proxy-issued resources', () => {
129129
expect(url.searchParams.get('client_id')).toBe(CIMD_ID)
130130
})
131131

132-
test('rejects a CIMD client whose document does not list the redirect_uri', async () => {
132+
test('rejects a CIMD client whose document lists OTHER redirect_uris', async () => {
133133
const store = new MemoryStore()
134134
const deps: AuthorizeInterceptorDeps = {
135135
...authorizeDeps(store),
136-
// Document fetched but the requested URI is not in it — or the document
137-
// could not be verified at all, which the resolver reports the same way.
138-
getRegisteredRedirectUris: async () => [],
136+
// Document fetched and valid, but the requested URI is not in it. A valid
137+
// document always has at least one entry (the resolver rejects empty ones),
138+
// so a NON-EMPTY list that excludes the request is the real "not allowed".
139+
getRegisteredRedirectUris: async () => ['https://somewhere-else.example.com/cb'],
139140
}
140141

141142
const { result } = await handleAuthorize(
@@ -149,6 +150,30 @@ describe('callback interception for proxy-issued resources', () => {
149150
expect(result.error_description).toContain('redirect_uri')
150151
})
151152

153+
test('passes a CIMD client through unintercepted when the document cannot be read', async () => {
154+
// An empty list can only mean "unresolvable" — the resolver rejects documents
155+
// with no redirect_uris. Rejecting here would turn a bot-protected client host
156+
// or a transient outage into a failed login, for a request that worked before
157+
// interception existed. Stand aside and let the IdP validate instead.
158+
const store = new MemoryStore()
159+
const CIMD_ID = 'https://claude.ai/api/mcp/client-metadata.json'
160+
const deps: AuthorizeInterceptorDeps = {
161+
...authorizeDeps(store),
162+
getRegisteredRedirectUris: async () => [],
163+
}
164+
165+
const { result, sessionKey } = await handleAuthorize(
166+
mcpAuthorizeParams({ client_id: CIMD_ID }),
167+
deps,
168+
)
169+
170+
expect(sessionKey).toBeUndefined()
171+
if (result.type !== 'redirect') throw new Error('expected pass-through redirect')
172+
const url = new URL(result.url)
173+
expect(url.searchParams.get('client_id')).toBe(CIMD_ID)
174+
expect(url.searchParams.get('redirect_uri')).toBe(REGISTERED_REDIRECT)
175+
})
176+
152177
test('does NOT intercept a non-SMART request for an unlisted resource', async () => {
153178
const store = new MemoryStore()
154179
const { result, sessionKey } = await handleAuthorize(

0 commit comments

Comments
 (0)