omniauth-vitsme 0.3.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8d349b546c14e9d6f7ec72ccd03a9aac7e8db44d711523c12d904a210cb65c1c
4
- data.tar.gz: 839203e14305b75c1bdec348436be83745a9c582cf9dc37360d2421a6dbce54d
3
+ metadata.gz: 6b035cc18e25e105be04f7bb8f4a97ab65ae642cf4496efed8029311f73bcd8d
4
+ data.tar.gz: 44f4d03aecd52c51abf90fead1e1326508791192563149ebe854101f4409fcf6
5
5
  SHA512:
6
- metadata.gz: 645645d4761352f621b88994d6d95d53a9d9ef882b5e878b68f137153cd95ed13a514507dbcc070ce4c352381aeb9fe19da415716678c5781030cb7793d99659
7
- data.tar.gz: 90749983f5c6ba32121b08bebcf34d36fbeaf387d7fb5bd7268a3312fc589b9345300238f9b1e60f43a62211953de22c180a0b3fff998c06936918dfb1b81704
6
+ metadata.gz: 8ae6b954c46918c6e03104a3d81f153add7d6c44a1d3a227a9fd534f03566b37c330a70d2a76a63111830113b29e3a98ff0556dfb99a7aa01c7becee82980df6
7
+ data.tar.gz: 8ea8e3002b36594fb4c998be15515887b8801c26bcde6a57d17dda4c4fc7add6d0c06d1dcf04248b9dc919e2ec200088e0a10fbd821474c772df6fc048f1f8a4
data/AGENTS.md CHANGED
@@ -30,8 +30,8 @@ person's verified details to another.
30
30
  6. **`fluid_` means unverified.** Without the `fluid` scope you never see one. With it, give such
31
31
  people a limited, tentative account and merge it when `extra.fluid_uids` shows up on a verified
32
32
  sign-in.
33
- 7. **Read everything in the callback.** Tokens live 5 seconds and there is no refresh token. Nothing
34
- can be fetched later.
33
+ 7. **Read everything in the callback.** Tokens live 5 seconds and there is no refresh token. The one
34
+ exception is KNOT claims, below.
35
35
  8. **Find returning people by `extra.lookup_uids`** (plus `fluid_uids` with FLUID), current first, and
36
36
  move a record found under an older uid to `auth.uid`.
37
37
  9. **User-facing copy says "verified"**, not "vits.me-verified". There is no need to name vits.me to
@@ -42,6 +42,15 @@ person's verified details to another.
42
42
  11. **Ask only for scopes the site registered.** Its page at vits.me/developer sets which it may ask
43
43
  for, and vits.me refuses a request for any other. `il_government_id_number` is enabled by vits.me
44
44
  on request, per site.
45
+ 12. **KNOT claims come from partners, and partners come and go.** Don't hard-code a partner or the
46
+ shape of its claims from memory: read the scope's description and documentation link from
47
+ `OmniAuth::Vitsme::Knot.scopes` (or the site's page at vits.me/developer). Claims arrive in
48
+ `extra.knot_claims`, already verified; missing is unknown, and a partner that can't be asked shows
49
+ in `extra.knot_errors` without failing the sign-in. While the person's consent stands, refresh with
50
+ `OmniAuth::Vitsme::Knot.fresh_claims`, and handle `ConsentRequired` by letting go of what you had.
51
+ 13. **Verify every change event** with `OmniAuth::Vitsme::Events.verify!` before acting on it, answer
52
+ `202`, and treat it as idempotent. It never carries values: refetch on `:claims_changed`, forget on
53
+ `:consent_withdrawn`, whose `reason` is `withdrawn` (the person) or `retired` (the partner left).
45
54
 
46
55
  ## Doing the work
47
56
 
data/CHANGELOG.md CHANGED
@@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.0] - 2026-11-10
11
+
12
+ ### Added
13
+
14
+ - KNOT claims, from partner services. The gem knows no partner: any `knot:<knot>:<name>` scope passes
15
+ the boot-time scope check, and vits.me refuses one your site wasn't granted. When UserInfo carries
16
+ KNOT sources, the strategy fetches each partner's answer during the callback, verifies its signature
17
+ against the source's `jwks_uri`, issuer, audience (your client_id) and `knot_txn`, and puts the claims
18
+ in `extra.knot_claims` as sent. A partner that can't be asked shows in `extra.knot_errors` and doesn't
19
+ fail the sign-in.
20
+ - `OmniAuth::Vitsme::Knot.scopes` reads the active KNOT scopes as vits.me describes them now (what each
21
+ answer holds, and a documentation link).
22
+ - `OmniAuth::Vitsme::Knot.fresh_claims` asks for KNOT claims again without the person, while their
23
+ standing consent holds, raising `ConsentRequired` once it doesn't.
24
+ - `OmniAuth::Vitsme::Events.verify!` verifies the change events vits.me pushes to a site's change
25
+ notifications URL: `:claims_changed`, and `:consent_withdrawn` with a `reason` of `withdrawn` or
26
+ `retired` (the partner or scope left).
27
+ - `OmniAuth::Vitsme::Test.mock_auth(knot_claims:)`.
28
+ - `data/claims.json` has a generic `knots` section and the introduction's 30-second lifetime
29
+ (catalog 3). It lists no partner.
30
+
31
+ ### Changed
32
+
33
+ - `extra.raw_info` no longer carries `_claim_names`, `_claim_sources` or `knot_txn`: they are spent
34
+ during the callback.
35
+
10
36
  ## [0.3.0] - 2026-11-05
11
37
 
12
38
  ### Added
data/README.md CHANGED
@@ -26,7 +26,7 @@ rules built in.
26
26
 
27
27
  ```ruby
28
28
  # Gemfile
29
- gem "omniauth-vitsme", "~> 0.3"
29
+ gem "omniauth-vitsme", "~> 0.4"
30
30
  gem "omniauth-rails_csrf_protection" # OmniAuth 2 starts sign-in by POST only
31
31
  ```
32
32
 
@@ -158,6 +158,69 @@ is still pending can sign in too:
158
158
  `extra.fluid_uids`. Merge the tentative account into the verified one, and drop tentative accounts
159
159
  nobody claimed after a week.
160
160
 
161
+ ## Partner services (KNOT claims)
162
+
163
+ Some facts come from partner services rather than vits.me. Partners come and go, so this gem knows none
164
+ of them: it handles whatever KNOT scopes your site chose to work with. The active ones, each with what its
165
+ answer holds and a documentation link, are listed by vits.me; choose them under Partners on your site's
166
+ form at vits.me/developer, and its page there shows the ones it holds:
167
+
168
+ ```ruby
169
+ OmniAuth::Vitsme::Knot.scopes # [{ "scope" => "knot:<knot>:<name>", "knot" => "…", "claim" => "…",
170
+ # "title" => {…}, "description" => {…}, "documentation_url" => "…" }]
171
+ ```
172
+
173
+ Ask for one like any other scope:
174
+
175
+ ```ruby
176
+ provider :vitsme, client_id: ENV["VITSME_APP_ID"], client_secret: ENV["VITSME_APP_SECRET"],
177
+ scope: %i[openid knot:<knot>:<name>]
178
+ ```
179
+
180
+ The person agrees on vits.me's consent screen. During the callback the strategy fetches the partner's
181
+ answer, verifies its signature, and hands you the claims as the partner sent them:
182
+
183
+ ```ruby
184
+ auth.extra.knot_claims # { "<claim>" => <the partner's answer> }; its shape is in the partner's documentation
185
+ auth.extra.knot_errors # { "<knot>" => "HTTP 503" } when a partner could not be asked
186
+ ```
187
+
188
+ A claim missing from `knot_claims` is unknown, never false. A partner that cannot be reached never
189
+ fails the sign-in.
190
+
191
+ **Fresh claims, without the person.** While they keep the consent (a year from their last agreement),
192
+ ask again:
193
+
194
+ ```ruby
195
+ result = OmniAuth::Vitsme::Knot.fresh_claims(uid: person.vitsme_uid, scopes: %w[knot:<knot>:<name>],
196
+ client_id: ENV["VITSME_APP_ID"], client_secret: ENV["VITSME_APP_SECRET"])
197
+ result.claims # as above
198
+ # raises OmniAuth::Vitsme::Knot::ConsentRequired once they no longer share it
199
+ ```
200
+
201
+ **Change events.** Set a change notifications URL on your site's page, and vits.me POSTs to it when a
202
+ partner says something changed for someone, when they stop sharing, or when a partner or scope leaves.
203
+ Verify each one:
204
+
205
+ ```ruby
206
+ class VitsmeEventsController < ActionController::API
207
+ def create
208
+ event = OmniAuth::Vitsme::Events.verify!(request.raw_post, client_id: ENV["VITSME_APP_ID"],
209
+ environment: Rails.env)
210
+ person = Person.find_by(vitsme_uid: event.uid)
211
+ case event.type
212
+ when :claims_changed then RefreshPartnerClaimsJob.perform_later(person.id, event.scopes) if person
213
+ when :consent_withdrawn then person&.forget_partner_claims!(event.scopes) # event.reason: "withdrawn" or "retired"
214
+ end
215
+ head :accepted
216
+ rescue OmniAuth::Vitsme::Events::Invalid
217
+ head :bad_request
218
+ end
219
+ end
220
+ ```
221
+
222
+ Events never carry values, may arrive twice, and are retried until you answer `202`.
223
+
161
224
  ## Finding a returning person
162
225
 
163
226
  Look a person up by `extra.lookup_uids`, current uid first, plus `extra.fluid_uids` if you use FLUID.
@@ -200,7 +263,9 @@ OmniAuth sends failures to `OmniAuth.config.on_failure` (in Rails, `/auth/failur
200
263
  lookup_uids: ["solid_…", "solid_<earlier>"],
201
264
  fluid_uids: [],
202
265
  claims: { "name" => "Jane D.", "age__over_18" => true }, # typed, shared claims only
203
- raw_info: { "sub" => "solid_…", … } # the UserInfo response, verbatim
266
+ knot_claims: { "<claim>" => … }, # from partner services, verified
267
+ knot_errors: {}, # partners that could not be asked
268
+ raw_info: { "sub" => "solid_…", … } # the UserInfo response, without KNOT sources
204
269
  }
205
270
  }
206
271
  ```
data/data/claims.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "catalog_version": 2,
2
+ "catalog_version": 3,
3
3
  "about": "Every scope, claim, gate and refusal reason a vits.me relying party can use. Source of truth for omniauth-vitsme (OmniAuth::Vitsme::Catalog) and for agents and developers choosing scopes. Mirrors the vits.me server: config/initializers/doorkeeper.rb (scopes), config/initializers/doorkeeper_openid_connect.rb (claims), config/initializers/authorizations_controller_extension.rb (value domains, gates, refusals).",
4
4
  "conventions": {
5
5
  "delivery": "Every claim except sub comes from the UserInfo endpoint, never the ID token. omniauth-vitsme fetches UserInfo on every sign-in.",
@@ -149,6 +149,14 @@
149
149
  "incompatible_with": ["fluid"]
150
150
  }
151
151
  },
152
+ "knots": {
153
+ "scope_pattern": "^knot:[^:\\s]+:\\S+$",
154
+ "scope_format": "knot:<knot>:<name>",
155
+ "description": "Claims from a partner service (a KNOT) that verifies what vits.me does not, served to your site directly. Partners come and go, so none is listed here: GET <issuer>/knot/scopes lists the active KNOT scopes, each with what its answer holds and a documentation URL (OmniAuth::Vitsme::Knot.scopes), and your site's page at vits.me/developer shows the ones staff granted it. The person agrees on vits.me's consent screen; left on, the agreement stands for a year and can be withdrawn at any time. When a partner leaves, its consents end and you get consent_withdrawn with reason retired.",
156
+ "auth_hash": "extra.knot_claims (by claim name, fetched and verified during the callback) and extra.knot_errors (by source). Missing is unknown, never false.",
157
+ "fresh_claims": "While the consent stands: OmniAuth::Vitsme::Knot.fresh_claims(uid:, scopes:, client_id:, client_secret:). Raises ConsentRequired once it no longer does.",
158
+ "events": "Set a change notifications URL on your registration; verify each POST with OmniAuth::Vitsme::Events.verify! (types claims_changed, consent_withdrawn with reason withdrawn or retired) and answer 202."
159
+ },
152
160
  "refusals": {
153
161
  "nationality_gate": {
154
162
  "error": "access_denied",
@@ -175,6 +183,7 @@
175
183
  "authorization_code": 5,
176
184
  "access_token": 5,
177
185
  "id_token": 5,
186
+ "knot_introduction": 30,
178
187
  "refresh_token": null
179
188
  }
180
189
  }
@@ -17,6 +17,9 @@ module OmniAuth
17
17
  # expected_uid: ->(env) { ... }, # "details about a signed-in person" only
18
18
  # require_verified: true # refuse pending (fluid_) identities
19
19
  #
20
+ # A KNOT scope (knot:<knot>:<name>) asks a partner service; its claims arrive verified in
21
+ # extra.knot_claims (OmniAuth::Vitsme::Knot).
22
+ #
20
23
  # See README.md for the auth hash, the failure types and the rules (the uid is private).
21
24
  class Vitsme < OpenIDConnect
22
25
  option :name, 'vitsme'
@@ -80,7 +83,17 @@ module OmniAuth
80
83
  end
81
84
 
82
85
  def extra
83
- OmniAuth::Vitsme::AuthShape.extra(uid, raw_info)
86
+ OmniAuth::Vitsme::AuthShape.extra(uid, raw_info, knot: knot)
87
+ end
88
+
89
+ # The KNOT sources in UserInfo, fetched and verified now: their introductions live 30 seconds.
90
+ def knot
91
+ return @knot if defined?(@knot)
92
+
93
+ @knot = (if raw_info['_claim_sources']
94
+ OmniAuth::Vitsme::Knot.resolve(raw_info,
95
+ client_id: client_options.identifier)
96
+ end)
84
97
  end
85
98
 
86
99
  # vits.me issues no refresh token and its access token lapses five seconds after issue: everything
@@ -7,6 +7,8 @@ module OmniAuth
7
7
  module AuthShape
8
8
  SOLID_PREFIX = 'solid_'
9
9
  FLUID_PREFIX = 'fluid_'
10
+ # Spent as soon as the strategy fetched them, and none of your app's business afterwards.
11
+ KNOT_SOURCE_KEYS = %w[_claim_names _claim_sources knot_txn].freeze
10
12
 
11
13
  module_function
12
14
 
@@ -24,7 +26,9 @@ module OmniAuth
24
26
  }.compact
25
27
  end
26
28
 
27
- def extra(uid, raw_info)
29
+ # `knot` is the Knot::Result for this sign-in's KNOT sources, if it had any.
30
+ def extra(uid, raw_info, knot: nil)
31
+ knot ||= Knot::Result.empty
28
32
  {
29
33
  identity: identity_kind(uid),
30
34
  # Look a returning person up by every one of these, current first: a record keyed by a defunct
@@ -33,7 +37,11 @@ module OmniAuth
33
37
  # Tentative accounts (keyed by a pending signup's FLUID) to merge into this person.
34
38
  fluid_uids: Array(raw_info['fluid']),
35
39
  claims: claims(raw_info),
36
- raw_info: raw_info
40
+ # Claims from KNOTs, by claim name, already fetched and verified ({ "<claim>" => <answer> }),
41
+ # and why a KNOT could not be asked, by source. A claim missing here is unknown, never false.
42
+ knot_claims: knot.claims,
43
+ knot_errors: knot.errors,
44
+ raw_info: raw_info.except(*KNOT_SOURCE_KEYS)
37
45
  }
38
46
  end
39
47
 
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'json'
4
+ require 'omniauth/vitsme/knot'
4
5
 
5
6
  module OmniAuth
6
7
  module Vitsme
@@ -31,8 +32,10 @@ module OmniAuth
31
32
  GATE_PATTERN.match?(scope.to_s)
32
33
  end
33
34
 
35
+ # KNOT scopes (knot:<knot>:<name>) are registered data on vits.me, not catalog entries: any well-formed
36
+ # one is accepted here, and vits.me refuses one your site was not granted.
34
37
  def known_scope?(scope)
35
- scopes.include?(scope.to_s) || gate_scope?(scope)
38
+ scopes.include?(scope.to_s) || gate_scope?(scope) || Knot.scope?(scope)
36
39
  end
37
40
 
38
41
  # The OmniAuth failure type for a `vitsme_error` refusal reason, or nil for an unknown one.
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'omniauth/vitsme/jws'
4
+ require 'omniauth/vitsme/issuers'
5
+
6
+ module OmniAuth
7
+ module Vitsme
8
+ # The change events vits.me POSTs to your site's change notifications URL (Security Event Tokens,
9
+ # RFC 8417, pushed per RFC 8935 with Content-Type application/secevent+jwt), while someone keeps a
10
+ # standing consent with your site:
11
+ #
12
+ # :claims_changed what a KNOT would tell you about them changed: ask again (Knot.fresh_claims)
13
+ # :consent_withdrawn they stopped sharing (reason "withdrawn"), or the partner or scope left
14
+ # (reason "retired"): let go of what you were given
15
+ #
16
+ # An event never carries values. Answer 202; anything else is retried. Events are idempotent and may
17
+ # arrive twice.
18
+ #
19
+ # event = OmniAuth::Vitsme::Events.verify!(request.raw_post, client_id: ENV['VITSME_APP_ID'])
20
+ # person = Person.find_by(vitsme_uid: event.uid) # the uid is private, as everywhere
21
+ module Events
22
+ TYPES = {
23
+ 'https://vits.me/secevent/knot/claims-changed' => :claims_changed,
24
+ 'https://vits.me/secevent/knot/consent-withdrawn' => :consent_withdrawn
25
+ }.freeze
26
+ CONTENT_TYPE = 'application/secevent+jwt'
27
+ MAX_AGE = 300
28
+
29
+ class Invalid < StandardError; end
30
+
31
+ Event = Struct.new(:type, :uid, :scopes, :reason, :id, :issued_at, keyword_init: true)
32
+
33
+ module_function
34
+
35
+ def verify!(token, client_id:, environment: :production, issuer: nil, now: Time.now)
36
+ issuer ||= Vitsme.issuer(environment)
37
+ claims = JWS.verify!(token, jwks_uri: "#{issuer}/oauth/discovery/keys", iss: issuer, aud: client_id,
38
+ typ: 'secevent+jwt', expiring: false, now: now)
39
+ raise Invalid, 'stale or future event' unless (claims['iat'].to_i - now.to_i).abs <= MAX_AGE
40
+
41
+ event(claims, issuer)
42
+ rescue JWS::Invalid, Keys::Unavailable => e
43
+ raise Invalid, e.message
44
+ end
45
+
46
+ def event(claims, issuer)
47
+ sub_id = claims['sub_id']
48
+ unless sub_id.is_a?(Hash) && sub_id['format'] == 'iss_sub' && sub_id['iss'] == issuer
49
+ raise Invalid,
50
+ 'unsupported subject'
51
+ end
52
+
53
+ type_uri, body = Hash(claims['events']).find { |uri, _| TYPES.key?(uri) }
54
+ raise Invalid, 'unsupported event' unless type_uri
55
+
56
+ body = Hash(body)
57
+ Event.new(type: TYPES[type_uri], uid: sub_id['sub'], scopes: body['scope'].to_s.split, reason: body['reason'],
58
+ id: claims['jti'], issued_at: Time.at(claims['iat'].to_i))
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'net/http'
5
+ require 'uri'
6
+
7
+ module OmniAuth
8
+ module Vitsme
9
+ # The few HTTPS calls the gem makes outside the OpenID Connect flow: KNOT claims, fresh introductions
10
+ # and published keys. Short timeouts; any transport failure is HTTP::Unavailable.
11
+ module HTTP
12
+ TIMEOUT = 5
13
+
14
+ class Unavailable < StandardError; end
15
+
16
+ Response = Struct.new(:status, :body) do
17
+ def ok? = status.between?(200, 299)
18
+ def json = JSON.parse(body)
19
+ end
20
+
21
+ module_function
22
+
23
+ def get(url, headers = {})
24
+ request(Net::HTTP::Get.new(URI(url), headers))
25
+ end
26
+
27
+ def post_form(url, form, basic: nil, headers: {})
28
+ req = Net::HTTP::Post.new(URI(url), headers)
29
+ req.set_form_data(form)
30
+ req.basic_auth(*basic) if basic
31
+ request(req)
32
+ end
33
+
34
+ def request(req)
35
+ uri = req.uri
36
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https',
37
+ open_timeout: TIMEOUT, read_timeout: TIMEOUT) do |http|
38
+ http.request(req)
39
+ end
40
+ Response.new(response.code.to_i, response.body.to_s)
41
+ rescue SocketError, SystemCallError, IOError, Net::OpenTimeout, Net::ReadTimeout, OpenSSL::SSL::SSLError => e
42
+ raise Unavailable, e.class.name
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json/jwt'
4
+ require 'omniauth/vitsme/keys'
5
+
6
+ module OmniAuth
7
+ module Vitsme
8
+ # Verifies a signed JWT from vits.me or a KNOT: RS256 against the keys at `jwks_uri`, then the claims
9
+ # the caller names. Raises JWS::Invalid with a short reason.
10
+ module JWS
11
+ LEEWAY = 30
12
+
13
+ class Invalid < StandardError; end
14
+
15
+ module_function
16
+
17
+ # The verified claims, as a Hash with string keys.
18
+ def verify!(token, jwks_uri:, iss:, aud:, typ: nil, expiring: true, now: Time.now)
19
+ claims = signed_claims(token, jwks_uri: jwks_uri, typ: typ)
20
+ raise Invalid, 'unexpected issuer' unless claims['iss'] == iss
21
+ raise Invalid, 'not addressed to you' unless Array(claims['aud']).include?(aud)
22
+ raise Invalid, 'expired' if expiring && claims['exp'].to_i + LEEWAY < now.to_i
23
+
24
+ claims
25
+ end
26
+
27
+ # The payload once the signature checks out. `alg: none` and every algorithm but RS256 are refused.
28
+ def signed_claims(token, jwks_uri:, typ:)
29
+ header = JSON::JWT.decode(token.to_s, :skip_verification).header
30
+ raise Invalid, 'unexpected type' if typ && header['typ'] != typ
31
+
32
+ keys = Keys.for(jwks_uri, kid: header['kid'])
33
+ JSON::JWT.decode(token.to_s, keys, [:RS256]).to_h.transform_keys(&:to_s)
34
+ rescue JSON::JWT::Exception => e
35
+ raise Invalid, e.class.name.split('::').last
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json/jwt'
4
+ require 'omniauth/vitsme/http'
5
+
6
+ module OmniAuth
7
+ module Vitsme
8
+ # Published signing keys (a JWKS URL's JSON::JWK::Set), cached per URL for TTL seconds and fetched again
9
+ # once when a token names a key the cache does not hold (rotation).
10
+ module Keys
11
+ TTL = 600
12
+
13
+ class Unavailable < StandardError; end
14
+
15
+ @cache = {}
16
+ @mutex = Mutex.new
17
+
18
+ class << self
19
+ def for(jwks_uri, kid:)
20
+ set = cached(jwks_uri)
21
+ return set if kid.nil? || set.any? { |jwk| jwk[:kid] == kid }
22
+
23
+ fetch(jwks_uri)
24
+ end
25
+
26
+ def clear!
27
+ @mutex.synchronize { @cache.clear }
28
+ end
29
+
30
+ private
31
+
32
+ def cached(jwks_uri)
33
+ entry = @mutex.synchronize { @cache[jwks_uri] }
34
+ return entry[:set] if entry && entry[:at] > Time.now - TTL
35
+
36
+ fetch(jwks_uri)
37
+ end
38
+
39
+ def fetch(jwks_uri)
40
+ response = HTTP.get(jwks_uri, 'Accept' => 'application/json')
41
+ raise Unavailable, "keys: HTTP #{response.status}" unless response.ok?
42
+
43
+ set = JSON::JWK::Set.new(response.json)
44
+ @mutex.synchronize { @cache[jwks_uri] = { set: set, at: Time.now } }
45
+ set
46
+ rescue JSON::ParserError, HTTP::Unavailable => e
47
+ raise Unavailable, "keys: #{e.message}"
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'omniauth/vitsme/http'
4
+ require 'omniauth/vitsme/jws'
5
+ require 'omniauth/vitsme/issuers'
6
+
7
+ module OmniAuth
8
+ module Vitsme
9
+ # Claims from KNOTs: partner services that verify what vits.me does not (organization memberships, for
10
+ # one) and serve it to sites directly, with vits.me brokering identity and consent.
11
+ #
12
+ # vits.me's UserInfo names each KNOT source with an `endpoint`, an `access_token` (an introduction,
13
+ # encrypted to the KNOT and opaque to you), and the KNOT's `iss` and `jwks_uri`. The KNOT answers with a
14
+ # JWS, `aud` your client_id and `sub` the response's `knot_txn`. Introductions live 30 seconds and work
15
+ # once, so the strategy fetches them while it builds the auth hash.
16
+ module Knot
17
+ # Partners come and go, so the gem knows none of them: any well-formed KNOT scope is accepted, and
18
+ # vits.me refuses one your site was not granted.
19
+ SCOPE_PATTERN = /\Aknot:[^:\s]+:\S+\z/
20
+
21
+ class ConsentRequired < StandardError; end
22
+ class InvalidClient < StandardError; end
23
+ class Unavailable < StandardError; end
24
+
25
+ # `claims` by claim name ({ "<claim>" => <answer> }); `errors` by source, for KNOTs that could not
26
+ # be asked or whose answer did not verify. A claim missing from `claims` is unknown, never false.
27
+ Result = Struct.new(:claims, :errors) do
28
+ def self.empty = new({}, {})
29
+ end
30
+
31
+ module_function
32
+
33
+ def scope?(scope) = SCOPE_PATTERN.match?(scope.to_s)
34
+
35
+ # The active KNOT scopes, as vits.me describes them now (GET <issuer>/knot/scopes): each one's
36
+ # `scope`, `knot`, `claim`, `title`, `description` and `documentation_url`. For developers and their
37
+ # tools; signing in never depends on it. Raises Unavailable.
38
+ def scopes(environment: :production, issuer: nil)
39
+ response = HTTP.get("#{issuer || Vitsme.issuer(environment)}/knot/scopes", 'Accept' => 'application/json')
40
+ raise Unavailable, "vits.me: HTTP #{response.status}" unless response.ok?
41
+
42
+ response.json.fetch('knot_scopes')
43
+ rescue HTTP::Unavailable, JSON::ParserError, KeyError => e
44
+ raise Unavailable, e.message
45
+ end
46
+
47
+ # Fetches and verifies every KNOT source in a UserInfo (or POST /knot/introductions) response.
48
+ def resolve(response, client_id:)
49
+ names = response['_claim_names'] || {}
50
+ sources = response['_claim_sources'] || {}
51
+ txn = response['knot_txn']
52
+ sources.each_with_object(Result.empty) do |(source, spec), result|
53
+ answer = answer(spec, client_id: client_id, txn: txn)
54
+ names.each { |claim, from| result.claims[claim] = answer[claim] if from == source && answer.key?(claim) }
55
+ rescue JWS::Invalid, Keys::Unavailable, HTTP::Unavailable, Unavailable => e
56
+ result.errors[source] = e.message
57
+ end
58
+ end
59
+
60
+ # Fresh KNOT claims about someone, without them present, while they keep a standing consent with your
61
+ # site: vits.me introduces your site to each KNOT again. `uid` is the person's uid at your site.
62
+ # Raises ConsentRequired when nothing asked for is still agreed, InvalidClient for wrong credentials,
63
+ # and Unavailable when vits.me cannot be reached.
64
+ def fresh_claims(uid:, scopes:, client_id:, client_secret:, environment: :production, issuer: nil)
65
+ issuer ||= Vitsme.issuer(environment)
66
+ response = HTTP.post_form("#{issuer}/knot/introductions", { sub: uid, scope: Array(scopes).join(' ') },
67
+ basic: [client_id, client_secret], headers: { 'Accept' => 'application/json' })
68
+ case response.status
69
+ when 200 then resolve(response.json, client_id: client_id)
70
+ when 403 then raise ConsentRequired, 'the person does not let your site be told this'
71
+ when 401 then raise InvalidClient, 'vits.me did not accept the client credentials'
72
+ else raise Unavailable, "vits.me: HTTP #{response.status}"
73
+ end
74
+ rescue HTTP::Unavailable, JSON::ParserError => e
75
+ raise Unavailable, e.message
76
+ end
77
+
78
+ def answer(spec, client_id:, txn:)
79
+ response = HTTP.get(spec.fetch('endpoint'), 'Authorization' => "Bearer #{spec.fetch('access_token')}",
80
+ 'Accept' => 'application/jwt')
81
+ raise Unavailable, "HTTP #{response.status}" unless response.ok?
82
+
83
+ claims = JWS.verify!(response.body, jwks_uri: spec.fetch('jwks_uri'), iss: spec.fetch('iss'), aud: client_id)
84
+ raise JWS::Invalid, 'answer for another transaction' unless txn && claims['sub'] == txn
85
+
86
+ claims
87
+ rescue KeyError
88
+ raise Unavailable, 'incomplete source'
89
+ end
90
+ end
91
+ end
92
+ end
@@ -13,6 +13,7 @@ module OmniAuth
13
13
  # OmniAuth::Vitsme::Test.mock_auth(claims: { age__over_18: true }) # provider :vitsme
14
14
  # OmniAuth::Vitsme::Test.mock_auth(:verify, uid: person.vitsme_uid, claims: { name: 'Jane D.' })
15
15
  # OmniAuth::Vitsme::Test.mock_failure(:vitsme, :nationality_gate_refused)
16
+ # OmniAuth::Vitsme::Test.mock_auth(knot_claims: { '<claim>' => <what the partner answers> })
16
17
  #
17
18
  # The auth hash is built by the same code as the real strategy's (AuthShape), so `info`, `extra`
18
19
  # and the typed claims look exactly as they will in production. Test mode skips the trip to vits.me
@@ -40,15 +41,17 @@ module OmniAuth
40
41
  info.merge(claims)
41
42
  end
42
43
 
43
- def auth_hash(provider = :vitsme, identity: :solid, uid: nil, claims: {}, defunct_uids: [], fluid_uids: [])
44
+ def auth_hash(provider = :vitsme, identity: :solid, uid: nil, claims: {}, defunct_uids: [], fluid_uids: [],
45
+ knot_claims: {}, knot_errors: {})
44
46
  uid ||= self.uid(identity)
45
47
  raw = raw_info(uid: uid, claims: claims, defunct_uids: defunct_uids, fluid_uids: fluid_uids)
48
+ knot = Knot::Result.new(knot_claims.transform_keys(&:to_s), knot_errors.transform_keys(&:to_s))
46
49
  OmniAuth::AuthHash.new(
47
50
  provider: provider.to_s,
48
51
  uid: uid,
49
52
  info: AuthShape.info(uid, raw),
50
53
  credentials: { id_token: 'test.id.token', scope: 'openid' },
51
- extra: AuthShape.extra(uid, raw)
54
+ extra: AuthShape.extra(uid, raw, knot: knot)
52
55
  )
53
56
  end
54
57
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module OmniAuth
4
4
  module Vitsme
5
- VERSION = '0.3.0'
5
+ VERSION = '0.4.0'
6
6
  end
7
7
  end
@@ -4,4 +4,6 @@ require 'omniauth/vitsme/version'
4
4
  require 'omniauth/vitsme/issuers'
5
5
  require 'omniauth/vitsme/catalog'
6
6
  require 'omniauth/vitsme/auth_shape'
7
+ require 'omniauth/vitsme/knot'
8
+ require 'omniauth/vitsme/events'
7
9
  require 'omniauth/strategies/vitsme'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: omniauth-vitsme
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yuval Tobias
@@ -49,7 +49,12 @@ files:
49
49
  - lib/omniauth/vitsme.rb
50
50
  - lib/omniauth/vitsme/auth_shape.rb
51
51
  - lib/omniauth/vitsme/catalog.rb
52
+ - lib/omniauth/vitsme/events.rb
53
+ - lib/omniauth/vitsme/http.rb
52
54
  - lib/omniauth/vitsme/issuers.rb
55
+ - lib/omniauth/vitsme/jws.rb
56
+ - lib/omniauth/vitsme/keys.rb
57
+ - lib/omniauth/vitsme/knot.rb
53
58
  - lib/omniauth/vitsme/test.rb
54
59
  - lib/omniauth/vitsme/version.rb
55
60
  - llms.txt