Safire is a Ruby gem for healthcare client applications that implements SMART App Launch 2.2.0 and UDAP Security STU2 / v2.0.0 server metadata discovery. It handles SMART OAuth 2.0 authorization against HL7 FHIR servers, covering PKCE, private key JWT assertions, and the Backend Services system-to-system flow, so you can focus on your application rather than protocol plumbing.
- Dynamic Client Registration (RFC 7591): obtain a
client_idat runtime by POSTing client metadata to the server's registration endpoint - Discovery (
/.well-known/smart-configuration) - Public Client (PKCE)
- Confidential Symmetric Client (
client_secret+ HTTP Basic Auth) - Confidential Asymmetric Client (
private_key_jwtwith RS384/ES384) - POST-Based Authorization
- Backend Services (
client_credentialsgrant, JWT assertion, no user interaction or PKCE; scope defaults tosystem/*.rs)
Server metadata discovery is implemented. Pass protocol: :udap to fetch /.well-known/udap:
client = Safire::Client.new(
{ base_url: 'https://fhir.example.com' },
protocol: :udap
)
metadata = client.server_metadata(verify_chain: false) # development/test only
# => #<Safire::Protocols::UdapMetadata ...>
# Community-scoped discovery
metadata = client.server_metadata(community: 'https://udap.example.org/community1', verify_chain: false)Production UDAP discovery requires trust anchors plus an explicit certificate revocation policy
(crls: or revocation_checker:). Use verify_chain: false only for development or tests.
Auth flows (DCR, JWT assertion, Tiered OAuth) are planned. See ROADMAP.md for details.
Requires Ruby ≥ 4.0.4.
gem 'safire'bundle installrequire 'safire'
# Step 1 — Create a client (Hash config or Safire::ClientConfig.new)
client = Safire::Client.new(
{
base_url: 'https://launch.smarthealthit.org/v/r4/sim/eyJoIjoiMSJ9/fhir',
client_id: 'my_client_id',
redirect_uri: 'https://myapp.example.com/callback',
scopes: ['openid', 'profile', 'patient/*.read']
}
)
# Step 2 — Discover SMART metadata (lazy — only called when needed)
metadata = client.server_metadata
puts metadata.authorization_endpoint
puts metadata.capabilities.join(', ')
# Step 3 — Build the authorization URL (Safire generates state + PKCE automatically)
auth_data = client.authorization_url
# auth_data => { auth_url:, state:, code_verifier: }
# Store state and code_verifier server-side, redirect the user to auth_data[:auth_url]
# Step 4 — Exchange the authorization code for tokens (on callback)
token_data = client.request_access_token(
code: params[:code],
code_verifier: session[:code_verifier]
)
# token_data => { "access_token" => "...", "token_type" => "Bearer", ... }
# Step 5 — Refresh when the access token expires
new_tokens = client.refresh_token(refresh_token: token_data['refresh_token'])client_type: |
Authentication | When to use |
|---|---|---|
:public (default) |
PKCE only | Browser/mobile apps that cannot store a secret |
:confidential_symmetric |
HTTP Basic Auth (client_secret) |
Server-side apps with a securely stored secret |
:confidential_asymmetric |
JWT assertion (private_key_jwt, RS384/ES384) |
Server-side apps using a registered key pair |
For a confidential asymmetric client, provide a private key and key ID:
client = Safire::Client.new(
{
base_url: 'https://fhir.example.com',
client_id: 'my_client_id',
redirect_uri: 'https://myapp.example.com/callback',
scopes: ['openid', 'profile', 'patient/*.read'],
private_key: OpenSSL::PKey::RSA.new(File.read('private_key.pem')),
kid: 'my-key-id-123'
},
client_type: :confidential_asymmetric
)
# Authorization and token exchange are identical — Safire builds the JWT assertion automaticallyNo user interaction, redirect URI, or PKCE required — the client authenticates entirely via a signed JWT assertion:
client = Safire::Client.new(
{
base_url: 'https://fhir.example.com',
client_id: 'my_backend_client',
private_key: OpenSSL::PKey::RSA.new(File.read('private_key.pem')),
kid: 'my-key-id-123',
scopes: ['system/Patient.rs', 'system/Observation.rs']
}
)
token_data = client.request_backend_token
# token_data => { "access_token" => "...", "token_type" => "Bearer", "expires_in" => 300, ... }
# Override scope or credentials per call
token_data = client.request_backend_token(
scopes: ['system/Patient.rs'],
private_key: OpenSSL::PKey::RSA.new(File.read('new_key.pem')),
kid: 'new-key-id'
)
# Validate the token response (flow: :backend_services also checks expires_in)
client.token_response_valid?(token_data, flow: :backend_services)Safire.configure do |config|
config.logger = Rails.logger # Default: $stdout
config.log_http = true # Log HTTP requests (sensitive headers always filtered)
endSee the Configuration Guide for all options including user_agent, log_level, and SSL settings.
A Sinatra-based demo is included in examples/sinatra_app/:
bin/demo
# Visit http://localhost:4567Demonstrates Dynamic Client Registration, SMART discovery, UDAP discovery with signed_metadata trust validation, all authorization flows, token refresh, and backend services token requests. See examples/sinatra_app/README.md for details.
bin/setup # Install dependencies
bundle exec rspec # Run tests
bin/console # Interactive promptTo serve the docs locally:
bin/docs
cd docs && bundle install && bundle exec jekyll serve
# Visit http://localhost:4000/safire/Bug reports and pull requests are welcome. Please read CONTRIBUTION.md before opening a PR — it covers branch naming, commit message style, and the sign-off requirement.
Available as open source under the Apache 2.0 License.
Parts of this project were developed with AI assistance (Claude Code) and reviewed by maintainers.