From 71b4ab419593b0e86f6053b2928ad569e806a83a Mon Sep 17 00:00:00 2001 From: Josh Mandel Date: Sat, 7 Dec 2024 14:51:45 -0600 Subject: [PATCH 1/8] Initial prompt=none support --- .source_version | 1 + backend/errors.ts | 6 +-- backend/routes/auth/authorize.ts | 71 ++++++++++++++++++++++++++++++++ package-lock.json | 4 +- 4 files changed, 78 insertions(+), 4 deletions(-) diff --git a/.source_version b/.source_version index e69de29..8b13789 100644 --- a/.source_version +++ b/.source_version @@ -0,0 +1 @@ + diff --git a/backend/errors.ts b/backend/errors.ts index 8b41217..da7e307 100644 --- a/backend/errors.ts +++ b/backend/errors.ts @@ -30,9 +30,9 @@ export class OAuthError extends HttpError super(message, ...args) } - errorId(id: string) { - this.id = id - return this + errorId(id: "login_required" | "interaction_required" | "invalid_request" | "invalid_client" | "invalid_scope" | string) { + this.id = id; + return this; } render(req: Request, res: Response) { diff --git a/backend/routes/auth/authorize.ts b/backend/routes/auth/authorize.ts index 272e2c3..7d7b736 100644 --- a/backend/routes/auth/authorize.ts +++ b/backend/routes/auth/authorize.ts @@ -40,6 +40,8 @@ export interface AuthorizeParams { encounter?: string auth_success?: "0" | "1" login_success?: string + prompt?: string + id_token_hint?: string } @@ -57,6 +59,8 @@ export default class AuthorizeHandler { protected scope: ScopeSet; + protected idTokenUser?: string; + public static handle(req: Request, res: Response) { if (req.method === "POST") { requireUrlencodedPost(req) @@ -466,6 +470,46 @@ export default class AuthorizeHandler { } } + private validateIdTokenHint(idTokenHint: string): void { + if (!idTokenHint) { + throw new InvalidRequestError("id_token_hint is required when prompt=none") + .errorId("invalid_request") + .status(400); + } + + try { + // Verify the token was issued by us and is still valid + const decoded = jwt.verify(idTokenHint, config.privateKeyAsPem, + { algorithms: config.supportedAlgorithms as jwt.Algorithm } + + ) as unknown as { + + fhirUser: string; + exp: number; + }; + + // Require patient-type user + if (!decoded.fhirUser?.startsWith("Patient/")) { + throw new InvalidRequestError("id_token_hint must reference a patient user") + .errorId("login_required") + .status(400); + } + + // Store the user ID for later use + this.idTokenUser = decoded.fhirUser; + + } catch (e) { + if (e instanceof jwt.TokenExpiredError) { + throw new InvalidRequestError("id_token_hint has expired") + .errorId("login_required") + .status(400); + } + throw new InvalidRequestError("Invalid id_token_hint") + .errorId("invalid_request") + .status(400); + } + } + /** * The client constructs the request URI by adding the following * parameters to the query component of the authorization endpoint URI @@ -492,6 +536,33 @@ export default class AuthorizeHandler { { const { params, launchOptions } = this + // Handle prompt=none flow + if (params.prompt === "none") { + // Validate id_token_hint first + this.validateIdTokenHint(params.id_token_hint!); + + // For prompt=none, force patient-standalone launch + launchOptions.launch_type = "patient-standalone"; + launchOptions.skip_login = true; + launchOptions.skip_auth = true; + + // Set the patient from the validated ID token + const patientId = this.idTokenUser!.split("/")[1]; + launchOptions.patient.set(patientId); + + // Validate the request before proceeding + this.validateAuthorizeRequest(); + + // Create and redirect with the new authorization code + const RedirectURL = new URL(decodeURIComponent(params.redirect_uri)); + RedirectURL.searchParams.set("code", this.createAuthCode()); + if (params.state) { + RedirectURL.searchParams.set("state", params.state); + } + return this.response.redirect(RedirectURL.href); + } + + // Continue with existing authorization flow this.validateAuthorizeRequest(); // Handle response from dialogs diff --git a/package-lock.json b/package-lock.json index 6e0c9d2..0e17b59 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,6 @@ "@types/node": "^16.11.49", "@types/react": "^18.0.17", "@types/react-dom": "^18.0.6", - "@types/selenium-webdriver": "^4.1.3", "cors": "^2.8.5", "express": "^4.18.2", "fhirclient": "^2.5.2", @@ -48,6 +47,7 @@ "@types/pem-jwk": "^2.0.0", "@types/react-router-dom": "^5.3.3", "@types/react-syntax-highlighter": "^15.5.13", + "@types/selenium-webdriver": "^4.1.3", "chai": "^4.3.6", "chai-as-promised": "^7.1.1", "mocha": "^8.4.0", @@ -6014,6 +6014,7 @@ "version": "4.1.5", "resolved": "https://registry.npmjs.org/@types/selenium-webdriver/-/selenium-webdriver-4.1.5.tgz", "integrity": "sha512-Lfu97JK5b2jAxCUHH8uMjmhUiQZCGDyVzSAskFFZuWcprtcwjMkEPZE/SiIM5hOGQJVs982BAF26a3kmw8iiJw==", + "dev": true, "dependencies": { "@types/ws": "*" } @@ -31410,6 +31411,7 @@ "version": "4.1.5", "resolved": "https://registry.npmjs.org/@types/selenium-webdriver/-/selenium-webdriver-4.1.5.tgz", "integrity": "sha512-Lfu97JK5b2jAxCUHH8uMjmhUiQZCGDyVzSAskFFZuWcprtcwjMkEPZE/SiIM5hOGQJVs982BAF26a3kmw8iiJw==", + "dev": true, "requires": { "@types/ws": "*" } From 8f7d5507523af8bbab087454a086b307a87a3da0 Mon Sep 17 00:00:00 2001 From: Josh Mandel Date: Sat, 7 Dec 2024 15:14:56 -0600 Subject: [PATCH 2/8] cache issued id tokens for use in prompt=none --- backend/config.ts | 5 ++- backend/routes/auth/authorize.ts | 65 +++++++++++++------------------- backend/routes/auth/token.ts | 18 +++++++++ 3 files changed, 49 insertions(+), 39 deletions(-) diff --git a/backend/config.ts b/backend/config.ts index bec5f52..e48e267 100644 --- a/backend/config.ts +++ b/backend/config.ts @@ -1,4 +1,5 @@ import FS from "fs" +import { TokenCache } from './lib/TokenCache' const { env } = process @@ -65,6 +66,8 @@ export default { /** * Associated endpoints for imaging, etc */ - associatedEndpoints: JSON.parse(env.ASSOCIATED_ENDPOINTS || "[]") + associatedEndpoints: JSON.parse(env.ASSOCIATED_ENDPOINTS || "[]"), + + tokenCache: new TokenCache() } diff --git a/backend/routes/auth/authorize.ts b/backend/routes/auth/authorize.ts index 7d7b736..6f6ea22 100644 --- a/backend/routes/auth/authorize.ts +++ b/backend/routes/auth/authorize.ts @@ -18,6 +18,8 @@ import { InvalidScopeError, OAuthError } from "../../errors" +import crypto from 'crypto' +import { TokenContext } from '../../lib/TokenCache' export interface AuthorizeParams { @@ -470,44 +472,28 @@ export default class AuthorizeHandler { } } - private validateIdTokenHint(idTokenHint: string): void { + private validateIdTokenHint(idTokenHint: string): TokenContext { if (!idTokenHint) { throw new InvalidRequestError("id_token_hint is required when prompt=none") .errorId("invalid_request") .status(400); } - try { - // Verify the token was issued by us and is still valid - const decoded = jwt.verify(idTokenHint, config.privateKeyAsPem, - { algorithms: config.supportedAlgorithms as jwt.Algorithm } - - ) as unknown as { - - fhirUser: string; - exp: number; - }; - - // Require patient-type user - if (!decoded.fhirUser?.startsWith("Patient/")) { - throw new InvalidRequestError("id_token_hint must reference a patient user") - .errorId("login_required") - .status(400); - } - - // Store the user ID for later use - this.idTokenUser = decoded.fhirUser; + // Hash the provided id_token_hint + const idTokenHash = crypto + .createHash('sha256') + .update(idTokenHint) + .digest('hex'); - } catch (e) { - if (e instanceof jwt.TokenExpiredError) { - throw new InvalidRequestError("id_token_hint has expired") - .errorId("login_required") - .status(400); - } - throw new InvalidRequestError("Invalid id_token_hint") - .errorId("invalid_request") + // Look up the context + const context = config.tokenCache.get(idTokenHash); + if (!context) { + throw new InvalidRequestError("Unknown or expired id_token_hint") + .errorId("login_required") .status(400); } + + return context; } /** @@ -538,22 +524,24 @@ export default class AuthorizeHandler { // Handle prompt=none flow if (params.prompt === "none") { - // Validate id_token_hint first - this.validateIdTokenHint(params.id_token_hint!); + // Get the previous authorization context + const context = this.validateIdTokenHint(params.id_token_hint!); + console.log("Prev token context", context) - // For prompt=none, force patient-standalone launch + // Set up launch params from previous context launchOptions.launch_type = "patient-standalone"; launchOptions.skip_login = true; launchOptions.skip_auth = true; - - // Set the patient from the validated ID token - const patientId = this.idTokenUser!.split("/")[1]; - launchOptions.patient.set(patientId); + launchOptions.patient.set(context.patient || ""); + launchOptions.scope = context.scope; + // Validate the request before proceeding this.validateAuthorizeRequest(); - - // Create and redirect with the new authorization code + console.log("validated authz request", launchOptions); + + + // Create and redirect with new authorization code const RedirectURL = new URL(decodeURIComponent(params.redirect_uri)); RedirectURL.searchParams.set("code", this.createAuthCode()); if (params.state) { @@ -561,6 +549,7 @@ export default class AuthorizeHandler { } return this.response.redirect(RedirectURL.href); } + console.log("no prompt=none, continuing"); // Continue with existing authorization flow this.validateAuthorizeRequest(); diff --git a/backend/routes/auth/token.ts b/backend/routes/auth/token.ts index e4f3745..cb25b9f 100644 --- a/backend/routes/auth/token.ts +++ b/backend/routes/auth/token.ts @@ -621,6 +621,24 @@ export default class TokenHandler { // as well as the Pragma response header field with a value of no-cache. res.set({ "Cache-Control": "no-store", "Pragma": "no-cache" }); + if (tokenResponse.id_token) { + // Store the context associated with this id_token + const idTokenHash = crypto + .createHash('sha256') + .update(tokenResponse.id_token) + .digest('hex'); + + config.tokenCache.set({ + id_token_hash: idTokenHash, + scope: authorizationToken.scope, + patient: authorizationToken.context?.patient, + user: authorizationToken.user!, + client_id: authorizationToken.client_id!, + context: authorizationToken.context || {}, + exp: Math.floor(Date.now()/1000) + (authorizationToken.accessTokensExpireIn || 3600) + }); + } + res.json(tokenResponse); } } From 963ee03e72b1e517564ef38c0824060055267336 Mon Sep 17 00:00:00 2001 From: Josh Mandel Date: Sat, 7 Dec 2024 15:20:04 -0600 Subject: [PATCH 3/8] Add caching class --- backend/lib/TokenCache.ts | 48 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 backend/lib/TokenCache.ts diff --git a/backend/lib/TokenCache.ts b/backend/lib/TokenCache.ts new file mode 100644 index 0000000..31f952f --- /dev/null +++ b/backend/lib/TokenCache.ts @@ -0,0 +1,48 @@ +/** + * TokenContext represents the data associated with an issued token. + * NOTE: In a production environment, this should also include session binding + * to prevent token reuse across different user sessions. + */ +export interface TokenContext { + id_token_hash: string + scope: string + patient?: string + user: string + client_id: string + context: Record + exp: number +} + +/** + * A simple in-memory cache for token contexts. + * + * SECURITY NOTE: This is a reference implementation. + * In a production environment, you should: + * 1. Bind tokens to user sessions to prevent unauthorized reuse + * 2. Use a persistent storage mechanism + */ +export class TokenCache { + private cache: Map = new Map(); + private readonly maxSize: number = 1000; + + public set(context: TokenContext): void { + // If we're at capacity, remove oldest entries + while (this.cache.size >= this.maxSize) { + const firstKey = this.cache.keys().next().value; + this.cache.delete(firstKey); + } + + this.cache.set(context.id_token_hash, context); + } + + public get(idTokenHash: string): TokenContext | undefined { + const context = this.cache.get(idTokenHash); + if (context && + Date.now() < context.exp * 1000) { + return context; + } + // Clean up expired entries + this.cache.delete(idTokenHash); + return undefined; + } +} From 2cd2bfb2fd3cc1d8ee2d7dba827d8109b61eb77f Mon Sep 17 00:00:00 2001 From: Josh Mandel Date: Sun, 8 Dec 2024 22:45:29 -0600 Subject: [PATCH 4/8] Expose client registration for the sim-bound client_id --- backend/routes/auth/clients.ts | 74 ++++++++++++++++++++++++++++++++++ backend/routes/auth/index.ts | 3 +- 2 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 backend/routes/auth/clients.ts diff --git a/backend/routes/auth/clients.ts b/backend/routes/auth/clients.ts new file mode 100644 index 0000000..4d43037 --- /dev/null +++ b/backend/routes/auth/clients.ts @@ -0,0 +1,74 @@ +import { Request, Response } from "express" +import LaunchOptions from "../../../src/isomorphic/LaunchOptions" +import { InvalidRequestError } from "../../errors" + +function formatClientName(clientId: string): string { + return clientId + .split(/[-_.]/) + .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(' ') +} + +export default function getClientRegistration(req: Request, res: Response) { + try { + const launchOptions = new LaunchOptions(req.params.sim || "") + const requestedClientId = req.params.clientId + + // Validate client_id matches if specified in launch options + if (launchOptions.client_id && launchOptions.client_id !== requestedClientId) { + return res.status(404).json({ + error: "not_found", + error_description: "Client not found" + }) + } + + // Build response following OAuth 2.0 Dynamic Client Registration spec + const clientMetadata: Record = { + client_id: requestedClientId, + client_id_issued_at: Math.floor(Date.now() / 1000), + + // Client identity + client_name: formatClientName(requestedClientId), + client_uri: `https://example.com/apps/${requestedClientId}`, + logo_uri: `https://via.placeholder.com/150?text=${encodeURIComponent(formatClientName(requestedClientId))}`, + tos_uri: `https://example.com/apps/${requestedClientId}/tos`, + policy_uri: `https://example.com/apps/${requestedClientId}/privacy`, + contacts: [`support@${requestedClientId}.example.com`], + + // OAuth capabilities + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + token_endpoint_auth_method: "none", // default for public clients + scope: launchOptions.scope || "launch/patient offline_access openid fhirUser" + } + + // Add redirect URIs if specified + if (launchOptions.redirect_uris) { + clientMetadata.redirect_uris = launchOptions.redirect_uris.split(/\s*,\s*/) + } + + // Add asymmetric auth properties + if (launchOptions.client_type === "confidential-asymmetric") { + clientMetadata.token_endpoint_auth_method = "private_key_jwt" + if (launchOptions.jwks_url) { + clientMetadata.jwks_uri = launchOptions.jwks_url + } + if (launchOptions.jwks) { + clientMetadata.jwks = JSON.parse(launchOptions.jwks) + } + } + + // Add backend service properties + if (launchOptions.client_type === "backend-service") { + clientMetadata.grant_types = ["client_credentials"] + clientMetadata.token_endpoint_auth_method = "private_key_jwt" + } + + // Pretty print the response + res.setHeader('Content-Type', 'application/json') + res.send(JSON.stringify(clientMetadata, null, 2)) + + } catch (error) { + throw new InvalidRequestError("Invalid launch options: " + error) + } +} \ No newline at end of file diff --git a/backend/routes/auth/index.ts b/backend/routes/auth/index.ts index a279763..25d0a31 100644 --- a/backend/routes/auth/index.ts +++ b/backend/routes/auth/index.ts @@ -6,6 +6,7 @@ import register from "./register" import TokenHandler from "./token" import AuthorizeHandler from "./authorize" import { asyncRouteWrap } from "../../lib" +import getClientRegistration from "./clients" const authServer = express.Router({ mergeParams: true }) @@ -19,6 +20,6 @@ authServer.post("/introspect", urlencoded, asyncRouteWrap(introspect)) authServer.post("/revoke" , urlencoded, asyncRouteWrap(revoke)) authServer.post("/manage" , urlencoded, asyncRouteWrap(manage)) authServer.post("/register" , urlencoded, asyncRouteWrap(register)) - +authServer.get("/clients/:clientId", asyncRouteWrap(getClientRegistration)) export default authServer From 50e6a7ae646680ab5ccca7257ad6c5f442c5ba11 Mon Sep 17 00:00:00 2001 From: Josh Mandel Date: Mon, 9 Dec 2024 12:48:37 -0600 Subject: [PATCH 5/8] prompt=none simulates mode based on user type --- backend/lib/TokenCache.ts | 2 +- backend/routes/auth/authorize.ts | 23 +++++++++++++++++++---- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/backend/lib/TokenCache.ts b/backend/lib/TokenCache.ts index 31f952f..109768f 100644 --- a/backend/lib/TokenCache.ts +++ b/backend/lib/TokenCache.ts @@ -9,7 +9,7 @@ export interface TokenContext { patient?: string user: string client_id: string - context: Record + contex: Record exp: number } diff --git a/backend/routes/auth/authorize.ts b/backend/routes/auth/authorize.ts index 6f6ea22..6ae12a7 100644 --- a/backend/routes/auth/authorize.ts +++ b/backend/routes/auth/authorize.ts @@ -71,6 +71,7 @@ export default class AuthorizeHandler { const params: AuthorizeParams = req.method === "POST" ? req.body : req.query try { + console.log("handling authz", params.launch, req.params.sim); var launchOptions = new LaunchOptions(String(params.launch || "") || req.params.sim || "") } catch (ex) { throw new InvalidRequestError("Invalid launch options: " + ex) @@ -334,6 +335,7 @@ export default class AuthorizeHandler { const { params, launchOptions } = this const scope = new ScopeSet(decodeURIComponent(this.params.scope)); + console.log("Create authz code with", scope); const code: SMART.AuthorizationToken = { context: { @@ -407,6 +409,8 @@ export default class AuthorizeHandler { } } + console.log("Authz code as", code, launchOptions); + return jwt.sign(code, config.jwtSecret, { expiresIn: "5m" }); } @@ -527,15 +531,26 @@ export default class AuthorizeHandler { // Get the previous authorization context const context = this.validateIdTokenHint(params.id_token_hint!); console.log("Prev token context", context) - + + // Set up launch params from previous context - launchOptions.launch_type = "patient-standalone"; launchOptions.skip_login = true; launchOptions.skip_auth = true; - launchOptions.patient.set(context.patient || ""); + + if (context.patient) { + launchOptions.patient.set(context.patient); + } + if (context.user.startsWith("Practitioner")) { + launchOptions.provider.set(context.user.split("/")[1]); + launchOptions.launch_type = "provider-standalone"; + } + if (context.user.startsWith("Patient")) { + launchOptions.patient.set(context.patient); + launchOptions.launch_type = "patient-standalone"; + } launchOptions.scope = context.scope; - + // Validate the request before proceeding this.validateAuthorizeRequest(); console.log("validated authz request", launchOptions); From 9fac9712b15a688d8cfd0ccb83d370f4da90450d Mon Sep 17 00:00:00 2001 From: Josh Mandel Date: Tue, 10 Dec 2024 12:47:03 -0600 Subject: [PATCH 6/8] Remove debug logging --- backend/routes/auth/authorize.ts | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/backend/routes/auth/authorize.ts b/backend/routes/auth/authorize.ts index 6ae12a7..9032fe6 100644 --- a/backend/routes/auth/authorize.ts +++ b/backend/routes/auth/authorize.ts @@ -71,7 +71,6 @@ export default class AuthorizeHandler { const params: AuthorizeParams = req.method === "POST" ? req.body : req.query try { - console.log("handling authz", params.launch, req.params.sim); var launchOptions = new LaunchOptions(String(params.launch || "") || req.params.sim || "") } catch (ex) { throw new InvalidRequestError("Invalid launch options: " + ex) @@ -335,7 +334,6 @@ export default class AuthorizeHandler { const { params, launchOptions } = this const scope = new ScopeSet(decodeURIComponent(this.params.scope)); - console.log("Create authz code with", scope); const code: SMART.AuthorizationToken = { context: { @@ -409,8 +407,6 @@ export default class AuthorizeHandler { } } - console.log("Authz code as", code, launchOptions); - return jwt.sign(code, config.jwtSecret, { expiresIn: "5m" }); } @@ -530,8 +526,6 @@ export default class AuthorizeHandler { if (params.prompt === "none") { // Get the previous authorization context const context = this.validateIdTokenHint(params.id_token_hint!); - console.log("Prev token context", context) - // Set up launch params from previous context launchOptions.skip_login = true; @@ -553,8 +547,6 @@ export default class AuthorizeHandler { // Validate the request before proceeding this.validateAuthorizeRequest(); - console.log("validated authz request", launchOptions); - // Create and redirect with new authorization code const RedirectURL = new URL(decodeURIComponent(params.redirect_uri)); @@ -564,8 +556,6 @@ export default class AuthorizeHandler { } return this.response.redirect(RedirectURL.href); } - console.log("no prompt=none, continuing"); - // Continue with existing authorization flow this.validateAuthorizeRequest(); From 57ef0be4363045c03a54d3b861f2757e1732ec6b Mon Sep 17 00:00:00 2001 From: Josh Mandel Date: Tue, 10 Dec 2024 12:52:33 -0600 Subject: [PATCH 7/8] typo --- backend/lib/TokenCache.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/lib/TokenCache.ts b/backend/lib/TokenCache.ts index 109768f..31f952f 100644 --- a/backend/lib/TokenCache.ts +++ b/backend/lib/TokenCache.ts @@ -9,7 +9,7 @@ export interface TokenContext { patient?: string user: string client_id: string - contex: Record + context: Record exp: number } From 0ca4ce9ce5fcb813739c3cea6d163d5fe8a38c15 Mon Sep 17 00:00:00 2001 From: Josh Mandel Date: Tue, 10 Dec 2024 13:06:23 -0600 Subject: [PATCH 8/8] Fix type error --- backend/routes/auth/authorize.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/routes/auth/authorize.ts b/backend/routes/auth/authorize.ts index 9032fe6..b205ac7 100644 --- a/backend/routes/auth/authorize.ts +++ b/backend/routes/auth/authorize.ts @@ -539,7 +539,7 @@ export default class AuthorizeHandler { launchOptions.launch_type = "provider-standalone"; } if (context.user.startsWith("Patient")) { - launchOptions.patient.set(context.patient); + launchOptions.patient.set(context.user.split("/")[1]); launchOptions.launch_type = "patient-standalone"; }