Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for prompt=none when an id_token_hint is available #40

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.