ohauthcat 0.0.2

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: a0c59afce443365c755849cc1d3091effaab35db9c2cbaefe66b5f236fedc146
4
+ data.tar.gz: 6e04987049f21d722eb0dfcefd7b312ab46503d3935e2e6d76d5dab20b583d05
5
+ SHA512:
6
+ metadata.gz: 626c678287200914a7bb4685ee3df8e5ea2bfe038de5dd54d30d57a984245ae59a3de854bdb0ad99cf0e718df82d87b99b3a66369002e494f5ce414f3db16345
7
+ data.tar.gz: 3c07a3d8924eb21990bbd7a61154f90386bca15e8cd3fbee952c30b99d8fdf10306d7202601af2ac93741a222707f80ff7f805142d3ee4a262e8b41046ad16ca
data/LICENSE ADDED
@@ -0,0 +1,35 @@
1
+ OhAuthCat SDK and CLI Licence
2
+
3
+ Copyright (c) 2026 Generic Technologies Private Limited. All rights reserved.
4
+ OhAuthCat is a product of Generic Technologies Private Limited.
5
+
6
+ This software development kit (the "Software") is licensed, not sold, under
7
+ the OhAuthCat SDK and CLI Licence, version 1.0, published at
8
+ https://ohauthcat.com/legal/sdk-licence (the "Licence"). By downloading,
9
+ installing or using the Software you accept the Licence. The summary below is
10
+ for convenience; if it differs from the Licence, the Licence prevails.
11
+
12
+ While you have an active agreement with OhAuthCat for its hosted service, you
13
+ may use, copy, read, modify and make derivative works of the Software, and
14
+ distribute it, modified or not, only as an integrated part of your own
15
+ application, solely to access and use the OhAuthCat service.
16
+
17
+ You must not:
18
+
19
+ 1. use the Software, or any modified version or part of it, with any service
20
+ other than the OhAuthCat service;
21
+ 2. distribute, publish or make the Software, or any modified version or part
22
+ of it, available on its own, as a standalone library, package, SDK, fork or
23
+ tool;
24
+ 3. remove or alter any copyright notice, this file or THIRD_PARTY_LICENSES;
25
+ 4. use the Software or its source code to build or help build a product or
26
+ service that competes with the OhAuthCat service; or
27
+ 5. license or distribute the Software under terms that would require it to be
28
+ disclosed or licensed as open source or distributed free of charge.
29
+
30
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
31
+ IMPLIED, TO THE EXTENT THE LAW ALLOWS. LIABILITY IS LIMITED AS SET OUT IN THE
32
+ LICENCE.
33
+
34
+ Third-party components distributed with the Software are listed in
35
+ THIRD_PARTY_LICENSES and are governed by their own licences.
data/README.md ADDED
@@ -0,0 +1,94 @@
1
+ # OhAuthCat Ruby SDK
2
+
3
+ Server-side SDK for OhAuthCat enterprise SSO (SAML, OIDC) and SCIM directory sync.
4
+ Ruby 3.2+, standard library only (no runtime gem dependencies).
5
+
6
+ ## Install
7
+
8
+ ```sh
9
+ gem install ohauthcat -v 0.0.2
10
+ ```
11
+
12
+ ### Before the first release
13
+
14
+ The `ohauthcat` gem is not published to RubyGems yet, so the install command above fails until the first release. Until then, install it from the source repository. The repository is private: you need read access to `bitbucket.org/the-genie-team/ohauthcat-sdk-ruby` over SSH. Run these commands from your project directory; the `v0.0.2` tag matches the version above (until that tag exists, replace `--branch v0.0.2` with `--branch dev` in the `git clone` command).
15
+
16
+ ```sh
17
+ git clone --depth 1 --branch v0.0.2 git@bitbucket.org:the-genie-team/ohauthcat-sdk-ruby.git
18
+ (cd ohauthcat-sdk-ruby && gem build ohauthcat.gemspec)
19
+ gem install ./ohauthcat-sdk-ruby/ohauthcat-0.0.2.gem
20
+ ```
21
+
22
+ Documentation: https://docs.ohauthcat.com. Licensed under the OhAuthCat SDK Licence (see `LICENSE`); third-party notices are in `THIRD_PARTY_LICENSES`.
23
+
24
+ ```ruby
25
+ require "ohauthcat"
26
+
27
+ client = OhAuthCat::Client.new(api_key: ENV.fetch("OHAUTHCAT_API_KEY"), client_id: "pk_...")
28
+
29
+ org = client.organizations.get_organization("org_...")
30
+ client.directories.list_directory_users_auto_paging("dir_...", query: { limit: 100 }).each { |user| }
31
+
32
+ # Webhooks: verify the raw body before parsing.
33
+ event = OhAuthCat::Webhooks.verify(request.raw_post, request.headers["OhAuthCat-Signature"], secret)
34
+ ```
35
+
36
+ ## Sign-in
37
+
38
+ Every sign-in needs a random `state` that you keep in the user's session and compare on the callback, or an attacker can sign your user into the attacker's account (login CSRF). `get_authorization_url` generates one when you pass none and returns it as `auth[:state]`, with the PKCE `auth[:code_verifier]`. Refuse a missing or different state before exchanging the code (Rails controller shown):
39
+
40
+ ```ruby
41
+ REDIRECT_URI = "https://app.example.com/callback"
42
+
43
+ def sso_start
44
+ auth = client.sso_helpers.get_authorization_url(redirect_uri: REDIRECT_URI, domain: "customer.com")
45
+ session[:sso_state] = auth[:state]
46
+ session[:sso_verifier] = auth[:code_verifier]
47
+ redirect_to auth[:url], allow_other_host: true
48
+ end
49
+
50
+ def sso_callback
51
+ expected = session.delete(:sso_state).to_s
52
+ verifier = session.delete(:sso_verifier).to_s
53
+ unless !expected.empty? && !verifier.empty? &&
54
+ ActiveSupport::SecurityUtils.secure_compare(expected, params[:state].to_s)
55
+ return head :bad_request # never exchange the code
56
+ end
57
+
58
+ result = client.sso_helpers.get_profile_and_token(code: params[:code].to_s, code_verifier: verifier,
59
+ redirect_uri: REDIRECT_URI)
60
+ # result.profile.email: create your session for this user
61
+ rescue OhAuthCat::RestartSignInError
62
+ redirect_to "/login" # the code was used or expired; start again
63
+ end
64
+ ```
65
+
66
+ Outside Rails, compare with `OpenSSL.fixed_length_secure_compare` after checking both strings have the same length.
67
+
68
+ Guests: a sign-in admitted under the organization's guest policy (an email domain outside its verified domains) has `OhAuthCat::AccessPolicy.guest?(result.profile)` true (`access_policy` is `AccessPolicy::GUEST`); for every other sign-in the field is absent. `email_domain_verified` stays true for guests, so check this field when guests must get less access.
69
+
70
+ ## Portal links
71
+
72
+ A setup link lasts `expires_in` seconds, 300 (5 minutes) to 2,592,000 (30 days); leave it out for the default of 604,800 (7 days). A link created with a secret key opens once; revoke it to end it early, and `expires_at` on the result says when it ends. `ttl` (60 to 300 seconds) is only for sealed single-use embed links; never send both.
73
+
74
+ ```ruby
75
+ link = client.portal.create_portal_link({ "organization" => "org_...", "intents" => ["sso"], "expires_in" => 86_400 })
76
+ link.url
77
+ ```
78
+
79
+ - Every v1 operation is generated from the pinned spec. Responses are typed models
80
+ (`OhAuthCat::Models::*`, for example `org.name`), validated against the spec; `model.to_h` is the raw
81
+ JSON escape hatch. Request bodies take a model or a Hash.
82
+ - Standby failover: after 3 consecutive edge failures the client moves to `api.oauthcat.com` /
83
+ `auth.oauthcat.com`, re-probes the primary every 60 s and returns when healthy (`standby:`,
84
+ `standby_api_base_url:`, `standby_auth_base_url:`, `standby_threshold:`, `standby_cooldown:`).
85
+ - Errors are `OhAuthCat::Error` subclasses by type (`NotFoundError`, `RateLimitedError`, ...)
86
+ with the stable `code` (see `OhAuthCat::Generated::ERROR_CODES`).
87
+ - Writes carry an `Idempotency-Key` (generated, or `idempotency_key:`). GETs and keyed writes retry
88
+ on 429/503 with backoff; the SSO code exchange is never retried.
89
+
90
+ Development: `make ruby-test` and `make ruby-contract` from the repository root.
91
+
92
+ ## Development
93
+
94
+ This repository holds only the ruby SDK. Contributor commands, the generator and the rules are in [AGENTS.md](AGENTS.md); releases in [RELEASING.md](RELEASING.md). Generated code comes from the pinned API spec in `spec/`; never edit it by hand.
@@ -0,0 +1,5 @@
1
+ The OhAuthCat Ruby SDK uses only the Ruby standard library (net/http, json,
2
+ openssl, securerandom, uri). It bundles and depends on no third-party gems.
3
+
4
+ Development-only (never a gem dependency): minitest (MIT), run from the
5
+ Ruby installation's bundled gems.
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OhAuthCat
4
+ # Values of Profile#access_policy.
5
+ module AccessPolicy
6
+ # The sign-in was admitted under the organization's guest policy (a domain outside its verified domains).
7
+ GUEST = "guest"
8
+
9
+ # True when the profile signed in as a guest. email_domain_verified stays true for guests.
10
+ def self.guest?(profile)
11
+ !profile.nil? && profile.access_policy == GUEST
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,333 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OhAuthCat
4
+ # The OhAuthCat server SDK. Every v1 operation is on a resource group, for example
5
+ # client.organizations.get_organization("org_..."). Responses are decoded JSON (Hash, Array, ...).
6
+ class Client
7
+ include Generated::ResourceAccessors
8
+
9
+ API_VERSION = /\A1\.[0-9]+\z/
10
+
11
+ attr_reader :api_key, :client_id, :api_base_url, :auth_base_url, :api_version, :timeout, :max_retries, :max_pages
12
+
13
+ # transport: optional callable (method, uri, headers, body, timeout) -> [status, headers, body].
14
+ # sleeper: optional callable(seconds) used between retries.
15
+ def initialize(api_key:, client_id: nil, api_base_url: "https://api.ohauthcat.com",
16
+ auth_base_url: "https://auth.ohauthcat.com", api_version: nil, timeout: 30,
17
+ max_retries: 2, max_pages: 1000, transport: nil, sleeper: nil, standby: nil,
18
+ standby_api_base_url: nil, standby_auth_base_url: nil, standby_threshold: 3,
19
+ standby_cooldown: 60, clock: nil)
20
+ raise ArgumentError, "api_key is required" unless api_key.is_a?(String) && !api_key.strip.empty? && header_text?(api_key)
21
+ if client_id && !(client_id.start_with?("pk_") && client_id.length > 3 && header_text?(client_id))
22
+ raise ArgumentError, "client_id must be a publishable key (pk_...)"
23
+ end
24
+ raise ArgumentError, "api_version must look like 1.N" if api_version && !api_version.match?(API_VERSION)
25
+ raise ArgumentError, "timeout must be positive" unless timeout.is_a?(Numeric) && timeout.positive?
26
+ raise ArgumentError, "max_retries must be 0..10" unless max_retries.is_a?(Integer) && max_retries.between?(0, 10)
27
+ raise ArgumentError, "max_pages must be positive" unless max_pages.is_a?(Integer) && max_pages.positive?
28
+
29
+ @api_key = api_key
30
+ @client_id = client_id
31
+ @api_base_url = base(api_base_url)
32
+ @auth_base_url = base(auth_base_url)
33
+ @api_version = api_version
34
+ @timeout = timeout
35
+ @max_retries = max_retries
36
+ @max_pages = max_pages
37
+ @transport = transport || method(:net_http)
38
+ @sleeper = sleeper || ->(seconds) { sleep(seconds) }
39
+ @standby = Standby.new(api: @api_base_url, auth: @auth_base_url, enabled: standby,
40
+ fallback_api: standby_api_base_url, fallback_auth: standby_auth_base_url,
41
+ threshold: standby_threshold, cooldown: standby_cooldown,
42
+ clock: clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
43
+ end
44
+
45
+ # The edge failover state (see Standby).
46
+ attr_reader :standby
47
+
48
+ def sso_helpers
49
+ @sso_helpers ||= SsoHelpers.new(self)
50
+ end
51
+
52
+ def passkey_helpers
53
+ @passkey_helpers ||= PasskeyHelpers.new(self)
54
+ end
55
+
56
+ def webhooks
57
+ Webhooks
58
+ end
59
+
60
+ # Sends one operation by name. Options: idempotency_key:, timeout:, max_retries:, access_token:.
61
+ def request(operation, path, query, body, **options)
62
+ send_request(operation, path, query, body, **options)[:value]
63
+ end
64
+
65
+ # Enumerates every item of a list operation, following next_cursor.
66
+ def paginate(operation, path, query, model: nil, **options)
67
+ route = route(operation)
68
+ raise Error.client("invalid_pagination") unless route.list
69
+
70
+ start = (query || {}).transform_keys(&:to_s)
71
+ raise Error.client("invalid_pagination") if start.key?("before")
72
+
73
+ Enumerator.new do |out|
74
+ q = start.dup
75
+ seen = {}
76
+ seen[q["after"]] = true if q["after"].is_a?(String) && !q["after"].empty?
77
+ pages = 0
78
+ loop do
79
+ raise Error.client("invalid_pagination") if (pages += 1) > @max_pages
80
+
81
+ result = request(operation, path, q, nil, **options)
82
+ raise Error.client("response_invalid") unless result.is_a?(Hash) && result["data"].is_a?(Array)
83
+
84
+ data = result["data"]
85
+ more = result["has_more"] == true
86
+ cursor = result["next_cursor"]
87
+ if more && (!cursor.is_a?(String) || cursor.empty? || data.empty? || seen[cursor])
88
+ raise Error.client("invalid_pagination")
89
+ end
90
+
91
+ data.each { |item| out << (model ? model.from_json(item) : item) }
92
+ break unless more
93
+
94
+ seen[cursor] = true
95
+ q["after"] = cursor
96
+ end
97
+ end
98
+ end
99
+
100
+ # Operations whose request body is not JSON, with the media type sent as Content-Type.
101
+ # The pinned contract does not carry request media, so a test checks this list against the spec.
102
+ RAW_BODY_MEDIA = {
103
+ "confirmDirectorySourceOAuth" => "application/x-www-form-urlencoded",
104
+ "importDirectoryCSV" => "text/csv",
105
+ "importPortalDirectoryCSV" => "text/csv",
106
+ "removeFromWaitlistOneClick" => "application/x-www-form-urlencoded",
107
+ "unsubscribeSubprocessorNoticesOneClick" => "application/x-www-form-urlencoded"
108
+ }.freeze
109
+
110
+ # Replaces the users and groups of a directory whose provider is csv with the content of the
111
+ # file (a String, sent as-is), as a text/csv body. The generated
112
+ # directories.import_directory_c_s_v cannot send this body. Options as for #request.
113
+ def import_directory_csv(directory, csv, dry_run: nil, **options)
114
+ raise Error.client("request_invalid") unless directory.is_a?(String) && !directory.strip.empty?
115
+ raise Error.client("request_invalid") unless csv.is_a?(String) && !csv.empty?
116
+ raise Error.client("request_invalid") unless dry_run.nil? || dry_run == true || dry_run == false
117
+
118
+ query = dry_run.nil? ? nil : { "dry_run" => dry_run }
119
+ value = send_request("importDirectoryCSV", [directory], query, nil, raw_body: csv, **options)[:value]
120
+ Models::DirectorySyncResult.from_json(value)
121
+ end
122
+
123
+ def send_request(operation, path, query, body, idempotency_key: nil, timeout: nil, max_retries: nil, access_token: nil, raw_body: nil)
124
+ raise Error.client("request_invalid") if !raw_body.nil? && (!body.nil? || !RAW_BODY_MEDIA.key?(operation) || !raw_body.is_a?(String))
125
+
126
+ route = route(operation)
127
+ build_uri(route, path, query, @api_base_url)
128
+ token = @api_key
129
+ if route.auth == "accessToken"
130
+ raise Error.client("request_invalid") unless access_token.is_a?(String) && !access_token.strip.empty? && header_text?(access_token)
131
+
132
+ token = access_token
133
+ elsif access_token
134
+ raise Error.client("request_invalid")
135
+ end
136
+ headers = {
137
+ "Authorization" => "Bearer #{token}",
138
+ "Accept" => route.media.empty? ? "application/json" : route.media,
139
+ "User-Agent" => "ohauthcat-sdk-ruby/#{VERSION}"
140
+ }
141
+ headers["OhAuthCat-Version"] = @api_version if @api_version
142
+ retryable = %w[GET HEAD].include?(route.method)
143
+ if route.method != "GET"
144
+ key = idempotency_key || SecureRandom.uuid
145
+ raise Error.client("request_invalid") if !key.is_a?(String) || key.strip.empty? || key.length > 255 || !header_text?(key)
146
+
147
+ headers["Idempotency-Key"] = key
148
+ retryable = route.idempotent_key
149
+ end
150
+ # A consumed authorization code must never be replayed.
151
+ retryable = false if operation == "exchangeSsoCode"
152
+ payload = nil
153
+ if !raw_body.nil?
154
+ payload = raw_body.b
155
+ headers["Content-Type"] = RAW_BODY_MEDIA.fetch(operation)
156
+ elsif !body.nil?
157
+ payload = JSON.generate(Model.encode(body))
158
+ headers["Content-Type"] = "application/json"
159
+ end
160
+ retries = max_retries || @max_retries
161
+ request_timeout = timeout || @timeout
162
+ probe(request_timeout)
163
+ primary_error = nil
164
+ 2.times do
165
+ snap = @standby.snapshot(route.server == :auth)
166
+ uri = build_uri(route, path, query, snap.origin)
167
+ begin
168
+ result = attempt(route, uri, headers, payload, request_timeout, retries, retryable, snap)
169
+ @standby.success(snap.revision) unless snap.on_standby
170
+ return result
171
+ rescue Error => e
172
+ unless Standby.edge_error?(e)
173
+ @standby.success(snap.revision) if !snap.on_standby && e.status >= 400
174
+ raise
175
+ end
176
+ if snap.on_standby
177
+ @standby.unavailable(snap.revision)
178
+ raise(primary_error || snap.retained || e)
179
+ end
180
+ switched = @standby.edge(snap.revision, e)
181
+ if primary_error.nil? && switched && retryable
182
+ primary_error = e
183
+ next
184
+ end
185
+ raise
186
+ end
187
+ end
188
+ raise ConnectionError.new("The request could not be sent", code: "transport_error")
189
+ end
190
+
191
+ def attempt(route, uri, headers, payload, request_timeout, retries, retryable, snap)
192
+ attempt = 0
193
+ loop do
194
+ status, response_headers, raw = call_transport(route.method, uri, headers, payload, request_timeout)
195
+ @standby.success(snap.revision) if !snap.on_standby && [429, 503].include?(status)
196
+ if [429, 503].include?(status) && retryable && attempt < retries
197
+ delay = self.class.retry_delay(header(response_headers, "Retry-After"), attempt)
198
+ if delay
199
+ @sleeper.call(delay)
200
+ attempt += 1
201
+ next
202
+ end
203
+ end
204
+ request_id = header(response_headers, "Request-Id")
205
+ raise Error.from_response(status, raw, request_id) unless status.between?(200, 299)
206
+ return { status: status, value: nil, request_id: request_id } if status == 204 || raw.nil? || raw.empty?
207
+
208
+ text = raw.dup.force_encoding(Encoding::UTF_8)
209
+ return { status: status, value: text, request_id: request_id } if !route.media.empty? && route.media != "application/json"
210
+
211
+ begin
212
+ return { status: status, value: JSON.parse(text), request_id: request_id }
213
+ rescue JSON::ParserError, EncodingError
214
+ raise Error.new("The response was not valid JSON", code: "response_invalid", status: status, request_id: request_id)
215
+ end
216
+ end
217
+ end
218
+
219
+ # Re-probes the primary origins while on standby: both must answer /healthz with {"status":"ok"}.
220
+ def probe(request_timeout)
221
+ revision = @standby.begin_probe
222
+ return if revision.nil?
223
+
224
+ healthy = false
225
+ begin
226
+ cap = [request_timeout, 1].min
227
+ healthy = [@standby.api, @standby.auth].all? do |origin|
228
+ status, _headers, raw = @transport.call("GET", URI.parse("#{origin}/healthz"), { "Accept" => "application/json" }, nil, cap)
229
+ status == 200 && raw.to_s.bytesize <= 1024 && JSON.parse(raw.to_s) == { "status" => "ok" }
230
+ end
231
+ rescue StandardError
232
+ healthy = false
233
+ ensure
234
+ @standby.finish_probe(revision, healthy)
235
+ end
236
+ end
237
+
238
+ # Retry-After seconds or HTTP date, else jittered exponential backoff; nil when too long.
239
+ def self.retry_delay(value, attempt)
240
+ if value.is_a?(String)
241
+ text = value.strip
242
+ if text.match?(/\A\d+(\.\d+)?\z/)
243
+ seconds = text.to_f
244
+ return seconds > 60 ? nil : seconds
245
+ end
246
+ begin
247
+ seconds = Time.httpdate(text) - Time.now
248
+ return seconds > 60 ? nil : seconds if seconds >= 0
249
+ rescue ArgumentError
250
+ nil
251
+ end
252
+ end
253
+ limit = [10.0, 2**attempt.clamp(0, 4)].min
254
+ (limit / 2) + (rand * limit / 2)
255
+ end
256
+
257
+ private :attempt, :probe
258
+
259
+ private
260
+
261
+ def route(operation)
262
+ Generated::ROUTES[operation] || raise(Error.client("unknown_operation"))
263
+ end
264
+
265
+ def base(url)
266
+ uri = URI.parse(url.to_s)
267
+ raise ArgumentError, "invalid base URL" unless %w[http https].include?(uri.scheme) && uri.host && uri.query.nil? && uri.fragment.nil?
268
+
269
+ url.to_s.chomp("/")
270
+ rescue URI::InvalidURIError
271
+ raise ArgumentError, "invalid base URL"
272
+ end
273
+
274
+ def header_text?(value)
275
+ value.match?(/\A[\x20-\x7e]*\z/)
276
+ end
277
+
278
+ def header(headers, name)
279
+ headers&.each { |k, v| return v if k.to_s.casecmp?(name) }
280
+ nil
281
+ end
282
+
283
+ def encode(value)
284
+ URI.encode_www_form_component(value.to_s).gsub("+", "%20")
285
+ end
286
+
287
+ def build_uri(route, path, query, base_url)
288
+ values = Array(path)
289
+ index = 0
290
+ built = route.path.gsub(/\{[^}]+\}/) do
291
+ value = values[index]
292
+ index += 1
293
+ raise Error.client("request_invalid") unless value.is_a?(String) && !value.strip.empty?
294
+
295
+ encode(value)
296
+ end
297
+ raise Error.client("request_invalid") unless index == values.length
298
+
299
+ pairs = []
300
+ (query || {}).each do |key, value|
301
+ Array(value).each do |item|
302
+ next if item.nil?
303
+ raise Error.client("request_invalid") if item.is_a?(Hash) || item.is_a?(Array)
304
+
305
+ pairs << "#{encode(key)}=#{encode(item)}"
306
+ end
307
+ end
308
+ URI.parse(base_url + built + (pairs.empty? ? "" : "?#{pairs.join("&")}"))
309
+ end
310
+
311
+ def call_transport(http_method, uri, headers, payload, timeout)
312
+ @transport.call(http_method, uri, headers, payload, timeout)
313
+ rescue Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout, Timeout::Error
314
+ raise ConnectionError.new("The request timed out", code: "request_timeout")
315
+ rescue Error
316
+ raise
317
+ rescue StandardError
318
+ raise ConnectionError.new("The request could not be sent", code: "transport_error")
319
+ end
320
+
321
+ def net_http(http_method, uri, headers, payload, timeout)
322
+ http = Net::HTTP.new(uri.host, uri.port)
323
+ http.use_ssl = uri.scheme == "https"
324
+ http.open_timeout = [timeout, 10].min
325
+ http.read_timeout = timeout
326
+ http.write_timeout = timeout
327
+ request = Net::HTTPGenericRequest.new(http_method, !payload.nil?, true, uri.request_uri, headers)
328
+ request.body = payload if payload
329
+ response = http.start { |connection| connection.request(request) }
330
+ [response.code.to_i, response.each_header.to_h, response.body.to_s]
331
+ end
332
+ end
333
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OhAuthCat
4
+ UNKNOWN_ERROR = "unknown_error"
5
+
6
+ # An error returned by, or while talking to, the OhAuthCat API. Subclasses follow the
7
+ # catalogue's error type; #code is the stable code (see Generated::ERROR_CODES).
8
+ class Error < StandardError
9
+ attr_reader :code, :status, :type, :param, :doc_url, :request_id
10
+
11
+ def initialize(message, code:, status: 0, type: nil, param: nil, doc_url: nil, request_id: nil)
12
+ super(message)
13
+ @code = code
14
+ @status = status
15
+ @type = type
16
+ @param = param
17
+ @doc_url = doc_url
18
+ @request_id = request_id
19
+ end
20
+
21
+ # Error catalogue page for a code.
22
+ def self.doc_url_for(code)
23
+ "https://docs.ohauthcat.com/errors/#{code}"
24
+ end
25
+
26
+ # A refusal raised by the SDK before any request. With param, the message and #param
27
+ # name the argument that was wrong.
28
+ def self.client(code, param = nil)
29
+ message = "OhAuthCat SDK error: #{code}"
30
+ message += " (#{param} is missing or invalid)" if param
31
+ Error.new(message, code: code, param: param, doc_url: doc_url_for(code))
32
+ end
33
+
34
+ # Builds the typed error from a raw error body. Never raises.
35
+ def self.from_response(status, body, request_id_header = nil)
36
+ header_id = trimmed(request_id_header)
37
+ parsed = begin
38
+ JSON.parse(body.to_s)
39
+ rescue JSON::ParserError, EncodingError
40
+ nil
41
+ end
42
+ inner = parsed.is_a?(Hash) ? parsed["error"] : nil
43
+ unless inner.is_a?(Hash)
44
+ return Error.new("Unexpected error response (HTTP #{status})", code: UNKNOWN_ERROR, status: status, request_id: header_id)
45
+ end
46
+
47
+ field = ->(key) { trimmed(inner[key]) }
48
+ code = field.call("code") || UNKNOWN_ERROR
49
+ klass = code == "plan_limit_reached" ? PlanLimitReachedError : TYPES.fetch(field.call("type"), Error)
50
+ klass.new(field.call("message") || "OhAuthCat API error: #{code}",
51
+ code: code, status: status, type: field.call("type"), param: field.call("param"),
52
+ doc_url: field.call("doc_url"), request_id: field.call("request_id") || header_id)
53
+ end
54
+
55
+ def self.trimmed(value)
56
+ return nil unless value.is_a?(String)
57
+
58
+ text = value.strip
59
+ text.empty? ? nil : text
60
+ end
61
+ end
62
+
63
+ class InvalidRequestError < Error; end
64
+ class AuthenticationError < Error; end
65
+ class PermissionError < Error; end
66
+ # plan_limit_reached: the plan refuses this growth. #metric is the enforced monthly ceiling
67
+ # named in param, or nil when the refusal is about a resource count or a missing feature.
68
+ # Existing resources and users keep working.
69
+ class PlanLimitReachedError < PermissionError
70
+ METRICS = %w[monthly_active_users audit_log_events pipes_token_fetches].freeze
71
+
72
+ def metric
73
+ METRICS.include?(param) ? param : nil
74
+ end
75
+ end
76
+
77
+ class NotFoundError < Error; end
78
+ class ConflictError < Error; end
79
+ class RateLimitedError < Error; end
80
+ class IdpError < Error; end
81
+ class UnavailableError < Error; end
82
+ # The network failed or timed out (codes transport_error, request_timeout).
83
+ class ConnectionError < Error; end
84
+
85
+ # The authorization code cannot be reused: start a fresh sign-in.
86
+ class RestartSignInError < Error
87
+ def initialize(cause_error)
88
+ super("Restart the sign-in flow", code: cause_error.code, status: cause_error.status, type: cause_error.type,
89
+ param: cause_error.param, doc_url: cause_error.doc_url, request_id: cause_error.request_id)
90
+ end
91
+ end
92
+
93
+ Error::TYPES = {
94
+ "invalid_request" => InvalidRequestError, "authentication" => AuthenticationError,
95
+ "permission" => PermissionError, "not_found" => NotFoundError, "conflict" => ConflictError,
96
+ "rate_limited" => RateLimitedError, "idp_error" => IdpError, "unavailable" => UnavailableError
97
+ }.freeze
98
+ end