zoreal-oauth2 0.1.3 → 0.1.5
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/README.md +271 -15
- data/lib/zoreal/oauth2/client.rb +37 -11
- data/lib/zoreal/oauth2/login.rb +15 -0
- data/lib/zoreal/oauth2/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e564eea3a16252cff4698e23df5fbfd5c15f825fffb87924151a1c138b82c3da
|
|
4
|
+
data.tar.gz: a83e1a55610407dee2d652a09da1cce208d9226d644589bd6ff8419b42503135
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: fcf40f7e79505d1d392ad956090961ac397de69ab3e21ce90c00a9c7b90f42ff6fd02275612eed1cb2a45c5f11180a28cd020cd69fe68560f615d6f62268b4d2
|
|
7
|
+
data.tar.gz: 192160f6d509783536e587d57965ec9a50df27078d80f47f7cd0881833ef5eb897bf288f55042eb6025c9f2c08f5e3d0a3512e0e4c9d5829143a6ec282321de5
|
data/README.md
CHANGED
|
@@ -26,6 +26,41 @@ Or directly: `gem install zoreal-oauth2`.
|
|
|
26
26
|
|
|
27
27
|
Ruby >= 3.1. One dependency: `jwt`.
|
|
28
28
|
|
|
29
|
+
## Getting your credentials
|
|
30
|
+
|
|
31
|
+
Everything the client constructor needs comes from a ZOREAL **asset**.
|
|
32
|
+
|
|
33
|
+
1. Create an account at **https://zoreal.com** and open **Assets**.
|
|
34
|
+
2. **Create an asset** — a *website* (a domain you own) or an *app bundle* (a
|
|
35
|
+
reverse-DNS bundle id). An asset is the thing users log in to; its token is
|
|
36
|
+
your `client_id` and it looks like `ast_...`.
|
|
37
|
+
3. On the asset, open the **OAuth2** tab and set:
|
|
38
|
+
- the **redirect URIs** and **JavaScript origins** your app uses (requests
|
|
39
|
+
from anything not registered are rejected — this is the core control),
|
|
40
|
+
- the **scopes** the client is allowed to request (see the catalogue below),
|
|
41
|
+
- your **client authentication**: generate a **client secret**
|
|
42
|
+
(`client_secret_basic`), or register a **JWKS** for `private_key_jwt`. A
|
|
43
|
+
public client authenticates with PKCE alone and no secret.
|
|
44
|
+
4. A website asset must **verify its domain** (a DNS or meta-tag proof, shown in
|
|
45
|
+
the dashboard) before it can request personal-data scopes or sign users in;
|
|
46
|
+
the verified domain is what your users' `sub` is pairwise against.
|
|
47
|
+
|
|
48
|
+
The `client_id` is public (it ships in your frontend). The client secret is not
|
|
49
|
+
— keep it in your server's secret store, never in the browser.
|
|
50
|
+
|
|
51
|
+
### There is no test-identity sandbox — and that is deliberate
|
|
52
|
+
|
|
53
|
+
ZOREAL **never issues fake or sandbox humans**: a pool of test identities would
|
|
54
|
+
be a fraud vector against the exact thing the product proves. So you always
|
|
55
|
+
authenticate **real** ZOREAL IDs.
|
|
56
|
+
|
|
57
|
+
To develop and test, **create a free ZOREAL ID for yourself** (enrol in the
|
|
58
|
+
ZOREAL ID app) and sign in with it. Mark your asset's environment **sandbox**
|
|
59
|
+
in the dashboard while building — a sandbox asset may register `http://localhost`
|
|
60
|
+
origins and redirect URIs that a production asset may not — and flip it to
|
|
61
|
+
production when you ship. The identities are real either way; only the allowed
|
|
62
|
+
origins differ.
|
|
63
|
+
|
|
29
64
|
## Quick start
|
|
30
65
|
|
|
31
66
|
Build one client at boot and share it; it is thread-safe.
|
|
@@ -67,6 +102,115 @@ if user.nil?
|
|
|
67
102
|
end
|
|
68
103
|
```
|
|
69
104
|
|
|
105
|
+
## Assurance levels — `acr`, and requiring a liveness check
|
|
106
|
+
|
|
107
|
+
### What `acr` is
|
|
108
|
+
|
|
109
|
+
`acr` is an OpenID Connect standard claim — *Authentication Context Class
|
|
110
|
+
Reference*. It is a single string in the ID token that says **how strongly this
|
|
111
|
+
particular login was authenticated**. Every ZOREAL login carries one, and it is
|
|
112
|
+
the difference between "someone who once enrolled this identity is behind this
|
|
113
|
+
request" and "a live human, verified to be the right one, is behind this request
|
|
114
|
+
right now".
|
|
115
|
+
|
|
116
|
+
It answers a question the `sub` cannot. `sub` tells you *who* (a stable, pairwise
|
|
117
|
+
identifier for this person at your site). `acr` tells you *how sure ZOREAL is that
|
|
118
|
+
the person is really there for this login*. A stolen, unlocked phone can still
|
|
119
|
+
produce a `sub`; it cannot produce a fresh `zoreal.live`.
|
|
120
|
+
|
|
121
|
+
### The three levels
|
|
122
|
+
|
|
123
|
+
Ordered weakest to strongest. Each is what actually happened, never what was
|
|
124
|
+
requested — a login that could only reach a weaker level says so honestly rather
|
|
125
|
+
than claiming the level you asked for.
|
|
126
|
+
|
|
127
|
+
| `acr` | What the holder did | `amr` | What it proves | What it does **not** prove |
|
|
128
|
+
|---|---|---|---|---|
|
|
129
|
+
| `zoreal.session` | Nothing — a returning holder at a site they have used before, resumed silently from an existing ZOREAL session, no phone interaction | `[]` | Continuity: the same browser/session ZOREAL already knew | That the holder is present, or even awake |
|
|
130
|
+
| `zoreal.device` | Approved the login on their enrolled phone: a signature from a key in the phone's secure element, released by a local biometric or passcode unlock | `["hwk","user"]` | Possession of the enrolled device **and** a local unlock on it | That a live face was captured for *this* login — an unlocked phone in the wrong hands still signs |
|
|
131
|
+
| `zoreal.live` | All of the above **plus** a fresh face capture this login: a flash-plus-zoom video scored for presentation attacks and screen replay (moire), matched 1:1 against the government document read at enrolment | `["hwk","face","user"]` | A live, real, unique human, verified to be the enrolled person, **at the moment of this login** | — (this is the strongest level) |
|
|
132
|
+
|
|
133
|
+
`amr` (*Authentication Methods References*) is the companion claim listing the
|
|
134
|
+
factors used: `hwk` a hardware key, `user` a user-presence/unlock gesture, `face`
|
|
135
|
+
a face biometric. `zoreal.live` is exactly `zoreal.device` with `face` added,
|
|
136
|
+
because a live login is a device approval with a capture on top.
|
|
137
|
+
|
|
138
|
+
The **default is `zoreal.device`**, never `zoreal.session`: a login that asks for
|
|
139
|
+
nothing still requires the enrolled phone and a local unlock. Silence has to be
|
|
140
|
+
explicitly asked for (`prompt=none`), and it succeeds only for a returning holder
|
|
141
|
+
at a site whose consent they have already given.
|
|
142
|
+
|
|
143
|
+
### When to require which
|
|
144
|
+
|
|
145
|
+
- **`zoreal.session`** — you never *require* this; it is what a returning holder
|
|
146
|
+
gets for a low-stakes convenience re-auth when they ask for the silent path.
|
|
147
|
+
- **`zoreal.device`** (the default) — a forum, a community, a normal account
|
|
148
|
+
login. Possession of the enrolled phone plus a local unlock is a high bar
|
|
149
|
+
already; most sites want exactly this and should pass no `acr` at all.
|
|
150
|
+
- **`zoreal.live`** — a bank onboarding, a high-value transaction, an age-gated
|
|
151
|
+
purchase, a first login, a "confirm it is really you" step before a sensitive
|
|
152
|
+
action. Anywhere a *fresh, unforgeable proof of the live, right human* is worth
|
|
153
|
+
the few seconds a face capture costs.
|
|
154
|
+
|
|
155
|
+
### Requesting versus verifying — the one rule that matters
|
|
156
|
+
|
|
157
|
+
Requesting a level and verifying it are **two separate steps, and only the second
|
|
158
|
+
is security**:
|
|
159
|
+
|
|
160
|
+
1. **Request** it on the wire, in the frontend, with the SDK's
|
|
161
|
+
`acr_values: 'zoreal.live'`. This is what makes the holder's ZOREAL ID app run
|
|
162
|
+
the face capture before it will approve. It is **advisory** — it shapes what
|
|
163
|
+
the holder is asked to do, nothing more. A browser is attacker-controlled; a
|
|
164
|
+
value that only travels through it proves nothing.
|
|
165
|
+
2. **Verify** it here, at token exchange, by passing `acr:`. The signed `acr`
|
|
166
|
+
claim in the ID token — minted by ZOREAL, not by the browser — is the proof.
|
|
167
|
+
|
|
168
|
+
```ruby
|
|
169
|
+
login = ZOREAL_OAUTH.authenticate(
|
|
170
|
+
code: params[:code], code_verifier: params[:code_verifier],
|
|
171
|
+
nonce: params[:nonce],
|
|
172
|
+
acr: 'zoreal.live' # raises VerificationError unless the signed token says so
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
login.acr # "zoreal.live" — what actually happened
|
|
176
|
+
login.live? # convenience: acr == "zoreal.live"
|
|
177
|
+
login.satisfies_acr?('zoreal.device')# true (live is stronger than device)
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
**An RP that requests `zoreal.live` on the wire but never passes `acr:` here has
|
|
181
|
+
checked nothing** — it has only asked the holder nicely and then trusted a value
|
|
182
|
+
it never validated.
|
|
183
|
+
|
|
184
|
+
### How the check behaves
|
|
185
|
+
|
|
186
|
+
Verification satisfies **upward**: `zoreal.session < zoreal.device <
|
|
187
|
+
zoreal.live`, so a requirement of `zoreal.device` accepts a `zoreal.live` token
|
|
188
|
+
(the holder gave you *more* assurance than you demanded). A token whose `acr` is
|
|
189
|
+
below the requirement, missing entirely, or outside the vocabulary is refused
|
|
190
|
+
with `VerificationError`. An unknown *required* value — a typo like
|
|
191
|
+
`'zoreal.liveness'` — raises `ConfigurationError` instead, because that is a bug
|
|
192
|
+
in your code, not a bad token, and failing every login silently is worse than
|
|
193
|
+
saying so.
|
|
194
|
+
|
|
195
|
+
If you prefer to branch rather than raise, omit `acr:` and inspect the result:
|
|
196
|
+
|
|
197
|
+
```ruby
|
|
198
|
+
login = ZOREAL_OAUTH.authenticate(code:, code_verifier:, nonce:)
|
|
199
|
+
unless login.satisfies_acr?('zoreal.live')
|
|
200
|
+
# step the user up, or refuse the sensitive action
|
|
201
|
+
end
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
### `acr` versus the assurance block
|
|
205
|
+
|
|
206
|
+
Do not confuse `acr` with `login.assurance`. `acr` grades *this login event*.
|
|
207
|
+
The **assurance block** (`login.assurance`) describes the *identity behind it* —
|
|
208
|
+
how the person was verified at enrolment (`uniqueness` basis, `verified_on`
|
|
209
|
+
month, whether chip liveness was proven, the `trust_tier`, the device's
|
|
210
|
+
`key_protection`). One is about now; the other is about who they are. A high-value
|
|
211
|
+
flow usually wants both: `acr: 'zoreal.live'` for presence, and the assurance
|
|
212
|
+
block for the strength of the underlying identity proofing.
|
|
213
|
+
|
|
70
214
|
## Client authentication: all four registered methods
|
|
71
215
|
|
|
72
216
|
| `token_endpoint_auth_method` | Configuration | What travels |
|
|
@@ -96,10 +240,123 @@ a `private_key` implies `private_key_jwt`, neither means `none`.
|
|
|
96
240
|
and `portrait` from `/userinfo` (`portrait` is registrable but not served by
|
|
97
241
|
the provider yet, and returns nil until it is).
|
|
98
242
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
243
|
+
## Scopes and claims
|
|
244
|
+
|
|
245
|
+
Scopes are requested in the **frontend** (the SDK's `scope` string, always
|
|
246
|
+
starting with `openid`), consented to by the holder, and pre-authorized on your
|
|
247
|
+
asset. What each grants and where it is delivered:
|
|
248
|
+
|
|
249
|
+
| Scope | Claims | Delivered in | Tier | Requires |
|
|
250
|
+
|---|---|---|---|---|
|
|
251
|
+
| `openid` | `sub`, `iss`, `aud`, `exp`, `iat`, `nonce`, `auth_time`, `acr`, `amr`, and the assurance block | ID token | A | any client |
|
|
252
|
+
| `zoreal.age` | `age_over_13/16/18/21/65` booleans — only the thresholds you registered, never an age or birthdate | ID token | A | any client |
|
|
253
|
+
| `zoreal.nationality` | `nationality` (ISO 3166-1 alpha-3) | ID token | A | any client |
|
|
254
|
+
| `email` | `email`, `email_verified` | `/userinfo` | B | confidential client + verified domain |
|
|
255
|
+
| `profile.name` | `name`, `given_name`, `family_name` | `/userinfo` | B | confidential client + verified domain |
|
|
256
|
+
| `profile.birthdate` | `birthdate` (full ISO 8601 date) | `/userinfo` | B | confidential client + verified domain |
|
|
257
|
+
| `profile.document` | `document_type`, `document_number`, `issuing_country`, `document_expires_on` | `/userinfo` | B | confidential client + verified domain |
|
|
258
|
+
| `profile.portrait` | `portrait` (the chip's facial image; GDPR Article 9 data) | `/userinfo` | C | confidential client + verified domain — *registrable but not served yet* |
|
|
259
|
+
|
|
260
|
+
- **Tier A** rides in the ID token and is available to every client, so the
|
|
261
|
+
no-backend browser button can use it. **Tier B and C** are personal data,
|
|
262
|
+
served only from `/userinfo` to a confidential client on a domain you have
|
|
263
|
+
verified, and never placed in a browser token.
|
|
264
|
+
- **Age thresholds are a fixed set** — 13, 16, 18, 21, 65 — that you register on
|
|
265
|
+
the asset. `login.age_over?(n)` returns `nil` for a threshold you did not
|
|
266
|
+
register (no claim was minted), which is different from `false`.
|
|
267
|
+
|
|
268
|
+
## Error reference
|
|
269
|
+
|
|
270
|
+
`exchange` / `authenticate` raise `ExchangeError`, which carries the provider's
|
|
271
|
+
own `oauth_error` code and `description` verbatim. What you will actually see:
|
|
272
|
+
|
|
273
|
+
| `oauth_error` | Cause | Retryable? |
|
|
274
|
+
|---|---|---|
|
|
275
|
+
| `invalid_grant` | The code is spent — unknown, expired (60s), already used, PKCE mismatch, or the asset's domain verification lapsed mid-flow | No. Start a **new** login; the code cannot be reused |
|
|
276
|
+
| `invalid_request` | Client authentication failed — wrong secret, a bad `private_key_jwt` assertion, or `tls_client_auth` (not accepted at `/token` yet) | No. Fix your client configuration |
|
|
277
|
+
| `unsupported_grant_type` | Something other than `authorization_code` reached `/token` | No. A bug |
|
|
278
|
+
|
|
279
|
+
Errors that surface in the **frontend** instead, before your backend is
|
|
280
|
+
involved (from the SDK's `onError` / `onNonOAuthError`), so handle them there:
|
|
281
|
+
|
|
282
|
+
| Where | Code | Meaning |
|
|
283
|
+
|---|---|---|
|
|
284
|
+
| `/pair` | `invalid_scope` | A scope not on the asset's allowed list, or a Tier B scope from a public client |
|
|
285
|
+
| `/pair` | `invalid_request` | Missing PKCE/nonce, an unverified sector, an unregistered `redirect_uri`, or an unknown `acr_values` |
|
|
286
|
+
| `/pair` | `login_required` | `prompt=none` with no silent session to resume — the expected quiet outcome, not a failure |
|
|
287
|
+
| pairing | `request_denied` | The holder declined in their ZOREAL ID app — **not an error to alarm on**; offer to try again |
|
|
288
|
+
| pairing | `request_expired` | The pairing window elapsed, or a required liveness the device could not meet — offer to try again |
|
|
289
|
+
|
|
290
|
+
The gem's other error classes: `ConfigurationError` (you built the client wrong,
|
|
291
|
+
or asked to verify an acr outside the vocabulary — a bug in your code, not a bad
|
|
292
|
+
token), `VerificationError` (the ID token did not verify: signature, `iss`,
|
|
293
|
+
`aud`, `exp`, `nonce`, or the acr floor), and `UserinfoError` (the `/userinfo`
|
|
294
|
+
call failed). A returning user matched on `sub` can survive a rescued
|
|
295
|
+
`UserinfoError`; a signup that needs the email cannot.
|
|
296
|
+
|
|
297
|
+
## The assurance block
|
|
298
|
+
|
|
299
|
+
`login.assurance` is the ID token's `zoreal` claim — a hash describing the
|
|
300
|
+
strength of the *identity* behind this login (distinct from `acr`, which grades
|
|
301
|
+
the *login event*). Its keys and their value sets:
|
|
302
|
+
|
|
303
|
+
| Key | Values | Meaning |
|
|
304
|
+
|---|---|---|
|
|
305
|
+
| `uniqueness` | `personal_number` \| `document` \| `none` | The anchor the holder is deduplicated on. `personal_number` (a national number from the chip) is strongest; `none` means no reliable anchor |
|
|
306
|
+
| `verified_on` | `"YYYY-MM"` | The month the underlying document was verified. Quantised to a month on purpose — a day-precision date is a cross-site correlator |
|
|
307
|
+
| `chip_liveness_proven` | `true` \| `false` | Whether the passport chip's active-authentication challenge was proven (a genuine chip, not a clone) |
|
|
308
|
+
| `trust_tier` | `high` \| `standard` | `high` when `chip_liveness_proven`, else `standard` |
|
|
309
|
+
| `key_protection` | `secure_enclave` \| `strongbox` \| `tee` \| `software` | How the holder's device key is protected. `software` means no hardware attestation |
|
|
310
|
+
|
|
311
|
+
A high-value flow usually pairs `acr: 'zoreal.live'` (fresh presence) with a
|
|
312
|
+
check on the assurance block (identity strength) — e.g. requiring
|
|
313
|
+
`uniqueness == 'personal_number'` and `trust_tier == 'high'`.
|
|
314
|
+
|
|
315
|
+
## A complete example
|
|
316
|
+
|
|
317
|
+
A Rails controller, end to end — the shape a real integration takes:
|
|
318
|
+
|
|
319
|
+
```ruby
|
|
320
|
+
# config/routes.rb
|
|
321
|
+
post '/auth/zoreal', to: 'sessions#zoreal'
|
|
322
|
+
|
|
323
|
+
# app/controllers/sessions_controller.rb
|
|
324
|
+
class SessionsController < ApplicationController
|
|
325
|
+
# Your frontend's ZorealLogin onSuccess posts { code, code_verifier, nonce }
|
|
326
|
+
# here over your own TLS. Protect this endpoint with your normal CSRF /
|
|
327
|
+
# same-origin controls, exactly as you would any login endpoint — the ZOREAL
|
|
328
|
+
# nonce protects the token, not your route.
|
|
329
|
+
def zoreal
|
|
330
|
+
login = ZOREAL_OAUTH.authenticate(
|
|
331
|
+
code: params[:code],
|
|
332
|
+
code_verifier: params[:code_verifier],
|
|
333
|
+
nonce: params[:nonce]
|
|
334
|
+
# acr: 'zoreal.live' # add for a step-up / high-value login
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
user = User.find_by(provider: 'zoreal', uid: login.sub)
|
|
338
|
+
if user.nil?
|
|
339
|
+
# Claim an existing account that owns this verified email rather than
|
|
340
|
+
# colliding on the unique index; otherwise create one.
|
|
341
|
+
user = User.find_by(email: login.email) if login.email_verified?
|
|
342
|
+
user ||= User.new(email: login.email, full_name: login.name)
|
|
343
|
+
user.update!(provider: 'zoreal', uid: login.sub)
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
reset_session # fixation defence
|
|
347
|
+
session[:user_id] = user.id
|
|
348
|
+
render json: { ok: true }
|
|
349
|
+
rescue Zoreal::OAuth2::ExchangeError, Zoreal::OAuth2::VerificationError => e
|
|
350
|
+
# A spent code or a token that did not verify: the login must be restarted.
|
|
351
|
+
Rails.logger.warn("ZOREAL login failed: #{e.message}")
|
|
352
|
+
render json: { error: 'sign_in_failed' }, status: :unauthorized
|
|
353
|
+
rescue Zoreal::OAuth2::UserinfoError => e
|
|
354
|
+
# Personal data was unreachable. Fine for a returning user matched on sub;
|
|
355
|
+
# fatal for a signup that needs the email.
|
|
356
|
+
render json: { error: 'sign_in_failed' }, status: :unauthorized
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
```
|
|
103
360
|
|
|
104
361
|
## Things worth knowing before you integrate
|
|
105
362
|
|
|
@@ -114,20 +371,19 @@ signup that needs the email cannot.
|
|
|
114
371
|
rotates every `sub` you have stored. Plan domain changes as a migration.
|
|
115
372
|
- **ES256 only.** The provider signs with nothing else, and this gem refuses
|
|
116
373
|
other algorithms rather than negotiating.
|
|
117
|
-
- **Always pass the nonce through
|
|
118
|
-
|
|
119
|
-
ID token
|
|
374
|
+
- **Always pass the nonce through, and protect your own endpoint too.** The SDK
|
|
375
|
+
generates the nonce and gives it to your frontend in `onSuccess`; passing it
|
|
376
|
+
here lets the gem confirm the ID token was minted for *this* login rather than
|
|
377
|
+
substituted. Two things it does **not** do: it is not your endpoint's CSRF
|
|
378
|
+
token (protect your `/auth/zoreal` route with your framework's normal CSRF /
|
|
379
|
+
same-origin defence), and PKCE — not the nonce — is what proves whoever
|
|
380
|
+
exchanges the code is whoever started the flow.
|
|
120
381
|
- **Email is a deliberate choice.** It is a Tier B scope precisely because a
|
|
121
382
|
shared email defeats the unlinkability the pairwise `sub` provides. Request
|
|
122
383
|
it because you need it, not because the checkbox is familiar.
|
|
123
|
-
- **
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
## Development against a local provider
|
|
128
|
-
|
|
129
|
-
Point `issuer:` at your provider instance. The issuer value must match the `iss` inside the
|
|
130
|
-
tokens exactly — it is compared, not normalized.
|
|
384
|
+
- **The `issuer` must match the token's `iss` exactly** — it is compared, not
|
|
385
|
+
normalized. Production is `https://id.zoreal.com`; override `issuer:` only
|
|
386
|
+
when pointing at a non-production provider you were given.
|
|
131
387
|
|
|
132
388
|
## The ZOREAL OAuth2 library family
|
|
133
389
|
|
data/lib/zoreal/oauth2/client.rb
CHANGED
|
@@ -30,6 +30,10 @@ module Zoreal
|
|
|
30
30
|
JWKS_CACHE_KEY = 'zoreal_oauth2_jwks'.freeze
|
|
31
31
|
|
|
32
32
|
AUTH_METHODS = %w[none client_secret_basic private_key_jwt tls_client_auth].freeze
|
|
33
|
+
# The assurance vocabulary, weakest to strongest. Verification accepts
|
|
34
|
+
# equal or stronger: an RP requiring zoreal.device is satisfied by a
|
|
35
|
+
# zoreal.live token, never the reverse.
|
|
36
|
+
ACR_ORDER = { 'zoreal.session' => 0, 'zoreal.device' => 1, 'zoreal.live' => 2 }.freeze
|
|
33
37
|
# The provider rejects an assertion whose exp is more than 60 seconds
|
|
34
38
|
# out, so that is the lifetime, not a choice.
|
|
35
39
|
ASSERTION_LIFETIME = 60
|
|
@@ -80,12 +84,19 @@ module Zoreal
|
|
|
80
84
|
|
|
81
85
|
# The whole login, in order: exchange the code (with the PKCE verifier
|
|
82
86
|
# the browser SDK handed over), verify the ID token against the JWKS,
|
|
83
|
-
# check the nonce when the caller has it
|
|
84
|
-
#
|
|
85
|
-
#
|
|
86
|
-
|
|
87
|
+
# check the nonce when the caller has it, and — when the caller passes
|
|
88
|
+
# acr: — refuse a token whose assurance is below it. Returns a Login;
|
|
89
|
+
# personal data is NOT fetched here, because the ID token never carries
|
|
90
|
+
# it and not every caller wants it — Login#userinfo fetches on first use.
|
|
91
|
+
#
|
|
92
|
+
# REQUESTING an assurance on the wire (the SDK's acr_values) is
|
|
93
|
+
# advisory; the signed acr claim is the proof, and this parameter is
|
|
94
|
+
# where a relying party that asked for a liveness check verifies it
|
|
95
|
+
# actually happened. An RP that requires zoreal.live and never passes
|
|
96
|
+
# acr: here has checked nothing.
|
|
97
|
+
def authenticate(code:, code_verifier:, nonce: nil, acr: nil)
|
|
87
98
|
tokens = exchange(code: code, code_verifier: code_verifier)
|
|
88
|
-
claims = verify_id_token(tokens['id_token'], nonce: nonce)
|
|
99
|
+
claims = verify_id_token(tokens['id_token'], nonce: nonce, acr: acr)
|
|
89
100
|
Login.new(client: self, claims: claims,
|
|
90
101
|
id_token: tokens['id_token'],
|
|
91
102
|
access_token: tokens['access_token'],
|
|
@@ -116,12 +127,12 @@ module Zoreal
|
|
|
116
127
|
body
|
|
117
128
|
end
|
|
118
129
|
|
|
119
|
-
# ES256 against the provider's JWKS, plus iss, aud, exp
|
|
120
|
-
#
|
|
121
|
-
# the claims. There is no RS256
|
|
122
|
-
# nothing with RSA, and accepting a
|
|
123
|
-
# confusion starts.
|
|
124
|
-
def verify_id_token(id_token, nonce: nil)
|
|
130
|
+
# ES256 against the provider's JWKS, plus iss, aud, exp, the nonce
|
|
131
|
+
# binding when the caller has the nonce, and the assurance floor when
|
|
132
|
+
# the caller passes acr:. Returns the claims. There is no RS256
|
|
133
|
+
# fallback on purpose: ZOREAL signs nothing with RSA, and accepting a
|
|
134
|
+
# second algorithm is how algorithm confusion starts.
|
|
135
|
+
def verify_id_token(id_token, nonce: nil, acr: nil)
|
|
125
136
|
claims, = JWT.decode(
|
|
126
137
|
id_token, nil, true,
|
|
127
138
|
algorithms: ['ES256'],
|
|
@@ -135,6 +146,7 @@ module Zoreal
|
|
|
135
146
|
if !nil_or_empty?(nonce) && claims['nonce'] != nonce
|
|
136
147
|
raise VerificationError, 'the ID token nonce is not the one this login started with'
|
|
137
148
|
end
|
|
149
|
+
verify_acr!(claims, acr) unless nil_or_empty?(acr)
|
|
138
150
|
|
|
139
151
|
claims
|
|
140
152
|
rescue JWT::DecodeError => e
|
|
@@ -162,6 +174,20 @@ module Zoreal
|
|
|
162
174
|
|
|
163
175
|
private
|
|
164
176
|
|
|
177
|
+
# Equal or stronger satisfies; anything else — weaker, missing, or a
|
|
178
|
+
# value outside the vocabulary — is refused. An unknown REQUIREMENT is a
|
|
179
|
+
# caller bug and says so plainly rather than failing every login.
|
|
180
|
+
def verify_acr!(claims, required)
|
|
181
|
+
required_rank = ACR_ORDER[required]
|
|
182
|
+
raise ConfigurationError, "unknown required acr #{required}; supported: #{ACR_ORDER.keys.join(', ')}" if required_rank.nil?
|
|
183
|
+
|
|
184
|
+
actual_rank = ACR_ORDER[claims['acr']]
|
|
185
|
+
return if actual_rank && actual_rank >= required_rank
|
|
186
|
+
|
|
187
|
+
raise VerificationError,
|
|
188
|
+
"the ID token says acr #{claims['acr'].inspect}, below the required #{required}"
|
|
189
|
+
end
|
|
190
|
+
|
|
165
191
|
def jwks
|
|
166
192
|
cached = @cache.read(JWKS_CACHE_KEY)
|
|
167
193
|
return cached if cached
|
data/lib/zoreal/oauth2/login.rb
CHANGED
|
@@ -31,6 +31,21 @@ module Zoreal
|
|
|
31
31
|
claims['acr']
|
|
32
32
|
end
|
|
33
33
|
|
|
34
|
+
# A fresh liveness capture backed this login. The convenience spelling
|
|
35
|
+
# of acr == 'zoreal.live'; for enforcement, pass acr: to authenticate
|
|
36
|
+
# and let verification refuse the token instead of checking after.
|
|
37
|
+
def live?
|
|
38
|
+
acr == 'zoreal.live'
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Equal or stronger satisfies, on the client's ordering
|
|
42
|
+
# (session < device < live). Unknown values satisfy nothing.
|
|
43
|
+
def satisfies_acr?(required)
|
|
44
|
+
actual = Client::ACR_ORDER[acr]
|
|
45
|
+
wanted = Client::ACR_ORDER[required]
|
|
46
|
+
!actual.nil? && !wanted.nil? && actual >= wanted
|
|
47
|
+
end
|
|
48
|
+
|
|
34
49
|
def amr
|
|
35
50
|
claims['amr']
|
|
36
51
|
end
|