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 +4 -4
- data/AGENTS.md +11 -2
- data/CHANGELOG.md +26 -0
- data/README.md +67 -2
- data/data/claims.json +10 -1
- data/lib/omniauth/strategies/vitsme.rb +14 -1
- data/lib/omniauth/vitsme/auth_shape.rb +10 -2
- data/lib/omniauth/vitsme/catalog.rb +4 -1
- data/lib/omniauth/vitsme/events.rb +62 -0
- data/lib/omniauth/vitsme/http.rb +46 -0
- data/lib/omniauth/vitsme/jws.rb +39 -0
- data/lib/omniauth/vitsme/keys.rb +52 -0
- data/lib/omniauth/vitsme/knot.rb +92 -0
- data/lib/omniauth/vitsme/test.rb +5 -2
- data/lib/omniauth/vitsme/version.rb +1 -1
- data/lib/omniauth/vitsme.rb +2 -0
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6b035cc18e25e105be04f7bb8f4a97ab65ae642cf4496efed8029311f73bcd8d
|
|
4
|
+
data.tar.gz: 44f4d03aecd52c51abf90fead1e1326508791192563149ebe854101f4409fcf6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
34
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
+
"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
|
-
|
|
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
|
-
|
|
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
|
data/lib/omniauth/vitsme/test.rb
CHANGED
|
@@ -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
|
|
data/lib/omniauth/vitsme.rb
CHANGED
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.
|
|
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
|