zoreal-oauth2 0.1.3 → 0.1.4

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 316d86bac36a690c10fdfefbf5b5f1ebbadce45e56e69d0d0074d641ff9e1f07
4
- data.tar.gz: c47f42263f881132a9281b70402654bb4f07bfa9b7963544a712e4f4ee8c8246
3
+ metadata.gz: 7179440f857aa507bde5dd88b3c5ebf1f714fd73e8ecc3b3c136a6b47f2cc69e
4
+ data.tar.gz: 24f5ad9f699eda4f6b92c99226ce572cd9e015356ec8e11747726ac55d6005c1
5
5
  SHA512:
6
- metadata.gz: c2341f70010a3769490afe488816d3a223b243693c1bdf70ffbfd1f7d0ce81443ba55c32126bb3f9629232530ae3590d3c85ab0a5f3820c0cc48b4a78fb67c61
7
- data.tar.gz: 10447f93e400fb9154fe8e59a46655640ed7f995c39c54b23fb23ebbbbf134c650ad278dc2e1ed4904de39afec1873cd3b0f284a4a98e7dce843981fd478339f
6
+ metadata.gz: 145f11a19c406f246ae6258aa7e402a8888882009c3f51d0e694e889aedd6611df04734ab398ff8c6fe51f3940e7b017c78e64bace0072a7cd52dcade5922054
7
+ data.tar.gz: 067e2b2041d54ba943db77313b14821896574414c784a1c629e1726ce966d1b7b76a78a37e3d1a4a249600b93ed1b66ea3c471ad0983ea0304909b9d77795c21
data/README.md CHANGED
@@ -67,6 +67,115 @@ if user.nil?
67
67
  end
68
68
  ```
69
69
 
70
+ ## Assurance levels — `acr`, and requiring a liveness check
71
+
72
+ ### What `acr` is
73
+
74
+ `acr` is an OpenID Connect standard claim — *Authentication Context Class
75
+ Reference*. It is a single string in the ID token that says **how strongly this
76
+ particular login was authenticated**. Every ZOREAL login carries one, and it is
77
+ the difference between "someone who once enrolled this identity is behind this
78
+ request" and "a live human, verified to be the right one, is behind this request
79
+ right now".
80
+
81
+ It answers a question the `sub` cannot. `sub` tells you *who* (a stable, pairwise
82
+ identifier for this person at your site). `acr` tells you *how sure ZOREAL is that
83
+ the person is really there for this login*. A stolen, unlocked phone can still
84
+ produce a `sub`; it cannot produce a fresh `zoreal.live`.
85
+
86
+ ### The three levels
87
+
88
+ Ordered weakest to strongest. Each is what actually happened, never what was
89
+ requested — a login that could only reach a weaker level says so honestly rather
90
+ than claiming the level you asked for.
91
+
92
+ | `acr` | What the holder did | `amr` | What it proves | What it does **not** prove |
93
+ |---|---|---|---|---|
94
+ | `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 |
95
+ | `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 |
96
+ | `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) |
97
+
98
+ `amr` (*Authentication Methods References*) is the companion claim listing the
99
+ factors used: `hwk` a hardware key, `user` a user-presence/unlock gesture, `face`
100
+ a face biometric. `zoreal.live` is exactly `zoreal.device` with `face` added,
101
+ because a live login is a device approval with a capture on top.
102
+
103
+ The **default is `zoreal.device`**, never `zoreal.session`: a login that asks for
104
+ nothing still requires the enrolled phone and a local unlock. Silence has to be
105
+ explicitly asked for (`prompt=none`), and it succeeds only for a returning holder
106
+ at a site whose consent they have already given.
107
+
108
+ ### When to require which
109
+
110
+ - **`zoreal.session`** — you never *require* this; it is what a returning holder
111
+ gets for a low-stakes convenience re-auth when they ask for the silent path.
112
+ - **`zoreal.device`** (the default) — a forum, a community, a normal account
113
+ login. Possession of the enrolled phone plus a local unlock is a high bar
114
+ already; most sites want exactly this and should pass no `acr` at all.
115
+ - **`zoreal.live`** — a bank onboarding, a high-value transaction, an age-gated
116
+ purchase, a first login, a "confirm it is really you" step before a sensitive
117
+ action. Anywhere a *fresh, unforgeable proof of the live, right human* is worth
118
+ the few seconds a face capture costs.
119
+
120
+ ### Requesting versus verifying — the one rule that matters
121
+
122
+ Requesting a level and verifying it are **two separate steps, and only the second
123
+ is security**:
124
+
125
+ 1. **Request** it on the wire, in the frontend, with the SDK's
126
+ `acr_values: 'zoreal.live'`. This is what makes the holder's ZOREAL ID app run
127
+ the face capture before it will approve. It is **advisory** — it shapes what
128
+ the holder is asked to do, nothing more. A browser is attacker-controlled; a
129
+ value that only travels through it proves nothing.
130
+ 2. **Verify** it here, at token exchange, by passing `acr:`. The signed `acr`
131
+ claim in the ID token — minted by ZOREAL, not by the browser — is the proof.
132
+
133
+ ```ruby
134
+ login = ZOREAL_OAUTH.authenticate(
135
+ code: params[:code], code_verifier: params[:code_verifier],
136
+ nonce: params[:nonce],
137
+ acr: 'zoreal.live' # raises VerificationError unless the signed token says so
138
+ )
139
+
140
+ login.acr # "zoreal.live" — what actually happened
141
+ login.live? # convenience: acr == "zoreal.live"
142
+ login.satisfies_acr?('zoreal.device')# true (live is stronger than device)
143
+ ```
144
+
145
+ **An RP that requests `zoreal.live` on the wire but never passes `acr:` here has
146
+ checked nothing** — it has only asked the holder nicely and then trusted a value
147
+ it never validated.
148
+
149
+ ### How the check behaves
150
+
151
+ Verification satisfies **upward**: `zoreal.session < zoreal.device <
152
+ zoreal.live`, so a requirement of `zoreal.device` accepts a `zoreal.live` token
153
+ (the holder gave you *more* assurance than you demanded). A token whose `acr` is
154
+ below the requirement, missing entirely, or outside the vocabulary is refused
155
+ with `VerificationError`. An unknown *required* value — a typo like
156
+ `'zoreal.liveness'` — raises `ConfigurationError` instead, because that is a bug
157
+ in your code, not a bad token, and failing every login silently is worse than
158
+ saying so.
159
+
160
+ If you prefer to branch rather than raise, omit `acr:` and inspect the result:
161
+
162
+ ```ruby
163
+ login = ZOREAL_OAUTH.authenticate(code:, code_verifier:, nonce:)
164
+ unless login.satisfies_acr?('zoreal.live')
165
+ # step the user up, or refuse the sensitive action
166
+ end
167
+ ```
168
+
169
+ ### `acr` versus the assurance block
170
+
171
+ Do not confuse `acr` with `login.assurance`. `acr` grades *this login event*.
172
+ The **assurance block** (`login.assurance`) describes the *identity behind it* —
173
+ how the person was verified at enrolment (`uniqueness` basis, `verified_on`
174
+ month, whether chip liveness was proven, the `trust_tier`, the device's
175
+ `key_protection`). One is about now; the other is about who they are. A high-value
176
+ flow usually wants both: `acr: 'zoreal.live'` for presence, and the assurance
177
+ block for the strength of the underlying identity proofing.
178
+
70
179
  ## Client authentication: all four registered methods
71
180
 
72
181
  | `token_endpoint_auth_method` | Configuration | What travels |
@@ -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. Returns a Login; personal data
84
- # is NOT fetched here, because the ID token never carries it and not
85
- # every caller wants it Login#userinfo fetches on first use.
86
- def authenticate(code:, code_verifier:, nonce: nil)
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 and — when the
120
- # caller passes the nonce the SDK generated the nonce binding. Returns
121
- # the claims. There is no RS256 fallback on purpose: ZOREAL signs
122
- # nothing with RSA, and accepting a second algorithm is how algorithm
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
@@ -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
@@ -1,5 +1,5 @@
1
1
  module Zoreal
2
2
  module OAuth2
3
- VERSION = '0.1.3'.freeze
3
+ VERSION = '0.1.4'.freeze
4
4
  end
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: zoreal-oauth2
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.3
4
+ version: 0.1.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - ZOREAL