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.
- checksums.yaml +4 -4
- data/.claude/skills/release-safire/SKILL.md +151 -0
- data/CHANGELOG.md +33 -0
- data/Gemfile.lock +7 -7
- data/README.md +2 -1
- data/ROADMAP.md +2 -5
- data/docs/adr/ADR-009-oauth-error-hierarchy.md +80 -0
- data/docs/adr/ADR-010-optional-client-id-dcr-temp-client.md +60 -0
- data/docs/adr/index.md +3 -1
- data/docs/advanced.md +22 -25
- data/docs/configuration/client-setup.md +4 -0
- data/docs/configuration/index.md +3 -3
- data/docs/installation.md +1 -0
- data/docs/smart-on-fhir/backend-services/index.md +2 -2
- data/docs/smart-on-fhir/backend-services/token-request.md +1 -1
- data/docs/smart-on-fhir/confidential-asymmetric/index.md +2 -2
- data/docs/smart-on-fhir/confidential-symmetric/index.md +1 -1
- data/docs/smart-on-fhir/discovery/capability-checks.md +7 -0
- data/docs/smart-on-fhir/dynamic-client-registration/index.md +103 -0
- data/docs/smart-on-fhir/dynamic-client-registration/registration.md +160 -0
- data/docs/smart-on-fhir/dynamic-client-registration/response.md +152 -0
- data/docs/smart-on-fhir/index.md +2 -1
- data/docs/smart-on-fhir/post-based-authorization.md +1 -1
- data/docs/smart-on-fhir/public-client/index.md +1 -1
- data/docs/troubleshooting/auth-errors.md +20 -0
- data/docs/troubleshooting/client-errors.md +42 -0
- data/docs/troubleshooting/index.md +3 -2
- data/docs/udap.md +1 -0
- data/lib/safire/client.rb +30 -1
- data/lib/safire/client_config.rb +8 -24
- data/lib/safire/errors.rb +89 -38
- data/lib/safire/protocols/smart.rb +120 -27
- data/lib/safire/protocols/smart_metadata.rb +6 -0
- data/lib/safire/uri_validation.rb +34 -0
- data/lib/safire/version.rb +1 -1
- data/lib/safire.rb +1 -0
- metadata +8 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e2907e363c56d115aedcc6b5bb0aa55a1780757a7a9fc8dfc3f8853262c4c926
|
|
4
|
+
data.tar.gz: 2cf29245a45cb4ad067418a1a23ad2c3bcddac91c3bb672a0b2b6901391af761
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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 (
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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[
|
|
146
|
-
session[:refresh_token] = response[
|
|
147
|
-
session[:token_expires_at] = Time.current + response[
|
|
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[
|
|
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
|
|
182
|
-
merged
|
|
183
|
-
client.authorization_url(
|
|
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
|
-
|
|
211
|
-
session[:
|
|
212
|
-
session[: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
|
-
|
|
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
|
-
|
|
227
|
-
pkce_verifier: session.delete(:pkce_verifier)
|
|
224
|
+
code_verifier: session.delete(:code_verifier)
|
|
228
225
|
)
|
|
229
226
|
|
|
230
|
-
session[:access_token] = token_response[
|
|
231
|
-
session[:refresh_token] = token_response[
|
|
232
|
-
session[:token_expires_at] = Time.current + token_response[
|
|
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
|
data/docs/configuration/index.md
CHANGED
|
@@ -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 |
|
|
45
|
-
| `redirect_uri` | String |
|
|
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,7 +2,7 @@
|
|
|
2
2
|
layout: default
|
|
3
3
|
title: Backend Services Workflow
|
|
4
4
|
parent: SMART
|
|
5
|
-
nav_order:
|
|
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
|
|
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:
|
|
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
|
|
|
@@ -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?`)
|