safire 0.1.0 → 0.3.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.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/release-safire/SKILL.md +151 -0
  3. data/CHANGELOG.md +72 -1
  4. data/Gemfile +1 -0
  5. data/Gemfile.lock +9 -7
  6. data/README.md +40 -7
  7. data/ROADMAP.md +4 -7
  8. data/docs/Gemfile.lock +2 -2
  9. data/docs/_config.yml +4 -1
  10. data/docs/adr/ADR-002-facade-and-forwardable.md +1 -1
  11. data/docs/adr/ADR-003-protocol-vs-client-type.md +1 -1
  12. data/docs/adr/ADR-006-lazy-discovery.md +1 -1
  13. data/docs/adr/ADR-008-warn-return-false-for-compliance-validation.md +4 -3
  14. data/docs/adr/ADR-009-oauth-error-hierarchy.md +80 -0
  15. data/docs/adr/ADR-010-optional-client-id-dcr-temp-client.md +60 -0
  16. data/docs/adr/index.md +3 -1
  17. data/docs/advanced.md +22 -25
  18. data/docs/configuration/client-setup.md +13 -7
  19. data/docs/configuration/index.md +3 -3
  20. data/docs/index.md +6 -4
  21. data/docs/installation.md +9 -5
  22. data/docs/smart-on-fhir/backend-services/index.md +92 -0
  23. data/docs/smart-on-fhir/backend-services/token-request.md +207 -0
  24. data/docs/smart-on-fhir/confidential-asymmetric/authorization.md +1 -1
  25. data/docs/smart-on-fhir/confidential-asymmetric/index.md +8 -5
  26. data/docs/smart-on-fhir/confidential-asymmetric/token-exchange.md +1 -1
  27. data/docs/smart-on-fhir/confidential-symmetric/authorization.md +1 -1
  28. data/docs/smart-on-fhir/confidential-symmetric/index.md +3 -3
  29. data/docs/smart-on-fhir/confidential-symmetric/token-exchange.md +1 -1
  30. data/docs/smart-on-fhir/discovery/capability-checks.md +32 -1
  31. data/docs/smart-on-fhir/discovery/index.md +3 -3
  32. data/docs/smart-on-fhir/discovery/metadata.md +1 -1
  33. data/docs/smart-on-fhir/dynamic-client-registration/index.md +103 -0
  34. data/docs/smart-on-fhir/dynamic-client-registration/registration.md +160 -0
  35. data/docs/smart-on-fhir/dynamic-client-registration/response.md +152 -0
  36. data/docs/smart-on-fhir/index.md +23 -13
  37. data/docs/smart-on-fhir/post-based-authorization.md +2 -2
  38. data/docs/smart-on-fhir/public-client/authorization.md +1 -1
  39. data/docs/smart-on-fhir/public-client/index.md +3 -3
  40. data/docs/smart-on-fhir/public-client/token-exchange.md +1 -1
  41. data/docs/troubleshooting/auth-errors.md +24 -1
  42. data/docs/troubleshooting/client-errors.md +72 -0
  43. data/docs/troubleshooting/index.md +4 -3
  44. data/docs/udap.md +5 -3
  45. data/lib/safire/client.rb +58 -20
  46. data/lib/safire/client_config.rb +12 -28
  47. data/lib/safire/errors.rb +89 -38
  48. data/lib/safire/jwt_assertion.rb +1 -1
  49. data/lib/safire/protocols/behaviours.rb +6 -0
  50. data/lib/safire/protocols/smart.rb +212 -44
  51. data/lib/safire/protocols/smart_metadata.rb +17 -2
  52. data/lib/safire/uri_validation.rb +34 -0
  53. data/lib/safire/version.rb +1 -1
  54. data/lib/safire.rb +1 -0
  55. data/safire.gemspec +6 -2
  56. metadata +16 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f9d11ee8ab8db74c9b225f12835caaf092616a0044372252617783b6187e3043
4
- data.tar.gz: 8d73cdb90ed9d4ca7ec1e028bfe3e68464c7284f73e2d101a084be346e9b0085
3
+ metadata.gz: e2907e363c56d115aedcc6b5bb0aa55a1780757a7a9fc8dfc3f8853262c4c926
4
+ data.tar.gz: 2cf29245a45cb4ad067418a1a23ad2c3bcddac91c3bb672a0b2b6901391af761
5
5
  SHA512:
6
- metadata.gz: bf3cd169be16cb9598f2de3f572d0efeeb6c0ed98826b0690588c684e15c2a95afc6cf340d35a53c0f6d9eebc11c59dafdcdd02e71b0ddb8fbf629071644d90b
7
- data.tar.gz: 6beaec7923441fb46d8fc4cc193ffff16354bb60fb62f141a3a36131e22f275df3d4ddfa31dd5548eebb6ad3a8872cd952c4cc4ab9dc31968b9eb644b0d097be
6
+ metadata.gz: 814cf46717388979bd1a5e7a3666a30fcd1cdc19fd6eb4f71e30a0916d91993104bdf94566736390e3ec39f320eef1ec1bd1621afcacfc8c89b9de376522d038
7
+ data.tar.gz: 15dbfb7e5927ebbf91ae9aad6e2658f7f4cc9e42a6e0282267844c063e4693651a6c7348b6e71f24dfc5d75d525b5b0a3a0339e3888d9410c6c15babbbdf8944
@@ -0,0 +1,151 @@
1
+ ---
2
+ name: release-safire
3
+ description: Run the full Safire gem release workflow
4
+ argument-hint: Optional target version (e.g. 0.3.0); omit to auto-determine from CHANGELOG
5
+ ---
6
+
7
+ # Safire Release Workflow
8
+
9
+ You are guiding the user through a complete Safire gem release. Follow each phase in order. **Never modify files or run commands without explicit user approval.** All commits must use `-s` (Signed-off-by) and one-line subjects.
10
+
11
+ ## Context
12
+
13
+ Before proceeding, gather context by reading these files directly (do not shell out):
14
+ - Read `lib/safire/version.rb` to determine the current version
15
+ - Read `CHANGELOG.md` to find the `## [Unreleased]` section and its entries
16
+ - Run `git branch --show-current` to confirm the current branch
17
+ - Run `git status --short` to check for any uncommitted changes
18
+
19
+ ---
20
+
21
+ ## Phase 1: Determine Target Version
22
+
23
+ Target version argument: $ARGUMENTS
24
+
25
+ If `$ARGUMENTS` is blank, analyze the [Unreleased] CHANGELOG section and recommend a version bump:
26
+ - **PATCH** (X.Y.Z+1): bug fixes only
27
+ - **MINOR** (X.Y+1.0): new backward-compatible features
28
+ - **MAJOR** (X+1.0.0): breaking changes
29
+
30
+ Present your recommendation with reasoning. Ask the user to confirm or provide a different version before proceeding.
31
+
32
+ ---
33
+
34
+ ## Phase 2: Pre-Release Checks
35
+
36
+ Present this checklist and ask the user to approve running all checks before proceeding:
37
+
38
+ 1. `bundle exec rspec` — all tests must pass
39
+ 2. `bundle exec rubocop` — zero offenses
40
+ 3. `bundle exec bundler-audit check --update` — no known vulnerabilities
41
+ 4. `cd docs && bundle exec jekyll build` — docs must build clean
42
+
43
+ Run each check sequentially and report results. If any check fails, stop and clearly describe what needs to be fixed. Do not proceed to Phase 3 until all checks pass.
44
+
45
+ ---
46
+
47
+ ## Phase 3: Create Release Branch
48
+
49
+ Ask the user to approve creating the release branch, then run:
50
+
51
+ ```
52
+ git checkout main
53
+ git pull origin main
54
+ git checkout -b release-X.Y.Z
55
+ ```
56
+
57
+ Replace `X.Y.Z` with the confirmed target version.
58
+
59
+ ---
60
+
61
+ ## Phase 4: Update CHANGELOG.md (docs commit)
62
+
63
+ Show the user the exact diff you will make:
64
+ - Rename `## [Unreleased]` → `## [X.Y.Z] - YYYY-MM-DD` (use today's date)
65
+ - Add a fresh empty `## [Unreleased]` section above the new versioned entry
66
+
67
+ Wait for approval, then edit `CHANGELOG.md`.
68
+
69
+ After editing, ask the user to approve this commit:
70
+ ```
71
+ git add CHANGELOG.md
72
+ git commit -s -m "Update CHANGELOG for vX.Y.Z"
73
+ ```
74
+
75
+ Stage `CHANGELOG.md` only — no other files.
76
+
77
+ ---
78
+
79
+ ## Phase 5: Bump Version and Update Gemfile.lock (release commit)
80
+
81
+ Show the user the exact change to `lib/safire/version.rb`:
82
+ ```ruby
83
+ VERSION = 'X.Y.Z'.freeze
84
+ ```
85
+
86
+ Wait for approval, then edit the file and run `bundle install` to regenerate `Gemfile.lock`.
87
+
88
+ Ask the user to approve this commit:
89
+ ```
90
+ git add lib/safire/version.rb Gemfile.lock
91
+ git commit -s -m "Bump version to X.Y.Z"
92
+ ```
93
+
94
+ Stage `lib/safire/version.rb` and `Gemfile.lock` only — no other files.
95
+
96
+ ---
97
+
98
+ ## Phase 6: Local Gem Verification
99
+
100
+ Ask the user to approve running local verification (no files will be committed):
101
+
102
+ ```bash
103
+ gem build safire.gemspec
104
+ gem install ./safire-X.Y.Z.gem
105
+ ruby -e "require 'safire'; puts Safire::VERSION"
106
+ rm safire-X.Y.Z.gem
107
+ ```
108
+
109
+ The `ruby -e` line must print `X.Y.Z`. If it does not, stop and report the issue. Never commit the `.gem` file.
110
+
111
+ ---
112
+
113
+ ## Phase 7: Push Release Branch
114
+
115
+ Ask the user to approve:
116
+ ```
117
+ git push -u origin release-X.Y.Z
118
+ ```
119
+
120
+ ---
121
+
122
+ ## Phase 8: Open Release PR
123
+
124
+ Show the proposed PR and ask for approval before running `gh pr create`:
125
+
126
+ - **Title:** `Release vX.Y.Z`
127
+ - **Body:** the full CHANGELOG entry for this version (the `## [X.Y.Z] - YYYY-MM-DD` block)
128
+
129
+ ---
130
+
131
+ ## Phase 9: Post-Merge Instructions
132
+
133
+ After the PR is created, tell the user the remaining manual steps:
134
+
135
+ 1. **Merge the PR** once CI passes and it is approved.
136
+ 2. **Create a GitHub Release** after merge:
137
+ - Tag: `vX.Y.Z` on `main`
138
+ - Title: `Safire vX.Y.Z`
139
+ - Notes: the CHANGELOG entry for this version
140
+ 3. **Publishing is automated** — `.github/workflows/publish-gem.yml` triggers on release creation.
141
+ 4. **Verify** once published: `gem info safire -r`
142
+
143
+ ---
144
+
145
+ ## Rules (never violate)
146
+
147
+ - All commits use `-s`; subjects are one-line only
148
+ - Two-commit structure on the release branch: docs commit (CHANGELOG) then release commit (version.rb + Gemfile.lock)
149
+ - Never commit a `.gem` file
150
+ - Separate doc changes from code changes into distinct commits
151
+ - Always get explicit user approval before modifying files or running commands
data/CHANGELOG.md CHANGED
@@ -7,13 +7,84 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.0] - 2026-04-15
11
+
12
+ ### Added
13
+
14
+ - `Safire::Client#register_client` implements the OAuth 2.0 Dynamic Client Registration
15
+ Protocol (RFC 7591): POSTs client metadata to the server's registration endpoint and
16
+ returns the response as a Hash containing at minimum a `client_id`
17
+ - Endpoint is resolved from SMART discovery (`registration_endpoint` field) when not
18
+ supplied explicitly via the `registration_endpoint:` keyword argument; HTTPS is
19
+ enforced on the endpoint regardless of source
20
+ - Supports an optional initial access token via the `authorization:` keyword argument
21
+ (full `Authorization` header value including token type prefix)
22
+ - Raises `Safire::Errors::DiscoveryError` when no registration endpoint is available,
23
+ `Safire::Errors::RegistrationError` on server error or a 2xx response missing
24
+ `client_id`, and `Safire::Errors::NetworkError` on transport failure
25
+ - `Safire::Errors::RegistrationError` — new error class for Dynamic Client Registration
26
+ failures; inherits from `Safire::Errors::OAuthError` with `status`, `error_code`,
27
+ `error_description`, and `received_fields` attributes
28
+ - `Safire::Errors::OAuthError` — new shared base class for `RegistrationError`,
29
+ `TokenError`, and `AuthError`; provides `status`, `error_code`, and
30
+ `error_description` attributes and can be used as a single rescue point for any
31
+ server-side OAuth protocol error
32
+
33
+ ### Changed
34
+
35
+ - `client_id` is now optional at `ClientConfig` and `Protocols::Smart` initialization;
36
+ all authorization flows (`authorization_url`, `request_access_token`, `refresh_token`,
37
+ `request_backend_token`) validate its presence at call time and raise
38
+ `Safire::Errors::ConfigurationError` if it is absent
39
+ - `Protocols::Smart#token_endpoint` now raises `Safire::Errors::DiscoveryError` when
40
+ the discovery response does not include a `token_endpoint` field, rather than silently
41
+ passing `nil` to the HTTP client
42
+
43
+ ## [0.2.0] - 2026-04-04
44
+
45
+ ### Added
46
+
47
+ - SMART Backend Services Authorization flow (`client_credentials` grant) via
48
+ `Safire::Client#request_backend_token` and `Safire::Protocols::Smart#request_backend_token`:
49
+ - Authenticates exclusively via a signed JWT assertion (RS384 or ES384); no redirect,
50
+ PKCE, or user interaction required
51
+ - Scope defaults to `["system/*.rs"]` when none is configured or provided
52
+ - `private_key` and `kid` can be overridden per call
53
+ - `token_response_valid?` now accepts a `flow:` keyword argument (`:app_launch` default):
54
+ when `flow: :backend_services`, also validates `expires_in` presence (required per
55
+ SMART Backend Services spec)
56
+ - `token_response_valid?` accepts both `"Bearer"` (SMART App Launch spec) and `"bearer"`
57
+ (SMART Backend Services) as valid `token_type` values; the non-compliance warning
58
+ now references the expected value for the active flow
59
+ - `SmartMetadata#supports_backend_services?` returns `true` when the server advertises the
60
+ `client_credentials` grant type and supports `private_key_jwt` authentication
61
+ (i.e. `grant_types_supported` includes `"client_credentials"` and
62
+ `supports_asymmetric_auth?` is `true`)
63
+
64
+ ### Changed
65
+
66
+ - Corrected spec name throughout: "SMART on FHIR" → "SMART App Launch" per the
67
+ [SMART App Launch IG](https://hl7.org/fhir/smart-app-launch/); Backend Services is
68
+ presented as a feature within the spec, not a separate spec
69
+ - `redirect_uri` and `authorization_endpoint` are now optional in `Safire::Protocols::Smart`;
70
+ both are validated only when `authorization_url` is called (app launch flow)
71
+ - `redirect_uri` is now optional in `Safire::ClientConfig` to support backend services
72
+ clients that operate without a redirect URI; the field is still validated when provided
73
+
74
+ ### Fixed
75
+
76
+ - YARD API docs nav links broken after in-page navigation: relative hrefs from the nav
77
+ iframe were resolved against the parent window URL (which changes on each navigation)
78
+ instead of the iframe base; `bin/docs` now patches the generated `full_list.js` to
79
+ resolve links to absolute URLs before messaging the parent
80
+
10
81
  ## [0.1.0] - 2026-03-25
11
82
 
12
83
  ### Added
13
84
 
14
85
  - `Safire::Client` facade with `protocol:` (`:smart`) and `client_type:`
15
86
  (`:public`, `:confidential_symmetric`, `:confidential_asymmetric`) keywords
16
- - SMART on FHIR App Launch 2.2.0 support via `Safire::Protocols::Smart`:
87
+ - SMART App Launch 2.2.0 support via `Safire::Protocols::Smart`:
17
88
  - Server metadata discovery from `/.well-known/smart-configuration`
18
89
  - Authorization URL builder for GET and POST-based authorization
19
90
  (`authorize-post` capability)
data/Gemfile CHANGED
@@ -20,6 +20,7 @@ group :development do
20
20
  end
21
21
 
22
22
  group :test do
23
+ gem 'dotenv', '~> 3.0'
23
24
  gem 'simplecov', require: false
24
25
  gem 'simplecov-cobertura', require: false
25
26
  gem 'webmock', '~> 3.18'
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- safire (0.1.0)
4
+ safire (0.3.0)
5
5
  activesupport (~> 8.0.0)
6
6
  addressable (~> 2.8)
7
7
  faraday (~> 2.14)
@@ -47,6 +47,7 @@ GEM
47
47
  reline (>= 0.3.8)
48
48
  diff-lcs (1.6.2)
49
49
  docile (1.4.1)
50
+ dotenv (3.2.0)
50
51
  drb (2.2.3)
51
52
  erb (5.0.3)
52
53
  faraday (2.14.1)
@@ -65,7 +66,7 @@ GEM
65
66
  pp (>= 0.6.0)
66
67
  rdoc (>= 4.0.0)
67
68
  reline (>= 0.4.2)
68
- json (2.19.2)
69
+ json (2.19.3)
69
70
  jwt (2.10.2)
70
71
  base64
71
72
  language_server-protocol (3.17.0.5)
@@ -77,7 +78,7 @@ GEM
77
78
  net-http (0.9.1)
78
79
  uri (>= 0.11.1)
79
80
  parallel (1.27.0)
80
- parser (3.3.10.2)
81
+ parser (3.3.11.1)
81
82
  ast (~> 2.4.1)
82
83
  racc
83
84
  pp (0.6.3)
@@ -117,11 +118,11 @@ GEM
117
118
  diff-lcs (>= 1.2.0, < 2.0)
118
119
  rspec-support (~> 3.13.0)
119
120
  rspec-support (3.13.6)
120
- rubocop (1.86.0)
121
+ rubocop (1.86.1)
121
122
  json (~> 2.3)
122
123
  language_server-protocol (~> 3.17.0.2)
123
124
  lint_roller (~> 1.1.0)
124
- parallel (~> 1.10)
125
+ parallel (>= 1.10)
125
126
  parser (>= 3.3.0.2)
126
127
  rainbow (>= 2.2.2, < 4.0)
127
128
  regexp_parser (>= 2.9.3, < 3.0)
@@ -147,7 +148,7 @@ GEM
147
148
  simplecov_json_formatter (0.1.4)
148
149
  stringio (3.1.9)
149
150
  thor (1.4.0)
150
- timecop (0.9.10)
151
+ timecop (0.9.11)
151
152
  tzinfo (2.0.6)
152
153
  concurrent-ruby (~> 1.0)
153
154
  unicode-display_width (3.2.0)
@@ -158,7 +159,7 @@ GEM
158
159
  addressable (>= 2.8.0)
159
160
  crack (>= 0.3.2)
160
161
  hashdiff (>= 0.4.0, < 2.0.0)
161
- yard (0.9.38)
162
+ yard (0.9.41)
162
163
 
163
164
  PLATFORMS
164
165
  ruby
@@ -167,6 +168,7 @@ PLATFORMS
167
168
  DEPENDENCIES
168
169
  bundler-audit (~> 0.9)
169
170
  debug
171
+ dotenv (~> 3.0)
170
172
  pry
171
173
  pry-byebug
172
174
  rspec (~> 3.12)
data/README.md CHANGED
@@ -5,19 +5,21 @@
5
5
  [![Coverage](https://codecov.io/gh/vanessuniq/safire/branch/main/graph/badge.svg)](https://codecov.io/gh/vanessuniq/safire)
6
6
  [![Documentation](https://img.shields.io/badge/docs-yard-blue.svg)](https://vanessuniq.github.io/safire)
7
7
 
8
- Safire is a lean Ruby library that implements [SMART on FHIR](https://hl7.org/fhir/smart-app-launch/) and [UDAP](https://hl7.org/fhir/us/udap-security/) client protocols for healthcare applications.
8
+ Safire is a Ruby gem implementing the [SMART App Launch 2.2.0](https://hl7.org/fhir/smart-app-launch/) specification and the [UDAP Security](https://hl7.org/fhir/us/udap-security/) protocol for healthcare client applications. It handles 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.
9
9
 
10
10
  ---
11
11
 
12
12
  ## Features
13
13
 
14
- ### SMART on FHIR App Launch (v2.2.0)
14
+ ### SMART App Launch (v2.2.0)
15
15
 
16
+ - Dynamic Client Registration (RFC 7591): obtain a `client_id` at runtime by POSTing client metadata to the server's registration endpoint
16
17
  - Discovery (`/.well-known/smart-configuration`)
17
18
  - Public Client (PKCE)
18
19
  - Confidential Symmetric Client (`client_secret` + HTTP Basic Auth)
19
20
  - Confidential Asymmetric Client (`private_key_jwt` with RS384/ES384)
20
21
  - POST-Based Authorization
22
+ - Backend Services (`client_credentials` grant, JWT assertion, no user interaction or PKCE; scope defaults to `system/*.rs`)
21
23
 
22
24
  ### UDAP
23
25
 
@@ -46,10 +48,12 @@ require 'safire'
46
48
 
47
49
  # Step 1 — Create a client (Hash config or Safire::ClientConfig.new)
48
50
  client = Safire::Client.new(
49
- base_url: 'https://launch.smarthealthit.org/v/r4/sim/eyJoIjoiMSJ9/fhir',
50
- client_id: 'my_client_id',
51
- redirect_uri: 'https://myapp.example.com/callback',
52
- scopes: ['openid', 'profile', 'patient/*.read']
51
+ {
52
+ base_url: 'https://launch.smarthealthit.org/v/r4/sim/eyJoIjoiMSJ9/fhir',
53
+ client_id: 'my_client_id',
54
+ redirect_uri: 'https://myapp.example.com/callback',
55
+ scopes: ['openid', 'profile', 'patient/*.read']
56
+ }
53
57
  )
54
58
 
55
59
  # Step 2 — Discover SMART metadata (lazy — only called when needed)
@@ -98,6 +102,35 @@ client = Safire::Client.new(
98
102
  # Authorization and token exchange are identical — Safire builds the JWT assertion automatically
99
103
  ```
100
104
 
105
+ ### Backend Services (system-to-system)
106
+
107
+ No user interaction, redirect URI, or PKCE required — the client authenticates entirely via a signed JWT assertion:
108
+
109
+ ```ruby
110
+ client = Safire::Client.new(
111
+ {
112
+ base_url: 'https://fhir.example.com',
113
+ client_id: 'my_backend_client',
114
+ private_key: OpenSSL::PKey::RSA.new(File.read('private_key.pem')),
115
+ kid: 'my-key-id-123',
116
+ scopes: ['system/Patient.rs', 'system/Observation.rs']
117
+ }
118
+ )
119
+
120
+ token_data = client.request_backend_token
121
+ # token_data => { "access_token" => "...", "token_type" => "Bearer", "expires_in" => 300, ... }
122
+
123
+ # Override scope or credentials per call
124
+ token_data = client.request_backend_token(
125
+ scopes: ['system/Patient.rs'],
126
+ private_key: OpenSSL::PKey::RSA.new(File.read('new_key.pem')),
127
+ kid: 'new-key-id'
128
+ )
129
+
130
+ # Validate the token response (flow: :backend_services also checks expires_in)
131
+ client.token_response_valid?(token_data, flow: :backend_services)
132
+ ```
133
+
101
134
  ---
102
135
 
103
136
  ## Configuration
@@ -122,7 +155,7 @@ bin/demo
122
155
  # Visit http://localhost:4567
123
156
  ```
124
157
 
125
- Demonstrates SMART discovery, all authorization flows, and token refresh. See [`examples/sinatra_app/README.md`](examples/sinatra_app/README.md) for details.
158
+ Demonstrates Dynamic Client Registration, SMART discovery, all authorization flows, token refresh, and backend services token requests. See [`examples/sinatra_app/README.md`](examples/sinatra_app/README.md) for details.
126
159
 
127
160
  ---
128
161
 
data/ROADMAP.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Safire Roadmap
2
2
 
3
- ## Current Release — v0.0.1
3
+ ## Current Release — v0.2.0
4
4
 
5
5
  Safire is in early development (pre-release). The API is functional but not yet stable — breaking changes may occur before v1.0.0. Published to [RubyGems](https://rubygems.org/gems/safire).
6
6
 
@@ -10,7 +10,7 @@ Feedback, bug reports, and pull requests are welcome via the [issue tracker](htt
10
10
 
11
11
  ## Implemented Features
12
12
 
13
- ### SMART on FHIR App Launch (v2.2.0)
13
+ ### SMART App Launch (v2.2.0)
14
14
 
15
15
  - **Discovery** — lazy fetch of `/.well-known/smart-configuration`; metadata cached per client instance
16
16
  - **Public Client** — PKCE-only authorization code flow (RS256/ES256)
@@ -19,16 +19,13 @@ Feedback, bug reports, and pull requests are welcome via the [issue tracker](htt
19
19
  - **POST-Based Authorization** — form-encoded authorization requests
20
20
  - **JWT Assertion Builder** — signed JWT assertions with configurable `kid` and expiry
21
21
  - **PKCE** — automatic code verifier and challenge generation
22
+ - **Backend Services** — `client_credentials` grant for system-to-system flows; JWT assertion (RS384/ES384); no user interaction, redirect, or PKCE required; scope defaults to `system/*.rs` when not configured
23
+ - **Dynamic Client Registration** — runtime client registration per [RFC 7591](https://www.rfc-editor.org/rfc/rfc7591); endpoint discovered from SMART metadata or supplied explicitly; supports initial access token
22
24
 
23
25
  ---
24
26
 
25
27
  ## Planned Features
26
28
 
27
- ### SMART on FHIR
28
-
29
- - **Backend Services** — `client_credentials` grant for system-to-system flows (no user interaction)
30
- - **Dynamic Client Registration** — programmatic client registration per [RFC 7591](https://www.rfc-editor.org/rfc/rfc7591)
31
-
32
29
  ### UDAP Security
33
30
 
34
31
  - **UDAP Discovery** — `/.well-known/udap` metadata fetch and validation
data/docs/Gemfile.lock CHANGED
@@ -4,7 +4,7 @@ GEM
4
4
  addressable (2.8.8)
5
5
  public_suffix (>= 2.0.2, < 8.0)
6
6
  base64 (0.3.0)
7
- bigdecimal (4.0.1)
7
+ bigdecimal (4.1.1)
8
8
  colorator (1.1.0)
9
9
  concurrent-ruby (1.3.6)
10
10
  csv (3.3.5)
@@ -140,7 +140,7 @@ DEPENDENCIES
140
140
  CHECKSUMS
141
141
  addressable (2.8.8) sha256=7c13b8f9536cf6364c03b9d417c19986019e28f7c00ac8132da4eb0fe393b057
142
142
  base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
143
- bigdecimal (4.0.1) sha256=8b07d3d065a9f921c80ceaea7c9d4ae596697295b584c296fe599dd0ad01c4a7
143
+ bigdecimal (4.1.1) sha256=1c09efab961da45203c8316b0cdaec0ff391dfadb952dd459584b63ebf8054ca
144
144
  colorator (1.1.0) sha256=e2f85daf57af47d740db2a32191d1bdfb0f6503a0dfbc8327d0c9154d5ddfc38
145
145
  concurrent-ruby (1.3.6) sha256=6b56837e1e7e5292f9864f34b69c5a2cbc75c0cf5338f1ce9903d10fa762d5ab
146
146
  csv (3.3.5) sha256=6e5134ac3383ef728b7f02725d9872934f523cb40b961479f69cf3afa6c8e73f
data/docs/_config.yml CHANGED
@@ -19,7 +19,10 @@
19
19
  # in the templates via {{ site.myvariable }}.
20
20
 
21
21
  title: Safire Documentation
22
- description: SMART on FHIR and UDAP implementation library for Ruby
22
+ tagline: Ruby gem for SMART App Launch and UDAP protocols
23
+ description: SMART App Launch and UDAP implementation library for Ruby
24
+ author: Vanessa Fotso
25
+ lang: en-US
23
26
  baseurl: /safire
24
27
  url: https://vanessuniq.github.io
25
28
 
@@ -13,7 +13,7 @@ nav_order: 2
13
13
 
14
14
  ## Context
15
15
 
16
- Safire must support multiple authorization protocols (SMART on FHIR, UDAP) from a single public entry point. There are several ways to structure this:
16
+ Safire must support multiple authorization protocols (SMART App Launch, UDAP) from a single public entry point. There are several ways to structure this:
17
17
 
18
18
  **Option A — Monolithic `Client`:** implement all protocol logic directly inside `Safire::Client`. Simple at first, but grows unbounded as each protocol adds methods, and makes it impossible to test protocol logic in isolation.
19
19
 
@@ -13,7 +13,7 @@ nav_order: 3
13
13
 
14
14
  ## Context
15
15
 
16
- `Safire::Client` needs to support multiple healthcare authorization protocols (SMART on FHIR, UDAP) and, within SMART, multiple client authentication methods (public, confidential symmetric, confidential asymmetric). There are two ways to model this:
16
+ `Safire::Client` needs to support multiple healthcare authorization protocols (SMART, UDAP) and, within SMART, multiple client authentication methods (public, confidential symmetric, confidential asymmetric). There are two ways to model this:
17
17
 
18
18
  **Option A — flat enum:** a single parameter enumerating every combination.
19
19
 
@@ -13,7 +13,7 @@ nav_order: 6
13
13
 
14
14
  ## Context
15
15
 
16
- SMART on FHIR clients need the authorization server's endpoints (`authorization_endpoint`, `token_endpoint`) to build authorization URLs and request tokens. These are obtained by fetching `/.well-known/smart-configuration`. There are two approaches:
16
+ SMART clients need the authorization server's endpoints (`authorization_endpoint`, `token_endpoint`) to build authorization URLs and request tokens. These are obtained by fetching `/.well-known/smart-configuration`. There are two approaches:
17
17
 
18
18
  **Option A — eager discovery:** fetch metadata in `Smart#initialize`.
19
19
 
@@ -25,7 +25,7 @@ The question is: what should compliance checks do when they find a violation?
25
25
 
26
26
  **Option B — Warn and return false:** log a warning via `Safire.logger` for each violation found, then return `false`. Never raise.
27
27
 
28
- Option A treats a non-compliant server as an unrecoverable error. In practice, some production FHIR servers have minor token response non-compliance (e.g. `token_type: "bearer"` in lowercase rather than `"Bearer"`) but are otherwise functional. Raising an exception would prevent Safire from working with those servers entirely, with no way for callers to override the decision.
28
+ Option A treats a non-compliant server as an unrecoverable error. In practice, some production FHIR servers have minor token response non-compliance (e.g. returning `token_type: "BEARER"` instead of the spec-required value) but are otherwise functional. Raising an exception would prevent Safire from working with those servers entirely, with no way for callers to override the decision.
29
29
 
30
30
  Option B lets the caller decide what to do: they can check the return value, observe the warnings in their logs, and choose to proceed or abort. This is consistent with how Ruby standard library methods (e.g. `URI.parse`, `JSON.parse` with `rescue nil`) handle validation — surface the issue, let the caller decide.
31
31
 
@@ -38,9 +38,9 @@ There is also a clear boundary: **the caller controls the config** (configuratio
38
38
  Compliance validation methods use the **warn + return false** pattern:
39
39
 
40
40
  ```ruby
41
- def token_response_valid?(response)
41
+ def token_response_valid?(response, flow: :app_launch)
42
42
  # ...
43
- Safire.logger.warn("SMART token response non-compliance: token_type is #{...}; expected 'Bearer'")
43
+ Safire.logger.warn("SMART token response non-compliance: token_type is #{...}; expected 'Bearer' (SMART App Launch spec)")
44
44
  false
45
45
  end
46
46
 
@@ -72,3 +72,4 @@ Configuration validation (`ClientConfig#validate!`, `Smart#validate!`) raises `C
72
72
  **Trade-offs:**
73
73
  - Callers who do not call `token_response_valid?` get no compliance signal at all — non-compliant responses are silently accepted; this is intentional (opt-in, not opt-out)
74
74
  - The distinction between "warn + return false" and "raise" must be maintained consistently — new validation methods should follow the same rule: server behaviour → warn; caller configuration → raise
75
+ - `token_response_valid?` accepts a `flow:` keyword argument (`:app_launch` default, `:backend_services`) that adjusts which fields are required and what the warning messages say. For example, `token_type` must be `"Bearer"` (App Launch spec) or `"bearer"` (Backend Services spec), and `expires_in` is RECOMMENDED for App Launch but REQUIRED for Backend Services. Callers opt in to the stricter backend-services validation by passing `flow: :backend_services`
@@ -0,0 +1,80 @@
1
+ ---
2
+ layout: default
3
+ title: "ADR-009: OAuthError base class and ReceivesFields mixin for protocol error hierarchy"
4
+ parent: Architecture Decision Records
5
+ nav_order: 9
6
+ ---
7
+
8
+ # ADR-009: OAuthError base class and ReceivesFields mixin for protocol error hierarchy
9
+
10
+ **Status:** Accepted
11
+
12
+ ---
13
+
14
+ ## Context
15
+
16
+ Three protocol operations can return OAuth2-style error responses: token exchange (`TokenError`), authorization failure (`AuthError`), and dynamic client registration (`RegistrationError`). All three carry the same RFC-defined fields — HTTP `status`, an OAuth2 `error` code, and an `error_description` string — and all three produce structured error messages from those fields.
17
+
18
+ `TokenError` and `RegistrationError` have a second failure path: the server returns a 2xx response but omits the field the caller requires (`access_token` or `client_id`). In that case the error must report which fields were present in the response, without logging their values, to assist debugging without leaking data.
19
+
20
+ Without a shared foundation, each error class would duplicate the constructor, the attribute readers, and the message-building logic.
21
+
22
+ **Option A — Duplicate per class:** each error class independently defines `attr_reader :status, :error_code, :error_description`, its own constructor, and its own `build_message` method.
23
+
24
+ **Option B — Extract a shared base:** introduce `OAuthError < Error` with a template-method design; each subclass overrides `operation_label` to supply the lead phrase of the error message. Extract a `ReceivesFields` mixin for the structural failure path (`received_fields` attribute + constructor forwarding), included only by the two classes that need it.
25
+
26
+ ---
27
+
28
+ ## Decision
29
+
30
+ Option B — `OAuthError` as a shared base class with `ReceivesFields` as a private mixin.
31
+
32
+ ```ruby
33
+ class OAuthError < Error
34
+ attr_reader :status, :error_code, :error_description
35
+
36
+ def initialize(status: nil, error_code: nil, error_description: nil)
37
+ @status = status; @error_code = error_code; @error_description = error_description
38
+ super(build_message)
39
+ end
40
+
41
+ private
42
+
43
+ def operation_label
44
+ raise NotImplementedError, "#{self.class} must define #operation_label"
45
+ end
46
+
47
+ def build_message
48
+ parts = [operation_label]
49
+ parts << "HTTP #{@status}" if @status
50
+ parts << @error_code if @error_code
51
+ parts << @error_description if @error_description
52
+ parts.join(' — ')
53
+ end
54
+ end
55
+
56
+ module ReceivesFields
57
+ def self.included(base) = base.attr_reader :received_fields
58
+ def initialize(received_fields: nil, **) = (@received_fields = received_fields; super(**))
59
+ end
60
+
61
+ class TokenError < OAuthError; include ReceivesFields; ... end
62
+ class AuthError < OAuthError; ... end
63
+ class RegistrationError < OAuthError; include ReceivesFields; ... end
64
+ ```
65
+
66
+ Each subclass defines only `operation_label` and, when needed, overrides `build_message` for the structural path.
67
+
68
+ ---
69
+
70
+ ## Consequences
71
+
72
+ **Benefits:**
73
+ - DRY: each concrete error class is a handful of lines; the shared attributes and message format are defined once
74
+ - Consistent error messages across all three operations; callers and log parsers see the same structure
75
+ - `ReceivesFields` is `@api private` — callers interact only with `TokenError` and `RegistrationError`; the mixin is an implementation detail
76
+ - Adding a new OAuth-style error (e.g. for a future introspection endpoint) requires only a new subclass with `operation_label`
77
+
78
+ **Trade-offs:**
79
+ - The `operation_label` template method raises `NotImplementedError` at runtime if a subclass forgets to define it; a compile-time check is not possible in Ruby
80
+ - `ReceivesFields` modifies the constructor via `super(**)` forwarding, which requires care when the inheritance chain has multiple `initialize` overrides