safire 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.rspec +1 -0
- data/.rubocop.yml +62 -0
- data/.tool-versions +1 -0
- data/CHANGELOG.md +35 -0
- data/CODE_OF_CONDUCT.md +17 -0
- data/CONTRIBUTION.md +283 -0
- data/Gemfile +26 -0
- data/Gemfile.lock +186 -0
- data/LICENSE +201 -0
- data/README.md +159 -0
- data/ROADMAP.md +54 -0
- data/Rakefile +26 -0
- data/docs/.gitignore +5 -0
- data/docs/404.html +25 -0
- data/docs/Gemfile +37 -0
- data/docs/Gemfile.lock +195 -0
- data/docs/_config.yml +103 -0
- data/docs/_includes/footer_custom.html +6 -0
- data/docs/_includes/head_custom.html +14 -0
- data/docs/_sass/custom/custom.scss +108 -0
- data/docs/adr/ADR-001-activesupport-dependency.md +50 -0
- data/docs/adr/ADR-002-facade-and-forwardable.md +79 -0
- data/docs/adr/ADR-003-protocol-vs-client-type.md +67 -0
- data/docs/adr/ADR-004-clientconfig-immutability-and-entity-masking.md +59 -0
- data/docs/adr/ADR-005-per-client-http-ownership.md +58 -0
- data/docs/adr/ADR-006-lazy-discovery.md +83 -0
- data/docs/adr/ADR-007-https-only-redirects-and-localhost-exception.md +59 -0
- data/docs/adr/ADR-008-warn-return-false-for-compliance-validation.md +74 -0
- data/docs/adr/index.md +22 -0
- data/docs/advanced.md +284 -0
- data/docs/configuration/client-setup.md +158 -0
- data/docs/configuration/index.md +60 -0
- data/docs/configuration/logging.md +86 -0
- data/docs/index.md +64 -0
- data/docs/installation.md +96 -0
- data/docs/security.md +256 -0
- data/docs/smart-on-fhir/confidential-asymmetric/authorization.md +72 -0
- data/docs/smart-on-fhir/confidential-asymmetric/index.md +162 -0
- data/docs/smart-on-fhir/confidential-asymmetric/token-exchange.md +250 -0
- data/docs/smart-on-fhir/confidential-symmetric/authorization.md +75 -0
- data/docs/smart-on-fhir/confidential-symmetric/index.md +69 -0
- data/docs/smart-on-fhir/confidential-symmetric/token-exchange.md +215 -0
- data/docs/smart-on-fhir/discovery/capability-checks.md +142 -0
- data/docs/smart-on-fhir/discovery/index.md +96 -0
- data/docs/smart-on-fhir/discovery/metadata.md +147 -0
- data/docs/smart-on-fhir/index.md +72 -0
- data/docs/smart-on-fhir/post-based-authorization.md +190 -0
- data/docs/smart-on-fhir/public-client/authorization.md +112 -0
- data/docs/smart-on-fhir/public-client/index.md +80 -0
- data/docs/smart-on-fhir/public-client/token-exchange.md +249 -0
- data/docs/troubleshooting/auth-errors.md +124 -0
- data/docs/troubleshooting/client-errors.md +130 -0
- data/docs/troubleshooting/index.md +99 -0
- data/docs/troubleshooting/token-errors.md +99 -0
- data/docs/udap.md +78 -0
- data/lib/safire/client.rb +195 -0
- data/lib/safire/client_config.rb +169 -0
- data/lib/safire/client_config_builder.rb +72 -0
- data/lib/safire/entity.rb +26 -0
- data/lib/safire/errors.rb +247 -0
- data/lib/safire/http_client.rb +87 -0
- data/lib/safire/jwt_assertion.rb +237 -0
- data/lib/safire/middleware/https_only_redirects.rb +39 -0
- data/lib/safire/pkce.rb +39 -0
- data/lib/safire/protocols/behaviours.rb +54 -0
- data/lib/safire/protocols/smart.rb +378 -0
- data/lib/safire/protocols/smart_metadata.rb +231 -0
- data/lib/safire/version.rb +4 -0
- data/lib/safire.rb +54 -0
- data/safire.gemspec +36 -0
- metadata +184 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
---
|
|
2
|
+
layout: default
|
|
3
|
+
title: Troubleshooting
|
|
4
|
+
nav_order: 8
|
|
5
|
+
has_children: true
|
|
6
|
+
permalink: /troubleshooting/
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Troubleshooting
|
|
10
|
+
|
|
11
|
+
{: .no_toc }
|
|
12
|
+
|
|
13
|
+
<div class="code-example" markdown="1">
|
|
14
|
+
Common issues and solutions when integrating SMART on FHIR with Safire.
|
|
15
|
+
</div>
|
|
16
|
+
|
|
17
|
+
## Table of contents
|
|
18
|
+
{: .no_toc .text-delta }
|
|
19
|
+
|
|
20
|
+
1. TOC
|
|
21
|
+
{:toc}
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## Error Types
|
|
26
|
+
|
|
27
|
+
Safire raises typed errors so you can handle each failure category separately:
|
|
28
|
+
|
|
29
|
+
| Error class | When raised |
|
|
30
|
+
|-------------|-------------|
|
|
31
|
+
| `Safire::Errors::ConfigurationError` | Missing or invalid client configuration — caught at construction time |
|
|
32
|
+
| `Safire::Errors::DiscoveryError` | SMART metadata fetch failed (HTTP error, invalid JSON) |
|
|
33
|
+
| `Safire::Errors::TokenError` | Token exchange or refresh failed (OAuth error, missing fields) |
|
|
34
|
+
| `Safire::Errors::NetworkError` | Transport-level failure (connection refused, timeout, blocked redirect) |
|
|
35
|
+
|
|
36
|
+
All Safire errors inherit from `Safire::Errors::Error`, so you can catch everything with a single rescue if needed.
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
begin
|
|
40
|
+
tokens = client.request_access_token(code: code, code_verifier: verifier)
|
|
41
|
+
rescue Safire::Errors::ConfigurationError => e
|
|
42
|
+
# Client misconfiguration — fix before retrying
|
|
43
|
+
Rails.logger.error("Configuration error: #{e.message}")
|
|
44
|
+
render plain: 'Server configuration error', status: :internal_server_error
|
|
45
|
+
rescue Safire::Errors::TokenError => e
|
|
46
|
+
# OAuth error — e.status, e.error_code, e.error_description are all available
|
|
47
|
+
Rails.logger.error("Token error: #{e.message}")
|
|
48
|
+
redirect_to launch_path, alert: 'Authorization failed. Please try again.'
|
|
49
|
+
rescue Safire::Errors::NetworkError => e
|
|
50
|
+
Rails.logger.error("Network error: #{e.message}")
|
|
51
|
+
render plain: 'Server temporarily unavailable', status: :service_unavailable
|
|
52
|
+
end
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## Debugging
|
|
58
|
+
|
|
59
|
+
### Enable detailed logging
|
|
60
|
+
|
|
61
|
+
```ruby
|
|
62
|
+
Safire.configure do |config|
|
|
63
|
+
config.logger = Rails.logger
|
|
64
|
+
config.log_level = Logger::DEBUG
|
|
65
|
+
end
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
HTTP request logging is on by default. Sensitive headers (`Authorization`) are always filtered. Request and response bodies are never logged.
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
INFO: request: POST https://fhir.example.com/token
|
|
72
|
+
INFO: request: Authorization: [FILTERED]
|
|
73
|
+
INFO: response: Status 200
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
To disable HTTP logging:
|
|
77
|
+
|
|
78
|
+
```ruby
|
|
79
|
+
Safire.configure { |c| c.log_http = false }
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### Test against the SMART reference server
|
|
83
|
+
|
|
84
|
+
```ruby
|
|
85
|
+
# .env.development
|
|
86
|
+
FHIR_BASE_URL=https://launch.smarthealthit.org/v/r4/sim/eyJoIjoiMSJ9/fhir
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Visit [launch.smarthealthit.org](https://launch.smarthealthit.org) to configure simulated patients and launch contexts.
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
## Getting Help
|
|
94
|
+
|
|
95
|
+
- **Check the logs first** — Safire logs one line per error with all relevant context
|
|
96
|
+
- **Test endpoints manually** — `curl https://fhir.example.com/.well-known/smart-configuration`
|
|
97
|
+
- **Open an issue** — [github.com/vanessuniq/safire/issues](https://github.com/vanessuniq/safire/issues)
|
|
98
|
+
|
|
99
|
+
When reporting an issue, include: Safire version (`Safire::VERSION`), Ruby version, the error message and backtrace, and the server type if known. Never include credentials or tokens.
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
---
|
|
2
|
+
layout: default
|
|
3
|
+
title: Token and PKCE Errors
|
|
4
|
+
parent: Troubleshooting
|
|
5
|
+
nav_order: 2
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Token and PKCE Errors
|
|
9
|
+
|
|
10
|
+
{: .no_toc }
|
|
11
|
+
|
|
12
|
+
## Table of contents
|
|
13
|
+
{: .no_toc .text-delta }
|
|
14
|
+
|
|
15
|
+
1. TOC
|
|
16
|
+
{:toc}
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## Token Exchange Errors
|
|
21
|
+
|
|
22
|
+
### `TokenError`: Token request failed
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
Safire::Errors::TokenError: Token request failed — HTTP 400 — invalid_grant — Authorization code has expired
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Common OAuth error codes and their meaning:
|
|
29
|
+
|
|
30
|
+
| `error_code` | Cause | Action |
|
|
31
|
+
|--------------|-------|--------|
|
|
32
|
+
| `invalid_grant` | Code expired or already used | Codes are single-use; the user must re-authorize |
|
|
33
|
+
| `invalid_client` | Client ID or credentials not recognized | Verify registration and credentials |
|
|
34
|
+
| `invalid_request` | Missing required parameter | Check `redirect_uri` matches exactly what was registered |
|
|
35
|
+
| `unauthorized_client` | Client not authorized for this grant type | Verify server-side client configuration |
|
|
36
|
+
|
|
37
|
+
The `redirect_uri` in the token request must exactly match the one used in the authorization request and the one registered with the server — including trailing slashes.
|
|
38
|
+
|
|
39
|
+
### `TokenError`: Missing access token
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
Safire::Errors::TokenError: Missing access token in response; received fields: token_type, expires_in
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The server returned a 200 response without an `access_token`. This usually means the server returned an OAuth error body with a 200 status (non-standard behaviour). Inspect the received field names in the error message to diagnose what the server actually returned.
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## Refresh Token Errors
|
|
50
|
+
|
|
51
|
+
### `TokenError`: Refresh token invalid or expired
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
Safire::Errors::TokenError: Token request failed — HTTP 400 — invalid_grant — Refresh token expired
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Refresh tokens expire, get revoked, or may be single-use on some servers. When a refresh fails with `invalid_grant`, re-authenticate rather than retrying:
|
|
58
|
+
|
|
59
|
+
```ruby
|
|
60
|
+
def refresh_access_token
|
|
61
|
+
new_tokens = client.refresh_token(refresh_token: session[:refresh_token])
|
|
62
|
+
session[:access_token] = new_tokens['access_token']
|
|
63
|
+
session[:refresh_token] = new_tokens['refresh_token'] if new_tokens['refresh_token']
|
|
64
|
+
rescue Safire::Errors::TokenError => e
|
|
65
|
+
raise unless e.error_code == 'invalid_grant'
|
|
66
|
+
|
|
67
|
+
clear_session
|
|
68
|
+
redirect_to launch_path, alert: 'Session expired. Please sign in again.'
|
|
69
|
+
end
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Some servers issue a new refresh token on each refresh (rotating tokens). Always update both `access_token` and `refresh_token` from the response.
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## PKCE Errors
|
|
77
|
+
|
|
78
|
+
### Invalid `code_challenge` at the server
|
|
79
|
+
|
|
80
|
+
**Symptom:** authorization fails at the server with a PKCE-related error.
|
|
81
|
+
|
|
82
|
+
**Cause:** the `code_verifier` used in the token exchange does not match the `code_challenge` sent in the authorization request. This almost always means the verifier was regenerated rather than stored and retrieved.
|
|
83
|
+
|
|
84
|
+
Store the verifier from `authorization_url` and use it unchanged in the token exchange:
|
|
85
|
+
|
|
86
|
+
```ruby
|
|
87
|
+
# On launch — store the verifier
|
|
88
|
+
auth_data = client.authorization_url
|
|
89
|
+
session[:code_verifier] = auth_data[:code_verifier]
|
|
90
|
+
|
|
91
|
+
# On callback — use exactly what was stored
|
|
92
|
+
tokens = client.request_access_token(
|
|
93
|
+
code: params[:code],
|
|
94
|
+
code_verifier: session[:code_verifier]
|
|
95
|
+
)
|
|
96
|
+
session.delete(:code_verifier) # discard after use
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Never call `Safire::PKCE.generate_code_verifier` in the callback — a new verifier will not match the challenge already sent to the server.
|
data/docs/udap.md
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
---
|
|
2
|
+
layout: default
|
|
3
|
+
title: UDAP
|
|
4
|
+
nav_order: 5
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# UDAP
|
|
8
|
+
|
|
9
|
+
{: .no_toc }
|
|
10
|
+
|
|
11
|
+
<div class="code-example" markdown="1">
|
|
12
|
+
**Status:** Planned — see [ROADMAP.md](https://github.com/vanessuniq/safire/blob/main/ROADMAP.md) for timeline and progress.
|
|
13
|
+
</div>
|
|
14
|
+
|
|
15
|
+
## Table of contents
|
|
16
|
+
{: .no_toc .text-delta }
|
|
17
|
+
|
|
18
|
+
1. TOC
|
|
19
|
+
{:toc}
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Overview
|
|
24
|
+
|
|
25
|
+
UDAP (Unified Data Access Profiles) is a security framework for healthcare data exchange defined by the [UDAP Security Implementation Guide](https://hl7.org/fhir/us/udap-security/). It extends standard OAuth 2.0 with X.509 certificate-based identity, dynamic client registration, and trust community models — designed primarily for backend system-to-system integration and cross-organizational data access.
|
|
26
|
+
|
|
27
|
+
UDAP is a separate protocol from SMART on FHIR. In Safire, it is selected via `protocol: :udap` rather than a `client_type:`. Watch the [GitHub repository](https://github.com/vanessuniq/safire) for release announcements.
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## Planned Features
|
|
32
|
+
|
|
33
|
+
### Discovery
|
|
34
|
+
|
|
35
|
+
- **UDAP Discovery** (`/.well-known/udap`) — fetch server metadata and trust anchors
|
|
36
|
+
|
|
37
|
+
### Client Flows
|
|
38
|
+
|
|
39
|
+
- **Dynamic Client Registration (DCR)** — one-time registration using a signed software statement to obtain a `client_id`; required only when the client has not previously registered with the server and if the server supports DCR
|
|
40
|
+
- **JWT Client Authentication** — authenticate on every request using a signed JWT assertion (Authentication Token, AnT) with an X.509 certificate chain in the `x5c` header; the registered `client_id` is reused as `iss` and `sub` in each assertion
|
|
41
|
+
- **Tiered OAuth** — delegated authorization for multi-system access per the UDAP Security IG
|
|
42
|
+
- **Pushed Authorization Requests (RFC 9126)** — PAR support for pre-registering authorization requests
|
|
43
|
+
|
|
44
|
+
### Trust Framework
|
|
45
|
+
|
|
46
|
+
- **Certificate Validation** — verify server and client certificates against trust anchors
|
|
47
|
+
- **Trust Community Support** — integration with UDAP trust communities (e.g. Carequality, CommonWell)
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## When to Use UDAP
|
|
52
|
+
|
|
53
|
+
| Scenario | Why UDAP |
|
|
54
|
+
|----------|----------|
|
|
55
|
+
| **Backend / B2B Integration** | Server-to-server flows without user interaction; certificate-based identity replaces pre-shared secrets |
|
|
56
|
+
| **Dynamic Client Registration** | Clients can register programmatically without manual server-side approval |
|
|
57
|
+
| **Cross-Organization Access** | Trust communities allow clients to be recognized across participant organizations without per-server registration |
|
|
58
|
+
| **High-Assurance Identity** | X.509 certificates provide stronger identity guarantees than client secrets |
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## Comparison with SMART on FHIR
|
|
63
|
+
|
|
64
|
+
| Feature | SMART on FHIR | UDAP |
|
|
65
|
+
|---------|---------------|------|
|
|
66
|
+
| Primary use case | User-facing apps, EHR launch | B2B, backend services, cross-org access |
|
|
67
|
+
| Client registration | Pre-registered per server, optional DCR (recommended) | Dynamic (DCR) or pre-registered |
|
|
68
|
+
| Authentication | Client secrets or `private_key_jwt` | Signed JWT assertions (AnT) with X.509 `x5c` chain |
|
|
69
|
+
| Trust model | Per-server registration | Certificate-based trust communities |
|
|
70
|
+
| Safire selection | `client_type: :public / :confidential_symmetric / :confidential_asymmetric` | `protocol: :udap` (planned) |
|
|
71
|
+
|
|
72
|
+
### Resources
|
|
73
|
+
|
|
74
|
+
- [UDAP Security IG](https://hl7.org/fhir/us/udap-security/) — HL7 Implementation Guide
|
|
75
|
+
- [UDAP JWT Client Auth](https://www.udap.org/udap-jwt-client-auth.html) — JWT assertion specification
|
|
76
|
+
- [UDAP Dynamic Client Registration](https://www.udap.org/udap-dynamic-client-registration.html) — DCR specification
|
|
77
|
+
- [RFC 9126 — Pushed Authorization Requests](https://datatracker.ietf.org/doc/html/rfc9126)
|
|
78
|
+
- [UDAP Tiered OAuth](https://hl7.org/fhir/us/udap-security/b2b.html) — Delegated authorization
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
module Safire
|
|
2
|
+
# Unified facade client for SMART on FHIR and (future) UDAP authorization flows.
|
|
3
|
+
#
|
|
4
|
+
# This class is the main entry point for integrating SMART on FHIR authorization via Safire.
|
|
5
|
+
# It supports discovery of server metadata and provides a unified interface for building
|
|
6
|
+
# authorization URLs, exchanging authorization codes, and refreshing tokens.
|
|
7
|
+
#
|
|
8
|
+
# Configuration is provided via {Safire::ClientConfig} or a Hash. At minimum:
|
|
9
|
+
#
|
|
10
|
+
# * :base_url [String] FHIR base URL used for SMART discovery
|
|
11
|
+
# * :client_id [String] OAuth2 client identifier
|
|
12
|
+
# * :redirect_uri [String] redirect URI registered with the authorization server
|
|
13
|
+
# * :scopes [Array<String>] default scopes requested during authorization
|
|
14
|
+
# * :client_secret [String, optional] required for confidential_symmetric clients
|
|
15
|
+
# * :private_key [OpenSSL::PKey, String, optional] private key for confidential_asymmetric clients
|
|
16
|
+
# * :kid [String, optional] key ID matching the registered public key for asymmetric clients
|
|
17
|
+
# * :jwt_algorithm [String, optional] JWT signing algorithm (RS384 or ES384). Auto-detected if not provided
|
|
18
|
+
# * :jwks_uri [String, optional] URL to client's JWKS for jku header in JWT assertions
|
|
19
|
+
#
|
|
20
|
+
# The +protocol:+ keyword selects the authorization protocol:
|
|
21
|
+
#
|
|
22
|
+
# * :smart (default) — SMART App Launch 2.2.0
|
|
23
|
+
# * :udap — UDAP Security (future; not yet implemented)
|
|
24
|
+
#
|
|
25
|
+
# The +client_type:+ keyword controls how the SMART client authenticates at the token endpoint:
|
|
26
|
+
#
|
|
27
|
+
# * :public (default) — no client authentication; client_id sent in request body
|
|
28
|
+
# * :confidential_symmetric — HTTP Basic auth using client_secret
|
|
29
|
+
# * :confidential_asymmetric — private_key_jwt assertion (JWT signed with private key)
|
|
30
|
+
#
|
|
31
|
+
# client_type is validated for :smart and ignored for :udap. UDAP clients authenticate via
|
|
32
|
+
# signed JWT assertions (Authentication Token / AnT) with an X.509 certificate chain in the
|
|
33
|
+
# x5c JOSE header; the authentication method is not user-configurable for UDAP. DCR is
|
|
34
|
+
# typically performed once to obtain a client_id, which is then reused as iss/sub in every
|
|
35
|
+
# subsequent AnT. The unregistered client flow (§8.1) allows client_credentials grant without
|
|
36
|
+
# prior DCR when identity can be fully determined from certificate attributes alone.
|
|
37
|
+
#
|
|
38
|
+
# @note Future kwargs (not yet implemented):
|
|
39
|
+
#
|
|
40
|
+
# flow: [Symbol] the authorization flow for this client.
|
|
41
|
+
# SMART values:
|
|
42
|
+
# nil / absent — :app_launch (default): SMART App Launch, authorization_code grant
|
|
43
|
+
# :backend_services — SMART Backend Services, client_credentials grant;
|
|
44
|
+
# private_key_jwt is implied; client_type validation is skipped
|
|
45
|
+
# UDAP values (protocol: :udap):
|
|
46
|
+
# :b2b — client_credentials grant, server-to-server
|
|
47
|
+
# :b2c — authorization_code grant, user-facing
|
|
48
|
+
# :tiered_oauth — authorization_code + IdP identity delegation
|
|
49
|
+
#
|
|
50
|
+
# Contract methods will be extended per flow in a future PR.
|
|
51
|
+
# When protocol: :udap is fully implemented, client_type: will default to nil
|
|
52
|
+
# (not applicable) and the flow: kwarg will drive B2B vs B2C selection.
|
|
53
|
+
#
|
|
54
|
+
# @!attribute [r] config
|
|
55
|
+
# @return [Safire::ClientConfig] the resolved client configuration
|
|
56
|
+
#
|
|
57
|
+
# @!attribute [r] protocol
|
|
58
|
+
# @return [Symbol] the selected protocol (:smart or :udap)
|
|
59
|
+
#
|
|
60
|
+
# @!attribute [r] client_type
|
|
61
|
+
# @return [Symbol] the client authentication method
|
|
62
|
+
# (:public, :confidential_symmetric, or :confidential_asymmetric)
|
|
63
|
+
#
|
|
64
|
+
# @see Safire::ClientConfig
|
|
65
|
+
# @see Safire::Protocols::Smart
|
|
66
|
+
# @see Safire::Protocols::Behaviours
|
|
67
|
+
#
|
|
68
|
+
# @example Step 0 – Initialize configuration
|
|
69
|
+
# config = Safire::ClientConfig.new(
|
|
70
|
+
# base_url: 'https://fhir.example.com',
|
|
71
|
+
# client_id: 'my_client_id',
|
|
72
|
+
# redirect_uri: 'https://myapp.example.com/callback',
|
|
73
|
+
# scopes: ['openid', 'profile', 'patient/*.read']
|
|
74
|
+
# )
|
|
75
|
+
#
|
|
76
|
+
# @example Step 1 – /launch route (authorization request)
|
|
77
|
+
# client = Safire::Client.new(config) # defaults to protocol: :smart, client_type: :public
|
|
78
|
+
# auth_data = client.authorization_url
|
|
79
|
+
#
|
|
80
|
+
# session[:state] = auth_data[:state]
|
|
81
|
+
# session[:code_verifier] = auth_data[:code_verifier]
|
|
82
|
+
#
|
|
83
|
+
# redirect_to auth_data[:auth_url]
|
|
84
|
+
#
|
|
85
|
+
# @example Step 2 – /callback route (token exchange)
|
|
86
|
+
# return head :unauthorized unless params[:state] == session[:state]
|
|
87
|
+
#
|
|
88
|
+
# client = Safire::Client.new(config)
|
|
89
|
+
# token_data = client.request_access_token(
|
|
90
|
+
# code: params[:code],
|
|
91
|
+
# code_verifier: session[:code_verifier]
|
|
92
|
+
# )
|
|
93
|
+
#
|
|
94
|
+
# @example Step 3 – Refreshing an access token
|
|
95
|
+
# client = Safire::Client.new(config)
|
|
96
|
+
# new_tokens = client.refresh_token(refresh_token: stored_refresh_token)
|
|
97
|
+
class Client
|
|
98
|
+
extend Forwardable
|
|
99
|
+
|
|
100
|
+
VALID_PROTOCOLS = %i[smart udap].freeze
|
|
101
|
+
|
|
102
|
+
PROTOCOL_CLASSES = {
|
|
103
|
+
smart: Protocols::Smart
|
|
104
|
+
# udap: Protocols::Udap # future
|
|
105
|
+
}.freeze
|
|
106
|
+
|
|
107
|
+
# Valid client_type values per protocol.
|
|
108
|
+
# nil means the protocol does not use client_type (e.g. UDAP authenticates via signed
|
|
109
|
+
# JWT assertions with an X.509 certificate chain; the authentication method is not
|
|
110
|
+
# user-configurable for UDAP).
|
|
111
|
+
PROTOCOL_CLIENT_TYPES = {
|
|
112
|
+
smart: %i[public confidential_symmetric confidential_asymmetric],
|
|
113
|
+
udap: nil # UDAP authenticates via signed JWT assertions (AnT) with X.509 certificate chain
|
|
114
|
+
}.freeze
|
|
115
|
+
|
|
116
|
+
def_delegators :protocol_client,
|
|
117
|
+
:server_metadata, :authorization_url,
|
|
118
|
+
:request_access_token, :refresh_token,
|
|
119
|
+
:token_response_valid?, :register_client
|
|
120
|
+
|
|
121
|
+
attr_reader :config, :protocol, :client_type
|
|
122
|
+
|
|
123
|
+
def initialize(config, protocol: :smart, client_type: :public)
|
|
124
|
+
@protocol = protocol.to_sym
|
|
125
|
+
@client_type = client_type.to_sym
|
|
126
|
+
@config = build_config(config)
|
|
127
|
+
|
|
128
|
+
validate_protocol!
|
|
129
|
+
validate_client_type!
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Changes the client type for this client.
|
|
133
|
+
#
|
|
134
|
+
# Updates the underlying protocol client in place — server metadata already
|
|
135
|
+
# fetched is preserved and no re-discovery occurs.
|
|
136
|
+
#
|
|
137
|
+
# @param new_client_type [Symbol, String] the new client type
|
|
138
|
+
# @return [Symbol] the new client type
|
|
139
|
+
# @raise [Safire::Errors::ConfigurationError] if the client type is not valid for this protocol
|
|
140
|
+
#
|
|
141
|
+
# @example Discover then switch client type
|
|
142
|
+
# client = Safire::Client.new(config) # defaults to :public
|
|
143
|
+
# metadata = client.server_metadata
|
|
144
|
+
#
|
|
145
|
+
# if metadata.supports_symmetric_auth?
|
|
146
|
+
# client.client_type = :confidential_symmetric
|
|
147
|
+
# end
|
|
148
|
+
def client_type=(new_client_type)
|
|
149
|
+
if PROTOCOL_CLIENT_TYPES[@protocol].nil?
|
|
150
|
+
Safire.logger.warn(
|
|
151
|
+
"client_type is not configurable for protocol: :#{@protocol}; " \
|
|
152
|
+
'UDAP clients authenticate via signed JWT assertions — ignoring'
|
|
153
|
+
)
|
|
154
|
+
return
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
@client_type = new_client_type.to_sym
|
|
158
|
+
validate_client_type!
|
|
159
|
+
@protocol_client&.client_type = @client_type
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
private
|
|
163
|
+
|
|
164
|
+
def protocol_client
|
|
165
|
+
@protocol_client ||= PROTOCOL_CLASSES.fetch(@protocol).new(config, client_type:)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def build_config(config)
|
|
169
|
+
return config if config.is_a?(Safire::ClientConfig)
|
|
170
|
+
|
|
171
|
+
Safire::ClientConfig.new(config)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def validate_protocol!
|
|
175
|
+
return if VALID_PROTOCOLS.include?(@protocol)
|
|
176
|
+
|
|
177
|
+
raise Errors::ConfigurationError.new(
|
|
178
|
+
invalid_attribute: :protocol,
|
|
179
|
+
invalid_value: @protocol,
|
|
180
|
+
valid_values: VALID_PROTOCOLS
|
|
181
|
+
)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def validate_client_type!
|
|
185
|
+
valid_types = PROTOCOL_CLIENT_TYPES[@protocol]
|
|
186
|
+
return if valid_types.nil? || valid_types.include?(@client_type)
|
|
187
|
+
|
|
188
|
+
raise Errors::ConfigurationError.new(
|
|
189
|
+
invalid_attribute: :client_type,
|
|
190
|
+
invalid_value: @client_type,
|
|
191
|
+
valid_values: valid_types
|
|
192
|
+
)
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
module Safire
|
|
2
|
+
# Client configuration entity providing necessary attributes to perform different
|
|
3
|
+
# auth flows such as SMART on FHIR puclic, confidential symmetric, confidential asymmetric
|
|
4
|
+
# clients, and backend services.
|
|
5
|
+
# The ClientConfig instance is passed to Safire::Client upon initialization.
|
|
6
|
+
#
|
|
7
|
+
# @!attribute [r] base_url
|
|
8
|
+
# @return [String] the base URL of the FHIR service
|
|
9
|
+
# @!attribute [r] issuer
|
|
10
|
+
# @return [String] the URL of the FHIR service from which the app wishes to retrieve FHIR data.
|
|
11
|
+
# Optionally provided. Will default to `base_url` if not provided.
|
|
12
|
+
# @!attribute [r] client_id
|
|
13
|
+
# @return [String] the client identifier issued to the app by the authorization server
|
|
14
|
+
# @!attribute [r] redirect_uri
|
|
15
|
+
# @return [String] the redirect URI registered by the app with the authorization server
|
|
16
|
+
# @!attribute [r] scopes
|
|
17
|
+
# @return [Array<String>] list of OAuth2 scopes describing the app's desired access.
|
|
18
|
+
# Optionally provided.
|
|
19
|
+
# @!attribute [r] authorization_endpoint
|
|
20
|
+
# @return [String] URL of the server’s OAuth2 Authorization Endpoint.
|
|
21
|
+
# => Optional, will be retrieved from the well-known smart-configuration if not provided
|
|
22
|
+
# @!attribute [r] token_endpoint
|
|
23
|
+
# @return [String] URL of the server's OAuth2 Token Endpoint.
|
|
24
|
+
# => Optional, will be retrieved from the well-known smart-configuration if not provided
|
|
25
|
+
# @!attribute [r] private_key
|
|
26
|
+
# @return [OpenSSL::PKey::RSA, OpenSSL::PKey::EC, String, nil] the private key for signing
|
|
27
|
+
# JWT assertions in confidential asymmetric auth. Can be an OpenSSL key object or PEM string.
|
|
28
|
+
# @!attribute [r] kid
|
|
29
|
+
# @return [String, nil] the key ID matching the public key registered with the authorization server.
|
|
30
|
+
# Required for confidential asymmetric authentication.
|
|
31
|
+
# @!attribute [r] jwt_algorithm
|
|
32
|
+
# @return [String, nil] the JWT signing algorithm (RS384 or ES384).
|
|
33
|
+
# Optional, auto-detected from key type if not provided.
|
|
34
|
+
# @!attribute [r] jwks_uri
|
|
35
|
+
# @return [String, nil] URL to the client's JWKS containing the public key.
|
|
36
|
+
# Optional, included as jku header in JWT assertions when provided.
|
|
37
|
+
#
|
|
38
|
+
# @example Initializing a ClientConfig
|
|
39
|
+
# config = Safire::ClientConfig.new(
|
|
40
|
+
# base_url: 'https://fhir.example.com',
|
|
41
|
+
# client_id: 'my_client_id',
|
|
42
|
+
# redirect_uri: 'https://myapp.example.com/callback',
|
|
43
|
+
# scopes: ['openid', 'profile', 'patient/*.read']
|
|
44
|
+
# )
|
|
45
|
+
# client = Safire::Client.new(config)
|
|
46
|
+
#
|
|
47
|
+
# @example Initializing a ClientConfig using the Builder
|
|
48
|
+
# config = Safire::ClientConfig.builder
|
|
49
|
+
# .base_url('https://fhir.example.com')
|
|
50
|
+
# .client_id('my_client_id')
|
|
51
|
+
# .redirect_uri('https://myapp.example.com/callback')
|
|
52
|
+
# .scopes(['openid', 'profile', 'patient/*.read'])
|
|
53
|
+
# .build
|
|
54
|
+
# client = Safire::Client.new(config)
|
|
55
|
+
#
|
|
56
|
+
# @see Safire::ClientConfigBuilder
|
|
57
|
+
class ClientConfig < Entity
|
|
58
|
+
ATTRIBUTES = %i[
|
|
59
|
+
base_url issuer client_id client_secret redirect_uri
|
|
60
|
+
scopes authorization_endpoint token_endpoint
|
|
61
|
+
private_key kid jwt_algorithm jwks_uri
|
|
62
|
+
].freeze
|
|
63
|
+
|
|
64
|
+
attr_reader(*ATTRIBUTES)
|
|
65
|
+
|
|
66
|
+
def initialize(config)
|
|
67
|
+
super(config, ATTRIBUTES)
|
|
68
|
+
|
|
69
|
+
@issuer ||= base_url
|
|
70
|
+
validate!
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
class << self
|
|
74
|
+
def builder
|
|
75
|
+
ClientConfigBuilder.new
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
SENSITIVE_ATTRIBUTES = %i[client_secret private_key].freeze
|
|
80
|
+
URI_ATTRS = %i[base_url redirect_uri issuer authorization_endpoint token_endpoint jwks_uri].freeze
|
|
81
|
+
OPTIONAL_URI_ATTRS = %i[authorization_endpoint token_endpoint jwks_uri].freeze
|
|
82
|
+
private_constant :SENSITIVE_ATTRIBUTES, :URI_ATTRS, :OPTIONAL_URI_ATTRS
|
|
83
|
+
|
|
84
|
+
# @api private
|
|
85
|
+
def inspect
|
|
86
|
+
attrs = ATTRIBUTES.map do |attr|
|
|
87
|
+
value = send(attr)
|
|
88
|
+
next if value.nil?
|
|
89
|
+
|
|
90
|
+
masked = SENSITIVE_ATTRIBUTES.include?(attr) ? '[FILTERED]' : value.inspect
|
|
91
|
+
"#{attr}: #{masked}"
|
|
92
|
+
end.compact.join(', ')
|
|
93
|
+
"#<#{self.class} #{attrs}>"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
protected
|
|
97
|
+
|
|
98
|
+
# @return [Array<Symbol>] attributes masked as '[FILTERED]' in #to_hash
|
|
99
|
+
def sensitive_attributes
|
|
100
|
+
SENSITIVE_ATTRIBUTES
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
# Validates all URI attributes for structure and HTTPS requirement.
|
|
106
|
+
#
|
|
107
|
+
# Per SMART App Launch 2.2.0 (§App Protection, §Confidential Asymmetric),
|
|
108
|
+
# all exchanges involving sensitive data SHALL use TLS. All endpoint URIs
|
|
109
|
+
# must therefore use the `https` scheme.
|
|
110
|
+
#
|
|
111
|
+
# Exception: `http` is permitted when the host is `localhost` or `127.0.0.1`
|
|
112
|
+
# to support local development without a TLS termination proxy.
|
|
113
|
+
#
|
|
114
|
+
# @raise [Errors::ConfigurationError] if any URI is malformed or uses HTTP on a non-localhost host
|
|
115
|
+
def validate_uris!
|
|
116
|
+
invalid_uris, non_https_uris = collect_uri_violations
|
|
117
|
+
return if invalid_uris.empty? && non_https_uris.empty?
|
|
118
|
+
|
|
119
|
+
raise Errors::ConfigurationError.new(
|
|
120
|
+
invalid_uri_attributes: invalid_uris,
|
|
121
|
+
non_https_uri_attributes: non_https_uris
|
|
122
|
+
)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def collect_uri_violations
|
|
126
|
+
invalid_uris = []
|
|
127
|
+
non_https_uris = []
|
|
128
|
+
|
|
129
|
+
URI_ATTRS.each do |attr|
|
|
130
|
+
value = send(attr)
|
|
131
|
+
next if value.nil? && OPTIONAL_URI_ATTRS.include?(attr)
|
|
132
|
+
|
|
133
|
+
case classify_uri(value)
|
|
134
|
+
when :invalid then invalid_uris << attr
|
|
135
|
+
when :non_https then non_https_uris << attr
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
[invalid_uris, non_https_uris]
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def classify_uri(value)
|
|
143
|
+
uri = Addressable::URI.parse(value)
|
|
144
|
+
return :invalid unless uri.scheme && uri.host
|
|
145
|
+
|
|
146
|
+
:non_https if uri.scheme != 'https' && !localhost_host?(uri.host)
|
|
147
|
+
rescue Addressable::URI::InvalidURIError
|
|
148
|
+
:invalid
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Returns true when the host is a local loopback address.
|
|
152
|
+
# HTTP is permitted for localhost to support development environments.
|
|
153
|
+
def localhost_host?(host)
|
|
154
|
+
%w[localhost 127.0.0.1].include?(host)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def validate!
|
|
158
|
+
required_attrs = %i[base_url client_id redirect_uri]
|
|
159
|
+
nil_vars = required_attrs.select { |attr| send(attr).nil? }
|
|
160
|
+
|
|
161
|
+
if nil_vars.empty?
|
|
162
|
+
validate_uris!
|
|
163
|
+
return
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
raise Errors::ConfigurationError.new(missing_attributes: nil_vars)
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|