Class: Zoreal::OAuth2::Client

Inherits:
Object
  • Object
show all
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
)

 = ZOREAL_CLIENT.authenticate(code: params[:code],
                                 code_verifier: params[:code_verifier],
                                 nonce: params[:nonce])
.sub            # the pairwise subject: your stable user key
.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

Instance Method Summary collapse

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.

Raises:



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_methodObject (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_idObject (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

#issuerObject (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.

Raises:

  • (ArgumentError)


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.

Raises:

  • (ArgumentError)


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: ->(options) {
      @cache.write(JWKS_CACHE_KEY, nil, expires_in: 0) if options[: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.message
end