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.
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
- scheme: 'https',
21
- host: 'vits.me',
22
- port: 443,
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
- raw_info['sub']
73
+ user_info.raw_attributes[:sub] || user_info.raw_attributes['sub'] || user_info.sub
34
74
  end
35
75
 
36
- info do
37
- {
38
- name: raw_info['name'] || raw_info['name__full'],
39
- first_name: raw_info['given_name'],
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 do
51
- { raw_info: raw_info }
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