Registered Clients
Overview
SMART apps that want to use this auth server must be registered. Registration stores the app's client_id, allowed redirect URIs, permitted scopes, and token TTL settings. The registry is PostgreSQL-backed — apps can be added, updated, or disabled at runtime without restarting the server.
RegisteredApp entity
@Entity
@Table(name = "registered_app")
public class RegisteredApp {
@Id
private String id; // UUID
private String clientId; // e.g. "ajfhir-smart-client"
private String redirectUri; // must match authorize request
private String allowedScopes; // comma-separated
private boolean active = true; // false = disabled
private Long accessTokenTtlSeconds; // null = use server default
}
The allowedScopes field stores a comma-separated list:
launch,launch/patient,openid,fhirUser,offline_access,
patient/Patient.rs,patient/Condition.rs,patient/MedicationRequest.rs
JpaRegisteredClientRepository
JpaRegisteredClientRepository implements Spring Authorization Server's RegisteredClientRepository interface, converting RegisteredApp JPA entities to RegisteredClient objects on the fly:
@Override
public RegisteredClient findByClientId(String clientId) {
return appRepository.findByClientId(clientId)
.filter(RegisteredApp::isActive)
.map(this::toRegisteredClient)
.orElse(null);
}
Every client is configured with:
- Public client (
ClientAuthenticationMethod.NONE) — no client secret - PKCE required (
requireProofKey(true)) — S256 only - No consent screen (
requireAuthorizationConsent(false)) — auto-approve - Refresh token rotation (
reuseRefreshTokens(false)) — new refresh token on each refresh
private RegisteredClient toRegisteredClient(RegisteredApp app) {
return RegisteredClient
.withId(app.getId())
.clientId(app.getClientId())
.clientAuthenticationMethod(ClientAuthenticationMethod.NONE)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
.redirectUri(app.getRedirectUri())
.tokenSettings(TokenSettings.builder()
.accessTokenTimeToLive(Duration.ofSeconds(ttl))
.reuseRefreshTokens(false)
.build())
.clientSettings(ClientSettings.builder()
.requireProofKey(true)
.requireAuthorizationConsent(false)
.build())
.build();
}
DataInitializer — seed data
On first startup, DataInitializer seeds the database with test clinicians and a registered SMART app. Both methods are idempotent — they check the table count before inserting.
@Component
public class DataInitializer implements CommandLineRunner {
public DataInitializer(ClinicianRepository clinicianRepository,
RegisteredAppRepository appRepository,
PasswordEncoder passwordEncoder) { ... }
@Override
public void run(String... args) {
seedClinicians();
seedRegisteredApps();
}
}
Clinicians seeded:
| Username | Password | Practitioner ID |
|---|---|---|
dr.smith | password | eProvider-ABC123 |
dr.jones | password | eProvider-DEF456 |
App seeded:
| client_id | redirect_uri | scopes |
|---|---|---|
ajfhir-smart-client | http://localhost:8081/callback | launch, openid, fhirUser, patient/Patient.rs, patient/Condition.rs, patient/MedicationRequest.rs, patient/Observation.rs |
Change default passwords and update fhirUserId values to match real Practitioner resources in your HAPI server before any non-local deployment.
Adding a new app at runtime
Insert a row directly — no restart needed:
INSERT INTO registered_app (id, client_id, redirect_uri, allowed_scopes, active)
VALUES (
gen_random_uuid(),
'my-new-app',
'https://my-app.hospital.org/callback',
'launch,openid,patient/Patient.rs,patient/Observation.rs',
true
);
The next authorize request for client_id=my-new-app will find it immediately.
Disabling an app
UPDATE registered_app SET active = false WHERE client_id = 'compromised-app';
All subsequent authorization requests for that app return an invalid_client error.
Next: Scope Interceptor →