omniauth-vitsme 0.1.0 → 0.2.1

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.
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OmniAuth
4
+ module Vitsme
5
+ # Turns a vits.me UserInfo response into the `info` and `extra` of the auth hash. One builder for
6
+ # the strategy and for OmniAuth::Vitsme::Test, so a mocked sign-in has exactly the real shape.
7
+ module AuthShape
8
+ SOLID_PREFIX = 'solid_'
9
+ FLUID_PREFIX = 'fluid_'
10
+
11
+ module_function
12
+
13
+ # :solid for a verified person, :fluid for a pending signup (only with the `fluid` scope).
14
+ def identity_kind(uid)
15
+ uid.to_s.start_with?(FLUID_PREFIX) ? :fluid : :solid
16
+ end
17
+
18
+ def info(uid, raw_info)
19
+ claims = claims(raw_info)
20
+ {
21
+ name: claims['name'] || claims['name__full'],
22
+ nationality: claims['nationality'],
23
+ verified: identity_kind(uid) == :solid
24
+ }.compact
25
+ end
26
+
27
+ def extra(uid, raw_info)
28
+ {
29
+ identity: identity_kind(uid),
30
+ # Look a returning person up by every one of these, current first: a record keyed by a defunct
31
+ # SOLID belongs to this person and should move to `uid`.
32
+ lookup_uids: [uid.to_s, *Array(raw_info['defunct_solids'])].uniq,
33
+ # Tentative accounts (keyed by a pending signup's FLUID) to merge into this person.
34
+ fluid_uids: Array(raw_info['fluid']),
35
+ claims: claims(raw_info),
36
+ raw_info: raw_info
37
+ }
38
+ end
39
+
40
+ # The disclosure claims vits.me returned, typed by the catalog. Identity claims (sub, solid,
41
+ # defunct_solids, fluid) are in `uid`, `lookup_uids` and `fluid_uids` instead; anything the
42
+ # catalog does not know stays in raw_info only.
43
+ def claims(raw_info)
44
+ Catalog.claims.each_with_object({}) do |(name, spec), out|
45
+ next if Catalog::DATA['identity_scopes'].key?(spec['scope'])
46
+
47
+ value = Catalog.coerce(name, raw_info[name])
48
+ out[name] = value unless value.nil?
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module OmniAuth
6
+ module Vitsme
7
+ # The scopes, claims, gates and refusal reasons vits.me offers, read from data/claims.json — the
8
+ # same file agents and developers read to choose scopes. The strategy validates `scope:` against it
9
+ # at boot and types every claim value by it.
10
+ module Catalog
11
+ PATH = File.expand_path('../../../data/claims.json', __dir__)
12
+ DATA = JSON.parse(File.read(PATH)).freeze
13
+ GATE_PATTERN = Regexp.new(DATA.dig('gates', 'openid_nationality', 'scope_pattern'))
14
+
15
+ module_function
16
+
17
+ def claims
18
+ DATA['claims']
19
+ end
20
+
21
+ def claim(name)
22
+ claims[name.to_s]
23
+ end
24
+
25
+ # Every requestable scope that is not a gate.
26
+ def scopes
27
+ @scopes ||= (DATA['identity_scopes'].keys + claims.values.map { |c| c['scope'] }).uniq.freeze
28
+ end
29
+
30
+ def gate_scope?(scope)
31
+ GATE_PATTERN.match?(scope.to_s)
32
+ end
33
+
34
+ def known_scope?(scope)
35
+ scopes.include?(scope.to_s) || gate_scope?(scope)
36
+ end
37
+
38
+ # The OmniAuth failure type for a `vitsme_error` refusal reason, or nil for an unknown one.
39
+ def failure_for(reason)
40
+ DATA.dig('refusals', reason.to_s, 'omniauth_failure')&.to_sym
41
+ end
42
+
43
+ # A claim value as the catalog types it. Tolerates the string forms older vits.me servers sent
44
+ # ("true", "37"); anything it cannot read as its type is dropped (nil), never guessed.
45
+ def coerce(name, value)
46
+ return nil if value.nil? || value == ''
47
+
48
+ case claim(name)&.fetch('type')
49
+ when 'boolean' then boolean(value)
50
+ when 'integer' then integer(value)
51
+ when 'string_array' then Array(value).map(&:to_s)
52
+ else value.to_s
53
+ end
54
+ end
55
+
56
+ def boolean(value)
57
+ case value
58
+ when true, 'true' then true
59
+ when false, 'false' then false
60
+ end
61
+ end
62
+
63
+ def integer(value)
64
+ return value if value.is_a?(Integer)
65
+
66
+ Integer(value.to_s, 10, exception: false)
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OmniAuth
4
+ module Vitsme
5
+ ISSUERS = {
6
+ production: 'https://vits.me',
7
+ staging: 'https://staging.vits.me',
8
+ development: 'https://vitsme.localhost'
9
+ }.freeze
10
+
11
+ # The vits.me issuer for an environment: :production, :staging or :development (a Rails.env works
12
+ # too — "test" maps to development).
13
+ def self.issuer(environment = :production)
14
+ key = environment.to_s == 'test' ? :development : environment.to_s.to_sym
15
+ ISSUERS.fetch(key) { raise ArgumentError, "omniauth-vitsme: unknown environment #{environment.inspect}" }
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+ require 'omniauth'
5
+ require 'omniauth/vitsme'
6
+
7
+ module OmniAuth
8
+ module Vitsme
9
+ # Test helpers for apps that sign people in with vits.me. Not loaded by default:
10
+ #
11
+ # require 'omniauth/vitsme/test'
12
+ # OmniAuth.config.test_mode = true
13
+ # OmniAuth::Vitsme::Test.mock_auth(claims: { age__over_18: true }) # provider :vitsme
14
+ # OmniAuth::Vitsme::Test.mock_auth(:verify, uid: person.vitsme_uid, claims: { name: 'Jane D.' })
15
+ # OmniAuth::Vitsme::Test.mock_failure(:vitsme, :nationality_gate_refused)
16
+ #
17
+ # The auth hash is built by the same code as the real strategy's (AuthShape), so `info`, `extra`
18
+ # and the typed claims look exactly as they will in production. Test mode skips the trip to vits.me
19
+ # but still runs the strategy's identity checks (expected_uid, require_verified) on the mock: mock
20
+ # someone other than the signed-in person to see :identity_mismatch. mock_failure is for refusals
21
+ # only vits.me makes (:nationality_gate_refused, :access_denied).
22
+ module Test
23
+ module_function
24
+
25
+ # A plausible private uid. Real ones are opaque; never derive anything from them.
26
+ def uid(identity = :solid)
27
+ "#{identity == :fluid ? AuthShape::FLUID_PREFIX : AuthShape::SOLID_PREFIX}#{SecureRandom.alphanumeric(52).downcase}"
28
+ end
29
+
30
+ # The UserInfo response vits.me would send: identity claims plus the disclosure claims given.
31
+ def raw_info(uid:, claims: {}, defunct_uids: [], fluid_uids: [])
32
+ claims = claims.transform_keys(&:to_s)
33
+ unknown = claims.keys.reject { |name| Catalog.claim(name) }
34
+ raise ArgumentError, "omniauth-vitsme: unknown claim(s) #{unknown.join(', ')}" unless unknown.empty?
35
+
36
+ info = { 'sub' => uid }
37
+ info['solid'] = uid if AuthShape.identity_kind(uid) == :solid
38
+ info['defunct_solids'] = defunct_uids unless defunct_uids.empty?
39
+ info['fluid'] = fluid_uids unless fluid_uids.empty?
40
+ info.merge(claims)
41
+ end
42
+
43
+ def auth_hash(provider = :vitsme, identity: :solid, uid: nil, claims: {}, defunct_uids: [], fluid_uids: [])
44
+ uid ||= self.uid(identity)
45
+ raw = raw_info(uid: uid, claims: claims, defunct_uids: defunct_uids, fluid_uids: fluid_uids)
46
+ OmniAuth::AuthHash.new(
47
+ provider: provider.to_s,
48
+ uid: uid,
49
+ info: AuthShape.info(uid, raw),
50
+ credentials: { id_token: 'test.id.token', scope: 'openid' },
51
+ extra: AuthShape.extra(uid, raw)
52
+ )
53
+ end
54
+
55
+ # Makes the next callback for `provider` succeed with this person.
56
+ def mock_auth(provider = :vitsme, **)
57
+ OmniAuth.config.mock_auth[provider.to_sym] = auth_hash(provider, **)
58
+ end
59
+
60
+ # Makes the next callback for `provider` fail with `type` (e.g. :identity_mismatch, :not_signed_in,
61
+ # :unverified_identity, :nationality_gate_refused, :access_denied).
62
+ def mock_failure(provider = :vitsme, type = :access_denied)
63
+ OmniAuth.config.mock_auth[provider.to_sym] = type.to_sym
64
+ end
65
+ end
66
+ end
67
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module OmniAuth
4
4
  module Vitsme
5
- VERSION = '0.1.0'
5
+ VERSION = '0.2.1'
6
6
  end
7
7
  end
@@ -1,4 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'omniauth/vitsme/version'
4
+ require 'omniauth/vitsme/issuers'
5
+ require 'omniauth/vitsme/catalog'
6
+ require 'omniauth/vitsme/auth_shape'
4
7
  require 'omniauth/strategies/vitsme'
data/llms.txt ADDED
@@ -0,0 +1,9 @@
1
+ # omniauth-vitsme
2
+
3
+ > OmniAuth strategy (Ruby/Rails) for vits.me: sign people in, ask about details they verified (name, age, nationality), and admit only verified nationals of chosen countries. The uid is a private per-site identifier; claims may be absent; follow-up questions about a signed-in person need `expected_uid`.
4
+
5
+ - [AGENTS.md](https://github.com/yuvaltobias/omniauth-vitsme/blob/main/AGENTS.md): rules for agents, read first
6
+ - [data/claims.json](https://github.com/yuvaltobias/omniauth-vitsme/blob/main/data/claims.json): every scope, claim, type, gate and refusal, machine-readable
7
+ - [README.md](https://github.com/yuvaltobias/omniauth-vitsme/blob/main/README.md): quick start, claims, verifying a signed-in person, gates, FLUID, failure types, auth hash, privacy rules, testing
8
+ - [examples/rails/app.rb](https://github.com/yuvaltobias/omniauth-vitsme/blob/main/examples/rails/app.rb): a one-file Rails app using sign-in, a gate and verification
9
+ - [Skill: vitsme-integrate](https://github.com/yuvaltobias/omniauth-vitsme/blob/main/.claude/skills/vitsme-integrate/SKILL.md): add vits.me to a Rails app end to end
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.1.0
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yuval Tobias
@@ -23,26 +23,36 @@ dependencies:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
25
  version: '0.8'
26
- description: OmniAuth OpenID Connect strategy for vits.me, digital identity platform.
26
+ description: Sign people in with vits.me, verify details about them, and gate sign-up
27
+ by nationality — an OpenID Connect strategy with identity matching, typed claims
28
+ and a machine-readable claim catalog.
27
29
  email:
28
30
  - yuval@mattyr.com
29
31
  executables: []
30
32
  extensions: []
31
33
  extra_rdoc_files: []
32
34
  files:
33
- - ".claude/settings.local.json"
34
- - ".rspec"
35
- - ".rspec_status"
36
- - ".rubocop.yml"
35
+ - AGENTS.md
37
36
  - CHANGELOG.md
38
37
  - LICENSE.txt
39
38
  - README.md
40
- - Rakefile
39
+ - data/claims.json
40
+ - lib/generators/omniauth_vitsme/install/install_generator.rb
41
+ - lib/generators/omniauth_vitsme/install/templates/_sign_in.html.erb.tt
42
+ - lib/generators/omniauth_vitsme/install/templates/add_vitsme_uid.rb.tt
43
+ - lib/generators/omniauth_vitsme/install/templates/omniauth_vitsme.rb.tt
44
+ - lib/generators/omniauth_vitsme/install/templates/vitsme.en.yml
45
+ - lib/generators/omniauth_vitsme/install/templates/vitsme.he.yml
46
+ - lib/generators/omniauth_vitsme/install/templates/vitsme_callbacks_controller.rb.tt
41
47
  - lib/omniauth-vitsme.rb
42
48
  - lib/omniauth/strategies/vitsme.rb
43
49
  - lib/omniauth/vitsme.rb
50
+ - lib/omniauth/vitsme/auth_shape.rb
51
+ - lib/omniauth/vitsme/catalog.rb
52
+ - lib/omniauth/vitsme/issuers.rb
53
+ - lib/omniauth/vitsme/test.rb
44
54
  - lib/omniauth/vitsme/version.rb
45
- - omniauth-vitsme.gemspec
55
+ - llms.txt
46
56
  homepage: https://github.com/yuvaltobias/omniauth-vitsme
47
57
  licenses:
48
58
  - MIT
@@ -50,6 +60,7 @@ metadata:
50
60
  homepage_uri: https://github.com/yuvaltobias/omniauth-vitsme
51
61
  source_code_uri: https://github.com/yuvaltobias/omniauth-vitsme
52
62
  changelog_uri: https://github.com/yuvaltobias/omniauth-vitsme/blob/main/CHANGELOG.md
63
+ documentation_uri: https://github.com/yuvaltobias/omniauth-vitsme#readme
53
64
  rubygems_mfa_required: 'true'
54
65
  rdoc_options: []
55
66
  require_paths:
@@ -58,14 +69,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
58
69
  requirements:
59
70
  - - ">="
60
71
  - !ruby/object:Gem::Version
61
- version: 3.0.0
72
+ version: '3.3'
62
73
  required_rubygems_version: !ruby/object:Gem::Requirement
63
74
  requirements:
64
75
  - - ">="
65
76
  - !ruby/object:Gem::Version
66
77
  version: '0'
67
78
  requirements: []
68
- rubygems_version: 3.7.0
79
+ rubygems_version: 4.0.20
69
80
  specification_version: 4
70
81
  summary: OmniAuth strategy for vits.me identity verification
71
82
  test_files: []
@@ -1,27 +0,0 @@
1
- {
2
- "permissions": {
3
- "allow": [
4
- "WebSearch",
5
- "WebFetch(domain:vits.me)",
6
- "WebFetch(domain:github.com)",
7
- "WebFetch(domain:rubygems.org)",
8
- "WebFetch(domain:msuliq.medium.com)",
9
- "Bash(gem contents:*)",
10
- "Bash(gem list:*)",
11
- "WebFetch(domain:raw.githubusercontent.com)",
12
- "Bash(curl:*)",
13
- "Bash(python3:*)",
14
- "Bash(ruby --version:*)",
15
- "Bash(git init:*)",
16
- "Bash(git add:*)",
17
- "Bash(git commit:*)",
18
- "Bash(bundle install:*)",
19
- "Bash(bundle exec rspec:*)",
20
- "Bash(bundle show:*)",
21
- "Bash(bundle exec rubocop:*)",
22
- "Bash(bundle update:*)",
23
- "Bash(gem build:*)",
24
- "Bash(bundle exec rake:*)"
25
- ]
26
- }
27
- }
data/.rspec DELETED
@@ -1,3 +0,0 @@
1
- --format documentation
2
- --color
3
- --require spec_helper
data/.rspec_status DELETED
@@ -1,29 +0,0 @@
1
- example_id | status | run_time |
2
- ------------------------------------------------- | ------ | --------------- |
3
- ./spec/omniauth/strategies/vitsme_spec.rb[1:1:1] | passed | 0.00013 seconds |
4
- ./spec/omniauth/strategies/vitsme_spec.rb[1:1:2] | passed | 0.00034 seconds |
5
- ./spec/omniauth/strategies/vitsme_spec.rb[1:1:3] | passed | 0.00014 seconds |
6
- ./spec/omniauth/strategies/vitsme_spec.rb[1:1:4] | passed | 0.00013 seconds |
7
- ./spec/omniauth/strategies/vitsme_spec.rb[1:1:5] | passed | 0.00026 seconds |
8
- ./spec/omniauth/strategies/vitsme_spec.rb[1:1:6] | passed | 0.00014 seconds |
9
- ./spec/omniauth/strategies/vitsme_spec.rb[1:1:7] | passed | 0.00014 seconds |
10
- ./spec/omniauth/strategies/vitsme_spec.rb[1:2:1] | passed | 0.00014 seconds |
11
- ./spec/omniauth/strategies/vitsme_spec.rb[1:2:2] | passed | 0.00014 seconds |
12
- ./spec/omniauth/strategies/vitsme_spec.rb[1:2:3] | passed | 0.00014 seconds |
13
- ./spec/omniauth/strategies/vitsme_spec.rb[1:2:4] | passed | 0.00014 seconds |
14
- ./spec/omniauth/strategies/vitsme_spec.rb[1:2:5] | passed | 0.00014 seconds |
15
- ./spec/omniauth/strategies/vitsme_spec.rb[1:2:6] | passed | 0.00013 seconds |
16
- ./spec/omniauth/strategies/vitsme_spec.rb[1:2:7] | passed | 0.00014 seconds |
17
- ./spec/omniauth/strategies/vitsme_spec.rb[1:3:1] | passed | 0.00015 seconds |
18
- ./spec/omniauth/strategies/vitsme_spec.rb[1:3:2] | passed | 0.00056 seconds |
19
- ./spec/omniauth/strategies/vitsme_spec.rb[1:4:1] | passed | 0.00024 seconds |
20
- ./spec/omniauth/strategies/vitsme_spec.rb[1:4:2] | passed | 0.00031 seconds |
21
- ./spec/omniauth/strategies/vitsme_spec.rb[1:4:3] | passed | 0.0003 seconds |
22
- ./spec/omniauth/strategies/vitsme_spec.rb[1:4:4] | passed | 0.00031 seconds |
23
- ./spec/omniauth/strategies/vitsme_spec.rb[1:4:5] | passed | 0.00033 seconds |
24
- ./spec/omniauth/strategies/vitsme_spec.rb[1:4:6] | passed | 0.00031 seconds |
25
- ./spec/omniauth/strategies/vitsme_spec.rb[1:4:7] | passed | 0.00033 seconds |
26
- ./spec/omniauth/strategies/vitsme_spec.rb[1:4:8] | passed | 0.00042 seconds |
27
- ./spec/omniauth/strategies/vitsme_spec.rb[1:4:9] | passed | 0.00098 seconds |
28
- ./spec/omniauth/strategies/vitsme_spec.rb[1:4:10] | passed | 0.00022 seconds |
29
- ./spec/omniauth/strategies/vitsme_spec.rb[1:5:1] | passed | 0.00266 seconds |
data/.rubocop.yml DELETED
@@ -1,39 +0,0 @@
1
- plugins:
2
- - rubocop-rspec
3
-
4
- AllCops:
5
- TargetRubyVersion: 3.0
6
- NewCops: enable
7
- SuggestExtensions: false
8
-
9
- Style/Documentation:
10
- Enabled: false
11
-
12
- Style/OpenStructUse:
13
- Exclude:
14
- - "spec/**/*"
15
-
16
- Naming/FileName:
17
- Exclude:
18
- - "lib/omniauth-vitsme.rb"
19
-
20
- Naming/VariableNumber:
21
- Enabled: false
22
-
23
- Metrics/BlockLength:
24
- Exclude:
25
- - "spec/**/*"
26
- - "*.gemspec"
27
-
28
- Metrics/ModuleLength:
29
- Exclude:
30
- - "spec/**/*"
31
-
32
- RSpec/ExampleLength:
33
- Max: 10
34
-
35
- RSpec/MultipleExpectations:
36
- Max: 5
37
-
38
- RSpec/NestedGroups:
39
- Max: 4
data/Rakefile DELETED
@@ -1,9 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'rspec/core/rake_task'
4
- require 'rubocop/rake_task'
5
-
6
- RSpec::Core::RakeTask.new(:spec)
7
- RuboCop::RakeTask.new(:rubocop)
8
-
9
- task default: %i[spec rubocop]
@@ -1,32 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative 'lib/omniauth/vitsme/version'
4
-
5
- Gem::Specification.new do |spec|
6
- spec.name = 'omniauth-vitsme'
7
- spec.version = OmniAuth::Vitsme::VERSION
8
- spec.authors = ['Yuval Tobias']
9
- spec.email = ['yuval@mattyr.com']
10
-
11
- spec.summary = 'OmniAuth strategy for vits.me identity verification'
12
- spec.description = 'OmniAuth OpenID Connect strategy for vits.me, ' \
13
- 'digital identity platform.'
14
- spec.homepage = 'https://github.com/yuvaltobias/omniauth-vitsme'
15
- spec.license = 'MIT'
16
- spec.required_ruby_version = '>= 3.0.0'
17
-
18
- spec.metadata['homepage_uri'] = spec.homepage
19
- spec.metadata['source_code_uri'] = spec.homepage
20
- spec.metadata['changelog_uri'] = "#{spec.homepage}/blob/main/CHANGELOG.md"
21
- spec.metadata['rubygems_mfa_required'] = 'true'
22
-
23
- spec.files = Dir.chdir(__dir__) do
24
- `git ls-files -z`.split("\x0").reject do |f|
25
- (File.expand_path(f) == __FILE__) ||
26
- f.start_with?('spec/', 'test/', '.git', '.github', 'Gemfile')
27
- end
28
- end
29
- spec.require_paths = ['lib']
30
-
31
- spec.add_dependency 'omniauth_openid_connect', '~> 0.8'
32
- end