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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: aa0610f9d0d2e8dd9f3712a4f5b071a5fd680d2218e8b6155e3383d95c191da1
|
|
4
|
+
data.tar.gz: cdf33cdcb3b192bd2ba1e05dd9836d4078fe9da5caa221b370746ebba2fd146a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f317e9bafb4ad03afcb8bbbe582e322c4d2da73f5adb9d30a11c44b04aa2b1913f0ff9350c1450bc5a7e48583339084e45c6248baecca57d27c1fed01379af87
|
|
7
|
+
data.tar.gz: 1706fb04b709cc4577f76095c2416b58a767c24e2feab20341a2a78fe7c0a0cc41205e10448ea7aecd0ddfd18645f07337ea703b54057275f993088b366a3ce8
|
data/AGENTS.md
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# AGENTS.md — omniauth-vitsme
|
|
2
|
+
|
|
3
|
+
You are helping someone sign people in with vits.me, or ask vits.me about them, from a Ruby/Rails
|
|
4
|
+
app. These rules are not style preferences: breaking one leaks a private identifier or credits one
|
|
5
|
+
person's verified details to another.
|
|
6
|
+
|
|
7
|
+
## Choosing what to ask for
|
|
8
|
+
|
|
9
|
+
- Scopes, claims, types, value formats and gates are all in [data/claims.json](data/claims.json).
|
|
10
|
+
Read it instead of guessing; an unknown scope raises at boot.
|
|
11
|
+
- Ask for the least that answers the question: `age__over_18` rather than `birthdate`, a gate
|
|
12
|
+
rather than `nationality` when "is this person Israeli?" is all you need.
|
|
13
|
+
- vits.me has no email, phone, picture or username claim. Ask the person for anything else.
|
|
14
|
+
|
|
15
|
+
## Rules
|
|
16
|
+
|
|
17
|
+
1. **The uid is private.** `auth.uid` (`sub`), `extra.lookup_uids` and `extra.fluid_uids` are
|
|
18
|
+
per-site secrets. Store them server-side as the lookup key, and never render, log, put in a URL,
|
|
19
|
+
federate, or derive a handle or email from them.
|
|
20
|
+
2. **Don't log the auth hash.** `request.env["omniauth.auth"]` holds the uid and every shared detail.
|
|
21
|
+
3. **Follow-up questions about a signed-in person need `expected_uid`.** A provider that verifies a
|
|
22
|
+
name, an age or a nationality of someone already signed in to the app must set
|
|
23
|
+
`expected_uid: ->(env) { ...the signed-in person's stored uid... }`. Without it, whoever signs in
|
|
24
|
+
at vits.me gets their details credited to the signed-in account. Don't set it on the sign-in
|
|
25
|
+
provider: there is nobody to expect yet.
|
|
26
|
+
4. **Missing is not false.** Any disclosure claim may be absent: not shared, not held, or hidden.
|
|
27
|
+
Branch on `true` / `false` / absent, and never default a missing boolean to `false`.
|
|
28
|
+
5. **A gate emits nothing.** `nationality_gate:` admits only verified nationals. Success is the
|
|
29
|
+
answer, and a refusal is `:nationality_gate_refused`. It can't be combined with the `fluid` scope.
|
|
30
|
+
6. **`fluid_` means unverified.** Without the `fluid` scope you never see one. With it, give such
|
|
31
|
+
people a limited, tentative account and merge it when `extra.fluid_uids` shows up on a verified
|
|
32
|
+
sign-in.
|
|
33
|
+
7. **Read everything in the callback.** Tokens live 5 seconds and there is no refresh token. Nothing
|
|
34
|
+
can be fetched later.
|
|
35
|
+
8. **Find returning people by `extra.lookup_uids`** (plus `fluid_uids` with FLUID), current first, and
|
|
36
|
+
move a record found under an older uid to `auth.uid`.
|
|
37
|
+
9. **User-facing copy says "verified"**, not "vits.me-verified". There is no need to name vits.me to
|
|
38
|
+
end users.
|
|
39
|
+
10. **Handle every failure type** in [README.md#failure-types](README.md#failure-types):
|
|
40
|
+
`not_signed_in`, `identity_mismatch`, `unverified_identity`, `nationality_gate_refused`,
|
|
41
|
+
`access_denied`.
|
|
42
|
+
|
|
43
|
+
## Doing the work
|
|
44
|
+
|
|
45
|
+
- Rails: `bin/rails g omniauth_vitsme:install` (see `--help`) writes the initializer, controller,
|
|
46
|
+
locales, button and migration correctly. Prefer it to hand-writing.
|
|
47
|
+
- Tests: `require "omniauth/vitsme/test"`, then `OmniAuth::Vitsme::Test.mock_auth(...)` /
|
|
48
|
+
`.mock_failure(...)`. Mocks have the real shape; unknown claims raise.
|
|
49
|
+
- A worked app: [examples/rails/app.rb](examples/rails/app.rb).
|
|
50
|
+
- The skill [.claude/skills/vitsme-integrate/SKILL.md](.claude/skills/vitsme-integrate/SKILL.md) walks
|
|
51
|
+
through adding vits.me to an app end to end.
|
|
52
|
+
|
|
53
|
+
## Working on this gem
|
|
54
|
+
|
|
55
|
+
- `bundle exec rspec && bundle exec rubocop` must pass. The suite boots the example Rails app.
|
|
56
|
+
- `data/claims.json` mirrors the vits.me server (`config/initializers/doorkeeper*.rb` in the
|
|
57
|
+
mattyr mono-repo). Change them together, and keep `spec/omniauth/vitsme/catalog_spec.rb`'s scope
|
|
58
|
+
list in step.
|
|
59
|
+
- `AuthShape` builds the auth hash for both the strategy and the test helper. Change the shape there
|
|
60
|
+
only.
|
|
61
|
+
- Pre-1.0: breaking changes are fine. Record them in CHANGELOG.md.
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,57 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.2.1] - 2026-11-02
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- The docs claimed OmniAuth's test mode skips the identity checks. It doesn't: test mode skips the
|
|
13
|
+
trip to vits.me, but `expected_uid` and `require_verified` still judge the mocked answer. The
|
|
14
|
+
README, the test helper and the skill now say so, and four specs pin the behaviour. The examples
|
|
15
|
+
mock someone else to get `:identity_mismatch` and keep `mock_failure` for refusals only vits.me
|
|
16
|
+
makes.
|
|
17
|
+
|
|
18
|
+
## [0.2.0] - 2026-11-02
|
|
19
|
+
|
|
20
|
+
A rewrite that brings the gem up to the vits.me platform. **Breaking:** the auth hash changed shape,
|
|
21
|
+
and Ruby ≥ 3.3 is required.
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
|
|
25
|
+
- `expected_uid:` for follow-up questions about a person already signed in. vits.me is told whom to
|
|
26
|
+
expect (OIDC `claims` sub value) and pre-selects that person. The callback refuses anyone else
|
|
27
|
+
(`:identity_mismatch`) and fails early when nobody is signed in (`:not_signed_in`). Earlier SOLIDs
|
|
28
|
+
listed in `defunct_solids` still match.
|
|
29
|
+
- `require_verified:` refuses pending (`fluid_`) identities (`:unverified_identity`). It is on by
|
|
30
|
+
default with `expected_uid`.
|
|
31
|
+
- `nationality_gate: %w[IL]` adds `openid_nationality__xx` gate scopes. A gate refusal is
|
|
32
|
+
`:nationality_gate_refused`, read from vits.me's machine-readable `vitsme_error`. Combining a gate
|
|
33
|
+
with `fluid` raises at boot.
|
|
34
|
+
- `environment:` (`:production` / `:staging` / `:development`) and `OmniAuth::Vitsme.issuer`. The host
|
|
35
|
+
is derived from the issuer.
|
|
36
|
+
- `data/claims.json`: a machine-readable catalog of every scope, claim, type, gate and refusal.
|
|
37
|
+
`scope:` is validated against it at boot.
|
|
38
|
+
- `extra.claims`: the shared claims, typed (booleans, integers). `extra.identity` (`:solid` / `:fluid`),
|
|
39
|
+
`extra.lookup_uids` (current + defunct) and `extra.fluid_uids`. `info.verified`.
|
|
40
|
+
- PKCE (S256) and `prompt: :login` by default. `redirect_uri` defaults to the provider's callback.
|
|
41
|
+
- `OmniAuth::Vitsme::Test` (`mock_auth`, `mock_failure`), which builds mocks with the real shape.
|
|
42
|
+
- `bin/rails g omniauth_vitsme:install`: initializer, callbacks controller, en/he messages, sign-in
|
|
43
|
+
button, routes and a `vitsme_uid` migration.
|
|
44
|
+
- `examples/rails/app.rb`, booted by the test suite. AGENTS.md, llms.txt and a Claude Code skill
|
|
45
|
+
(`.claude/skills/vitsme-integrate`).
|
|
46
|
+
|
|
47
|
+
### Changed
|
|
48
|
+
|
|
49
|
+
- `info` holds only what vits.me sends: `name`, `nationality`, `verified`. `credentials` holds
|
|
50
|
+
`id_token` and `scope` (vits.me issues no refresh token and its access token lapses in 5 s).
|
|
51
|
+
|
|
52
|
+
### Removed
|
|
53
|
+
|
|
54
|
+
- `info.first_name`, `last_name`, `email`, `birthday`, `age_over_*`: vits.me never sent the first
|
|
55
|
+
three. `birthday` conflated `birthdate` with a month-day value. The `age_over_*` flags passed
|
|
56
|
+
`"false"` through as a truthy string. Use `extra.claims`.
|
|
57
|
+
- Ruby 3.0–3.2 support.
|
|
58
|
+
|
|
8
59
|
## [0.1.0] - 2026-02-11
|
|
9
60
|
|
|
10
61
|
### Added
|
data/README.md
CHANGED
|
@@ -1,182 +1,280 @@
|
|
|
1
1
|
# OmniAuth vits.me
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
3
|
+
Sign people in with [vits.me](https://vits.me), ask about details they have verified there (a name, an
|
|
4
|
+
age, a nationality), and admit only verified nationals of chosen countries. An OmniAuth strategy on top
|
|
5
|
+
of [omniauth_openid_connect](https://github.com/omniauth/omniauth_openid_connect), with the vits.me
|
|
6
|
+
rules built in.
|
|
7
|
+
|
|
8
|
+
> **Agents:** read [AGENTS.md](AGENTS.md) first. Every scope and claim is in
|
|
9
|
+
> [data/claims.json](data/claims.json).
|
|
10
|
+
|
|
11
|
+
- [Quick start](#quick-start)
|
|
12
|
+
- [Environments](#environments)
|
|
13
|
+
- [Claims](#claims)
|
|
14
|
+
- [Verifying a signed-in person](#verifying-a-signed-in-person)
|
|
15
|
+
- [Gated communities](#gated-communities)
|
|
16
|
+
- [Unverified (FLUID) identities](#unverified-fluid-identities)
|
|
17
|
+
- [Finding a returning person](#finding-a-returning-person)
|
|
18
|
+
- [Failure types](#failure-types)
|
|
19
|
+
- [The auth hash](#the-auth-hash)
|
|
20
|
+
- [Operational notes](#operational-notes)
|
|
21
|
+
- [Privacy rules](#privacy-rules)
|
|
22
|
+
- [Testing](#testing)
|
|
23
|
+
- [Options](#options)
|
|
24
|
+
|
|
25
|
+
## Quick start
|
|
10
26
|
|
|
11
27
|
```ruby
|
|
12
|
-
|
|
28
|
+
# Gemfile
|
|
29
|
+
gem "omniauth-vitsme", "~> 0.2"
|
|
30
|
+
gem "omniauth-rails_csrf_protection" # OmniAuth 2 starts sign-in by POST only
|
|
13
31
|
```
|
|
14
32
|
|
|
15
|
-
|
|
33
|
+
The generator writes everything below: an initializer, a callbacks controller, failure messages
|
|
34
|
+
(en/he), a sign-in button, routes, and a migration adding a unique `vitsme_uid` column.
|
|
16
35
|
|
|
17
36
|
```
|
|
18
|
-
|
|
37
|
+
bin/rails g omniauth_vitsme:install [--model=User] [--scope=name] [--verify=age__over_18] [--gate=IL] [--fluid]
|
|
19
38
|
```
|
|
20
39
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
### Rails (Rack middleware)
|
|
40
|
+
Or by hand:
|
|
24
41
|
|
|
25
42
|
```ruby
|
|
26
43
|
Rails.application.config.middleware.use OmniAuth::Builder do
|
|
27
44
|
provider :vitsme,
|
|
28
45
|
client_id: ENV["VITSME_CLIENT_ID"],
|
|
29
|
-
client_secret: ENV["VITSME_CLIENT_SECRET"]
|
|
46
|
+
client_secret: ENV["VITSME_CLIENT_SECRET"],
|
|
47
|
+
environment: Rails.env.production? ? :production : :development
|
|
30
48
|
end
|
|
31
49
|
```
|
|
32
50
|
|
|
33
|
-
|
|
51
|
+
```erb
|
|
52
|
+
<%= button_to "Sign in", "/auth/vitsme", method: :post, data: { turbo: false } %>
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Client credentials come from the vits.me team: there is no self-service registration yet. Register
|
|
56
|
+
`https://<your host>/auth/vitsme/callback` (and one callback per extra provider) as redirect URIs.
|
|
57
|
+
|
|
58
|
+
## Environments
|
|
59
|
+
|
|
60
|
+
| `environment:` | Issuer |
|
|
61
|
+
|---|---|
|
|
62
|
+
| `:production` (default) | `https://vits.me` |
|
|
63
|
+
| `:staging` | `https://staging.vits.me` |
|
|
64
|
+
| `:development` / `"test"` | `https://vitsme.localhost` (local, mkcert certificate) |
|
|
65
|
+
|
|
66
|
+
`OmniAuth::Vitsme.issuer(Rails.env)` gives the same mapping. An explicit `issuer:` wins.
|
|
67
|
+
|
|
68
|
+
## Claims
|
|
69
|
+
|
|
70
|
+
Ask for a claim by its scope: `scope: %i[openid age__over_18 nationality]`. `openid` is always added.
|
|
71
|
+
An unknown scope raises `ArgumentError` when the app boots. The full list, with types and value
|
|
72
|
+
formats, is [data/claims.json](data/claims.json).
|
|
73
|
+
|
|
74
|
+
| Scope = claim | Type in `extra.claims` | Example |
|
|
75
|
+
|---|---|---|
|
|
76
|
+
| `name` | String: one name at the detail the person chose | `"Jane"`, `"J. D."`, `"Jane Doe"` |
|
|
77
|
+
| `name__full` | String | `"Jane Doe"` |
|
|
78
|
+
| `nationality` | String, ISO 3166-1 alpha-2 | `"IL"` |
|
|
79
|
+
| `nationality__in_eu`, `__in_eea`, `__in_esm`, `__gdpr_compliant` | Boolean | `true` |
|
|
80
|
+
| `il_government_id_number` | String | (sensitive: ask only with a legal need) |
|
|
81
|
+
| `age` | String: a threshold, generation, range or years | `"over_18"`, `"gen_x"`, `"25_to_34"`, `"37"` |
|
|
82
|
+
| `age__years` | Integer | `37` |
|
|
83
|
+
| `age__range` | String | `"25_to_34"` |
|
|
84
|
+
| `age__over_13`, `__over_18`, `__over_21` | Boolean | `true` |
|
|
85
|
+
| `birthdate` | String `YYYY-MM-DD` | `"1990-01-15"` |
|
|
86
|
+
| `birthday` | String `M-D` or `YYYY-MM-DD`, as the person chose | `"1-15"` |
|
|
87
|
+
| `birthyear` | String `YYYY` | `"1990"` |
|
|
88
|
+
|
|
89
|
+
**The person chooses.** On vits.me's consent screen the person picks what to share for each scope,
|
|
90
|
+
at the level of detail they prefer, or nothing at all.
|
|
91
|
+
|
|
92
|
+
**Missing is not false.** A claim may be absent even when its scope was granted: the person shared
|
|
93
|
+
nothing, holds no such detail, or hid it. `extra.claims["age__over_18"]` is `true`, `false`, or not
|
|
94
|
+
there. Treat "not there" as "unknown", never as "no":
|
|
34
95
|
|
|
35
96
|
```ruby
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
scope: [:openid, :name, :age__over_18, :nationality]
|
|
97
|
+
case auth.extra.claims["age__over_18"]
|
|
98
|
+
when true then admit
|
|
99
|
+
when false then refuse
|
|
100
|
+
else ask_them_to_share_it
|
|
41
101
|
end
|
|
42
102
|
```
|
|
43
103
|
|
|
44
|
-
|
|
104
|
+
There is no email, phone, picture or username. If you need a public handle, ask the person for one.
|
|
105
|
+
|
|
106
|
+
## Verifying a signed-in person
|
|
107
|
+
|
|
108
|
+
vits.me signs in **whoever is at its keyboard** and answers about them. A provider that asks about the
|
|
109
|
+
person *already signed in to your app* must make sure the answer is about them, or one person's
|
|
110
|
+
verified details get credited to another. `expected_uid` does that:
|
|
45
111
|
|
|
46
112
|
```ruby
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
end
|
|
113
|
+
provider :vitsme,
|
|
114
|
+
name: :vitsme_verify,
|
|
115
|
+
client_id: ENV["VITSME_CLIENT_ID"], client_secret: ENV["VITSME_CLIENT_SECRET"],
|
|
116
|
+
scope: %i[openid age__over_18],
|
|
117
|
+
expected_uid: ->(env) { User.find_by(id: env["rack.session"]["user_id"])&.vitsme_uid }
|
|
53
118
|
```
|
|
54
119
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
| `nationality` | ISO country code |
|
|
63
|
-
| `nationality__in_eu` | Whether the user is an EU national |
|
|
64
|
-
| `birthday` / `birthdate` | Date of birth |
|
|
65
|
-
| `birthyear` | Year of birth |
|
|
66
|
-
| `age__over_13` | Age verification (13+) |
|
|
67
|
-
| `age__over_18` | Age verification (18+) |
|
|
68
|
-
| `age__over_21` | Age verification (21+) |
|
|
120
|
+
- Nobody signed in: the request fails `:not_signed_in` without leaving your app.
|
|
121
|
+
- vits.me is told whom you expect (the standard OpenID Connect `claims` sub request). It opens the
|
|
122
|
+
pincode page on that person's account and refuses anyone else (`:identity_mismatch`).
|
|
123
|
+
- The callback checks again, in the middleware, before your controller runs: the answer must be about
|
|
124
|
+
`expected_uid` or a uid vits.me lists in `defunct_solids`. Otherwise it fails `:identity_mismatch`.
|
|
125
|
+
- With `expected_uid` set, a pending (`fluid_`) identity fails `:unverified_identity`
|
|
126
|
+
(`require_verified: false` turns that off).
|
|
69
127
|
|
|
70
|
-
|
|
128
|
+
Use `expected_uid` only for these follow-up questions. At sign-in there is nobody to expect yet.
|
|
71
129
|
|
|
72
|
-
|
|
130
|
+
## Gated communities
|
|
73
131
|
|
|
74
132
|
```ruby
|
|
75
|
-
|
|
76
|
-
provider: "vitsme",
|
|
77
|
-
uid: "pairwise-subject-identifier",
|
|
78
|
-
info: {
|
|
79
|
-
name: "Jane Doe",
|
|
80
|
-
first_name: "Jane",
|
|
81
|
-
last_name: "Doe",
|
|
82
|
-
email: "jane@example.com",
|
|
83
|
-
birthday: "1990-01-15",
|
|
84
|
-
nationality: "IL",
|
|
85
|
-
age_over_13: true,
|
|
86
|
-
age_over_18: true,
|
|
87
|
-
age_over_21: true
|
|
88
|
-
},
|
|
89
|
-
credentials: {
|
|
90
|
-
id_token: "eyJ...",
|
|
91
|
-
token: "access_token_value",
|
|
92
|
-
refresh_token: "refresh_token_value",
|
|
93
|
-
expires_in: 3600,
|
|
94
|
-
scope: "openid name"
|
|
95
|
-
},
|
|
96
|
-
extra: {
|
|
97
|
-
raw_info: {
|
|
98
|
-
sub: "pairwise-subject-identifier",
|
|
99
|
-
name: "Jane Doe",
|
|
100
|
-
# ... all raw claims from the userinfo endpoint
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
}
|
|
133
|
+
provider :vitsme, client_id: ..., client_secret: ..., nationality_gate: %w[IL]
|
|
104
134
|
```
|
|
105
135
|
|
|
106
|
-
Only
|
|
136
|
+
Only verified people holding one of the listed nationalities finish signing in. Several codes mean
|
|
137
|
+
any of them. vits.me asks the person to consent to this check. **The gate sends no claim back**: a
|
|
138
|
+
successful sign-in is the answer. Add the `nationality` scope if you also want the value. A person
|
|
139
|
+
who doesn't pass returns as `:nationality_gate_refused`, and no account is created.
|
|
107
140
|
|
|
108
|
-
|
|
141
|
+
A gate admits verified people only, so it can't be combined with the `fluid` scope (that raises at
|
|
142
|
+
boot). Make your gated sign-in button look different from an ordinary one, so people know before they
|
|
143
|
+
start. To build a community that way, follow madon's lead: gate the sign-in, then ask the new member
|
|
144
|
+
to pick a public handle. Never derive the handle from the uid.
|
|
109
145
|
|
|
110
|
-
##
|
|
146
|
+
## Unverified (FLUID) identities
|
|
111
147
|
|
|
112
|
-
|
|
148
|
+
By default only verified people can sign in. With `scope: %i[openid fluid]`, people whose verification
|
|
149
|
+
is still pending can sign in too:
|
|
113
150
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
| `discovery` | `true` | Fetch endpoints from `.well-known/openid-configuration` |
|
|
120
|
-
| `issuer` | `https://vits.me` | OIDC issuer URL |
|
|
121
|
-
| `response_type` | `code` | OAuth response type (Authorization Code Flow) |
|
|
151
|
+
- `uid` starts with `fluid_`, `extra.identity` is `:fluid`, and `info.verified` is `false`.
|
|
152
|
+
- A pending identity lasts one week. Give it a limited, tentative account.
|
|
153
|
+
- When the person is verified, a later sign-in returns their `solid_` uid with the pending uids in
|
|
154
|
+
`extra.fluid_uids`. Merge the tentative account into the verified one, and drop tentative accounts
|
|
155
|
+
nobody claimed after a week.
|
|
122
156
|
|
|
123
|
-
##
|
|
157
|
+
## Finding a returning person
|
|
124
158
|
|
|
125
|
-
|
|
159
|
+
Look a person up by `extra.lookup_uids`, current uid first, plus `extra.fluid_uids` if you use FLUID.
|
|
160
|
+
A record found under an older uid belongs to this person: move it to `auth.uid`.
|
|
126
161
|
|
|
127
162
|
```ruby
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
client_secret: ENV["VITSME_STAGING_CLIENT_SECRET"],
|
|
133
|
-
client_options: {
|
|
134
|
-
site: 'https://staging.vits.me',
|
|
135
|
-
authorize_url: 'https://staging.vits.me/oauth/authorize',
|
|
136
|
-
token_url: 'https://staging.vits.me/oauth/token',
|
|
137
|
-
userinfo_endpoint: 'https://staging.vits.me/userinfo'
|
|
138
|
-
}
|
|
139
|
-
end
|
|
163
|
+
uids = auth.extra.lookup_uids + auth.extra.fluid_uids
|
|
164
|
+
user = User.where(vitsme_uid: uids).min_by { |u| uids.index(u.vitsme_uid) }
|
|
165
|
+
user&.update!(vitsme_uid: auth.uid) unless user&.vitsme_uid == auth.uid
|
|
166
|
+
user ||= User.create!(vitsme_uid: auth.uid)
|
|
140
167
|
```
|
|
141
|
-
**Note:** The URLs in the example above are placeholders. Please refer to the official Vits.me documentation for the correct staging URLs.
|
|
142
168
|
|
|
143
|
-
|
|
169
|
+
(vits.me lists superseded uids in `defunct_solids` for a year after a re-key.)
|
|
144
170
|
|
|
145
|
-
|
|
171
|
+
## Failure types
|
|
146
172
|
|
|
147
|
-
|
|
173
|
+
OmniAuth sends failures to `OmniAuth.config.on_failure` (in Rails, `/auth/failure?message=<type>`).
|
|
148
174
|
|
|
149
|
-
|
|
175
|
+
| Type | Meaning |
|
|
176
|
+
|---|---|
|
|
177
|
+
| `:not_signed_in` | `expected_uid` returned nothing: nobody is signed in to your app. |
|
|
178
|
+
| `:identity_mismatch` | The answer was about someone other than `expected_uid`, whether caught by vits.me or by the callback. |
|
|
179
|
+
| `:unverified_identity` | A pending (`fluid_`) identity, refused by `require_verified`. |
|
|
180
|
+
| `:nationality_gate_refused` | The person doesn't hold a nationality the gate requires (or is unverified). |
|
|
181
|
+
| `:access_denied` | Any other refusal, e.g. the person cancelled. |
|
|
182
|
+
| others | From omniauth_openid_connect: `:csrf_detected`, `:invalid_grant`, `:timeout`, … |
|
|
183
|
+
|
|
184
|
+
## The auth hash
|
|
150
185
|
|
|
151
186
|
```ruby
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
187
|
+
{
|
|
188
|
+
provider: "vitsme",
|
|
189
|
+
uid: "solid_…", # private: store, never show or log
|
|
190
|
+
info: { name: "Jane D.", nationality: "IL", verified: true }, # only what was shared
|
|
191
|
+
credentials: { id_token: "…", scope: "openid name" }, # no refresh token
|
|
192
|
+
extra: {
|
|
193
|
+
identity: :solid, # or :fluid
|
|
194
|
+
lookup_uids: ["solid_…", "solid_<earlier>"],
|
|
195
|
+
fluid_uids: [],
|
|
196
|
+
claims: { "name" => "Jane D.", "age__over_18" => true }, # typed, shared claims only
|
|
197
|
+
raw_info: { "sub" => "solid_…", … } # the UserInfo response, verbatim
|
|
198
|
+
}
|
|
155
199
|
}
|
|
156
200
|
```
|
|
157
201
|
|
|
202
|
+
`info.name` is the shared `name` (or `name__full`). OmniAuth always includes the key, which may be nil.
|
|
203
|
+
|
|
204
|
+
## Operational notes
|
|
205
|
+
|
|
206
|
+
- **Read everything in the callback.** Authorization codes, access tokens and ID tokens live for
|
|
207
|
+
**5 seconds** and there is no refresh token. The strategy redeems the code and fetches UserInfo at
|
|
208
|
+
once. Keep your server's clock in sync (NTP): a few seconds of skew fails ID-token validation.
|
|
209
|
+
- **Every sign-in asks for the pincode.** A regular person's vits.me session lasts one site sign-in,
|
|
210
|
+
so there is no single sign-on between sites and `prompt=none` cannot succeed. `prompt: :login` is the
|
|
211
|
+
default. A "verify" round-trip asks for the pincode too.
|
|
212
|
+
- **No logout endpoint.** Sign people out of your app; there's nothing to end at vits.me.
|
|
213
|
+
- **PKCE (S256) is on** by default, alongside the client secret.
|
|
214
|
+
- **Claims arrive from UserInfo, not the ID token.** The strategy handles this.
|
|
215
|
+
|
|
216
|
+
## Privacy rules
|
|
217
|
+
|
|
218
|
+
- The uid (`sub`), `lookup_uids` and `fluid_uids` are **private, per-site identifiers**. Treat them
|
|
219
|
+
like session tokens: store them server-side as the lookup key, and **never** render them, log them,
|
|
220
|
+
put them in a URL, federate them, or derive a username, handle or email from them.
|
|
221
|
+
- Don't log `request.env["omniauth.auth"]`: it holds the uid and everything the person shared.
|
|
222
|
+
- Ask for the least you need. `age__over_18` answers "is this an adult?". `birthdate` answers far more.
|
|
223
|
+
- In copy your users read, call people "verified". There is no need to name vits.me.
|
|
224
|
+
|
|
225
|
+
## Testing
|
|
226
|
+
|
|
158
227
|
```ruby
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
228
|
+
require "omniauth/vitsme/test"
|
|
229
|
+
|
|
230
|
+
OmniAuth.config.test_mode = true
|
|
231
|
+
OmniAuth::Vitsme::Test.mock_auth(claims: { age__over_18: true }) # :vitsme
|
|
232
|
+
OmniAuth::Vitsme::Test.mock_auth(:vitsme_verify, uid: user.vitsme_uid, claims: { name: "Jane D." })
|
|
233
|
+
OmniAuth::Vitsme::Test.mock_auth(identity: :fluid) # pending signup
|
|
234
|
+
OmniAuth::Vitsme::Test.mock_auth(:vitsme_verify, uid: someone_else_uid) # → :identity_mismatch
|
|
235
|
+
OmniAuth::Vitsme::Test.mock_failure(:vitsme, :nationality_gate_refused)
|
|
167
236
|
```
|
|
168
237
|
|
|
169
|
-
|
|
238
|
+
Mocks are built by the same code as real sign-ins, so they have the real shape and types, and
|
|
239
|
+
unknown claims raise. Test mode skips the round trip to vits.me, but the strategy's identity checks
|
|
240
|
+
still run on the mock. A verify provider's `expected_uid` and `require_verified` judge the mocked
|
|
241
|
+
answer exactly as they would a real one: mock the signed-in person's uid to pass, or someone else's
|
|
242
|
+
to see `:identity_mismatch`. A mocked earlier uid (`defunct_uids:`) matches as it does in
|
|
243
|
+
production. `mock_failure` simulates refusals that only vits.me can make, such as
|
|
244
|
+
`:nationality_gate_refused` or `:access_denied`.
|
|
245
|
+
|
|
246
|
+
[examples/rails/app.rb](examples/rails/app.rb) is a one-file Rails app using sign-in, a gate and
|
|
247
|
+
verification. The test suite boots it against a stubbed vits.me.
|
|
248
|
+
|
|
249
|
+
## Options
|
|
250
|
+
|
|
251
|
+
| Option | Default | |
|
|
252
|
+
|---|---|---|
|
|
253
|
+
| `client_id`, `client_secret` | required | From the vits.me team |
|
|
254
|
+
| `environment` | `:production` | `:production`, `:staging`, `:development` |
|
|
255
|
+
| `issuer` | from `environment` | |
|
|
256
|
+
| `scope` | `[:openid]` | Validated against [data/claims.json](data/claims.json) |
|
|
257
|
+
| `nationality_gate` | `[]` | ISO alpha-2 codes, e.g. `%w[IL]` |
|
|
258
|
+
| `expected_uid` | `nil` | `->(env) { uid }` for follow-up questions about a signed-in person |
|
|
259
|
+
| `require_verified` | on when `expected_uid` is set | Refuse `fluid_` identities |
|
|
260
|
+
| `send_expected_uid` | `true` | Tell vits.me whom you expect |
|
|
261
|
+
| `prompt` | `:login` | |
|
|
262
|
+
| `pkce` | `true` | S256 |
|
|
263
|
+
| `name` | `"vitsme"` | Set a different name for each additional provider |
|
|
264
|
+
|
|
265
|
+
Everything else is passed to omniauth_openid_connect.
|
|
170
266
|
|
|
171
267
|
## Development
|
|
172
268
|
|
|
173
269
|
```
|
|
174
270
|
bundle install
|
|
175
|
-
bundle exec rspec
|
|
271
|
+
bundle exec rspec # strategy, catalog, generator, and the example Rails app
|
|
176
272
|
bundle exec rubocop
|
|
177
|
-
bundle exec rake # runs both
|
|
178
273
|
```
|
|
179
274
|
|
|
275
|
+
The catalog mirrors the vits.me server. When vits.me adds a scope, it lands in `data/claims.json` in
|
|
276
|
+
the same change (vits.me's `docs/adding-vitsme-scope.md`).
|
|
277
|
+
|
|
180
278
|
## License
|
|
181
279
|
|
|
182
280
|
MIT License. See [LICENSE.txt](LICENSE.txt).
|