Skip to content

Commit

Permalink
Merge branch 'jmandel-promptnone'
Browse files Browse the repository at this point in the history
  • Loading branch information
vlad-ignatov committed Feb 12, 2025
2 parents aa0f3b1 + 0ca4ce9 commit c9eee18
Show file tree
Hide file tree
Showing 9 changed files with 218 additions and 6 deletions.
1 change: 1 addition & 0 deletions .source_version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

5 changes: 4 additions & 1 deletion backend/config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import FS from "fs"
import { TokenCache } from './lib/TokenCache'

const { env } = process

Expand Down Expand Up @@ -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()

}
6 changes: 3 additions & 3 deletions backend/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
48 changes: 48 additions & 0 deletions backend/lib/TokenCache.ts
Original file line number Diff line number Diff line change
@@ -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<string, any>
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<string, TokenContext> = 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;
}
}
65 changes: 65 additions & 0 deletions backend/routes/auth/authorize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import {
InvalidScopeError,
OAuthError
} from "../../errors"
import crypto from 'crypto'
import { TokenContext } from '../../lib/TokenCache'


export interface AuthorizeParams {
Expand All @@ -40,6 +42,8 @@ export interface AuthorizeParams {
encounter?: string
auth_success?: "0" | "1"
login_success?: string
prompt?: string
id_token_hint?: string
}


Expand All @@ -57,6 +61,8 @@ export default class AuthorizeHandler {

protected scope: ScopeSet;

protected idTokenUser?: string;

public static handle(req: Request, res: Response) {
if (req.method === "POST") {
requireUrlencodedPost(req)
Expand Down Expand Up @@ -466,6 +472,30 @@ export default class AuthorizeHandler {
}
}

private validateIdTokenHint(idTokenHint: string): TokenContext {
if (!idTokenHint) {
throw new InvalidRequestError("id_token_hint is required when prompt=none")
.errorId("invalid_request")
.status(400);
}

// Hash the provided id_token_hint
const idTokenHash = crypto
.createHash('sha256')
.update(idTokenHint)
.digest('hex');

// 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;
}

/**
* The client constructs the request URI by adding the following
* parameters to the query component of the authorization endpoint URI
Expand All @@ -492,6 +522,41 @@ export default class AuthorizeHandler {
{
const { params, launchOptions } = this

// Handle prompt=none flow
if (params.prompt === "none") {
// Get the previous authorization context
const context = this.validateIdTokenHint(params.id_token_hint!);

// Set up launch params from previous context
launchOptions.skip_login = true;
launchOptions.skip_auth = true;

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.user.split("/")[1]);
launchOptions.launch_type = "patient-standalone";
}

launchOptions.scope = context.scope;

// Validate the request before proceeding
this.validateAuthorizeRequest();

// Create and redirect with 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
Expand Down
74 changes: 74 additions & 0 deletions backend/routes/auth/clients.ts
Original file line number Diff line number Diff line change
@@ -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<string, any> = {
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)
}
}
3 changes: 2 additions & 1 deletion backend/routes/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand All @@ -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
18 changes: 18 additions & 0 deletions backend/routes/auth/token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Expand Down
4 changes: 3 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit c9eee18

Please sign in to comment.