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:
| Bean | Who provides it | What happens without it |
|---|---|---|
RegisteredClientRepository | JpaRegisteredClientRepository (@Component) | Server won't start |
OAuth2AuthorizationService | AuthorizationServerConfig (@Bean) | invalid_grant on every token exchange |
OAuth2AuthorizationConsentService | AuthorizationServerConfig (@Bean) | NullPointerException at authorize endpoint |
JWKSource | RsaKeyConfig (@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:
| Object | Lifetime | Where stored | Purpose |
|---|---|---|---|
LaunchContext | 5 minutes, single-use | PostgreSQL | Binds launch token to patient/encounter |
OAuth2Authorization | 5 minutes | In-memory | Stores auth code between authorize and token |
LaunchContext (after use) | Until purge | PostgreSQL (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.