zoreal-oauth2 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 1e5d3f39f216e8d1785e62de549d19ca9aec733ca7a3c22391ff8b2a234a2cfd
4
+ data.tar.gz: 061177e1bcce5d19f45c3e0e5f150b9634993e0d1f0ca482a1b2cf145c6904b5
5
+ SHA512:
6
+ metadata.gz: a3192fbc0c628a62efb76bc8553641105675919d518bc1bd1e29a3b38baf97ebc8d67f002d6f9104fb7c64e5789c55b45b4d54e6fc91b6e1986349964938d85f
7
+ data.tar.gz: 4e7ca36900276481a1fccbb7e9eabf3bf089f65e745be5a79899314a40453fdd2fe4b5bc8400409b25865b34ea649e4245f4174509726912bf19637ce0a41079
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Bynn Intelligence, Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,152 @@
1
+ # zoreal-oauth2
2
+
3
+ Login with ZOREAL for Ruby backends: the relying-party half of the flow that
4
+ [`@zoreal/oauth2-react`](https://github.com/Bynn-Intelligence/zoreal-oauth2-react)
5
+ starts in the browser.
6
+
7
+ The browser SDK runs the pairing (QR or app link), and hands your frontend an
8
+ authorization `code` plus the `code_verifier` and `nonce` it generated. Your
9
+ frontend posts all three to your backend, and this gem does the rest: the code
10
+ exchange with your client authentication, ES256 verification of the ID token
11
+ against the provider's JWKS, and the `/userinfo` read for personal claims.
12
+
13
+ ```
14
+ zoreal-oauth2 (this gem) your backend: exchange, verify, userinfo
15
+ @zoreal/oauth2-react your frontend: the button, the QR, the polling
16
+ ```
17
+
18
+ ## Install
19
+
20
+ ```ruby
21
+ # Gemfile — until the gem is on rubygems.org, vendor it or use the git source:
22
+ gem 'zoreal-oauth2', git: 'https://github.com/Bynn-Intelligence/zoreal-oauth2-ruby'
23
+ ```
24
+
25
+ Ruby >= 3.1. One dependency: `jwt`.
26
+
27
+ ## Quick start
28
+
29
+ Build one client at boot and share it; it is thread-safe.
30
+
31
+ ```ruby
32
+ ZOREAL_OAUTH = Zoreal::OAuth2::Client.new(
33
+ client_id: ENV['ZOREAL_CLIENT_ID'], # ast_...
34
+ client_secret: Rails.application.credentials.dig(:zoreal, :client_secret),
35
+ issuer: ENV.fetch('ZOREAL_ISSUER', 'https://id.zoreal.com'),
36
+ cache: Rails.cache # optional, for the JWKS
37
+ )
38
+ ```
39
+
40
+ The endpoint your frontend posts to:
41
+
42
+ ```ruby
43
+ login = ZOREAL_OAUTH.authenticate(
44
+ code: params[:code],
45
+ code_verifier: params[:code_verifier], # PKCE is mandatory; the SDK hands it over
46
+ nonce: params[:nonce] # binds the ID token to this login
47
+ )
48
+
49
+ login.sub # "TC5X-JN7G-YTSE-6E63" — pairwise, stable for YOUR domain
50
+ login.acr # "zoreal.live" | "zoreal.device" | "zoreal.session"
51
+ login.assurance # uniqueness basis, verification month, chip liveness, trust tier
52
+ login.email # from /userinfo, when your client has the email scope
53
+ login.email_verified?
54
+ login.name # from /userinfo, profile.name scope
55
+ ```
56
+
57
+ Account matching, the shape that works:
58
+
59
+ ```ruby
60
+ user = User.find_by(provider: 'zoreal', uid: login.sub)
61
+ if user.nil?
62
+ user = User.find_by(email: login.email) if login.email_verified? # claim, don't collide
63
+ user ||= User.new(email: login.email)
64
+ user.update!(provider: 'zoreal', uid: login.sub)
65
+ end
66
+ ```
67
+
68
+ ## Client authentication: all four registered methods
69
+
70
+ | `token_endpoint_auth_method` | Configuration | What travels |
71
+ |---|---|---|
72
+ | `none` | nothing | a public client: PKCE alone, Tier A scopes only |
73
+ | `client_secret_basic` | `client_secret:` | the secret, as HTTP Basic |
74
+ | `private_key_jwt` | `private_key:` (PEM or `OpenSSL::PKey`), `private_key_kid:` optional | a fresh RFC 7523 assertion per exchange: `iss`=`sub`=client id, `aud`=`{issuer}/token`, 60-second life, single-use `jti`. P-256 signs ES256, RSA signs RS256 |
75
+ | `tls_client_auth` | `tls_client_cert:`, `tls_client_key:` | the TLS client certificate. Registrable today; the provider still answers 501 at `/token`, and that surfaces as the `ExchangeError` it is |
76
+
77
+ `auth_method:` may be omitted: a `client_secret` implies `client_secret_basic`,
78
+ a `private_key` implies `private_key_jwt`, neither means `none`.
79
+
80
+ ## What each call does
81
+
82
+ | Call | What happens |
83
+ |---|---|
84
+ | `authenticate(code:, code_verifier:, nonce: nil)` | `exchange` + `verify_id_token`, returns a `Login` |
85
+ | `exchange(code:, code_verifier:)` | `POST {issuer}/token` with the configured client authentication |
86
+ | `verify_id_token(jwt, nonce: nil)` | ES256 against `{issuer}/jwks`, checks `iss`, `aud`, `exp`, and `nonce` when given |
87
+ | `userinfo(access_token)` | `GET {issuer}/userinfo` with the Bearer token |
88
+ | `Login#userinfo` | the above, once, memoized; `{}` when there is no access token |
89
+
90
+ `Login` exposes every claim the scope catalogue can grant: `sub`, `acr`, `amr`,
91
+ `assurance`, `age_over?(n)`, `nationality` from the ID token; `email`,
92
+ `email_verified?`, `name`, `given_name`, `family_name`, `birthdate`,
93
+ `document_type`, `document_number`, `issuing_country`, `document_expires_on`
94
+ and `portrait` from `/userinfo` (`portrait` is registrable but not served by
95
+ the provider yet, and returns nil until it is).
96
+
97
+ Errors: `ConfigurationError`, `ExchangeError` (carries the provider's OAuth
98
+ error code and reason, verbatim), `VerificationError`, `UserinfoError`. A
99
+ returning user matched on `sub` can survive a rescued `UserinfoError`; a
100
+ signup that needs the email cannot.
101
+
102
+ ## Things worth knowing before you integrate
103
+
104
+ - **The ID token never carries personal data.** `sub`, timing, `acr`/`amr`,
105
+ the assurance block, and — if registered — `age_over_*` booleans and
106
+ `nationality`. Email, names, birthdate and document fields come only from
107
+ `/userinfo`, which is why `authenticate` alone is not enough for a signup.
108
+ - **The access token lives 10 minutes.** Read `/userinfo` while handling the
109
+ login; do not store the token for later.
110
+ - **`sub` is pairwise per verified domain.** It is the right account key and
111
+ it is derived from your registered sector: changing your asset's domain
112
+ rotates every `sub` you have stored. Plan domain changes as a migration.
113
+ - **ES256 only.** The provider signs with nothing else, and this gem refuses
114
+ other algorithms rather than negotiating.
115
+ - **Always pass the nonce through.** The SDK generates it and gives it to your
116
+ frontend in `onSuccess`; without it your backend cannot tell a substituted
117
+ ID token from the real one.
118
+ - **Email is a deliberate choice.** It is a Tier B scope precisely because a
119
+ shared email defeats the unlinkability the pairwise `sub` provides. Request
120
+ it because you need it, not because the checkbox is familiar.
121
+ - **Sandbox clients accept localhost origins; production clients do not.**
122
+ Registration lives in the ZOREAL dashboard on the asset's OAuth2 tab; Tier B
123
+ scopes (email, profile.\*) need a confidential client on a verified domain.
124
+
125
+ ## Development against a local provider
126
+
127
+ Point `issuer:` at your provider instance (for the Bynn stack:
128
+ `https://rails.bynn.io/id`). The issuer value must match the `iss` inside the
129
+ tokens exactly — it is compared, not normalized.
130
+
131
+ ## The ZOREAL OAuth2 library family
132
+
133
+ | Repository | Package | Role |
134
+ |---|---|---|
135
+ | zoreal-oauth2-react | @zoreal/oauth2-react (npm) | React frontend: the button, the QR, the polling |
136
+ | zoreal-oauth2-js | @zoreal/oauth2-js (npm) | Framework-free browser core |
137
+ | zoreal-oauth2-react-native | @zoreal/oauth2-react-native (npm) | React Native frontend |
138
+ | zoreal-oauth2-node | @zoreal/oauth2-node (npm) | Node.js backend |
139
+ | zoreal-oauth2-ruby | zoreal-oauth2 (RubyGems) | Ruby backend |
140
+ | zoreal-oauth2-python | zoreal-oauth2 (PyPI) | Python backend |
141
+ | zoreal-oauth2-php | zoreal/oauth2 (Packagist) | PHP backend |
142
+ | zoreal-oauth2-go | github.com/Bynn-Intelligence/zoreal-oauth2-go | Go backend |
143
+ | zoreal-oauth2-java | com.zoreal:oauth2 (Maven Central) | JVM backend |
144
+ | zoreal-oauth2-dotnet | Zoreal.OAuth2 (NuGet) | .NET backend |
145
+
146
+ The repository always carries the platform suffix; the package drops it where
147
+ the registry already scopes the ecosystem. None of them are named after a
148
+ framework, because none of them depend on one.
149
+
150
+ ## License
151
+
152
+ MIT.
@@ -0,0 +1,294 @@
1
+ require 'json'
2
+ require 'net/http'
3
+ require 'securerandom'
4
+ require 'uri'
5
+ require 'jwt'
6
+
7
+ module Zoreal
8
+ module OAuth2
9
+ # The relying-party client. One instance per registered ZOREAL client;
10
+ # thread-safe, so build it once at boot and share it.
11
+ #
12
+ # ZOREAL_CLIENT = Zoreal::OAuth2::Client.new(
13
+ # client_id: ENV['ZOREAL_CLIENT_ID'],
14
+ # client_secret: Rails.application.credentials.dig(:zoreal, :client_secret),
15
+ # issuer: ENV.fetch('ZOREAL_ISSUER', 'https://id.zoreal.com'),
16
+ # cache: Rails.cache
17
+ # )
18
+ #
19
+ # login = ZOREAL_CLIENT.authenticate(code: params[:code],
20
+ # code_verifier: params[:code_verifier],
21
+ # nonce: params[:nonce])
22
+ # login.sub # the pairwise subject: your stable user key
23
+ # login.userinfo # Tier B claims (email, name, ...), fetched once
24
+ class Client
25
+ DEFAULT_ISSUER = 'https://id.zoreal.com'.freeze
26
+ # The provider serves its JWKS with a 10-minute public cache; mirroring
27
+ # it here keeps a busy relying party off the endpoint without holding a
28
+ # rotated-out key longer than the provider itself would.
29
+ JWKS_TTL = 600
30
+ JWKS_CACHE_KEY = 'zoreal_oauth2_jwks'.freeze
31
+
32
+ AUTH_METHODS = %w[none client_secret_basic private_key_jwt tls_client_auth].freeze
33
+ # The provider rejects an assertion whose exp is more than 60 seconds
34
+ # out, so that is the lifetime, not a choice.
35
+ ASSERTION_LIFETIME = 60
36
+
37
+ attr_reader :client_id, :issuer, :auth_method
38
+
39
+ # Every registered token_endpoint_auth_method is supported:
40
+ #
41
+ # none a public client: no secret, no key, PKCE alone,
42
+ # and only ever Tier A scopes.
43
+ # client_secret_basic the secret travels as an HTTP Basic header.
44
+ # private_key_jwt the library signs a fresh RFC 7523 assertion per
45
+ # exchange from private_key (an OpenSSL::PKey or a
46
+ # PEM string; P-256 signs ES256, RSA signs RS256;
47
+ # private_key_kid sets the JWS kid header when your
48
+ # registered JWKS carries one).
49
+ # tls_client_auth tls_client_cert/tls_client_key are presented as
50
+ # the TLS client certificate. Registrable today;
51
+ # the provider itself still answers 501 at /token,
52
+ # and that surfaces as the ExchangeError it is.
53
+ #
54
+ # auth_method may be omitted: a client_secret implies
55
+ # client_secret_basic, a private_key implies private_key_jwt, neither
56
+ # means none.
57
+ #
58
+ # cache takes anything with read(key) and write(key, value, expires_in:)
59
+ # (ActiveSupport::Cache::Store is the intended shape). Without one, an
60
+ # in-process store is used; that is fine for one process and means each
61
+ # process of a multi-process server fetches the JWKS for itself.
62
+ def initialize(client_id:, client_secret: nil, issuer: DEFAULT_ISSUER,
63
+ auth_method: nil, private_key: nil, private_key_kid: nil,
64
+ tls_client_cert: nil, tls_client_key: nil,
65
+ cache: nil, timeout: 10)
66
+ raise ConfigurationError, 'client_id is required' if nil_or_empty?(client_id)
67
+ raise ConfigurationError, 'issuer is required' if nil_or_empty?(issuer)
68
+
69
+ @client_id = client_id
70
+ @client_secret = client_secret
71
+ @private_key = import_private_key(private_key)
72
+ @private_key_kid = private_key_kid
73
+ @tls_client_cert = import_certificate(tls_client_cert)
74
+ @tls_client_key = import_private_key(tls_client_key)
75
+ @auth_method = resolve_auth_method(auth_method)
76
+ @issuer = issuer.chomp('/')
77
+ @cache = cache || MemoryCache.new
78
+ @timeout = timeout
79
+ end
80
+
81
+ # The whole login, in order: exchange the code (with the PKCE verifier
82
+ # 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
+ tokens = exchange(code: code, code_verifier: code_verifier)
88
+ claims = verify_id_token(tokens['id_token'], nonce: nonce)
89
+ Login.new(client: self, claims: claims,
90
+ id_token: tokens['id_token'],
91
+ access_token: tokens['access_token'],
92
+ scope: tokens['scope'])
93
+ end
94
+
95
+ # POST /token. The verifier is mandatory: PKCE is required for every
96
+ # ZOREAL client, and the browser SDK that generated it hands it to your
97
+ # frontend precisely so your backend can present it here.
98
+ def exchange(code:, code_verifier:)
99
+ raise ArgumentError, 'code is required' if nil_or_empty?(code)
100
+ raise ArgumentError, 'code_verifier is required' if nil_or_empty?(code_verifier)
101
+
102
+ response = post_form("#{issuer}/token", {
103
+ 'grant_type' => 'authorization_code',
104
+ 'code' => code,
105
+ 'code_verifier' => code_verifier,
106
+ 'client_id' => client_id
107
+ })
108
+ body = parse_json(response.body)
109
+ unless response.is_a?(Net::HTTPSuccess)
110
+ raise ExchangeError.new(body['error'] || 'server_error',
111
+ body['error_description'] || "the provider answered #{response.code}",
112
+ status: response.code.to_i)
113
+ end
114
+ raise ExchangeError.new('server_error', 'no id_token in the token response') if nil_or_empty?(body['id_token'])
115
+
116
+ body
117
+ end
118
+
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)
125
+ claims, = JWT.decode(
126
+ id_token, nil, true,
127
+ algorithms: ['ES256'],
128
+ iss: issuer, verify_iss: true,
129
+ aud: client_id, verify_aud: true,
130
+ jwks: ->(options) {
131
+ @cache.write(JWKS_CACHE_KEY, nil, expires_in: 0) if options[:kid_not_found]
132
+ jwks
133
+ }
134
+ )
135
+ if !nil_or_empty?(nonce) && claims['nonce'] != nonce
136
+ raise VerificationError, 'the ID token nonce is not the one this login started with'
137
+ end
138
+
139
+ claims
140
+ rescue JWT::DecodeError => e
141
+ raise VerificationError, e.message
142
+ end
143
+
144
+ # GET /userinfo with the Bearer access token from the exchange. This is
145
+ # the only place personal claims (email, profile.*) are served, and the
146
+ # access token lives ten minutes, so call it as part of handling the
147
+ # login rather than storing the token for later.
148
+ def userinfo(access_token)
149
+ raise ArgumentError, 'access_token is required' if nil_or_empty?(access_token)
150
+
151
+ uri = URI("#{issuer}/userinfo")
152
+ request = Net::HTTP::Get.new(uri)
153
+ request['Authorization'] = "Bearer #{access_token}"
154
+ response = http_for(uri).request(request)
155
+ unless response.is_a?(Net::HTTPSuccess)
156
+ body = parse_json(response.body)
157
+ raise UserinfoError,
158
+ body['error_description'] || "userinfo answered #{response.code}"
159
+ end
160
+ parse_json(response.body)
161
+ end
162
+
163
+ private
164
+
165
+ def jwks
166
+ cached = @cache.read(JWKS_CACHE_KEY)
167
+ return cached if cached
168
+
169
+ uri = URI("#{issuer}/jwks")
170
+ begin
171
+ response = http_for(uri).request(Net::HTTP::Get.new(uri))
172
+ rescue SystemCallError, SocketError, IOError, Timeout::Error, OpenSSL::SSL::SSLError => e
173
+ raise VerificationError, "could not fetch the provider JWKS: #{e.message}"
174
+ end
175
+ raise VerificationError, "could not fetch the provider JWKS (#{response.code})" unless response.is_a?(Net::HTTPSuccess)
176
+
177
+ keys = JSON.parse(response.body, symbolize_names: true)
178
+ @cache.write(JWKS_CACHE_KEY, keys, expires_in: JWKS_TTL)
179
+ keys
180
+ end
181
+
182
+ def post_form(url, form)
183
+ uri = URI(url)
184
+ request = Net::HTTP::Post.new(uri)
185
+ # The form always carries client_id, whatever the auth method: the
186
+ # provider matches the code against it.
187
+ case auth_method
188
+ when 'client_secret_basic'
189
+ # The secret travels as the Basic password, never as a form field.
190
+ request.basic_auth(client_id, @client_secret)
191
+ when 'private_key_jwt'
192
+ form = form.merge(
193
+ 'client_assertion_type' => 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
194
+ 'client_assertion' => build_client_assertion
195
+ )
196
+ end
197
+ request.set_form_data(form)
198
+ http_for(uri).request(request)
199
+ end
200
+
201
+ # RFC 7523, in the shape the provider verifies: iss and sub are the
202
+ # client_id, aud is the token endpoint, exp is the capped 60 seconds,
203
+ # and jti is fresh because the provider enforces single use on it.
204
+ def build_client_assertion
205
+ now = Time.now.to_i
206
+ algorithm = @private_key.is_a?(OpenSSL::PKey::EC) ? 'ES256' : 'RS256'
207
+ headers = @private_key_kid ? { kid: @private_key_kid } : {}
208
+ JWT.encode(
209
+ {
210
+ iss: client_id, sub: client_id, aud: "#{issuer}/token",
211
+ exp: now + ASSERTION_LIFETIME, iat: now, jti: SecureRandom.uuid
212
+ },
213
+ @private_key, algorithm, headers
214
+ )
215
+ end
216
+
217
+ def http_for(uri)
218
+ http = Net::HTTP.new(uri.host, uri.port)
219
+ http.use_ssl = uri.scheme == 'https'
220
+ if auth_method == 'tls_client_auth' && http.use_ssl?
221
+ http.cert = @tls_client_cert
222
+ http.key = @tls_client_key
223
+ end
224
+ http.open_timeout = @timeout
225
+ http.read_timeout = @timeout
226
+ http
227
+ end
228
+
229
+ def resolve_auth_method(explicit)
230
+ method = explicit&.to_s
231
+ method ||= if !nil_or_empty?(@client_secret) then 'client_secret_basic'
232
+ elsif @private_key then 'private_key_jwt'
233
+ else 'none'
234
+ end
235
+ raise ConfigurationError, "unknown auth_method #{method}" unless AUTH_METHODS.include?(method)
236
+ raise ConfigurationError, 'client_secret_basic needs a client_secret' if method == 'client_secret_basic' && nil_or_empty?(@client_secret)
237
+ raise ConfigurationError, 'private_key_jwt needs a private_key' if method == 'private_key_jwt' && @private_key.nil?
238
+ if method == 'tls_client_auth' && (@tls_client_cert.nil? || @tls_client_key.nil?)
239
+ raise ConfigurationError, 'tls_client_auth needs tls_client_cert and tls_client_key'
240
+ end
241
+
242
+ method
243
+ end
244
+
245
+ def import_private_key(key)
246
+ return nil if key.nil?
247
+ return key unless key.is_a?(String)
248
+
249
+ OpenSSL::PKey.read(key)
250
+ rescue OpenSSL::PKey::PKeyError => e
251
+ raise ConfigurationError, "the private key did not parse: #{e.message}"
252
+ end
253
+
254
+ def import_certificate(cert)
255
+ return nil if cert.nil?
256
+ return cert unless cert.is_a?(String)
257
+
258
+ OpenSSL::X509::Certificate.new(cert)
259
+ rescue OpenSSL::X509::CertificateError => e
260
+ raise ConfigurationError, "the certificate did not parse: #{e.message}"
261
+ end
262
+
263
+ def parse_json(body)
264
+ JSON.parse(body.to_s)
265
+ rescue JSON::ParserError
266
+ {}
267
+ end
268
+
269
+ def nil_or_empty?(value)
270
+ value.nil? || value.to_s.strip.empty?
271
+ end
272
+
273
+ # The fallback JWKS cache: one process, TTL respected, no eviction
274
+ # beyond overwrite, because it only ever holds the one key set.
275
+ class MemoryCache
276
+ def initialize
277
+ @mutex = Mutex.new
278
+ @store = {}
279
+ end
280
+
281
+ def read(key)
282
+ @mutex.synchronize do
283
+ value, expires_at = @store[key]
284
+ expires_at && expires_at > Time.now ? value : nil
285
+ end
286
+ end
287
+
288
+ def write(key, value, expires_in:)
289
+ @mutex.synchronize { @store[key] = [value, Time.now + expires_in] }
290
+ end
291
+ end
292
+ end
293
+ end
294
+ end
@@ -0,0 +1,32 @@
1
+ module Zoreal
2
+ module OAuth2
3
+ class Error < StandardError; end
4
+
5
+ # The client was built without something it cannot work without.
6
+ class ConfigurationError < Error; end
7
+
8
+ # The provider refused the code exchange. `oauth_error` is the RFC 6749
9
+ # error code and `description` the provider's own reason, verbatim: the
10
+ # provider's words are the only signal that says WHY (a consumed code, a
11
+ # PKCE mismatch, a lapsed sector), and rewriting them hides it.
12
+ class ExchangeError < Error
13
+ attr_reader :oauth_error, :description, :status
14
+
15
+ def initialize(oauth_error, description, status: nil)
16
+ @oauth_error = oauth_error
17
+ @description = description
18
+ @status = status
19
+ super([oauth_error, description].compact.join(': '))
20
+ end
21
+ end
22
+
23
+ # The ID token did not verify: bad signature, wrong issuer or audience,
24
+ # expired, or a nonce that was not the one this login started with.
25
+ class VerificationError < Error; end
26
+
27
+ # /userinfo answered with anything but the claims. Callers that can live
28
+ # without personal data (a returning user matched by sub) may rescue this
29
+ # and continue; callers that need the email should not.
30
+ class UserinfoError < Error; end
31
+ end
32
+ end
@@ -0,0 +1,114 @@
1
+ module Zoreal
2
+ module OAuth2
3
+ # One verified login. The ID token claims are already checked when this
4
+ # exists; userinfo is fetched on first use, because the ID token never
5
+ # carries personal data and not every login needs any.
6
+ class Login
7
+ # The verified ID token claims and the raw compact JWT they came from.
8
+ attr_reader :claims, :id_token
9
+ # From the token response. The access token lives ten minutes.
10
+ attr_reader :access_token, :scope
11
+
12
+ def initialize(client:, claims:, id_token:, access_token: nil, scope: nil)
13
+ @client = client
14
+ @claims = claims
15
+ @id_token = id_token
16
+ @access_token = access_token
17
+ @scope = scope
18
+ end
19
+
20
+ # The pairwise subject: stable for your verified domain, meaningless to
21
+ # anyone else. This is the value to key accounts on — and it is derived
22
+ # from YOUR registered sector, so changing your asset's domain rotates
23
+ # every sub you have stored.
24
+ def sub
25
+ claims['sub']
26
+ end
27
+
28
+ # How the login was authenticated: zoreal.live, zoreal.device or
29
+ # zoreal.session. Describes what happened, never what was requested.
30
+ def acr
31
+ claims['acr']
32
+ end
33
+
34
+ def amr
35
+ claims['amr']
36
+ end
37
+
38
+ # The assurance block: uniqueness basis, verification month, chip
39
+ # liveness, trust tier, key protection.
40
+ def assurance
41
+ claims['zoreal']
42
+ end
43
+
44
+ # zoreal.age scope: the registered thresholds arrive as booleans
45
+ # (age_over_18 and so on), never an age.
46
+ def age_over?(threshold)
47
+ claims["age_over_#{threshold.to_i}"]
48
+ end
49
+
50
+ # zoreal.nationality scope: ISO 3166-1 alpha-3, read from the chip.
51
+ def nationality
52
+ claims['nationality']
53
+ end
54
+
55
+ # The Tier B claims, from /userinfo, fetched once and memoized. Raises
56
+ # UserinfoError when the endpoint refuses — rescue it if your flow can
57
+ # continue without personal data, as a returning user matched on sub
58
+ # can. Returns an empty hash when the exchange carried no access token.
59
+ def userinfo
60
+ @userinfo ||= access_token ? @client.userinfo(access_token) : {}
61
+ end
62
+
63
+ def email
64
+ userinfo['email']
65
+ end
66
+
67
+ def email_verified?
68
+ userinfo['email_verified'] == true
69
+ end
70
+
71
+ def name
72
+ userinfo['name']
73
+ end
74
+
75
+ def given_name
76
+ userinfo['given_name']
77
+ end
78
+
79
+ def family_name
80
+ userinfo['family_name']
81
+ end
82
+
83
+ # ISO 8601, from the profile.birthdate scope.
84
+ def birthdate
85
+ userinfo['birthdate']
86
+ end
87
+
88
+ # The profile.document scope: the document as presented, not an
89
+ # assertion about the person beyond it.
90
+ def document_type
91
+ userinfo['document_type']
92
+ end
93
+
94
+ def document_number
95
+ userinfo['document_number']
96
+ end
97
+
98
+ def issuing_country
99
+ userinfo['issuing_country']
100
+ end
101
+
102
+ def document_expires_on
103
+ userinfo['document_expires_on']
104
+ end
105
+
106
+ # The profile.portrait scope (Tier C): the chip's DG2 image. The scope
107
+ # is registrable but the provider does not serve the claim yet, so this
108
+ # returns nil until it does.
109
+ def portrait
110
+ userinfo['portrait']
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,5 @@
1
+ module Zoreal
2
+ module OAuth2
3
+ VERSION = '0.1.1'.freeze
4
+ end
5
+ end
@@ -0,0 +1,9 @@
1
+ require_relative 'oauth2/version'
2
+ require_relative 'oauth2/errors'
3
+ require_relative 'oauth2/client'
4
+ require_relative 'oauth2/login'
5
+
6
+ module Zoreal
7
+ module OAuth2
8
+ end
9
+ end
@@ -0,0 +1 @@
1
+ require_relative 'zoreal/oauth2'
metadata ADDED
@@ -0,0 +1,73 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: zoreal-oauth2
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.1
5
+ platform: ruby
6
+ authors:
7
+ - ZOREAL
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-08-27 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: jwt
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '2.7'
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '4'
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: '2.7'
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '4'
33
+ description: 'The relying-party half of Login with ZOREAL: exchanges the authorization
34
+ code your frontend received from @zoreal/oauth2-react, verifies the ID token against
35
+ the provider''s JWKS, and reads personal claims from /userinfo.'
36
+ email:
37
+ executables: []
38
+ extensions: []
39
+ extra_rdoc_files: []
40
+ files:
41
+ - LICENSE
42
+ - README.md
43
+ - lib/zoreal-oauth2.rb
44
+ - lib/zoreal/oauth2.rb
45
+ - lib/zoreal/oauth2/client.rb
46
+ - lib/zoreal/oauth2/errors.rb
47
+ - lib/zoreal/oauth2/login.rb
48
+ - lib/zoreal/oauth2/version.rb
49
+ homepage: https://zoreal.com
50
+ licenses:
51
+ - MIT
52
+ metadata:
53
+ source_code_uri: https://github.com/Bynn-Intelligence/zoreal-oauth2-ruby
54
+ post_install_message:
55
+ rdoc_options: []
56
+ require_paths:
57
+ - lib
58
+ required_ruby_version: !ruby/object:Gem::Requirement
59
+ requirements:
60
+ - - ">="
61
+ - !ruby/object:Gem::Version
62
+ version: '3.1'
63
+ required_rubygems_version: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
68
+ requirements: []
69
+ rubygems_version: 3.5.22
70
+ signing_key:
71
+ specification_version: 4
72
+ summary: Login with ZOREAL for Ruby backends.
73
+ test_files: []