safire 0.2.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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/release-safire/SKILL.md +151 -0
  3. data/CHANGELOG.md +33 -0
  4. data/Gemfile.lock +7 -7
  5. data/README.md +2 -1
  6. data/ROADMAP.md +2 -5
  7. data/docs/adr/ADR-009-oauth-error-hierarchy.md +80 -0
  8. data/docs/adr/ADR-010-optional-client-id-dcr-temp-client.md +60 -0
  9. data/docs/adr/index.md +3 -1
  10. data/docs/advanced.md +22 -25
  11. data/docs/configuration/client-setup.md +4 -0
  12. data/docs/configuration/index.md +3 -3
  13. data/docs/installation.md +1 -0
  14. data/docs/smart-on-fhir/backend-services/index.md +2 -2
  15. data/docs/smart-on-fhir/backend-services/token-request.md +1 -1
  16. data/docs/smart-on-fhir/confidential-asymmetric/index.md +2 -2
  17. data/docs/smart-on-fhir/confidential-symmetric/index.md +1 -1
  18. data/docs/smart-on-fhir/discovery/capability-checks.md +7 -0
  19. data/docs/smart-on-fhir/dynamic-client-registration/index.md +103 -0
  20. data/docs/smart-on-fhir/dynamic-client-registration/registration.md +160 -0
  21. data/docs/smart-on-fhir/dynamic-client-registration/response.md +152 -0
  22. data/docs/smart-on-fhir/index.md +2 -1
  23. data/docs/smart-on-fhir/post-based-authorization.md +1 -1
  24. data/docs/smart-on-fhir/public-client/index.md +1 -1
  25. data/docs/troubleshooting/auth-errors.md +20 -0
  26. data/docs/troubleshooting/client-errors.md +42 -0
  27. data/docs/troubleshooting/index.md +3 -2
  28. data/docs/udap.md +1 -0
  29. data/lib/safire/client.rb +30 -1
  30. data/lib/safire/client_config.rb +8 -24
  31. data/lib/safire/errors.rb +89 -38
  32. data/lib/safire/protocols/smart.rb +120 -27
  33. data/lib/safire/protocols/smart_metadata.rb +6 -0
  34. data/lib/safire/uri_validation.rb +34 -0
  35. data/lib/safire/version.rb +1 -1
  36. data/lib/safire.rb +1 -0
  37. metadata +8 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b9d2b84cc65eff523a4f1a8c86c5c2fe4f2089917062c115a12fb840e52369b4
4
- data.tar.gz: 0f9cb348d913947546dff15f2d5d7a7904ca62dfdbce01dd8117598811ce5ec9
3
+ metadata.gz: e2907e363c56d115aedcc6b5bb0aa55a1780757a7a9fc8dfc3f8853262c4c926
4
+ data.tar.gz: 2cf29245a45cb4ad067418a1a23ad2c3bcddac91c3bb672a0b2b6901391af761
5
5
  SHA512:
6
- metadata.gz: 955b05bf392bebc9202d4b4c623e7223a398a969e72a2ed399bf0d25b57436cf3b2bbc086c3cdc2902f22742c36139089ec59d0389f4708185920746575134b3
7
- data.tar.gz: 146ef00d7041a656c401e580e24cd309901b28a9b0584b37074f7067a2c76fe2f6548369feaa7692940af3aafc6fd3c36d045623e835b0bfdb33c0b468350d07
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,6 +7,39 @@ 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
+
10
43
  ## [0.2.0] - 2026-04-04
11
44
 
12
45
  ### Added
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- safire (0.2.0)
4
+ safire (0.3.0)
5
5
  activesupport (~> 8.0.0)
6
6
  addressable (~> 2.8)
7
7
  faraday (~> 2.14)
@@ -66,7 +66,7 @@ GEM
66
66
  pp (>= 0.6.0)
67
67
  rdoc (>= 4.0.0)
68
68
  reline (>= 0.4.2)
69
- json (2.19.2)
69
+ json (2.19.3)
70
70
  jwt (2.10.2)
71
71
  base64
72
72
  language_server-protocol (3.17.0.5)
@@ -78,7 +78,7 @@ GEM
78
78
  net-http (0.9.1)
79
79
  uri (>= 0.11.1)
80
80
  parallel (1.27.0)
81
- parser (3.3.10.2)
81
+ parser (3.3.11.1)
82
82
  ast (~> 2.4.1)
83
83
  racc
84
84
  pp (0.6.3)
@@ -118,11 +118,11 @@ GEM
118
118
  diff-lcs (>= 1.2.0, < 2.0)
119
119
  rspec-support (~> 3.13.0)
120
120
  rspec-support (3.13.6)
121
- rubocop (1.86.0)
121
+ rubocop (1.86.1)
122
122
  json (~> 2.3)
123
123
  language_server-protocol (~> 3.17.0.2)
124
124
  lint_roller (~> 1.1.0)
125
- parallel (~> 1.10)
125
+ parallel (>= 1.10)
126
126
  parser (>= 3.3.0.2)
127
127
  rainbow (>= 2.2.2, < 4.0)
128
128
  regexp_parser (>= 2.9.3, < 3.0)
@@ -148,7 +148,7 @@ GEM
148
148
  simplecov_json_formatter (0.1.4)
149
149
  stringio (3.1.9)
150
150
  thor (1.4.0)
151
- timecop (0.9.10)
151
+ timecop (0.9.11)
152
152
  tzinfo (2.0.6)
153
153
  concurrent-ruby (~> 1.0)
154
154
  unicode-display_width (3.2.0)
@@ -159,7 +159,7 @@ GEM
159
159
  addressable (>= 2.8.0)
160
160
  crack (>= 0.3.2)
161
161
  hashdiff (>= 0.4.0, < 2.0.0)
162
- yard (0.9.38)
162
+ yard (0.9.41)
163
163
 
164
164
  PLATFORMS
165
165
  ruby
data/README.md CHANGED
@@ -13,6 +13,7 @@ Safire is a Ruby gem implementing the [SMART App Launch 2.2.0](https://hl7.org/f
13
13
 
14
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)
@@ -154,7 +155,7 @@ bin/demo
154
155
  # Visit http://localhost:4567
155
156
  ```
156
157
 
157
- Demonstrates 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.
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.
158
159
 
159
160
  ---
160
161
 
data/ROADMAP.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Safire Roadmap
2
2
 
3
- ## Current Release — v0.1.0
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
 
@@ -20,15 +20,12 @@ Feedback, bug reports, and pull requests are welcome via the [issue tracker](htt
20
20
  - **JWT Assertion Builder** — signed JWT assertions with configurable `kid` and expiry
21
21
  - **PKCE** — automatic code verifier and challenge generation
22
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
23
24
 
24
25
  ---
25
26
 
26
27
  ## Planned Features
27
28
 
28
- ### SMART App Launch
29
-
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
@@ -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
@@ -0,0 +1,60 @@
1
+ ---
2
+ layout: default
3
+ title: "ADR-010: client_id optional at initialization — deferred validation for the DCR temp-client pattern"
4
+ parent: Architecture Decision Records
5
+ nav_order: 10
6
+ ---
7
+
8
+ # ADR-010: client_id optional at initialization — deferred validation for the DCR temp-client pattern
9
+
10
+ **Status:** Accepted
11
+
12
+ ---
13
+
14
+ ## Context
15
+
16
+ RFC 7591 Dynamic Client Registration requires calling the authorization server's registration endpoint before a `client_id` exists. The natural usage pattern is:
17
+
18
+ 1. Create a temporary `Safire::Client` with only `base_url` (no `client_id` yet)
19
+ 2. Call `register_client` to POST metadata and receive `client_id` from the server
20
+ 3. Build a properly configured client with the returned `client_id` for subsequent authorization flows
21
+
22
+ 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.
23
+
24
+ **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`.
25
+
26
+ **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.
27
+
28
+ ---
29
+
30
+ ## Decision
31
+
32
+ Option B — `client_id` is optional at `ClientConfig` and `Client` initialization.
33
+
34
+ Construction succeeds with only `base_url`:
35
+
36
+ ```ruby
37
+ temp_client = Safire::Client.new({ base_url: 'https://fhir.example.com' })
38
+ registration = temp_client.register_client({ client_name: 'My App', ... })
39
+
40
+ client = Safire::Client.new({
41
+ base_url: 'https://fhir.example.com',
42
+ client_id: registration['client_id'],
43
+ ...
44
+ })
45
+ ```
46
+
47
+ 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.
48
+
49
+ ---
50
+
51
+ ## Consequences
52
+
53
+ **Benefits:**
54
+ - The temp-client pattern works with the existing `Safire::Client` class; no new class is needed
55
+ - No proliferation of client variants; the public API surface stays small
56
+ - Consistent with how `token_endpoint` and `authorization_endpoint` are handled: both are optional at construction and resolved via lazy discovery when needed
57
+
58
+ **Trade-offs:**
59
+ - A misconfigured client (missing `client_id`) fails at call time rather than construction time; callers may see the error later than expected
60
+ - This is an intentional trade-off: `client_id` is now in the same category as other lazily-resolved attributes, so the surprise of deferred validation is mitigated by the established pattern
data/docs/adr/index.md CHANGED
@@ -19,4 +19,6 @@ Architecture Decision Records (ADRs) document significant design decisions made
19
19
  | [ADR-005]({% link adr/ADR-005-per-client-http-ownership.md %}) | Per-client `HTTPClient` ownership — no shared connection pool | Accepted |
20
20
  | [ADR-006]({% link adr/ADR-006-lazy-discovery.md %}) | Lazy SMART discovery — no HTTP in constructors | Accepted |
21
21
  | [ADR-007]({% link adr/ADR-007-https-only-redirects-and-localhost-exception.md %}) | HTTPS-only redirect enforcement and localhost exception | Accepted |
22
- | [ADR-008]({% link adr/ADR-008-warn-return-false-for-compliance-validation.md %}) | Warn and return false for compliance validation — raise only for configuration errors | Accepted |
22
+ | [ADR-008]({% link adr/ADR-008-warn-return-false-for-compliance-validation.md %}) | Warn and return false for compliance validation — raise only for configuration errors | Accepted |
23
+ | [ADR-009]({% link adr/ADR-009-oauth-error-hierarchy.md %}) | `OAuthError` base class and `ReceivesFields` mixin for protocol error hierarchy | Accepted |
24
+ | [ADR-010]({% link adr/ADR-010-optional-client-id-dcr-temp-client.md %}) | `client_id` optional at initialization — deferred validation for the DCR temp-client pattern | Accepted |
data/docs/advanced.md CHANGED
@@ -32,14 +32,7 @@ class SmartMetadataService
32
32
 
33
33
  def self.fetch(base_url)
34
34
  Rails.cache.fetch("smart_metadata:#{base_url}", expires_in: CACHE_TTL) do
35
- config = Safire::ClientConfig.new(
36
- base_url: base_url,
37
- client_id: 'discovery_only',
38
- redirect_uri: 'https://example.com',
39
- scopes: []
40
- )
41
- client = Safire::Client.new(config)
42
- client.server_metadata.to_hash
35
+ Safire::Client.new({ base_url: base_url }).server_metadata.to_hash
43
36
  end
44
37
  end
45
38
 
@@ -142,11 +135,11 @@ class TokenManager
142
135
  token_params = { refresh_token: session[:refresh_token] }
143
136
  response = client.refresh_token(token_params)
144
137
 
145
- session[:access_token] = response[:access_token]
146
- session[:refresh_token] = response[:refresh_token] || session[:refresh_token]
147
- session[:token_expires_at] = Time.current + response[:expires_in].to_i.seconds
138
+ session[:access_token] = response['access_token']
139
+ session[:refresh_token] = response['refresh_token'] || session[:refresh_token]
140
+ session[:token_expires_at] = Time.current + response['expires_in'].to_i.seconds
148
141
 
149
- response[:access_token]
142
+ response['access_token']
150
143
  end
151
144
  end
152
145
  ```
@@ -178,9 +171,9 @@ Override the default scopes for specific actions without reconfiguring the clien
178
171
 
179
172
  ```ruby
180
173
  def launch_with_scopes(client, extra_scopes: [])
181
- base_scopes = ['openid', 'profile', 'patient/*.read']
182
- merged = (base_scopes + extra_scopes).uniq
183
- client.authorization_url(scope_override: merged)
174
+ base_scopes = ['openid', 'profile', 'patient/*.read']
175
+ merged = (base_scopes + extra_scopes).uniq
176
+ client.authorization_url(custom_scopes: merged)
184
177
  end
185
178
 
186
179
  # Requesting additional write access for a specific workflow
@@ -207,11 +200,11 @@ class SmartAuthController < ApplicationController
207
200
 
208
201
  # Step 1 — Redirect user to the authorization server
209
202
  def launch
210
- auth_url = @client.authorization_url
211
- session[:pkce_verifier] = @client.code_verifier
212
- session[:state] = @client.state
203
+ auth_data = @client.authorization_url
204
+ session[:code_verifier] = auth_data[:code_verifier]
205
+ session[:state] = auth_data[:state]
213
206
 
214
- redirect_to auth_url, allow_other_host: true
207
+ redirect_to auth_data[:auth_url], allow_other_host: true
215
208
  end
216
209
 
217
210
  # Step 2 — Handle the authorization server callback
@@ -221,15 +214,19 @@ class SmartAuthController < ApplicationController
221
214
  return
222
215
  end
223
216
 
224
- token_response = @client.exchange_code_for_token(
217
+ unless params[:state] == session.delete(:state)
218
+ redirect_to root_path, alert: 'Invalid state parameter'
219
+ return
220
+ end
221
+
222
+ token_response = @client.request_access_token(
225
223
  code: params[:code],
226
- state: params[:state],
227
- pkce_verifier: session.delete(:pkce_verifier)
224
+ code_verifier: session.delete(:code_verifier)
228
225
  )
229
226
 
230
- session[:access_token] = token_response[:access_token]
231
- session[:refresh_token] = token_response[:refresh_token]
232
- session[:token_expires_at] = Time.current + token_response[:expires_in].to_i.seconds
227
+ session[:access_token] = token_response['access_token']
228
+ session[:refresh_token] = token_response['refresh_token']
229
+ session[:token_expires_at] = Time.current + token_response['expires_in'].to_i.seconds
233
230
 
234
231
  redirect_to dashboard_path
235
232
  end
@@ -45,6 +45,9 @@ config = Safire::ClientConfig.new(
45
45
  client = Safire::Client.new(config)
46
46
  ```
47
47
 
48
+ {: .note }
49
+ > `client_id` is the only authorization parameter validated at call time rather than at construction. `authorization_url`, `request_access_token`, `refresh_token`, and `request_backend_token` each raise `Safire::Errors::ConfigurationError` if `client_id` is absent when called. This means you can build a client without a `client_id` and call `register_client` to obtain one at runtime. See [Dynamic Client Registration]({{ site.baseurl }}/smart-on-fhir/dynamic-client-registration/) for details.
50
+
48
51
  ---
49
52
 
50
53
  ## Protocol and Client Type
@@ -157,4 +160,5 @@ config.inspect
157
160
  ## Next Steps
158
161
 
159
162
  - [Logging]({{ site.baseurl }}/configuration/logging/) — configure Safire's logger and HTTP request logging
163
+ - [Dynamic Client Registration]({{ site.baseurl }}/smart-on-fhir/dynamic-client-registration/) — obtain a `client_id` at runtime using RFC 7591
160
164
  - [SMART App Launch Workflows]({{ site.baseurl }}/smart-on-fhir/) — step-by-step authorization flow guides
@@ -41,12 +41,12 @@ flowchart TD
41
41
  | Parameter | Type | Required | Default | Description |
42
42
  |-----------|------|----------|---------|-------------|
43
43
  | `base_url` | String | Yes | — | FHIR server base URL |
44
- | `client_id` | String | Yes | — | OAuth2 client identifier |
45
- | `redirect_uri` | String | Yes | — | Registered callback URL |
44
+ | `client_id` | String | No | — | OAuth2 client identifier — required by all authorization flows; validated at call time, not at construction |
45
+ | `redirect_uri` | String | No | — | Registered callback URL — required for App Launch flows; not used in Backend Services |
46
46
  | `protocol:` | Symbol | No | `:smart` | Authorization protocol — `:smart` or `:udap` |
47
47
  | `client_type:` | Symbol | No | `:public` | SMART client type — `:public`, `:confidential_symmetric`, or `:confidential_asymmetric` |
48
48
  | `client_secret` | String | No | — | Required for `:confidential_symmetric` |
49
- | `private_key` | OpenSSL::PKey / String | No | — | RSA/EC private key; required for `:confidential_asymmetric` |
49
+ | `private_key` | OpenSSL::PKey / String | No | — | RSA/EC private key; required for `:confidential_asymmetric` and Backend Services |
50
50
  | `kid` | String | No | — | Key ID matching the public key registered with the server |
51
51
  | `jwt_algorithm` | String | No | auto | `RS384` or `ES384`; auto-detected from key type |
52
52
  | `jwks_uri` | String | No | — | URL to client's public JWKS, included as `jku` in JWT header |
data/docs/installation.md CHANGED
@@ -2,6 +2,7 @@
2
2
  layout: default
3
3
  title: Installation
4
4
  nav_order: 2
5
+ permalink: /installation/
5
6
  description: "How to install the Safire Ruby gem and get started with SMART App Launch and UDAP authorization in your healthcare application."
6
7
  ---
7
8
 
@@ -2,7 +2,7 @@
2
2
  layout: default
3
3
  title: Backend Services Workflow
4
4
  parent: SMART
5
- nav_order: 6
5
+ nav_order: 7
6
6
  has_children: true
7
7
  permalink: /smart-on-fhir/backend-services/
8
8
  ---
@@ -56,7 +56,7 @@ Suitable for:
56
56
 
57
57
  ### Client Registration
58
58
 
59
- Before making any token requests, the client **SHALL** register with the authorization server following the [confidential asymmetric client registration](https://hl7.org/fhir/smart-app-launch/client-confidential-asymmetric.html#registering-a-client-communicating-public-keys) steps defined in the SMART App Launch specification. Registration communicates the client's public key(s) to the server, either via a JWKS URI or by uploading the JWKS directly.
59
+ Before making any token requests, the client **SHALL** register with the authorization server following the [confidential asymmetric client registration](https://hl7.org/fhir/smart-app-launch/client-confidential-asymmetric.html#registering-a-client-communicating-public-keys) steps defined in the SMART App Launch specification. Registration communicates the client's public key(s) to the server, either via a JWKS URI or by uploading the JWKS directly. If the server supports RFC 7591, you can automate this step with Safire's `register_client` — see the [Dynamic Client Registration]({% link smart-on-fhir/dynamic-client-registration/index.md %}) guide.
60
60
 
61
61
  ### Key Pair and JWKS
62
62
 
@@ -117,7 +117,7 @@ end
117
117
  begin
118
118
  token_data = client.request_backend_token
119
119
  rescue Safire::Errors::ConfigurationError => e
120
- # private_key or kid missing from config and not passed at call time
120
+ # client_id, private_key, or kid missing only private_key and kid can be overridden at call time
121
121
  Rails.logger.error("Backend services misconfigured: #{e.message}")
122
122
  raise
123
123
  rescue Safire::Errors::TokenError => e
@@ -2,7 +2,7 @@
2
2
  layout: default
3
3
  title: Confidential Asymmetric Client Workflow
4
4
  parent: SMART
5
- nav_order: 4
5
+ nav_order: 5
6
6
  has_children: true
7
7
  permalink: /smart-on-fhir/confidential-asymmetric/
8
8
  ---
@@ -50,7 +50,7 @@ Suitable for:
50
50
 
51
51
  ## Prerequisites: Keys, JWKS, and Algorithm
52
52
 
53
- Before writing any flow code, you need a key pair, a key ID, and a way for the authorization server to verify your public key.
53
+ Before writing any flow code, you need a key pair, a key ID, and a way for the authorization server to verify your public key. If the server supports RFC 7591, you can register dynamically using Safire's `register_client` — see the [Dynamic Client Registration]({% link smart-on-fhir/dynamic-client-registration/index.md %}) guide.
54
54
 
55
55
  ### Generating a Key Pair
56
56
 
@@ -2,7 +2,7 @@
2
2
  layout: default
3
3
  title: Confidential Symmetric Client Workflow
4
4
  parent: SMART
5
- nav_order: 3
5
+ nav_order: 4
6
6
  has_children: true
7
7
  permalink: /smart-on-fhir/confidential-symmetric/
8
8
  ---
@@ -53,6 +53,10 @@ metadata.supports_openid_connect?
53
53
  metadata.supports_post_based_authorization?
54
54
  # => true if capabilities include "authorize-post"
55
55
 
56
+ # Dynamic Client Registration (RFC 7591)
57
+ metadata.supports_dynamic_registration?
58
+ # => true if registration_endpoint is present in the server's SMART metadata
59
+
56
60
  # Backend Services (client_credentials grant, no user interaction)
57
61
  metadata.supports_backend_services?
58
62
  # => true if:
@@ -63,6 +67,9 @@ metadata.supports_backend_services?
63
67
  {: .note }
64
68
  > `supports_backend_services?` checks `grant_types_supported` rather than a capability flag — it combines a grant type check with `supports_asymmetric_auth?` because the SMART Backend Services flow always authenticates via JWT assertion (`private_key_jwt`).
65
69
 
70
+ {: .note }
71
+ > `supports_dynamic_registration?` checks only for the presence of `registration_endpoint` in the server's SMART metadata. SMART App Launch 2.2.0 defines no dedicated capability string for DCR, so the endpoint field is the sole signal.
72
+
66
73
  ---
67
74
 
68
75
  ## Flag-Only Checks (`*_capability?`)