safire 0.2.0 → 0.4.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 (73) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/release-safire/SKILL.md +153 -0
  3. data/.rubocop.yml +11 -1
  4. data/.tool-versions +1 -1
  5. data/CHANGELOG.md +107 -1
  6. data/CONTRIBUTION.md +6 -1
  7. data/Gemfile +1 -2
  8. data/Gemfile.lock +47 -37
  9. data/README.md +60 -5
  10. data/ROADMAP.md +61 -12
  11. data/docs/Gemfile.lock +49 -45
  12. data/docs/_config.yml +2 -2
  13. data/docs/adr/ADR-001-activesupport-dependency.md +2 -2
  14. data/docs/adr/ADR-002-facade-and-forwardable.md +21 -6
  15. data/docs/adr/ADR-003-protocol-vs-client-type.md +13 -6
  16. data/docs/adr/ADR-004-clientconfig-immutability-and-entity-masking.md +34 -8
  17. data/docs/adr/ADR-006-lazy-discovery.md +64 -5
  18. data/docs/adr/ADR-007-https-only-redirects-and-localhost-exception.md +40 -10
  19. data/docs/adr/ADR-009-oauth-error-hierarchy.md +131 -0
  20. data/docs/adr/ADR-010-optional-client-id-dcr-temp-client.md +90 -0
  21. data/docs/adr/ADR-011-udap-stu2-discovery-conformance.md +113 -0
  22. data/docs/adr/ADR-012-udap-signed-metadata-validation.md +104 -0
  23. data/docs/adr/ADR-013-udap-registration-request-model.md +106 -0
  24. data/docs/adr/ADR-014-udap-software-statement-signing.md +122 -0
  25. data/docs/adr/index.md +9 -3
  26. data/docs/advanced.md +22 -25
  27. data/docs/configuration/client-setup.md +126 -10
  28. data/docs/configuration/index.md +11 -7
  29. data/docs/index.md +13 -6
  30. data/docs/installation.md +3 -2
  31. data/docs/security.md +44 -5
  32. data/docs/smart-on-fhir/backend-services/index.md +2 -2
  33. data/docs/smart-on-fhir/backend-services/token-request.md +1 -1
  34. data/docs/smart-on-fhir/confidential-asymmetric/index.md +2 -2
  35. data/docs/smart-on-fhir/confidential-symmetric/index.md +1 -1
  36. data/docs/smart-on-fhir/discovery/capability-checks.md +7 -0
  37. data/docs/smart-on-fhir/dynamic-client-registration/index.md +103 -0
  38. data/docs/smart-on-fhir/dynamic-client-registration/registration.md +160 -0
  39. data/docs/smart-on-fhir/dynamic-client-registration/response.md +161 -0
  40. data/docs/smart-on-fhir/index.md +2 -1
  41. data/docs/smart-on-fhir/post-based-authorization.md +1 -1
  42. data/docs/smart-on-fhir/public-client/index.md +1 -1
  43. data/docs/troubleshooting/auth-errors.md +20 -0
  44. data/docs/troubleshooting/client-errors.md +143 -0
  45. data/docs/troubleshooting/index.md +61 -4
  46. data/docs/udap/dynamic-client-registration/index.md +198 -0
  47. data/docs/udap/dynamic-client-registration/lifecycle.md +115 -0
  48. data/docs/udap/dynamic-client-registration/registration-metadata.md +133 -0
  49. data/docs/udap/dynamic-client-registration/software-statement.md +115 -0
  50. data/docs/udap.md +187 -64
  51. data/gemfiles/activesupport_71.gemfile +30 -0
  52. data/gemfiles/activesupport_71.gemfile.lock +291 -0
  53. data/lib/safire/client.rb +141 -44
  54. data/lib/safire/client_config.rb +114 -35
  55. data/lib/safire/client_config_builder.rb +18 -0
  56. data/lib/safire/errors.rb +100 -44
  57. data/lib/safire/http_client.rb +7 -2
  58. data/lib/safire/middleware/https_only_redirects.rb +10 -4
  59. data/lib/safire/protocols/behaviours.rb +12 -1
  60. data/lib/safire/protocols/oauth_response_handling.rb +48 -0
  61. data/lib/safire/protocols/smart.rb +108 -36
  62. data/lib/safire/protocols/smart_metadata.rb +6 -0
  63. data/lib/safire/protocols/udap.rb +534 -0
  64. data/lib/safire/protocols/udap_metadata.rb +429 -0
  65. data/lib/safire/protocols/udap_registration_metadata.rb +368 -0
  66. data/lib/safire/protocols/udap_signed_metadata_validator.rb +349 -0
  67. data/lib/safire/protocols/udap_software_statement.rb +348 -0
  68. data/lib/safire/protocols.rb +6 -0
  69. data/lib/safire/uri_validation.rb +102 -0
  70. data/lib/safire/version.rb +1 -1
  71. data/lib/safire.rb +2 -0
  72. data/safire.gemspec +7 -8
  73. metadata +42 -13
data/ROADMAP.md CHANGED
@@ -1,9 +1,12 @@
1
1
  # Safire Roadmap
2
2
 
3
- ## Current Release — v0.1.0
3
+ ## Latest Published Release — v0.4.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
 
7
+ The `main` branch may include features not yet published to RubyGems; see the
8
+ [Unreleased section of CHANGELOG.md](CHANGELOG.md#unreleased).
9
+
7
10
  Feedback, bug reports, and pull requests are welcome via the [issue tracker](https://github.com/vanessuniq/safire/issues).
8
11
 
9
12
  ---
@@ -20,25 +23,71 @@ Feedback, bug reports, and pull requests are welcome via the [issue tracker](htt
20
23
  - **JWT Assertion Builder** — signed JWT assertions with configurable `kid` and expiry
21
24
  - **PKCE** — automatic code verifier and challenge generation
22
25
  - **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
26
+ - **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
27
+
28
+ ### UDAP Security (STU2 / v2.0.0)
29
+
30
+ - **UDAP Discovery** — lazy fetch of `/.well-known/udap`; optional
31
+ community-scoped discovery; STU2 metadata parsing plus explicit
32
+ `UdapMetadata#valid?` structural validation
33
+ - **Signed Metadata Validation** — validates `signed_metadata` JWTs using RS256,
34
+ JOSE `x5c`, certificate chain and revocation policy, required claims, and
35
+ signed endpoint claim precedence
36
+ - **Protocol-Aware Client Facade** — `Safire::Client.new(..., protocol: :udap)`
37
+ exposes UDAP discovery while rejecting SMART-only `client_type:` values
38
+ - **UDAP Dynamic Client Registration** — certificate-backed STU2 new
39
+ registration, modification, and cancellation via signed software statements;
40
+ includes metadata validation, signed endpoint precedence, community-scoped
41
+ discovery, certification envelope handling, RFC 7591-shaped registration
42
+ response parsing, and cancellation confirmation through an empty
43
+ `grant_types` response
44
+
45
+ ### Demo Application
46
+
47
+ - **Protocol-Aware Workflows** — Sinatra demo supports protocol-aware server
48
+ setup, SMART discovery, registration, authorization, and token workflows,
49
+ plus UDAP signed metadata discovery and the registration/cancellation
50
+ lifecycle
23
51
 
24
52
  ---
25
53
 
26
54
  ## Planned Features
27
55
 
28
- ### SMART App Launch
29
-
30
- - **Dynamic Client Registration** — programmatic client registration per [RFC 7591](https://www.rfc-editor.org/rfc/rfc7591)
31
-
32
56
  ### UDAP Security
33
57
 
34
- - **UDAP Discovery** — `/.well-known/udap` metadata fetch and validation
35
- - **UDAP Dynamic Client Registration** — signed software statements and client registration
36
58
  - **UDAP JWT Client Auth** — B2B and consumer-facing authorization flows
37
59
  - **Tiered OAuth** — identity chaining for multi-system access
38
60
 
39
61
  ### Quality and Compliance
40
62
 
41
- - **Inferno SMART App Launch STU2.2 Test Suite** — full passing run against the [Inferno Framework](https://inferno-framework.github.io/)
63
+ - **Inferno SMART App Launch STU 2.2 Test Suite** — compliance validation using
64
+ [Inferno](https://inferno-framework.github.io/) as a mock EHR authorization server,
65
+ with Safire's demo app acting as the SMART client. Delivered in two phases.
66
+
67
+ **Phase 1 — HTTP-only flows (no browser automation required)**
68
+ - Discovery (local conformance gate): Safire discovers Inferno's
69
+ `/.well-known/smart-configuration` and validates the parsed metadata via
70
+ `SmartMetadata#valid?`; this is a local parsing and conformance check,
71
+ not an Inferno-driven assertion
72
+ - Backend Services (Inferno-driven): JWT assertion construction, `client_credentials`
73
+ token request format, and token response validation against Inferno's mock token
74
+ endpoint
75
+ - Inferno test results published as a GitHub Actions artifact (static HTML report
76
+ generated from Inferno's JSON output)
77
+
78
+ **Phase 2 — Authorization code flows (browser automation via Capybara)**
79
+ - Standalone Patient Launch for all three client types: public (PKCE-only),
80
+ confidential symmetric (`client_secret_basic`), and confidential asymmetric
81
+ (`private_key_jwt`)
82
+ - EHR Launch: Inferno redirects the user agent to the demo app's `GET /launch`
83
+ endpoint with `iss` and `launch` as query parameters, exercising the EHR-initiated
84
+ authorization code flow as a separate Inferno test group
85
+ - Infrastructure: Docker Compose for a local Inferno instance, Capybara and headless
86
+ Chrome for OAuth consent screen automation
87
+
88
+ **Note on Client Registration:** Inferno's reference server documentation states that
89
+ there is currently no registration process and apps must use preconfigured client IDs,
90
+ so DCR is not covered under this Inferno-based plan.
42
91
 
43
92
  ---
44
93
 
@@ -46,9 +95,9 @@ Feedback, bug reports, and pull requests are welcome via the [issue tracker](htt
46
95
 
47
96
  | Component | Version |
48
97
  |-----------|---------|
49
- | Ruby | ≥ 4.0.2 |
50
- | ActiveSupport | ~> 8.0 |
51
- | Rails (optional) | 7.x, 8.x |
98
+ | Ruby | ≥ 3.2 |
99
+ | ActiveSupport | ≥ 7.1, < 9 |
100
+ | Rails (optional) | ≥ 7.1 |
52
101
  | SMART App Launch | 2.2.0 (STU2) |
53
- | UDAP Security | 1.0 (planned) |
102
+ | UDAP Security | 2.0.0 (STU2 discovery and Dynamic Client Registration lifecycle implemented; auth flows planned) |
54
103
  | FHIR | R4, R4B |
data/docs/Gemfile.lock CHANGED
@@ -1,25 +1,28 @@
1
1
  GEM
2
2
  remote: https://rubygems.org/
3
3
  specs:
4
- addressable (2.8.8)
4
+ addressable (2.9.0)
5
5
  public_suffix (>= 2.0.2, < 8.0)
6
6
  base64 (0.3.0)
7
- bigdecimal (4.1.1)
7
+ bigdecimal (4.1.2)
8
8
  colorator (1.1.0)
9
9
  concurrent-ruby (1.3.6)
10
- csv (3.3.5)
10
+ csv (3.3.6)
11
11
  em-websocket (0.5.3)
12
12
  eventmachine (>= 0.12.9)
13
13
  http_parser.rb (~> 0)
14
14
  eventmachine (1.2.7)
15
- ffi (1.17.3)
16
- ffi (1.17.3-arm-linux-gnu)
17
- ffi (1.17.3-arm-linux-musl)
18
- ffi (1.17.3-x86_64-linux-gnu)
15
+ ffi (1.17.4)
16
+ ffi (1.17.4-arm-linux-gnu)
17
+ ffi (1.17.4-arm-linux-musl)
18
+ ffi (1.17.4-x86_64-linux-gnu)
19
19
  forwardable-extended (2.6.0)
20
- google-protobuf (4.33.4)
20
+ google-protobuf (4.34.1)
21
21
  bigdecimal
22
- rake (>= 13)
22
+ rake (~> 13.3)
23
+ google-protobuf (4.34.1-x86_64-linux-gnu)
24
+ bigdecimal
25
+ rake (~> 13.3)
23
26
  http_parser.rb (0.8.1)
24
27
  i18n (1.14.8)
25
28
  concurrent-ruby (~> 1.0)
@@ -48,13 +51,13 @@ GEM
48
51
  jekyll (>= 3.7, < 5.0)
49
52
  jekyll-sass-converter (3.1.0)
50
53
  sass-embedded (~> 1.75)
51
- jekyll-seo-tag (2.8.0)
54
+ jekyll-seo-tag (2.9.0)
52
55
  jekyll (>= 3.8, < 5.0)
53
56
  jekyll-sitemap (1.4.0)
54
57
  jekyll (>= 3.7, < 5.0)
55
58
  jekyll-watch (2.2.1)
56
59
  listen (~> 3.0)
57
- json (2.18.0)
60
+ json (2.19.5)
58
61
  just-the-docs (0.12.0)
59
62
  jekyll (>= 3.8.5)
60
63
  jekyll-include-cache
@@ -73,34 +76,34 @@ GEM
73
76
  mercenary (0.4.0)
74
77
  pathutil (0.16.2)
75
78
  forwardable-extended (~> 2.6)
76
- public_suffix (7.0.2)
77
- rake (13.3.1)
79
+ public_suffix (7.0.5)
80
+ rake (13.4.2)
78
81
  rb-fsevent (0.11.2)
79
82
  rb-inotify (0.11.1)
80
83
  ffi (~> 1.0)
81
84
  rexml (3.4.4)
82
85
  rouge (4.7.0)
83
86
  safe_yaml (1.0.5)
84
- sass-embedded (1.97.3)
87
+ sass-embedded (1.99.0)
85
88
  google-protobuf (~> 4.31)
86
89
  rake (>= 13)
87
- sass-embedded (1.97.3-aarch64-linux-android)
90
+ sass-embedded (1.99.0-aarch64-linux-android)
88
91
  google-protobuf (~> 4.31)
89
- sass-embedded (1.97.3-arm-linux-androideabi)
92
+ sass-embedded (1.99.0-arm-linux-androideabi)
90
93
  google-protobuf (~> 4.31)
91
- sass-embedded (1.97.3-arm-linux-gnueabihf)
94
+ sass-embedded (1.99.0-arm-linux-gnueabihf)
92
95
  google-protobuf (~> 4.31)
93
- sass-embedded (1.97.3-arm-linux-musleabihf)
96
+ sass-embedded (1.99.0-arm-linux-musleabihf)
94
97
  google-protobuf (~> 4.31)
95
- sass-embedded (1.97.3-riscv64-linux-android)
98
+ sass-embedded (1.99.0-riscv64-linux-android)
96
99
  google-protobuf (~> 4.31)
97
- sass-embedded (1.97.3-riscv64-linux-gnu)
100
+ sass-embedded (1.99.0-riscv64-linux-gnu)
98
101
  google-protobuf (~> 4.31)
99
- sass-embedded (1.97.3-riscv64-linux-musl)
102
+ sass-embedded (1.99.0-riscv64-linux-musl)
100
103
  google-protobuf (~> 4.31)
101
- sass-embedded (1.97.3-x86_64-linux-android)
104
+ sass-embedded (1.99.0-x86_64-linux-android)
102
105
  google-protobuf (~> 4.31)
103
- sass-embedded (1.97.3-x86_64-linux-gnu)
106
+ sass-embedded (1.99.0-x86_64-linux-gnu)
104
107
  google-protobuf (~> 4.31)
105
108
  terminal-table (3.0.2)
106
109
  unicode-display_width (>= 1.1.1, < 3)
@@ -138,30 +141,31 @@ DEPENDENCIES
138
141
  webrick
139
142
 
140
143
  CHECKSUMS
141
- addressable (2.8.8) sha256=7c13b8f9536cf6364c03b9d417c19986019e28f7c00ac8132da4eb0fe393b057
144
+ addressable (2.9.0) sha256=7fdf6ac3660f7f4e867a0838be3f6cf722ace541dd97767fa42bc6cfa980c7af
142
145
  base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
143
- bigdecimal (4.1.1) sha256=1c09efab961da45203c8316b0cdaec0ff391dfadb952dd459584b63ebf8054ca
146
+ bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd
144
147
  colorator (1.1.0) sha256=e2f85daf57af47d740db2a32191d1bdfb0f6503a0dfbc8327d0c9154d5ddfc38
145
148
  concurrent-ruby (1.3.6) sha256=6b56837e1e7e5292f9864f34b69c5a2cbc75c0cf5338f1ce9903d10fa762d5ab
146
- csv (3.3.5) sha256=6e5134ac3383ef728b7f02725d9872934f523cb40b961479f69cf3afa6c8e73f
149
+ csv (3.3.6) sha256=aba61e7e507a66f03d45cb1f3c4b6359861c3504038b422962875dce099e4456
147
150
  em-websocket (0.5.3) sha256=f56a92bde4e6cb879256d58ee31f124181f68f8887bd14d53d5d9a292758c6a8
148
151
  eventmachine (1.2.7) sha256=994016e42aa041477ba9cff45cbe50de2047f25dd418eba003e84f0d16560972
149
- ffi (1.17.3) sha256=0e9f39f7bb3934f77ad6feab49662be77e87eedcdeb2a3f5c0234c2938563d4c
150
- ffi (1.17.3-arm-linux-gnu) sha256=5bd4cea83b68b5ec0037f99c57d5ce2dd5aa438f35decc5ef68a7d085c785668
151
- ffi (1.17.3-arm-linux-musl) sha256=0d7626bb96265f9af78afa33e267d71cfef9d9a8eb8f5525344f8da6c7d76053
152
- ffi (1.17.3-x86_64-linux-gnu) sha256=3746b01f677aae7b16dc1acb7cb3cc17b3e35bdae7676a3f568153fb0e2c887f
152
+ ffi (1.17.4) sha256=bcd1642e06f0d16fc9e09ac6d49c3a7298b9789bcb58127302f934e437d60acf
153
+ ffi (1.17.4-arm-linux-gnu) sha256=d6dbddf7cb77bf955411af5f187a65b8cd378cb003c15c05697f5feee1cb1564
154
+ ffi (1.17.4-arm-linux-musl) sha256=9d4838ded0465bef6e2426935f6bcc93134b6616785a84ffd2a3d82bc3cf6f95
155
+ ffi (1.17.4-x86_64-linux-gnu) sha256=9d3db14c2eae074b382fa9c083fe95aec6e0a1451da249eab096c34002bc752d
153
156
  forwardable-extended (2.6.0) sha256=1bec948c469bbddfadeb3bd90eb8c85f6e627a412a3e852acfd7eaedbac3ec97
154
- google-protobuf (4.33.4) sha256=86921935b023ed0d872d6e84382e79016c91689be0520d614c74426778f13c16
157
+ google-protobuf (4.34.1) sha256=347181542b8d659c60f028fa3791c9cccce651a91ad27782dbc5c5e374796cdc
158
+ google-protobuf (4.34.1-x86_64-linux-gnu) sha256=87088c9fd8e47b5b40ca498fc1195add6149e941ff7e81c532a5b0b8876d4cc9
155
159
  http_parser.rb (0.8.1) sha256=9ae8df145b39aa5398b2f90090d651c67bd8e2ebfe4507c966579f641e11097a
156
160
  i18n (1.14.8) sha256=285778639134865c5e0f6269e0b818256017e8cde89993fdfcbfb64d088824a5
157
161
  jekyll (4.4.1) sha256=4c1144d857a5b2b80d45b8cf5138289579a9f8136aadfa6dd684b31fe2bc18c1
158
162
  jekyll-feed (0.17.0) sha256=689aab16c877949bb9e7a5c436de6278318a51ecb974792232fd94d8b3acfcc3
159
163
  jekyll-include-cache (0.2.1) sha256=c7d4b9e551732a27442cb2ce853ba36a2f69c66603694b8c1184c99ab1a1a205
160
164
  jekyll-sass-converter (3.1.0) sha256=83925d84f1d134410c11d0c6643b0093e82e3a3cf127e90757a85294a3862443
161
- jekyll-seo-tag (2.8.0) sha256=3f2ed1916d56f14ebfa38e24acde9b7c946df70cb183af2cb5f0598f21ae6818
165
+ jekyll-seo-tag (2.9.0) sha256=0260015a8e1df9bf195cdfb0c675b7b2883fd8cbf12556e1c1cbe36a831c6852
162
166
  jekyll-sitemap (1.4.0) sha256=0de08c5debc185ea5a8f980e1025c7cd3f8e0c35c8b6ef592f15c46235cf4218
163
167
  jekyll-watch (2.2.1) sha256=bc44ed43f5e0a552836245a54dbff3ea7421ecc2856707e8a1ee203a8387a7e1
164
- json (2.18.0) sha256=b10506aee4183f5cf49e0efc48073d7b75843ce3782c68dbeb763351c08fd505
168
+ json (2.19.5) sha256=218a18553e4801d579ca7e0f5bc72bafd776d7397238a1fb4e74db5b0a812c59
165
169
  just-the-docs (0.12.0) sha256=15f2839ac9082898d60f33b978aa6f8e46fc50ba8fac20ae7a7f0e1fb295523e
166
170
  kramdown (2.5.2) sha256=1ba542204c66b6f9111ff00dcc26075b95b220b07f2905d8261740c82f7f02fa
167
171
  kramdown-parser-gfm (1.1.0) sha256=fb39745516427d2988543bf01fc4cf0ab1149476382393e0e9c48592f6581729
@@ -170,23 +174,23 @@ CHECKSUMS
170
174
  logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
171
175
  mercenary (0.4.0) sha256=b25a1e4a59adca88665e08e24acf0af30da5b5d859f7d8f38fba52c28f405138
172
176
  pathutil (0.16.2) sha256=e43b74365631cab4f6d5e4228f812927efc9cb2c71e62976edcb252ee948d589
173
- public_suffix (7.0.2) sha256=9114090c8e4e7135c1fd0e7acfea33afaab38101884320c65aaa0ffb8e26a857
174
- rake (13.3.1) sha256=8c9e89d09f66a26a01264e7e3480ec0607f0c497a861ef16063604b1b08eb19c
177
+ public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623
178
+ rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701
175
179
  rb-fsevent (0.11.2) sha256=43900b972e7301d6570f64b850a5aa67833ee7d87b458ee92805d56b7318aefe
176
180
  rb-inotify (0.11.1) sha256=a0a700441239b0ff18eb65e3866236cd78613d6b9f78fea1f9ac47a85e47be6e
177
181
  rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142
178
182
  rouge (4.7.0) sha256=dba5896715c0325c362e895460a6d350803dbf6427454f49a47500f3193ea739
179
183
  safe_yaml (1.0.5) sha256=a6ac2d64b7eb027bdeeca1851fe7e7af0d668e133e8a88066a0c6f7087d9f848
180
- sass-embedded (1.97.3) sha256=c4136da69ae3acfa7b0809f4ec10891125edf57df214a2d1ab570f721f96f7a6
181
- sass-embedded (1.97.3-aarch64-linux-android) sha256=623b2f52fed6e3696c6445406e4efaef57b54442cc35604bfffbb82aef7d5c45
182
- sass-embedded (1.97.3-arm-linux-androideabi) sha256=e2ef33b187066e09374023e58e72cf3b5baabe6b77ecd74356fe9b4892a1c6e1
183
- sass-embedded (1.97.3-arm-linux-gnueabihf) sha256=ce443b57f3d7f03740267cf0f2cdff13e8055dd5938488967746f29f230222da
184
- sass-embedded (1.97.3-arm-linux-musleabihf) sha256=be3972424616f916ce1f4f41228266d57339e490dfd7ca0cea5588579564d4c0
185
- sass-embedded (1.97.3-riscv64-linux-android) sha256=201426b3e58611aa8cf34a7574df51905ec42fefb5a69982cc8497ac7fb26a6b
186
- sass-embedded (1.97.3-riscv64-linux-gnu) sha256=d7bac32f4de55c589a036da13ac4482bf5b7dfac980b4c0203d31a1bd9f07622
187
- sass-embedded (1.97.3-riscv64-linux-musl) sha256=621d981d700e2b8d0459b5ea696fff746dfa07d6b6bbc70cd982905214b07888
188
- sass-embedded (1.97.3-x86_64-linux-android) sha256=8f5e179bee8610be432499f228ea4e53ab362b1db0da1ae3cd3e76b114712372
189
- sass-embedded (1.97.3-x86_64-linux-gnu) sha256=173a4d0dbe2fffdf7482bd3e82fb597dfc658c18d1e8fd746aa7d5077ed4e850
184
+ sass-embedded (1.99.0) sha256=61942229dc7da4f335688196d43c1d3d5314060667a43ccf48f4b4c41b5578e1
185
+ sass-embedded (1.99.0-aarch64-linux-android) sha256=3b077c8436424b45eb04edaa46514fc92cc333f17b3fcca45857d124d453cdf1
186
+ sass-embedded (1.99.0-arm-linux-androideabi) sha256=9089a3aca8f16f855a2fc848c0762b09aef23251f91ec9714fea31c28c9094df
187
+ sass-embedded (1.99.0-arm-linux-gnueabihf) sha256=f5f0748934660cda6948917af6dc974caf4d36ea6121e450982153b7d9c49b55
188
+ sass-embedded (1.99.0-arm-linux-musleabihf) sha256=29a4056e76bc136025ba5d03e82d86ecdabfb6c07a473a4fdedcd72fb28dbbc4
189
+ sass-embedded (1.99.0-riscv64-linux-android) sha256=7ac1145c066670d5918a7afae8445338c45b4d8e34ad2968bda7099665ba8d15
190
+ sass-embedded (1.99.0-riscv64-linux-gnu) sha256=b8c421eba2e41fa3c94bffc971611d803aae336586a3baf2933c99e1c7548f88
191
+ sass-embedded (1.99.0-riscv64-linux-musl) sha256=63b924eb97548bd1f31193c36fab89bb100cd1a9bd144789a9a596fc98244a09
192
+ sass-embedded (1.99.0-x86_64-linux-android) sha256=39c51b80c292fdb5157fee9166793374104c4e6639d77dfefc36c339aa5dce12
193
+ sass-embedded (1.99.0-x86_64-linux-gnu) sha256=a4e2ae5e9951815cb8b3ab408cee8b800852491988a57de735f18d259c70d7c4
190
194
  terminal-table (3.0.2) sha256=f951b6af5f3e00203fb290a669e0a85c5dd5b051b3b023392ccfd67ba5abae91
191
195
  unicode-display_width (2.6.0) sha256=12279874bba6d5e4d2728cef814b19197dbb10d7a7837a869bab65da943b7f5a
192
196
  webrick (1.9.2) sha256=beb4a15fc474defed24a3bda4ffd88a490d517c9e4e6118c3edce59e45864131
data/docs/_config.yml CHANGED
@@ -19,8 +19,8 @@
19
19
  # in the templates via {{ site.myvariable }}.
20
20
 
21
21
  title: Safire Documentation
22
- tagline: Ruby gem for SMART App Launch and UDAP protocols
23
- description: SMART App Launch and UDAP implementation library for Ruby
22
+ tagline: Ruby gem for SMART App Launch and UDAP discovery and registration
23
+ description: SMART App Launch and UDAP discovery and registration library for Ruby
24
24
  author: Vanessa Fotso
25
25
  lang: en-US
26
26
  baseurl: /safire
@@ -31,7 +31,7 @@ Safire is also commonly used alongside Rails applications, where ActiveSupport i
31
31
 
32
32
  ## Decision
33
33
 
34
- Use `require 'active_support/all'` and treat ActiveSupport as a first-class runtime dependency (`spec.add_dependency 'activesupport', '~> 8.0.0'`).
34
+ Use `require 'active_support/all'` and treat ActiveSupport as a first-class runtime dependency (`spec.add_dependency 'activesupport', '>= 7.1', '< 9'`).
35
35
 
36
36
  ActiveSupport utilities (`present?`, `blank?`, `fetch`, safe navigation, etc.) may be used freely throughout the codebase without needing to track individual requires.
37
37
 
@@ -46,5 +46,5 @@ ActiveSupport utilities (`present?`, `blank?`, `fetch`, safe navigation, etc.) m
46
46
  - Minimal overhead in non-Rails applications due to AS autoloading
47
47
 
48
48
  **Trade-offs:**
49
- - `activesupport` is pinned to `~> 8.0.0` — non-Rails Ruby applications must accept this dependency; a future major AS version bump requires a Safire dependency update
49
+ - `activesupport` is constrained to `>= 7.1, < 9` — non-Rails Ruby applications must accept this dependency; a future major AS version bump (v9) requires a Safire dependency update
50
50
  - Developers unfamiliar with AS may not recognise AS methods as external — mitigated by the fact that AS conventions (`present?`, `blank?`) are widely known in the Ruby ecosystem
@@ -42,22 +42,33 @@ class Client
42
42
  def_delegators :protocol_client,
43
43
  :server_metadata, :authorization_url,
44
44
  :request_access_token, :refresh_token,
45
- :token_response_valid?, :register_client
45
+ :request_backend_token,
46
+ :token_response_valid?, :register_client,
47
+ :cancel_registration
46
48
 
47
49
  private
48
50
 
49
51
  def protocol_client
50
- @protocol_client ||= PROTOCOL_CLASSES.fetch(@protocol).new(config, client_type:)
52
+ @protocol_client ||= build_protocol_client
53
+ end
54
+
55
+ def build_protocol_client
56
+ case @protocol
57
+ when :smart then Protocols::Smart.new(config, client_type:)
58
+ when :udap then Protocols::Udap.new(config)
59
+ end
51
60
  end
52
61
  end
53
62
  ```
54
63
 
55
- Protocol implementations (`Protocols::Smart`, future `Protocols::Udap`) include `Protocols::Behaviours` to declare the required interface. Adding a new protocol requires:
64
+ `Protocols::Smart` and `Protocols::Udap` include `Protocols::Behaviours` to
65
+ declare the required interface. Unsupported methods inherit the default
66
+ `NotImplementedError`. Adding another protocol requires:
56
67
  1. Implementing the `Behaviours` interface in a new class
57
- 2. Adding the class to `PROTOCOL_CLASSES`
68
+ 2. Adding a `when` branch to `build_protocol_client` in `Client`
58
69
  3. Adding its valid client types to `PROTOCOL_CLIENT_TYPES`
59
70
 
60
- No changes to `Client` itself.
71
+ The original design aimed for "no changes to `Client` itself" when adding a protocol. That invariant was intentionally relaxed: protocols have different constructor signatures (`Smart` takes `client_type:`, `Udap` does not), and a small explicit `case` in `build_protocol_client` is preferable to forcing every protocol into a uniform constructor it does not need.
61
72
 
62
73
  **Why `Forwardable` over `method_missing`:** `Forwardable` is explicit — the delegated method list is visible in the class body, easy to grep, and YARD-documented. `method_missing` is implicit, difficult to introspect, and catches typos silently.
63
74
 
@@ -72,8 +83,12 @@ No changes to `Client` itself.
72
83
  - Protocol implementations are independently testable
73
84
  - `Forwardable` delegation is explicit and greppable
74
85
  - The `protocol:` keyword cleanly selects the implementation class without leaking subclass names to callers
75
- - `client_type=` mutation works naturally — the facade updates `@protocol_client` in place (see [ADR-006]({% link adr/ADR-006-lazy-discovery.md %}) for why this preserves cached discovery)
86
+ - SMART `client_type=` mutation works naturally — the facade updates
87
+ `@protocol_client` in place (see [ADR-006]({% link adr/ADR-006-lazy-discovery.md %})
88
+ for why this preserves cached discovery); UDAP rejects `client_type=` because
89
+ SMART client types are not applicable
76
90
 
77
91
  **Trade-offs:**
78
92
  - `Client` itself has no runtime behaviour — all logic lives in protocol classes; contributors must know to look in `Protocols::Smart` for SMART logic, not in `Client`
79
93
  - `def_delegators` does not forward keyword arguments transparently in all Ruby versions — method signatures in `Behaviours` must be compatible with delegation
94
+ - Adding a new protocol requires a one-line change to `build_protocol_client` in `Client`; this is a small, contained cost that buys protocol-specific constructor freedom
@@ -31,7 +31,13 @@ Safire::Client.new(config, protocol: :smart, client_type: :confidential_symmetri
31
31
  Safire::Client.new(config, protocol: :udap) # client_type not applicable
32
32
  ```
33
33
 
34
- The key structural difference: SMART has three client authentication methods; UDAP has none — UDAP always authenticates via signed JWT assertions (AnT) with an X.509 certificate chain, and this is not user-configurable. Mixing them into one flat enum would create invalid combinations (`:udap_public`, `:udap_confidential_symmetric`) and make `client_type=` mutation impossible to express cleanly.
34
+ The key structural difference: SMART has three client authentication methods,
35
+ while UDAP does not expose SMART-style client types. The UDAP specification
36
+ defines X.509-backed signed JWT assertions (AnT), although those authentication
37
+ flows are not yet implemented by Safire. Mixing the dimensions into one flat
38
+ enum would create invalid combinations (`:udap_public`,
39
+ `:udap_confidential_symmetric`) and make `client_type=` mutation impossible to
40
+ express cleanly.
35
41
 
36
42
  ---
37
43
 
@@ -44,12 +50,12 @@ VALID_PROTOCOLS = %i[smart udap].freeze
44
50
 
45
51
  PROTOCOL_CLIENT_TYPES = {
46
52
  smart: %i[public confidential_symmetric confidential_asymmetric],
47
- udap: nil # not user-configurable; AnT with x5c always used
53
+ udap: nil # SMART client type is not applicable
48
54
  }.freeze
49
55
  ```
50
56
 
51
57
  - `protocol:` is validated against `VALID_PROTOCOLS`; an unknown protocol raises `ConfigurationError`
52
- - `client_type:` is validated against `PROTOCOL_CLIENT_TYPES[@protocol]`; if `nil` (UDAP), validation is skipped and the setter logs a warning and no-ops rather than raising
58
+ - `client_type:` defaults to `nil`. For `:smart`, `nil` resolves to `:public` before validation. For `:udap`, `nil` is the only accepted value — passing any explicit `client_type:` at construction or via `client_type=` raises `ConfigurationError`
53
59
  - Changing `client_type=` on a SMART client updates the underlying protocol client in place — already-fetched server metadata is preserved and no re-discovery occurs
54
60
 
55
61
  ---
@@ -57,11 +63,12 @@ PROTOCOL_CLIENT_TYPES = {
57
63
  ## Consequences
58
64
 
59
65
  **Benefits:**
60
- - No invalid combinations — UDAP has no client type choices at all; this is enforced at the type level, not with runtime checks
66
+ - No invalid combinations — the facade rejects every explicit UDAP
67
+ `client_type:` value at construction and assignment
61
68
  - `client_type=` mutation is clean and natural for the "discover first, then select client type" pattern
62
69
  - Adding a new SMART client type requires only adding a symbol to `PROTOCOL_CLIENT_TYPES[:smart]`
63
- - Adding a new protocol requires adding a class to `PROTOCOL_CLASSES` and an entry to `PROTOCOL_CLIENT_TYPES`
70
+ - Adding a new protocol requires adding an entry to `PROTOCOL_CLIENT_TYPES` and a branch to `build_protocol_client` (see [ADR-002]({% link adr/ADR-002-facade-and-forwardable.md %}))
64
71
 
65
72
  **Trade-offs:**
66
73
  - Two keyword args instead of one — a caller needs to know which dimension belongs to which kwarg; mitigated by clear documentation and validation errors that name the invalid parameter
67
- - `client_type:` defaults to `:public` even when `protocol: :udap` — the value is ignored for UDAP, but setting it is technically a no-op with a warning rather than an error; this is intentional for resilience in generic caller code
74
+ - `client_type:` defaults to `nil` — for SMART callers who previously relied on the `:public` default, behavior is unchanged; for UDAP callers, passing an explicit value now raises rather than silently no-oping, which is stricter but prevents misconfiguration
@@ -13,7 +13,9 @@ nav_order: 4
13
13
 
14
14
  ## Context
15
15
 
16
- `ClientConfig` holds all credentials and endpoints for a Safire client — including `client_secret` and `private_key`. Two separate concerns need to be addressed:
16
+ `ClientConfig` holds all credentials and endpoints for a Safire client — including
17
+ `client_secret`, `private_key`, and the client certificate chain intended for
18
+ UDAP signing. Two separate concerns need to be addressed:
17
19
 
18
20
  **Concern 1 — Mutability:** should `ClientConfig` allow attributes to be changed after construction?
19
21
 
@@ -27,33 +29,57 @@ These two concerns are related: if `ClientConfig` is mutable, masking is harder
27
29
 
28
30
  ## Decision
29
31
 
30
- **`ClientConfig` is immutable after construction.** All attributes are `attr_reader` only — no setters. Validation runs once at construction. After `initialize` returns, the object's state cannot change.
32
+ **The `ClientConfig` configuration surface is immutable after construction.**
33
+ All attributes are `attr_reader` only — no setters — and validation runs once
34
+ at construction. Mutable credential collections that require a stable order,
35
+ such as `certificate_chain`, are defensively stored as described below.
31
36
 
32
37
  **Sensitive attributes are masked at two layers** via the `Entity` base class:
33
38
 
34
- **Layer 1 — `#to_hash`:** the `sensitive_attributes` hook (overridden in `ClientConfig` to return `[:client_secret, :private_key]`) causes those values to appear as `'[FILTERED]'` in any hash serialisation.
39
+ **Layer 1 — `#to_hash`:** the `sensitive_attributes` hook (overridden in
40
+ `ClientConfig` to return `[:client_secret, :private_key, :certificate_chain]`)
41
+ causes those values to appear as `'[FILTERED]'` in any hash serialisation.
35
42
 
36
43
  ```ruby
37
44
  def to_hash
38
- ATTRIBUTES.each_with_object({}) do |attr, hash|
39
- value = send(attr)
40
- hash[attr] = sensitive_attributes.include?(attr) ? '[FILTERED]' : value
45
+ instance_variables.each_with_object({}) do |var, hash|
46
+ key = var.to_s.delete_prefix('@').to_sym
47
+ value = instance_variable_get(var)
48
+ hash[key] = sensitive_attributes.include?(key) && !value.nil? ? '[FILTERED]' : value
41
49
  end
42
50
  end
43
51
  ```
44
52
 
45
53
  **Layer 2 — `#inspect`:** `ClientConfig` overrides `inspect` directly, emitting `[FILTERED]` for sensitive attributes. This prevents credential leakage in exception backtraces, IRB/pry sessions, and logging middleware that calls `inspect` on objects.
46
54
 
55
+ Although X.509 certificates contain public material, `certificate_chain` is
56
+ masked because it can be large and identifies the client's operational signing
57
+ identity. The configured chain collection is defensively copied and frozen.
58
+ PEM strings are copied and frozen; certificate objects are stored as immutable
59
+ DER snapshots and materialized as fresh `OpenSSL::X509::Certificate` instances
60
+ whenever the public accessor is called. Mutating either the caller-owned
61
+ certificate or an accessor result therefore cannot alter the configured
62
+ identity. Certificate parsing from PEM, private-key matching, validity checks,
63
+ and URI SAN checks remain the responsibility of the UDAP software-statement
64
+ builder. The leaf-first ordering follows the
65
+ [UDAP Security STU2 JWT header requirements](https://hl7.org/fhir/us/udap-security/STU2/general.html#jwt-headers).
66
+
47
67
  ---
48
68
 
49
69
  ## Consequences
50
70
 
51
71
  **Benefits:**
52
- - Thread-safe by default — a `ClientConfig` shared across threads has no mutable state
72
+ - Configuration attributes cannot be reassigned through `ClientConfig`
73
+ - The order and PEM contents of a configured certificate-chain collection
74
+ cannot be changed through the original input array or strings
53
75
  - Credentials cannot leak through `inspect`, `to_s`, exception trackers, or log output
54
- - Validation at construction means invalid configs are caught early, before any network calls
76
+ - Configuration shape and URI validation run at construction; flow-specific
77
+ credential and signing checks run when the operation uses them
55
78
  - The `sensitive_attributes` hook is extensible — subclasses can add fields without modifying `Entity`
56
79
 
57
80
  **Trade-offs:**
58
81
  - Callers cannot modify a `ClientConfig` in place — they must construct a new one; this is intentional and makes state changes explicit
59
82
  - `private_key` masking means the key object itself is not serialisable via `to_hash` — callers needing to inspect or store the key must access it directly via `config.private_key`
83
+ - `certificate_chain` masking similarly means callers must access
84
+ `config.certificate_chain` directly when passing the configured identity to a
85
+ signing operation
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  layout: default
3
- title: "ADR-006: Lazy SMART discovery — no HTTP in constructors"
3
+ title: "ADR-006: Lazy discovery — no HTTP in constructors"
4
4
  parent: Architecture Decision Records
5
5
  nav_order: 6
6
6
  ---
7
7
 
8
- # ADR-006: Lazy SMART discovery — no HTTP in constructors
8
+ # ADR-006: Lazy discovery — no HTTP in constructors
9
9
 
10
10
  **Status:** Accepted
11
11
 
@@ -53,7 +53,9 @@ With eager discovery, changing `client_type` must not trigger re-discovery — t
53
53
 
54
54
  ## Decision
55
55
 
56
- Discovery is lazy and memoised at the `Protocols::Smart` instance level:
56
+ Discovery is lazy and memoised at the protocol instance level. Both `Protocols::Smart` and `Protocols::Udap` follow this pattern.
57
+
58
+ **SMART** memoises a single metadata object at the instance level:
57
59
 
58
60
  ```ruby
59
61
  def server_metadata
@@ -68,15 +70,72 @@ end
68
70
 
69
71
  `Safire::Client` memoises the protocol client itself (`@protocol_client ||= ...`), so changing `client_type=` reuses the existing `Protocols::Smart` instance — and thus its already-fetched `@server_metadata` — rather than constructing a new one. This is the mechanism that prevents double-discovery on `client_type=` changes.
70
72
 
73
+ **UDAP** memoises a Hash keyed by community URI string or `:default`, plus the caller's trust
74
+ policy. The same server can host multiple communities at separate `?community=<uri>` scopes, and
75
+ the same community can be evaluated under different trust anchors, CRLs, or revocation checkers.
76
+ Cache hits revalidate the cached `signed_metadata` before reuse; if validation fails, the cached
77
+ entry is discarded and discovery is fetched again.
78
+
79
+ ```ruby
80
+ def server_metadata(community: nil, trusted_anchors: [], crls: [], revocation_checker: nil, verify_chain: true)
81
+ community = normalize_community(community)
82
+ trust_policy = {
83
+ trusted_anchors:,
84
+ crls:,
85
+ revocation_checker:,
86
+ verify_chain:,
87
+ allow_insecure_localhost: @allow_insecure_localhost
88
+ }
89
+ cache_key = build_cache_key(community, trusted_anchors, crls, revocation_checker, verify_chain)
90
+ cached_entry = @metadata_cache[cache_key]
91
+ return cached_entry.fetch(:metadata) if cached_entry && cached_entry_valid?(cached_entry, trust_policy)
92
+
93
+ @metadata_cache.delete(cache_key)
94
+
95
+ entry = fetch_metadata(
96
+ community:,
97
+ trust_policy:
98
+ )
99
+ @metadata_cache[cache_key] = entry
100
+ entry.fetch(:metadata)
101
+ end
102
+
103
+ def fetch_metadata(community:, trust_policy:)
104
+ endpoint = well_known_endpoint(community:)
105
+ response = @http_client.get(endpoint)
106
+ check_204!(response, endpoint:, community:)
107
+ raw = parse_discovery_body(response.body, endpoint)
108
+ signed_claims = validate_signed_metadata!(
109
+ raw,
110
+ endpoint:,
111
+ community:,
112
+ trust_policy:
113
+ )
114
+ {
115
+ metadata: UdapMetadata.new(
116
+ raw.merge(signed_claims),
117
+ allow_insecure_localhost: @allow_insecure_localhost
118
+ ),
119
+ raw:
120
+ }
121
+ end
122
+ ```
123
+
124
+ `server_metadata(community:, trusted_anchors:, crls:, revocation_checker:, verify_chain:)` uses UDAP-specific parameters. Calling any of these on a SMART client raises `ArgumentError` from Ruby's own keyword argument checking — this is intentional and correct, since community scoping and UDAP certificate trust policy are UDAP concepts.
125
+
126
+ A 204 response means the server has no UDAP workflows for that community. `Protocols::Udap` raises `DiscoveryError` before the body is parsed, with a descriptive message that identifies the community when one was requested.
127
+
71
128
  ---
72
129
 
73
130
  ## Consequences
74
131
 
75
132
  **Benefits:**
76
133
  - `Safire::Client.new` is instantaneous — no network calls, no stubs required at construction time
77
- - Configuration errors are raised before any HTTP call
134
+ - Construction-time configuration errors are raised before HTTP; requirements
135
+ specific to an operation are validated when that operation runs
78
136
  - Callers control when discovery happens — supports application-level caching patterns (see [Advanced Examples]({{ site.baseurl }}/advanced/#metadata-caching))
79
- - `client_type=` mutation preserves cached metadata — no re-discovery
137
+ - `client_type=` mutation preserves cached SMART metadata — no re-discovery
138
+ - UDAP community-and-trust-policy cache allows a single client instance to serve multiple communities without serving stale signed metadata
80
139
 
81
140
  **Trade-offs:**
82
141
  - Discovery errors surface at first use (e.g. `authorization_url`), not at construction — callers must handle `Errors::DiscoveryError` in their flow logic rather than at the `new` call site