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 +7 -0
- data/LICENSE +35 -0
- data/README.md +94 -0
- data/THIRD_PARTY_LICENSES +5 -0
- data/lib/ohauthcat/access_policy.rb +14 -0
- data/lib/ohauthcat/client.rb +333 -0
- data/lib/ohauthcat/errors.rb +98 -0
- data/lib/ohauthcat/generated.rb +2898 -0
- data/lib/ohauthcat/model.rb +129 -0
- data/lib/ohauthcat/models.rb +5127 -0
- data/lib/ohauthcat/passkey_helpers.rb +53 -0
- data/lib/ohauthcat/route.rb +6 -0
- data/lib/ohauthcat/sso_helpers.rb +100 -0
- data/lib/ohauthcat/standby.rb +129 -0
- data/lib/ohauthcat/version.rb +5 -0
- data/lib/ohauthcat/webhooks.rb +97 -0
- data/lib/ohauthcat.rb +20 -0
- metadata +58 -0
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,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
|