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 +7 -0
- data/LICENSE +21 -0
- data/README.md +152 -0
- data/lib/zoreal/oauth2/client.rb +294 -0
- data/lib/zoreal/oauth2/errors.rb +32 -0
- data/lib/zoreal/oauth2/login.rb +114 -0
- data/lib/zoreal/oauth2/version.rb +5 -0
- data/lib/zoreal/oauth2.rb +9 -0
- data/lib/zoreal-oauth2.rb +1 -0
- metadata +73 -0
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 @@
|
|
|
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: []
|