Skip to main content

Architecture Overview

System contextโ€‹

graph TD
P["๐Ÿ–ฅ๏ธ Patient Picker Portal<br/>http://localhost:9000/portal"]
A["๐Ÿ” Auth Server<br/>Spring Authorization Server<br/>port 9000"]
C["โš• SMART Client<br/>ajfhir-smart-client<br/>port 8080"]
F["๐Ÿ“ฆ HAPI FHIR JPA Server<br/>port 8080"]
DB["๐Ÿ—„๏ธ PostgreSQL<br/>clinicians ยท apps ยท launch tokens"]

P -->|"dr.smith logs in, picks patient"| A
A -->|"creates launch token"| DB
A -->|"redirects browser to"| C
C -->|"/.well-known/smart-configuration"| A
C -->|"GET /oauth2/authorize"| A
A -->|"shows login if needed"| P
C -->|"POST /oauth2/token"| A
A -->|"reads launch token from"| DB
A -->|"access_token + patient + id_token"| C
C -->|"Bearer token FHIR calls"| F
A -.->|"scope interceptor registered on"| F

Complete token flowโ€‹

sequenceDiagram
autonumber
participant P as Portal (dr.smith)
participant A as Auth Server :9000
participant C as SMART Client :8080
participant F as HAPI FHIR :8080

P->>A: POST /portal/launch (patientId=eXXX)
A->>A: LaunchContextService.createLaunchToken()
Note over A: token=abc123, patient=eXXX, expires=+5min
A-->>C: redirect GET /launch?iss=http://localhost:8080/fhir&launch=abc123

C->>A: GET /.well-known/smart-configuration
Note over C,A: via SmartDiscoveryProxyFilter on HAPI
A-->>C: authEndpoint, tokenEndpoint, capabilities, S256

C->>C: PkceHelper.generate() โ†’ verifier + challenge
C->>A: GET /oauth2/authorize?launch=abc123&code_challenge=S256_HASH&...
A-->>C: 302 โ†’ /callback?code=AUTH_CODE&state=NONCE

C->>A: POST /oauth2/token (code + code_verifier + client_id)
A->>A: Spring Auth Server verifies PKCE
A->>A: SmartTokenCustomizer reads launch=abc123
A->>A: LaunchContextService.resolveLaunchToken(abc123)
Note over A: marks token used (single-use)
A->>A: adds patient/encounter/need_patient_banner to JWT
A->>A: SmartTokenResponseConverter promotes to top-level fields
A-->>C: {access_token, patient, encounter, need_patient_banner, id_token}

C->>F: GET /fhir/Patient/eXXX [Authorization: Bearer ...]
F->>F: SmartScopeAuthorizationInterceptor validates scope
F-->>C: Patient resource

Key design decisionsโ€‹

Why Spring Authorization Server, not Keycloak?โ€‹

Spring Authorization Server (GA since 2022, Spring Boot 3 compatible) gives full control over the token customization โ€” adding patient, encounter, need_patient_banner as top-level token response fields requires a custom AuthenticationSuccessHandler on the token endpoint. In Keycloak this would require a custom SPI plugin and a running Keycloak server. With Spring Authorization Server it's a single @Component.

Why two beans that are easy to missโ€‹

Spring Authorization Server requires four beans to function:

BeanWho provides itWhat happens without it
RegisteredClientRepositoryJpaRegisteredClientRepository (@Component)Server won't start
OAuth2AuthorizationServiceAuthorizationServerConfig (@Bean)invalid_grant on every token exchange
OAuth2AuthorizationConsentServiceAuthorizationServerConfig (@Bean)NullPointerException at authorize endpoint
JWKSourceRsaKeyConfig (@Bean)Server won't start โ€” can't sign JWTs

OAuth2AuthorizationService and OAuth2AuthorizationConsentService are the ones most commonly missed. They store authorization codes between /authorize and /token โ€” without them the server accepts the authorize request but has nowhere to store the code, so the token exchange always returns invalid_grant.

Why top-level token response fields, not JWT claims onlyโ€‹

Spring Authorization Server's default token response only contains standard OAuth2 fields. SMART extras (patient, encounter, need_patient_banner) are added to the JWT claims by SmartTokenCustomizer, but the SMART client reads them from the raw JSON response body โ€” not from inside the decoded JWT. SmartTokenResponseConverter decodes the JWT, extracts the SMART claims, and writes them as top-level fields in the JSON response.

Why in-memory OAuth2AuthorizationServiceโ€‹

Authorization codes are valid for 5 minutes by default. The in-memory implementation is appropriate even in production for single-instance deployments because authorization objects are transient โ€” they're created at /authorize, consumed at /token, and never needed again. For clustered deployments, swap to JdbcOAuth2AuthorizationService.


Session vs authorization codeโ€‹

This server manages two independent short-lived stores:

ObjectLifetimeWhere storedPurpose
LaunchContext5 minutes, single-usePostgreSQLBinds launch token to patient/encounter
OAuth2Authorization5 minutesIn-memoryStores auth code between authorize and token
LaunchContext (after use)Until purgePostgreSQL (used=true)Audit trail

The launch token and the OAuth2 authorization code have independent lifecycles. The launch token is resolved when the code is exchanged โ€” at that point both are consumed.