ADR-010: client_id optional at initialization — deferred validation for the DCR temp-client pattern
Status: Accepted
Context
RFC 7591 Dynamic Client Registration requires calling the authorization server’s registration endpoint before a client_id exists. The natural usage pattern is:
- Create a temporary
Safire::Clientwith onlybase_url(noclient_idyet) - Call
register_clientto POST metadata and receiveclient_idfrom the server - Build a properly configured client with the returned
client_idfor subsequent authorization flows
Previously, ClientConfig#validate! required client_id to be present at construction time. A caller following the temp-client pattern could not even construct the initial client, so DCR was impossible without out-of-band client_id knowledge.
Option A — Keep client_id required; provide a separate class: introduce a Safire::RegistrationClient (or similar) that omits the client_id requirement, performs DCR, and returns a configured Safire::Client.
Option B — Make client_id optional at initialization; validate at call time: remove the construction-time presence check for client_id; each flow method that requires it validates and raises ConfigurationError at the point of the call.
Decision
Option B — client_id is optional at ClientConfig and Client initialization.
Construction succeeds with only base_url:
temp_client = Safire::Client.new({ base_url: 'https://fhir.example.com' })
registration = temp_client.register_client({ client_name: 'My App', ... })
client = Safire::Client.new({
base_url: 'https://fhir.example.com',
client_id: registration['client_id'],
...
})
Flow methods that require client_id (authorization_url, request_access_token, request_backend_token) validate its presence at call time and raise ConfigurationError if absent.
2026-07-04 amendment: UDAP certificate-backed temp clients
UDAP Dynamic Client Registration uses the same temp-client pattern, but the temporary client is identified by a private key and X.509 certificate chain rather than by SMART client type credentials:
temp_client = Safire::Client.new(
{
base_url: 'https://fhir.example.com',
private_key: File.read('client-key.pem'),
certificate_chain: [File.read('client-cert.pem')]
},
protocol: :udap
)
registration = temp_client.register_client(
metadata,
client_uri: 'https://client.example.com',
trusted_anchors: [udap_ca],
crls: [udap_crl]
)
client_id remains optional at construction. UDAP register_client discovers the registration endpoint, signs a software statement using the configured or per-call signing identity, and returns the server-assigned client_id.
Consequences
Benefits:
- The temp-client pattern works with the existing
Safire::Clientclass; no new class is needed - No proliferation of client variants; the public API surface stays small
- Consistent with how
token_endpointandauthorization_endpointare handled: both are optional at construction and resolved via lazy discovery when needed
Trade-offs:
- A misconfigured client (missing
client_id) fails at call time rather than construction time; callers may see the error later than expected - This is an intentional trade-off:
client_idis now in the same category as other lazily-resolved attributes, so the surprise of deferred validation is mitigated by the established pattern