Skip to main content

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:

UsernamePasswordPractitioner ID
dr.smithpasswordeProvider-ABC123
dr.jonespasswordeProvider-DEF456

App seeded:

client_idredirect_uriscopes
ajfhir-smart-clienthttp://localhost:8081/callbacklaunch, openid, fhirUser, patient/Patient.rs, patient/Condition.rs, patient/MedicationRequest.rs, patient/Observation.rs
warning

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 →