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.
- checksums.yaml +4 -4
- data/AGENTS.md +61 -0
- data/CHANGELOG.md +51 -0
- data/README.md +215 -117
- data/data/claims.json +175 -0
- data/lib/generators/omniauth_vitsme/install/install_generator.rb +125 -0
- data/lib/generators/omniauth_vitsme/install/templates/_sign_in.html.erb.tt +5 -0
- data/lib/generators/omniauth_vitsme/install/templates/add_vitsme_uid.rb.tt +9 -0
- data/lib/generators/omniauth_vitsme/install/templates/omniauth_vitsme.rb.tt +26 -0
- data/lib/generators/omniauth_vitsme/install/templates/vitsme.en.yml +13 -0
- data/lib/generators/omniauth_vitsme/install/templates/vitsme.he.yml +13 -0
- data/lib/generators/omniauth_vitsme/install/templates/vitsme_callbacks_controller.rb.tt +39 -0
- data/lib/omniauth/strategies/vitsme.rb +139 -21
- data/lib/omniauth/vitsme/auth_shape.rb +53 -0
- data/lib/omniauth/vitsme/catalog.rb +70 -0
- data/lib/omniauth/vitsme/issuers.rb +18 -0
- data/lib/omniauth/vitsme/test.rb +67 -0
- data/lib/omniauth/vitsme/version.rb +1 -1
- data/lib/omniauth/vitsme.rb +3 -0
- data/llms.txt +9 -0
- metadata +21 -10
- data/.claude/settings.local.json +0 -27
- data/.rspec +0 -3
- data/.rspec_status +0 -29
- data/.rubocop.yml +0 -39
- data/Rakefile +0 -9
- data/omniauth-vitsme.gemspec +0 -32
data/data/claims.json
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
{
|
|
2
|
+
"catalog_version": 2,
|
|
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
|
+
"conventions": {
|
|
5
|
+
"delivery": "Every claim except sub comes from the UserInfo endpoint, never the ID token. omniauth-vitsme fetches UserInfo on every sign-in.",
|
|
6
|
+
"absent_means_undisclosed": "A disclosure claim may be missing even when its scope was granted: the person chose to share nothing, holds no such detail, or hid it. Missing is never false.",
|
|
7
|
+
"person_chooses_the_value": "For disclosure claims the person picks the value on vits.me's consent screen, from options at different levels of detail (e.g. a given name, initials, or a full name). Ask for the least you need.",
|
|
8
|
+
"sub_is_private": "sub, solid, defunct_solids and fluid are private per-site identifiers. Store them; never render, log, put in a URL, federate or derive a public handle or email from them."
|
|
9
|
+
},
|
|
10
|
+
"identity_scopes": {
|
|
11
|
+
"openid": {
|
|
12
|
+
"required": true,
|
|
13
|
+
"claims": ["sub", "solid", "defunct_solids"],
|
|
14
|
+
"description": "Sign in. Always requested."
|
|
15
|
+
},
|
|
16
|
+
"fluid": {
|
|
17
|
+
"required": false,
|
|
18
|
+
"claims": ["fluid"],
|
|
19
|
+
"description": "Also sign in people whose verification is still pending (sub = fluid_...). Cannot be combined with a nationality gate.",
|
|
20
|
+
"doc": "https://github.com/yuvaltobias/omniauth-vitsme#unverified-fluid-identities"
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"claims": {
|
|
24
|
+
"sub": {
|
|
25
|
+
"scope": "openid",
|
|
26
|
+
"type": "string",
|
|
27
|
+
"format": "solid_<base32> for a verified person; fluid_<base32> for a pending signup (only with the fluid scope)",
|
|
28
|
+
"description": "Pairwise subject: the same person has a different sub at every site. The OmniAuth uid."
|
|
29
|
+
},
|
|
30
|
+
"solid": {
|
|
31
|
+
"scope": "openid",
|
|
32
|
+
"type": "string",
|
|
33
|
+
"format": "solid_<base32>",
|
|
34
|
+
"description": "Equal to sub for a verified person; absent for a pending signup."
|
|
35
|
+
},
|
|
36
|
+
"defunct_solids": {
|
|
37
|
+
"scope": "openid",
|
|
38
|
+
"type": "string_array",
|
|
39
|
+
"format": "[solid_<base32>, ...]",
|
|
40
|
+
"description": "Earlier SOLIDs this site was given for the same person (kept one year). If your record holds one of these, move it to the current sub."
|
|
41
|
+
},
|
|
42
|
+
"fluid": {
|
|
43
|
+
"scope": "fluid",
|
|
44
|
+
"type": "string_array",
|
|
45
|
+
"format": "[fluid_<base32>, ...]",
|
|
46
|
+
"description": "Pending-signup identifiers this site may have been given for the same person in the past week. Merge any tentative account keyed by one into this person."
|
|
47
|
+
},
|
|
48
|
+
"name": {
|
|
49
|
+
"scope": "name",
|
|
50
|
+
"type": "string",
|
|
51
|
+
"examples": ["Jane", "J. D.", "Jane D.", "Jane Doe"],
|
|
52
|
+
"description": "One name, at the level of detail the person chose. Not split into given and family names. May be in Hebrew script."
|
|
53
|
+
},
|
|
54
|
+
"name__full": {
|
|
55
|
+
"scope": "name__full",
|
|
56
|
+
"type": "string",
|
|
57
|
+
"examples": ["Jane Doe", "Jane M. Doe"],
|
|
58
|
+
"description": "A full name, chosen from the person's full-name forms."
|
|
59
|
+
},
|
|
60
|
+
"nationality": {
|
|
61
|
+
"scope": "nationality",
|
|
62
|
+
"type": "string",
|
|
63
|
+
"format": "ISO 3166-1 alpha-2, upper case",
|
|
64
|
+
"examples": ["IL"],
|
|
65
|
+
"description": "A nationality the person holds and chose to share."
|
|
66
|
+
},
|
|
67
|
+
"nationality__in_eu": {
|
|
68
|
+
"scope": "nationality__in_eu",
|
|
69
|
+
"type": "boolean",
|
|
70
|
+
"description": "Whether the shared nationality is an EU member state."
|
|
71
|
+
},
|
|
72
|
+
"nationality__in_eea": {
|
|
73
|
+
"scope": "nationality__in_eea",
|
|
74
|
+
"type": "boolean",
|
|
75
|
+
"description": "Whether the shared nationality is in the European Economic Area."
|
|
76
|
+
},
|
|
77
|
+
"nationality__in_esm": {
|
|
78
|
+
"scope": "nationality__in_esm",
|
|
79
|
+
"type": "boolean",
|
|
80
|
+
"description": "Whether the shared nationality is in the European Single Market."
|
|
81
|
+
},
|
|
82
|
+
"nationality__gdpr_compliant": {
|
|
83
|
+
"scope": "nationality__gdpr_compliant",
|
|
84
|
+
"type": "boolean",
|
|
85
|
+
"description": "Whether the shared nationality is an EEA member state or the United Kingdom."
|
|
86
|
+
},
|
|
87
|
+
"il_government_id_number": {
|
|
88
|
+
"scope": "il_government_id_number",
|
|
89
|
+
"type": "string",
|
|
90
|
+
"description": "Israeli government ID number (teudat zehut). Highly sensitive: request only with a legal need."
|
|
91
|
+
},
|
|
92
|
+
"age": {
|
|
93
|
+
"scope": "age",
|
|
94
|
+
"type": "string",
|
|
95
|
+
"values": ["under_13", "over_13", "over_18", "over_21", "gen_lost", "gen_greatest", "gen_silent", "gen_boomers", "gen_x", "gen_y", "gen_z", "gen_alpha", "under_18", "18_to_24", "25_to_34", "35_to_44", "45_to_54", "55_to_64", "over_65", "<years as digits>"],
|
|
96
|
+
"description": "The person's age at the level they chose: a threshold, a generation, a range, or exact years."
|
|
97
|
+
},
|
|
98
|
+
"age__years": {
|
|
99
|
+
"scope": "age__years",
|
|
100
|
+
"type": "integer",
|
|
101
|
+
"description": "Exact age in whole years."
|
|
102
|
+
},
|
|
103
|
+
"age__range": {
|
|
104
|
+
"scope": "age__range",
|
|
105
|
+
"type": "string",
|
|
106
|
+
"values": ["under_18", "18_to_24", "25_to_34", "35_to_44", "45_to_54", "55_to_64", "over_65"],
|
|
107
|
+
"description": "An age range."
|
|
108
|
+
},
|
|
109
|
+
"age__over_13": {
|
|
110
|
+
"scope": "age__over_13",
|
|
111
|
+
"type": "boolean",
|
|
112
|
+
"description": "Whether the person is 13 or older."
|
|
113
|
+
},
|
|
114
|
+
"age__over_18": {
|
|
115
|
+
"scope": "age__over_18",
|
|
116
|
+
"type": "boolean",
|
|
117
|
+
"description": "Whether the person is 18 or older."
|
|
118
|
+
},
|
|
119
|
+
"age__over_21": {
|
|
120
|
+
"scope": "age__over_21",
|
|
121
|
+
"type": "boolean",
|
|
122
|
+
"description": "Whether the person is 21 or older."
|
|
123
|
+
},
|
|
124
|
+
"birthdate": {
|
|
125
|
+
"scope": "birthdate",
|
|
126
|
+
"type": "string",
|
|
127
|
+
"format": "YYYY-MM-DD",
|
|
128
|
+
"description": "Full date of birth."
|
|
129
|
+
},
|
|
130
|
+
"birthday": {
|
|
131
|
+
"scope": "birthday",
|
|
132
|
+
"type": "string",
|
|
133
|
+
"format": "M-D (month-day, no year) or YYYY-MM-DD, as the person chose",
|
|
134
|
+
"description": "Birthday. The person may share it with or without the year."
|
|
135
|
+
},
|
|
136
|
+
"birthyear": {
|
|
137
|
+
"scope": "birthyear",
|
|
138
|
+
"type": "string",
|
|
139
|
+
"format": "YYYY",
|
|
140
|
+
"description": "Year of birth."
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
"gates": {
|
|
144
|
+
"openid_nationality": {
|
|
145
|
+
"scope_pattern": "^openid_nationality__[a-z]{2}$",
|
|
146
|
+
"scope_format": "openid_nationality__<iso 3166-1 alpha-2, lower case>",
|
|
147
|
+
"claims": [],
|
|
148
|
+
"description": "Only verified people holding one of the requested nationalities can finish signing in. Several codes are any-of. Emits no claim: a successful sign-in is the answer. vits.me asks the person's consent for the verdict. Refused people return with error=access_denied and vitsme_error=nationality_gate (or nationality_gate_unverified).",
|
|
149
|
+
"incompatible_with": ["fluid"]
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
"refusals": {
|
|
153
|
+
"nationality_gate": {
|
|
154
|
+
"error": "access_denied",
|
|
155
|
+
"omniauth_failure": "nationality_gate_refused",
|
|
156
|
+
"description": "The person holds none of the nationalities the gate requires (or has hidden them)."
|
|
157
|
+
},
|
|
158
|
+
"nationality_gate_unverified": {
|
|
159
|
+
"error": "access_denied",
|
|
160
|
+
"omniauth_failure": "nationality_gate_refused",
|
|
161
|
+
"description": "A pending (unverified) signup tried to pass a nationality gate."
|
|
162
|
+
},
|
|
163
|
+
"subject_mismatch": {
|
|
164
|
+
"error": "access_denied",
|
|
165
|
+
"omniauth_failure": "identity_mismatch",
|
|
166
|
+
"description": "The site said whom it expected (claims sub value) and a different person is signed in at vits.me."
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
"lifetimes_seconds": {
|
|
170
|
+
"authorization_code": 5,
|
|
171
|
+
"access_token": 5,
|
|
172
|
+
"id_token": 5,
|
|
173
|
+
"refresh_token": null
|
|
174
|
+
}
|
|
175
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rails/version'
|
|
4
|
+
require 'rails/generators'
|
|
5
|
+
require 'rails/generators/migration'
|
|
6
|
+
require 'omniauth/vitsme'
|
|
7
|
+
|
|
8
|
+
module OmniAuth
|
|
9
|
+
module Vitsme
|
|
10
|
+
module Generators
|
|
11
|
+
# bin/rails g omniauth_vitsme:install [--model=User] [--verify=age__over_18 name] [--gate=IL] [--fluid]
|
|
12
|
+
#
|
|
13
|
+
# Sign-in with vits.me wired the way the README describes: an initializer, a callbacks controller
|
|
14
|
+
# that finds a returning person by every uid vits.me lists for them, failure messages (en/he), a
|
|
15
|
+
# sign-in button partial, routes, and a migration adding a private, unique `vitsme_uid` column.
|
|
16
|
+
# --verify adds a second provider that asks about the person already signed in, with expected_uid.
|
|
17
|
+
class InstallGenerator < Rails::Generators::Base
|
|
18
|
+
include Rails::Generators::Migration
|
|
19
|
+
|
|
20
|
+
namespace 'omniauth_vitsme:install'
|
|
21
|
+
source_root File.expand_path('templates', __dir__)
|
|
22
|
+
|
|
23
|
+
class_option :model, type: :string, default: 'User', desc: 'The model a vits.me uid signs in as'
|
|
24
|
+
class_option :scope, type: :array, default: [], desc: 'Disclosure scopes to ask for at sign-in'
|
|
25
|
+
class_option :verify, type: :array, default: [],
|
|
26
|
+
desc: 'Scopes for a :vitsme_verify provider about the signed-in person'
|
|
27
|
+
class_option :gate, type: :array, default: [], desc: 'Nationalities (ISO alpha-2) required to sign in'
|
|
28
|
+
class_option :fluid, type: :boolean, default: false, desc: 'Also sign in people pending verification'
|
|
29
|
+
|
|
30
|
+
def self.next_migration_number(_dirname)
|
|
31
|
+
Time.now.utc.strftime('%Y%m%d%H%M%S')
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def check_options
|
|
35
|
+
scopes = sign_in_scopes + verify_scopes
|
|
36
|
+
unknown = scopes.reject { |s| Catalog.known_scope?(s) }
|
|
37
|
+
raise Thor::Error, "Unknown vits.me scope(s): #{unknown.join(', ')}" unless unknown.empty?
|
|
38
|
+
return unless options[:fluid] && !options[:gate].empty?
|
|
39
|
+
|
|
40
|
+
raise Thor::Error, '--fluid and --gate cannot be combined: a gate admits verified people only.'
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def create_initializer
|
|
44
|
+
template 'omniauth_vitsme.rb.tt', 'config/initializers/omniauth_vitsme.rb'
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def create_controller
|
|
48
|
+
template 'vitsme_callbacks_controller.rb.tt', 'app/controllers/vitsme_callbacks_controller.rb'
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def create_views_and_locales
|
|
52
|
+
template '_sign_in.html.erb.tt', 'app/views/vitsme/_sign_in.html.erb'
|
|
53
|
+
copy_file 'vitsme.en.yml', 'config/locales/vitsme.en.yml'
|
|
54
|
+
copy_file 'vitsme.he.yml', 'config/locales/vitsme.he.yml'
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def add_uid_migration
|
|
58
|
+
migration_template 'add_vitsme_uid.rb.tt', "db/migrate/add_vitsme_uid_to_#{table_name}.rb"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def add_routes
|
|
62
|
+
route "get '/auth/vitsme_verify/callback', to: 'vitsme_callbacks#verify'" if verify?
|
|
63
|
+
route "get '/auth/failure', to: 'vitsme_callbacks#failure'"
|
|
64
|
+
route "get '/auth/vitsme/callback', to: 'vitsme_callbacks#sign_in'"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def next_steps
|
|
68
|
+
say <<~TEXT
|
|
69
|
+
|
|
70
|
+
vits.me sign-in is wired. Next:
|
|
71
|
+
1. Ask the vits.me team for client credentials; set VITSME_CLIENT_ID / VITSME_CLIENT_SECRET.
|
|
72
|
+
Register #{callback_urls.join(' and ')} as redirect URIs.
|
|
73
|
+
2. Add gem "omniauth-rails_csrf_protection" (OmniAuth 2 signs in by POST only).
|
|
74
|
+
3. bin/rails db:migrate, then render "vitsme/sign_in" where people sign in.
|
|
75
|
+
4. Read the uid rules in the omniauth-vitsme README: store it, never show or log it.
|
|
76
|
+
TEXT
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def model
|
|
82
|
+
options[:model].camelize
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def session_key
|
|
86
|
+
"#{options[:model].underscore.tr('/', '_')}_id"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def table_name
|
|
90
|
+
options[:model].underscore.tr('/', '_').pluralize
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def sign_in_scopes
|
|
94
|
+
(['openid'] + options[:scope] + (options[:fluid] ? ['fluid'] : [])).uniq
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def verify_scopes
|
|
98
|
+
options[:verify].empty? ? [] : (['openid'] + options[:verify]).uniq
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def verify?
|
|
102
|
+
!options[:verify].empty?
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def gate
|
|
106
|
+
options[:gate].map(&:upcase)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def symbols(list)
|
|
110
|
+
"%i[#{list.join(' ')}]"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def callback_urls
|
|
114
|
+
paths = ['/auth/vitsme/callback']
|
|
115
|
+
paths << '/auth/vitsme_verify/callback' if verify?
|
|
116
|
+
paths.map { |p| "https://<your host>#{p}" }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def migration_version
|
|
120
|
+
"[#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}]"
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
<%%# OmniAuth 2 starts sign-in by POST only (add omniauth-rails_csrf_protection). %>
|
|
2
|
+
<%%= button_to t('vitsme.sign_in'), '/auth/vitsme', method: :post, data: { turbo: false } %>
|
|
3
|
+
<% if verify? -%>
|
|
4
|
+
<%%= button_to t('vitsme.verify'), '/auth/vitsme_verify', method: :post, data: { turbo: false } if session[:<%= session_key %>] %>
|
|
5
|
+
<% end -%>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The private, per-site identifier vits.me returns (the OmniAuth uid). Unique, never shown.
|
|
4
|
+
class AddVitsmeUidTo<%= table_name.camelize %> < ActiveRecord::Migration<%= migration_version %>
|
|
5
|
+
def change
|
|
6
|
+
add_column :<%= table_name %>, :vitsme_uid, :string
|
|
7
|
+
add_index :<%= table_name %>, :vitsme_uid, unique: true
|
|
8
|
+
end
|
|
9
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Sign in with vits.me (omniauth-vitsme). The uid it returns is a private, per-site identifier: store
|
|
4
|
+
# it to find the person again, never show it, log it, put it in a URL or build a handle from it.
|
|
5
|
+
vitsme = {
|
|
6
|
+
client_id: ENV.fetch('VITSME_CLIENT_ID', nil),
|
|
7
|
+
client_secret: ENV.fetch('VITSME_CLIENT_SECRET', nil),
|
|
8
|
+
environment: Rails.env.production? ? :production : :development
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
Rails.application.config.middleware.use OmniAuth::Builder do
|
|
12
|
+
provider :vitsme, vitsme.merge(
|
|
13
|
+
scope: <%= symbols(sign_in_scopes) %><% unless gate.empty? %>,
|
|
14
|
+
# Only verified nationals of these countries finish signing in; vits.me asks for their consent.
|
|
15
|
+
nationality_gate: %w[<%= gate.join(' ') %>]<% end %>
|
|
16
|
+
)
|
|
17
|
+
<% if verify? -%>
|
|
18
|
+
|
|
19
|
+
# Asks vits.me about the person already signed in here, and refuses an answer about anyone else.
|
|
20
|
+
provider :vitsme, vitsme.merge(
|
|
21
|
+
name: :vitsme_verify,
|
|
22
|
+
scope: <%= symbols(verify_scopes) %>,
|
|
23
|
+
expected_uid: ->(env) { <%= model %>.find_by(id: env['rack.session']['<%= session_key %>'])&.vitsme_uid }
|
|
24
|
+
)
|
|
25
|
+
<% end -%>
|
|
26
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
en:
|
|
2
|
+
vitsme:
|
|
3
|
+
sign_in: "Sign in"
|
|
4
|
+
verify: "Verify my details"
|
|
5
|
+
signed_in: "You are signed in."
|
|
6
|
+
verified: "Your details are verified."
|
|
7
|
+
failures:
|
|
8
|
+
default: "Signing in did not work. Please try again."
|
|
9
|
+
access_denied: "Signing in was cancelled."
|
|
10
|
+
not_signed_in: "Sign in first, then verify your details."
|
|
11
|
+
identity_mismatch: "That answer was about a different account than the one signed in here."
|
|
12
|
+
unverified_identity: "This needs a verified account. Finish verifying, then try again."
|
|
13
|
+
nationality_gate_refused: "Only verified nationals of the required countries can join."
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
he:
|
|
2
|
+
vitsme:
|
|
3
|
+
sign_in: "התחברות"
|
|
4
|
+
verify: "אימות הפרטים שלי"
|
|
5
|
+
signed_in: "התחברת בהצלחה."
|
|
6
|
+
verified: "הפרטים שלך אומתו."
|
|
7
|
+
failures:
|
|
8
|
+
default: "ההתחברות לא הצליחה. אפשר לנסות שוב."
|
|
9
|
+
access_denied: "ההתחברות בוטלה."
|
|
10
|
+
not_signed_in: "יש להתחבר קודם, ואז לאמת את הפרטים."
|
|
11
|
+
identity_mismatch: "התשובה התייחסה לחשבון אחר מזה שמחובר כאן."
|
|
12
|
+
unverified_identity: "נדרש חשבון מאומת. אפשר להשלים את האימות ולנסות שוב."
|
|
13
|
+
nationality_gate_refused: "רק אזרחים מאומתים של המדינות הנדרשות יכולים להצטרף."
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Where vits.me sends people back. Never log request.env['omniauth.auth']: it holds the private uid and
|
|
4
|
+
# whatever the person chose to share.
|
|
5
|
+
class VitsmeCallbacksController < ApplicationController
|
|
6
|
+
def sign_in
|
|
7
|
+
auth = request.env['omniauth.auth']
|
|
8
|
+
person = returning_person(auth) || <%= model %>.create!(vitsme_uid: auth.uid)
|
|
9
|
+
reset_session
|
|
10
|
+
session[:<%= session_key %>] = person.id
|
|
11
|
+
redirect_to root_path, notice: t('vitsme.signed_in')
|
|
12
|
+
end
|
|
13
|
+
<% if verify? -%>
|
|
14
|
+
|
|
15
|
+
# The strategy has already checked that this answer is about the person signed in here
|
|
16
|
+
# (expected_uid). A claim the person chose not to share is absent — never treat that as false.
|
|
17
|
+
def verify
|
|
18
|
+
claims = request.env['omniauth.auth'].extra.claims
|
|
19
|
+
# Keep what you asked for, e.g.: current_<%= options[:model].underscore %>.update!(adult: claims['age__over_18'])
|
|
20
|
+
redirect_to root_path, notice: t('vitsme.verified')
|
|
21
|
+
end
|
|
22
|
+
<% end -%>
|
|
23
|
+
|
|
24
|
+
# OmniAuth sends every refusal here with ?message=<failure type>.
|
|
25
|
+
def failure
|
|
26
|
+
redirect_to root_path, alert: t("vitsme.failures.#{params[:message]}", default: t('vitsme.failures.default'))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
# A returning person may be on file under an earlier uid (vits.me's defunct_solids) or, if you ask
|
|
32
|
+
# for the fluid scope, under a pending-signup uid. Move them to the current one.
|
|
33
|
+
def returning_person(auth)
|
|
34
|
+
uids = auth.extra.lookup_uids + auth.extra.fluid_uids
|
|
35
|
+
person = <%= model %>.where(vitsme_uid: uids).min_by { |p| uids.index(p.vitsme_uid) }
|
|
36
|
+
person.update!(vitsme_uid: auth.uid) if person && person.vitsme_uid != auth.uid
|
|
37
|
+
person
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -1,68 +1,186 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'uri'
|
|
3
5
|
require 'omniauth_openid_connect'
|
|
6
|
+
require 'omniauth/vitsme/issuers'
|
|
7
|
+
require 'omniauth/vitsme/catalog'
|
|
8
|
+
require 'omniauth/vitsme/auth_shape'
|
|
4
9
|
|
|
5
10
|
module OmniAuth
|
|
6
11
|
module Strategies
|
|
12
|
+
# Sign in with vits.me, or ask vits.me about someone already signed in to your app.
|
|
13
|
+
#
|
|
14
|
+
# provider :vitsme, client_id: ..., client_secret: ...,
|
|
15
|
+
# scope: %i[openid age__over_18], # checked against data/claims.json at boot
|
|
16
|
+
# nationality_gate: %w[IL], # optional: only verified IL nationals get through
|
|
17
|
+
# expected_uid: ->(env) { ... }, # "details about a signed-in person" only
|
|
18
|
+
# require_verified: true # refuse pending (fluid_) identities
|
|
19
|
+
#
|
|
20
|
+
# See README.md for the auth hash, the failure types and the rules (the uid is private).
|
|
7
21
|
class Vitsme < OpenIDConnect
|
|
8
22
|
option :name, 'vitsme'
|
|
9
23
|
|
|
24
|
+
option :environment, :production
|
|
25
|
+
option :issuer, nil
|
|
10
26
|
option :discovery, true
|
|
11
|
-
option :issuer, 'https://vits.me'
|
|
12
27
|
option :client_signing_alg, :RS256
|
|
13
28
|
option :scope, [:openid]
|
|
14
29
|
option :response_type, 'code'
|
|
15
30
|
option :response_mode, 'query'
|
|
31
|
+
# Every vits.me sign-in asks for the pincode; a regular person's session there lasts one site sign-in.
|
|
32
|
+
option :prompt, :login
|
|
33
|
+
option :pkce, true
|
|
16
34
|
|
|
17
35
|
option :client_options, {
|
|
18
36
|
identifier: nil,
|
|
19
37
|
secret: nil,
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
38
|
+
redirect_uri: nil,
|
|
39
|
+
scheme: nil,
|
|
40
|
+
host: nil,
|
|
41
|
+
port: nil,
|
|
23
42
|
authorization_endpoint: '/oauth/authorize',
|
|
24
43
|
token_endpoint: '/oauth/token',
|
|
25
44
|
userinfo_endpoint: '/oauth/userinfo',
|
|
26
|
-
jwks_uri: '/oauth/discovery/keys'
|
|
45
|
+
jwks_uri: '/oauth/discovery/keys',
|
|
46
|
+
end_session_endpoint: nil
|
|
27
47
|
}
|
|
28
48
|
|
|
29
49
|
option :client_id, nil
|
|
30
50
|
option :client_secret, nil
|
|
31
51
|
|
|
52
|
+
# Nationalities a person must hold to finish signing in (gate scopes `openid_nationality__xx`).
|
|
53
|
+
option :nationality_gate, []
|
|
54
|
+
|
|
55
|
+
# `->(env) { solid_or_nil }` — the uid your app holds for the person signed in to it. Set it on a
|
|
56
|
+
# provider that asks vits.me about that person (verify a name, an age, a nationality): the
|
|
57
|
+
# answer must be theirs. vits.me is told whom to expect, and the callback refuses anyone else.
|
|
58
|
+
option :expected_uid, nil
|
|
59
|
+
# Refuse a pending (fluid_) identity. nil = on whenever expected_uid is set.
|
|
60
|
+
option :require_verified, nil
|
|
61
|
+
# Tell vits.me whom you expect (OIDC `claims` sub value) so it pre-selects that person and refuses
|
|
62
|
+
# others. The callback checks either way.
|
|
63
|
+
option :send_expected_uid, true
|
|
64
|
+
|
|
65
|
+
FAILURES = %i[identity_mismatch not_signed_in unverified_identity nationality_gate_refused].freeze
|
|
66
|
+
|
|
67
|
+
def initialize(app, *args, &)
|
|
68
|
+
super
|
|
69
|
+
normalize_options!
|
|
70
|
+
end
|
|
71
|
+
|
|
32
72
|
def uid
|
|
33
|
-
|
|
73
|
+
user_info.raw_attributes[:sub] || user_info.raw_attributes['sub'] || user_info.sub
|
|
34
74
|
end
|
|
35
75
|
|
|
36
|
-
info
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
last_name: raw_info['family_name'],
|
|
41
|
-
email: raw_info['email'],
|
|
42
|
-
birthday: raw_info['birthdate'] || raw_info['birthday'],
|
|
43
|
-
nationality: raw_info['nationality'],
|
|
44
|
-
age_over_13: raw_info['age__over_13'],
|
|
45
|
-
age_over_18: raw_info['age__over_18'],
|
|
46
|
-
age_over_21: raw_info['age__over_21']
|
|
47
|
-
}.compact
|
|
76
|
+
# Methods, not the `info { }` DSL: OmniAuth merges a DSL block with every ancestor's, and the base
|
|
77
|
+
# OpenID Connect strategy's would add email, first_name, nickname… — fields vits.me never sends.
|
|
78
|
+
def info
|
|
79
|
+
OmniAuth::Vitsme::AuthShape.info(uid, raw_info)
|
|
48
80
|
end
|
|
49
81
|
|
|
50
|
-
extra
|
|
51
|
-
|
|
82
|
+
def extra
|
|
83
|
+
OmniAuth::Vitsme::AuthShape.extra(uid, raw_info)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# vits.me issues no refresh token and its access token lapses five seconds after issue: everything
|
|
87
|
+
# it can tell you is already in `info` and `extra`.
|
|
88
|
+
def credentials
|
|
89
|
+
{ id_token: access_token.id_token, scope: access_token.scope }
|
|
52
90
|
end
|
|
53
91
|
|
|
54
92
|
def raw_info
|
|
55
|
-
@raw_info ||= user_info.raw_attributes
|
|
93
|
+
@raw_info ||= user_info.raw_attributes.to_h.transform_keys(&:to_s)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def request_phase
|
|
97
|
+
return fail!(:not_signed_in) if options.expected_uid && expected_uid.empty?
|
|
98
|
+
|
|
99
|
+
super
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def authorize_uri
|
|
103
|
+
uri = super
|
|
104
|
+
return uri unless options.expected_uid && options.send_expected_uid && !expected_uid.empty?
|
|
105
|
+
|
|
106
|
+
with_query(uri, 'claims' => { id_token: { sub: { value: expected_uid } } }.to_json)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def callback_phase
|
|
110
|
+
if params['error'] && (failure = OmniAuth::Vitsme::Catalog.failure_for(params['vitsme_error']))
|
|
111
|
+
return fail!(failure, CallbackError.new(error: params['error'], reason: params['error_description']))
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
super
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# The identity checks run here, after the auth hash is built and before your app sees it.
|
|
118
|
+
def call_app!(env = @env)
|
|
119
|
+
return super unless on_callback_path? && env['omniauth.auth']
|
|
120
|
+
|
|
121
|
+
auth = env['omniauth.auth']
|
|
122
|
+
return fail!(:unverified_identity) if require_verified? && auth.dig('extra', 'identity').to_s == 'fluid'
|
|
123
|
+
return super unless options.expected_uid
|
|
124
|
+
return fail!(:not_signed_in) if expected_uid.empty?
|
|
125
|
+
return fail!(:identity_mismatch) unless Array(auth.dig('extra', 'lookup_uids')).include?(expected_uid)
|
|
126
|
+
|
|
127
|
+
super
|
|
56
128
|
end
|
|
57
129
|
|
|
58
130
|
private
|
|
59
131
|
|
|
132
|
+
def require_verified?
|
|
133
|
+
options.require_verified.nil? ? !options.expected_uid.nil? : options.require_verified
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def expected_uid
|
|
137
|
+
@expected_uid ||= options.expected_uid.call(env).to_s
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def normalize_options!
|
|
141
|
+
options.issuer ||= OmniAuth::Vitsme.issuer(options.environment)
|
|
142
|
+
scopes = (Array(options.scope).map(&:to_s) | ['openid']) +
|
|
143
|
+
Array(options.nationality_gate).map { |code| "openid_nationality__#{code.to_s.downcase}" }
|
|
144
|
+
validate_scopes!(scopes)
|
|
145
|
+
options.scope = scopes.uniq.map(&:to_sym)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def validate_scopes!(scopes)
|
|
149
|
+
unknown = scopes.reject { |s| OmniAuth::Vitsme::Catalog.known_scope?(s) }
|
|
150
|
+
unless unknown.empty?
|
|
151
|
+
raise ArgumentError, "omniauth-vitsme: unknown scope(s) #{unknown.join(', ')}. " \
|
|
152
|
+
"Known: #{OmniAuth::Vitsme::Catalog.scopes.join(', ')}, openid_nationality__<cc>."
|
|
153
|
+
end
|
|
154
|
+
return unless scopes.include?('fluid') && scopes.any? { |s| OmniAuth::Vitsme::Catalog.gate_scope?(s) }
|
|
155
|
+
|
|
156
|
+
raise ArgumentError, 'omniauth-vitsme: a nationality gate admits verified people only; ' \
|
|
157
|
+
'vits.me refuses it together with the fluid scope.'
|
|
158
|
+
end
|
|
159
|
+
|
|
60
160
|
def client_options
|
|
61
161
|
opts = options.client_options
|
|
162
|
+
issuer = URI(options.issuer)
|
|
163
|
+
opts.scheme ||= issuer.scheme
|
|
164
|
+
opts.host ||= issuer.host
|
|
165
|
+
opts.port ||= issuer.port
|
|
62
166
|
opts.identifier ||= options.client_id if options.client_id
|
|
63
167
|
opts.secret ||= options.client_secret if options.client_secret
|
|
64
168
|
opts
|
|
65
169
|
end
|
|
170
|
+
|
|
171
|
+
# The base strategy needs client_options.redirect_uri spelled out; default it to this provider's
|
|
172
|
+
# callback on the current host (without the callback's own code/state query).
|
|
173
|
+
def redirect_uri
|
|
174
|
+
return super if client_options.redirect_uri
|
|
175
|
+
|
|
176
|
+
full_host + callback_path
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def with_query(uri, extra)
|
|
180
|
+
parsed = URI(uri)
|
|
181
|
+
parsed.query = URI.encode_www_form(URI.decode_www_form(parsed.query.to_s) + extra.to_a)
|
|
182
|
+
parsed.to_s
|
|
183
|
+
end
|
|
66
184
|
end
|
|
67
185
|
end
|
|
68
186
|
end
|