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.
- checksums.yaml +4 -4
- data/.claude/skills/release-safire/SKILL.md +151 -0
- data/CHANGELOG.md +72 -1
- data/Gemfile +1 -0
- data/Gemfile.lock +9 -7
- data/README.md +40 -7
- data/ROADMAP.md +4 -7
- data/docs/Gemfile.lock +2 -2
- data/docs/_config.yml +4 -1
- data/docs/adr/ADR-002-facade-and-forwardable.md +1 -1
- data/docs/adr/ADR-003-protocol-vs-client-type.md +1 -1
- data/docs/adr/ADR-006-lazy-discovery.md +1 -1
- data/docs/adr/ADR-008-warn-return-false-for-compliance-validation.md +4 -3
- 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 +13 -7
- data/docs/configuration/index.md +3 -3
- data/docs/index.md +6 -4
- data/docs/installation.md +9 -5
- data/docs/smart-on-fhir/backend-services/index.md +92 -0
- data/docs/smart-on-fhir/backend-services/token-request.md +207 -0
- data/docs/smart-on-fhir/confidential-asymmetric/authorization.md +1 -1
- data/docs/smart-on-fhir/confidential-asymmetric/index.md +8 -5
- data/docs/smart-on-fhir/confidential-asymmetric/token-exchange.md +1 -1
- data/docs/smart-on-fhir/confidential-symmetric/authorization.md +1 -1
- data/docs/smart-on-fhir/confidential-symmetric/index.md +3 -3
- data/docs/smart-on-fhir/confidential-symmetric/token-exchange.md +1 -1
- data/docs/smart-on-fhir/discovery/capability-checks.md +32 -1
- data/docs/smart-on-fhir/discovery/index.md +3 -3
- data/docs/smart-on-fhir/discovery/metadata.md +1 -1
- 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 +23 -13
- data/docs/smart-on-fhir/post-based-authorization.md +2 -2
- data/docs/smart-on-fhir/public-client/authorization.md +1 -1
- data/docs/smart-on-fhir/public-client/index.md +3 -3
- data/docs/smart-on-fhir/public-client/token-exchange.md +1 -1
- data/docs/troubleshooting/auth-errors.md +24 -1
- data/docs/troubleshooting/client-errors.md +72 -0
- data/docs/troubleshooting/index.md +4 -3
- data/docs/udap.md +5 -3
- data/lib/safire/client.rb +58 -20
- data/lib/safire/client_config.rb +12 -28
- data/lib/safire/errors.rb +89 -38
- data/lib/safire/jwt_assertion.rb +1 -1
- data/lib/safire/protocols/behaviours.rb +6 -0
- data/lib/safire/protocols/smart.rb +212 -44
- data/lib/safire/protocols/smart_metadata.rb +17 -2
- data/lib/safire/uri_validation.rb +34 -0
- data/lib/safire/version.rb +1 -1
- data/lib/safire.rb +1 -0
- data/safire.gemspec +6 -2
- metadata +16 -3
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,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
|
|
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
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)
|
|
@@ -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.
|
|
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.
|
|
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.
|
|
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 (
|
|
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.
|
|
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.
|
|
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
|
[](https://codecov.io/gh/vanessuniq/safire)
|
|
6
6
|
[](https://vanessuniq.github.io/safire)
|
|
7
7
|
|
|
8
|
-
Safire is a
|
|
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
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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: "
|
|
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
|