Class: Zoreal::OAuth2::Client
- Inherits:
-
Object
- Object
- Zoreal::OAuth2::Client
- Defined in:
- lib/zoreal/oauth2/client.rb
Overview
The relying-party client. One instance per registered ZOREAL client; thread-safe, so build it once at boot and share it.
ZOREAL_CLIENT = Zoreal::OAuth2::Client.new(
client_id: ENV['ZOREAL_CLIENT_ID'],
client_secret: Rails.application.credentials.dig(:zoreal, :client_secret),
issuer: ENV.fetch('ZOREAL_ISSUER', 'https://id.zoreal.com'),
cache: Rails.cache
)
login = ZOREAL_CLIENT.authenticate(code: params[:code],
code_verifier: params[:code_verifier],
nonce: params[:nonce])
login.sub # the pairwise subject: your stable user key
login.userinfo # Tier B claims (email, name, ...), fetched once
Defined Under Namespace
Classes: MemoryCache
Constant Summary collapse
- DEFAULT_ISSUER =
'https://id.zoreal.com'.freeze
- JWKS_TTL =
The provider serves its JWKS with a 10-minute public cache; mirroring it here keeps a busy relying party off the endpoint without holding a rotated-out key longer than the provider itself would.
600- JWKS_CACHE_KEY =
'zoreal_oauth2_jwks'.freeze
- AUTH_METHODS =
%w[none client_secret_basic private_key_jwt tls_client_auth].freeze
- ACR_ORDER =
The assurance vocabulary, weakest to strongest. Verification accepts equal or stronger: an RP requiring zoreal.device is satisfied by a zoreal.live token, never the reverse.
{ 'zoreal.session' => 0, 'zoreal.device' => 1, 'zoreal.live' => 2 }.freeze
- ASSERTION_LIFETIME =
The provider rejects an assertion whose exp is more than 60 seconds out, so that is the lifetime, not a choice.
60
Instance Attribute Summary collapse
-
#auth_method ⇒ Object
readonly
Returns the value of attribute auth_method.
-
#client_id ⇒ Object
readonly
Returns the value of attribute client_id.
-
#issuer ⇒ Object
readonly
Returns the value of attribute issuer.
Instance Method Summary collapse
-
#authenticate(code:, code_verifier:, nonce: nil, acr: nil) ⇒ Object
The whole login, in order: exchange the code (with the PKCE verifier the browser SDK handed over), verify the ID token against the JWKS, check the nonce when the caller has it, and — when the caller passes acr: — refuse a token whose assurance is below it.
-
#exchange(code:, code_verifier:) ⇒ Object
POST /token.
-
#initialize(client_id:, client_secret: nil, issuer: DEFAULT_ISSUER, auth_method: nil, private_key: nil, private_key_kid: nil, tls_client_cert: nil, tls_client_key: nil, cache: nil, timeout: 10) ⇒ Client
constructor
Every registered token_endpoint_auth_method is supported:.
-
#userinfo(access_token) ⇒ Object
GET /userinfo with the Bearer access token from the exchange.
-
#verify_id_token(id_token, nonce: nil, acr: nil) ⇒ Object
ES256 against the provider's JWKS, plus iss, aud, exp, the nonce binding when the caller has the nonce, and the assurance floor when the caller passes acr:.
Constructor Details
#initialize(client_id:, client_secret: nil, issuer: DEFAULT_ISSUER, auth_method: nil, private_key: nil, private_key_kid: nil, tls_client_cert: nil, tls_client_key: nil, cache: nil, timeout: 10) ⇒ Client
Every registered token_endpoint_auth_method is supported:
none a public client: no secret, no key, PKCE alone,
and only ever Tier A scopes.
client_secret_basic the secret travels as an HTTP Basic header.
private_key_jwt the library signs a fresh RFC 7523 assertion per
exchange from private_key (an OpenSSL::PKey or a
PEM string; P-256 signs ES256, RSA signs RS256;
private_key_kid sets the JWS kid header when your
registered JWKS carries one).
tls_client_auth tls_client_cert/tls_client_key are presented as
the TLS client certificate. Registrable today;
the provider itself still answers 501 at /token,
and that surfaces as the ExchangeError it is.
auth_method may be omitted: a client_secret implies client_secret_basic, a private_key implies private_key_jwt, neither means none.
cache takes anything with read(key) and write(key, value, expires_in:) (ActiveSupport::Cache::Store is the intended shape). Without one, an in-process store is used; that is fine for one process and means each process of a multi-process server fetches the JWKS for itself.
66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 |
# File 'lib/zoreal/oauth2/client.rb', line 66 def initialize(client_id:, client_secret: nil, issuer: DEFAULT_ISSUER, auth_method: nil, private_key: nil, private_key_kid: nil, tls_client_cert: nil, tls_client_key: nil, cache: nil, timeout: 10) raise ConfigurationError, 'client_id is required' if nil_or_empty?(client_id) raise ConfigurationError, 'issuer is required' if nil_or_empty?(issuer) @client_id = client_id @client_secret = client_secret @private_key = import_private_key(private_key) @private_key_kid = private_key_kid @tls_client_cert = import_certificate(tls_client_cert) @tls_client_key = import_private_key(tls_client_key) @auth_method = resolve_auth_method(auth_method) @issuer = issuer.chomp('/') @cache = cache || MemoryCache.new @timeout = timeout end |
Instance Attribute Details
#auth_method ⇒ Object (readonly)
Returns the value of attribute auth_method.
41 42 43 |
# File 'lib/zoreal/oauth2/client.rb', line 41 def auth_method @auth_method end |
#client_id ⇒ Object (readonly)
Returns the value of attribute client_id.
41 42 43 |
# File 'lib/zoreal/oauth2/client.rb', line 41 def client_id @client_id end |
#issuer ⇒ Object (readonly)
Returns the value of attribute issuer.
41 42 43 |
# File 'lib/zoreal/oauth2/client.rb', line 41 def issuer @issuer end |
Instance Method Details
#authenticate(code:, code_verifier:, nonce: nil, acr: nil) ⇒ Object
The whole login, in order: exchange the code (with the PKCE verifier the browser SDK handed over), verify the ID token against the JWKS, check the nonce when the caller has it, and — when the caller passes acr: — refuse a token whose assurance is below it. Returns a Login; personal data is NOT fetched here, because the ID token never carries it and not every caller wants it — Login#userinfo fetches on first use.
REQUESTING an assurance on the wire (the SDK's acr_values) is advisory; the signed acr claim is the proof, and this parameter is where a relying party that asked for a liveness check verifies it actually happened. An RP that requires zoreal.live and never passes acr: here has checked nothing.
97 98 99 100 101 102 103 104 |
# File 'lib/zoreal/oauth2/client.rb', line 97 def authenticate(code:, code_verifier:, nonce: nil, acr: nil) tokens = exchange(code: code, code_verifier: code_verifier) claims = verify_id_token(tokens['id_token'], nonce: nonce, acr: acr) Login.new(client: self, claims: claims, id_token: tokens['id_token'], access_token: tokens['access_token'], scope: tokens['scope']) end |
#exchange(code:, code_verifier:) ⇒ Object
POST /token. The verifier is mandatory: PKCE is required for every ZOREAL client, and the browser SDK that generated it hands it to your frontend precisely so your backend can present it here.
109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 |
# File 'lib/zoreal/oauth2/client.rb', line 109 def exchange(code:, code_verifier:) raise ArgumentError, 'code is required' if nil_or_empty?(code) raise ArgumentError, 'code_verifier is required' if nil_or_empty?(code_verifier) response = post_form("#{issuer}/token", { 'grant_type' => 'authorization_code', 'code' => code, 'code_verifier' => code_verifier, 'client_id' => client_id }) body = parse_json(response.body) unless response.is_a?(Net::HTTPSuccess) raise ExchangeError.new(body['error'] || 'server_error', body['error_description'] || "the provider answered #{response.code}", status: response.code.to_i) end raise ExchangeError.new('server_error', 'no id_token in the token response') if nil_or_empty?(body['id_token']) body end |
#userinfo(access_token) ⇒ Object
GET /userinfo with the Bearer access token from the exchange. This is the only place personal claims (email, profile.*) are served, and the access token lives ten minutes, so call it as part of handling the login rather than storing the token for later.
160 161 162 163 164 165 166 167 168 169 170 171 172 173 |
# File 'lib/zoreal/oauth2/client.rb', line 160 def userinfo(access_token) raise ArgumentError, 'access_token is required' if nil_or_empty?(access_token) uri = URI("#{issuer}/userinfo") request = Net::HTTP::Get.new(uri) request['Authorization'] = "Bearer #{access_token}" response = http_for(uri).request(request) unless response.is_a?(Net::HTTPSuccess) body = parse_json(response.body) raise UserinfoError, body['error_description'] || "userinfo answered #{response.code}" end parse_json(response.body) end |
#verify_id_token(id_token, nonce: nil, acr: nil) ⇒ Object
ES256 against the provider's JWKS, plus iss, aud, exp, the nonce binding when the caller has the nonce, and the assurance floor when the caller passes acr:. Returns the claims. There is no RS256 fallback on purpose: ZOREAL signs nothing with RSA, and accepting a second algorithm is how algorithm confusion starts.
135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 |
# File 'lib/zoreal/oauth2/client.rb', line 135 def verify_id_token(id_token, nonce: nil, acr: nil) claims, = JWT.decode( id_token, nil, true, algorithms: ['ES256'], iss: issuer, verify_iss: true, aud: client_id, verify_aud: true, jwks: ->() { @cache.write(JWKS_CACHE_KEY, nil, expires_in: 0) if [:kid_not_found] jwks } ) if !nil_or_empty?(nonce) && claims['nonce'] != nonce raise VerificationError, 'the ID token nonce is not the one this login started with' end verify_acr!(claims, acr) unless nil_or_empty?(acr) claims rescue JWT::DecodeError => e raise VerificationError, e. end |