atlas-auth 0.1.0
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 +132 -0
- data/lib/atlas/authorization.rb +78 -0
- data/lib/atlas/client.rb +124 -0
- data/lib/atlas/error.rb +92 -0
- data/lib/atlas/handshake.rb +241 -0
- data/lib/atlas/jwks_cache.rb +138 -0
- data/lib/atlas/pagination.rb +52 -0
- data/lib/atlas/resources/actions.rb +46 -0
- data/lib/atlas/resources/actor_tokens.rb +18 -0
- data/lib/atlas/resources/api_keys.rb +38 -0
- data/lib/atlas/resources/attack_protection.rb +18 -0
- data/lib/atlas/resources/audit_logs.rb +14 -0
- data/lib/atlas/resources/base.rb +32 -0
- data/lib/atlas/resources/billing.rb +35 -0
- data/lib/atlas/resources/bot_signals.rb +26 -0
- data/lib/atlas/resources/branding.rb +24 -0
- data/lib/atlas/resources/data_subject_requests.rb +28 -0
- data/lib/atlas/resources/domains.rb +26 -0
- data/lib/atlas/resources/email_templates.rb +29 -0
- data/lib/atlas/resources/fga.rb +146 -0
- data/lib/atlas/resources/import_export.rb +35 -0
- data/lib/atlas/resources/instance.rb +18 -0
- data/lib/atlas/resources/instance_security.rb +46 -0
- data/lib/atlas/resources/invitations.rb +22 -0
- data/lib/atlas/resources/jwt_templates.rb +32 -0
- data/lib/atlas/resources/localizations.rb +30 -0
- data/lib/atlas/resources/log_streams.rb +35 -0
- data/lib/atlas/resources/lti_platforms.rb +30 -0
- data/lib/atlas/resources/managed_waf.rb +35 -0
- data/lib/atlas/resources/messaging.rb +32 -0
- data/lib/atlas/resources/network_acls.rb +30 -0
- data/lib/atlas/resources/oauth_clients.rb +55 -0
- data/lib/atlas/resources/oauth_providers.rb +44 -0
- data/lib/atlas/resources/organizations.rb +140 -0
- data/lib/atlas/resources/radius_clients.rb +30 -0
- data/lib/atlas/resources/rate_limit.rb +18 -0
- data/lib/atlas/resources/resource_servers.rb +30 -0
- data/lib/atlas/resources/restrictions.rb +29 -0
- data/lib/atlas/resources/risk_based_mfa.rb +18 -0
- data/lib/atlas/resources/roles.rb +55 -0
- data/lib/atlas/resources/scim_provisioning.rb +46 -0
- data/lib/atlas/resources/scim_tokens.rb +23 -0
- data/lib/atlas/resources/sessions.rb +29 -0
- data/lib/atlas/resources/sign_in_tokens.rb +17 -0
- data/lib/atlas/resources/sms_templates.rb +37 -0
- data/lib/atlas/resources/sso_connections.rb +35 -0
- data/lib/atlas/resources/sso_onboarding.rb +37 -0
- data/lib/atlas/resources/tokens.rb +15 -0
- data/lib/atlas/resources/users.rb +121 -0
- data/lib/atlas/resources/waitlist.rb +19 -0
- data/lib/atlas/resources/webhooks.rb +35 -0
- data/lib/atlas/transport.rb +158 -0
- data/lib/atlas/verifier.rb +227 -0
- data/lib/atlas/version.rb +6 -0
- data/lib/atlas.rb +41 -0
- metadata +148 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
require "digest"
|
|
7
|
+
require "base64"
|
|
8
|
+
|
|
9
|
+
require_relative "error"
|
|
10
|
+
require_relative "transport"
|
|
11
|
+
|
|
12
|
+
module Atlas
|
|
13
|
+
# A fresh session minted by redeeming a cross-property handshake nonce.
|
|
14
|
+
#
|
|
15
|
+
# These are the cookie VALUES a satellite's Rack/Rails handler sets as its own
|
|
16
|
+
# first-party cookies: +jwt+ as +__session+ (script-readable, short-lived) and
|
|
17
|
+
# +refresh_token+ as +__atlas_rt+ (HttpOnly).
|
|
18
|
+
#
|
|
19
|
+
# @!attribute [r] jwt
|
|
20
|
+
# @return [String] the +__session+ JWT value.
|
|
21
|
+
# @!attribute [r] refresh_token
|
|
22
|
+
# @return [String] the +__atlas_rt+ refresh value (set HttpOnly).
|
|
23
|
+
# @!attribute [r] session_id
|
|
24
|
+
# @return [String] the session id ("" when the server omits it).
|
|
25
|
+
# @!attribute [r] expires_in
|
|
26
|
+
# @return [Integer] seconds until the +__session+ JWT expires.
|
|
27
|
+
HandshakeSession = Struct.new(:jwt, :refresh_token, :session_id, :expires_in, keyword_init: true)
|
|
28
|
+
|
|
29
|
+
# Cross-property SSO handshake — the second property's server-side redeem step.
|
|
30
|
+
#
|
|
31
|
+
# When a satellite (a property on a DIFFERENT registrable domain than the main
|
|
32
|
+
# app) has no local session, a middleware bounces the browser through
|
|
33
|
+
# +GET {fapi_origin}/v1/client/handshake+, which — if the user has an Atlas
|
|
34
|
+
# session — redirects back with
|
|
35
|
+
# +?__atlas_hs=ok&__atlas_hu=<user_id>&__atlas_hn=<nonce>+. These helpers read
|
|
36
|
+
# those params and exchange the single-use nonce for a fresh session,
|
|
37
|
+
# server-to-server, so the tokens come back in the response BODY (never a URL).
|
|
38
|
+
#
|
|
39
|
+
# PKCE binding (RFC 7636, S256). The +__atlas_hn+ nonce rides the return URL,
|
|
40
|
+
# which lands in access logs, +Referer+ headers, and browser history — so the
|
|
41
|
+
# nonce alone MUST NOT be a bearer credential. Before bouncing to the handshake
|
|
42
|
+
# we mint a PKCE pair with {create_pkce_pair}: the +challenge+ (a SHA-256 hash)
|
|
43
|
+
# travels on the loggable outbound URL as +code_challenge+, while the +verifier+
|
|
44
|
+
# is stashed server-side in an HttpOnly cookie (+__atlas_hv+, path +/+, ~300s)
|
|
45
|
+
# that never appears in any URL. At redeem we send the +verifier+ back in the
|
|
46
|
+
# request BODY; the Atlas server hashes it and checks it matches the challenge
|
|
47
|
+
# it recorded against the nonce. An attacker who scrapes the nonce out of a log
|
|
48
|
+
# can't redeem it without the verifier, which was never logged. (The Atlas
|
|
49
|
+
# server now REQUIRES +code_verifier+ on redeem — an unbound redeem fails.)
|
|
50
|
+
#
|
|
51
|
+
# Same-registrable-domain SUBDOMAINS don't need this — the handshake sets a
|
|
52
|
+
# parent-domain cookie directly; this is only the cross-domain path.
|
|
53
|
+
#
|
|
54
|
+
# Framework-agnostic usage in a satellite's route handler (Rack/Rails/Sinatra):
|
|
55
|
+
#
|
|
56
|
+
# params = Atlas::Handshake.read_handshake_params(request.url)
|
|
57
|
+
# if params
|
|
58
|
+
# session = Atlas::Handshake.redeem_handshake(
|
|
59
|
+
# fapi_origin: "https://id.atlasauth.net",
|
|
60
|
+
# publishable_key: "pk_live_...",
|
|
61
|
+
# user_id: params[:user_id],
|
|
62
|
+
# nonce: params[:nonce],
|
|
63
|
+
# # The verifier we stashed in __atlas_hv when we bounced out (below):
|
|
64
|
+
# code_verifier: request.cookies["__atlas_hv"],
|
|
65
|
+
# )
|
|
66
|
+
# if session
|
|
67
|
+
# # Set the satellite's OWN first-party cookies:
|
|
68
|
+
# response.set_cookie("__session",
|
|
69
|
+
# value: session.jwt, path: "/", same_site: :lax, secure: true)
|
|
70
|
+
# response.set_cookie("__atlas_rt",
|
|
71
|
+
# value: session.refresh_token, path: "/",
|
|
72
|
+
# httponly: true, same_site: :lax, secure: true)
|
|
73
|
+
# response.delete_cookie("__atlas_hv") # single-use; done with it.
|
|
74
|
+
# end
|
|
75
|
+
# # else: nonce was bad/expired/used — fall through to sign-in.
|
|
76
|
+
# end
|
|
77
|
+
#
|
|
78
|
+
# A middleware that TRIGGERS the handshake mints a PKCE pair, stashes the
|
|
79
|
+
# verifier, and redirects once to
|
|
80
|
+
# +{fapi_origin}/v1/client/handshake?publishable_key={pk}&redirect_url={current_url}&code_challenge={challenge}+
|
|
81
|
+
# only when there is no local session AND the +__atlas_hs+ query param is absent
|
|
82
|
+
# (its presence means we already came back from the handshake), so it can never
|
|
83
|
+
# loop:
|
|
84
|
+
#
|
|
85
|
+
# pkce = Atlas::Handshake.create_pkce_pair
|
|
86
|
+
# response.set_cookie("__atlas_hv",
|
|
87
|
+
# value: pkce[:verifier], path: "/", max_age: 300,
|
|
88
|
+
# httponly: true, same_site: :lax, secure: true)
|
|
89
|
+
# url = "#{fapi_origin}/v1/client/handshake" \
|
|
90
|
+
# "?publishable_key=#{pk}" \
|
|
91
|
+
# "&redirect_url=#{CGI.escape(current_url)}" \
|
|
92
|
+
# "&code_challenge=#{pkce[:challenge]}"
|
|
93
|
+
# redirect_to url
|
|
94
|
+
module Handshake
|
|
95
|
+
module_function
|
|
96
|
+
|
|
97
|
+
# Mint a PKCE pair (RFC 7636, S256) for one handshake round-trip.
|
|
98
|
+
#
|
|
99
|
+
# The handshake nonce travels back to the satellite on a query string, so it
|
|
100
|
+
# is written to access logs, leaked in +Referer+ headers, and kept in browser
|
|
101
|
+
# history. If the nonce alone could be redeemed, anyone who reads one of those
|
|
102
|
+
# logs holds a usable credential. PKCE fixes that by splitting the secret in
|
|
103
|
+
# two: the +challenge+ (an irreversible SHA-256 hash) is what rides the
|
|
104
|
+
# loggable outbound URL as +code_challenge+, and the +verifier+ (the
|
|
105
|
+
# pre-image) is stashed server-side in an HttpOnly cookie (+__atlas_hv+) that
|
|
106
|
+
# never touches a URL. At redeem the verifier is sent in the request BODY and
|
|
107
|
+
# the Atlas server hashes it and checks it against the challenge it bound to
|
|
108
|
+
# the nonce — so a scraped nonce is inert without the verifier, which was
|
|
109
|
+
# never logged.
|
|
110
|
+
#
|
|
111
|
+
# +verifier+ is 32 bytes of {SecureRandom} base64url-encoded without padding
|
|
112
|
+
# (43 chars, well inside RFC 7636's 43–128 range). +challenge+ is the
|
|
113
|
+
# base64url-no-padding SHA-256 digest of the verifier's ASCII bytes (the S256
|
|
114
|
+
# transform). Both use +padding: false+ because RFC 7636 §3 forbids the
|
|
115
|
+
# trailing +=+ and requires the URL-safe alphabet.
|
|
116
|
+
#
|
|
117
|
+
# @return [Hash{Symbol=>String}] +{ verifier:, challenge: }+ — send
|
|
118
|
+
# +challenge+ on the outbound handshake URL as +code_challenge+, stash
|
|
119
|
+
# +verifier+ HttpOnly, and pass it back to {redeem_handshake} as
|
|
120
|
+
# +code_verifier+.
|
|
121
|
+
def create_pkce_pair
|
|
122
|
+
verifier = Base64.urlsafe_encode64(SecureRandom.random_bytes(32), padding: false)
|
|
123
|
+
challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false)
|
|
124
|
+
{ verifier: verifier, challenge: challenge }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Redeem a handshake nonce for a fresh session.
|
|
128
|
+
#
|
|
129
|
+
# POSTs to +{fapi_origin}/v1/client/handshake/redeem+ with header
|
|
130
|
+
# +X-Publishable-Key: {publishable_key}+ and JSON body
|
|
131
|
+
# +{ "user_id" => user_id, "nonce" => nonce, "code_verifier" => code_verifier }+.
|
|
132
|
+
#
|
|
133
|
+
# @param fapi_origin [String] origin of the Atlas Frontend API,
|
|
134
|
+
# e.g. +https://id.atlasauth.net+.
|
|
135
|
+
# @param publishable_key [String] the instance publishable key (+pk_...+).
|
|
136
|
+
# @param user_id [String] the +__atlas_hu+ value from the return URL.
|
|
137
|
+
# @param nonce [String] the single-use +__atlas_hn+ nonce from the return URL.
|
|
138
|
+
# @param code_verifier [String, nil] the PKCE verifier stashed (in the
|
|
139
|
+
# +__atlas_hv+ HttpOnly cookie) when the handshake was triggered — the
|
|
140
|
+
# pre-image of the +code_challenge+ that rode the outbound URL. The Atlas
|
|
141
|
+
# server now REQUIRES this: a redeem without the verifier that matches the
|
|
142
|
+
# bound challenge fails, and this returns +nil+ like any other bad-nonce.
|
|
143
|
+
# @param http [#call, nil] injectable requester matching the Transport seam —
|
|
144
|
+
# +call(method, url, headers, body) -> [status_integer, body_string]+.
|
|
145
|
+
# Defaults to {Atlas::NetHTTPRequester}.
|
|
146
|
+
#
|
|
147
|
+
# @return [Atlas::HandshakeSession, nil] the fresh session, or +nil+ when the
|
|
148
|
+
# nonce is missing/expired/already used, the verifier doesn't bind, or the
|
|
149
|
+
# request could not complete. A bad nonce is a normal "signed-out" signal —
|
|
150
|
+
# this NEVER raises on an auth failure or a transport error, so it can't
|
|
151
|
+
# take the page down.
|
|
152
|
+
def redeem_handshake(fapi_origin:, publishable_key:, user_id:, nonce:, code_verifier: nil, http: nil)
|
|
153
|
+
requester = http || NetHTTPRequester.new
|
|
154
|
+
url = "#{fapi_origin.sub(%r{/+\z}, '')}/v1/client/handshake/redeem"
|
|
155
|
+
headers = {
|
|
156
|
+
"content-type" => "application/json",
|
|
157
|
+
"accept" => "application/json",
|
|
158
|
+
"x-publishable-key" => publishable_key
|
|
159
|
+
}
|
|
160
|
+
payload = JSON.generate(
|
|
161
|
+
{ "user_id" => user_id, "nonce" => nonce, "code_verifier" => code_verifier }
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
status, text = requester.call("POST", url, headers, payload)
|
|
165
|
+
return nil unless status.is_a?(Integer) && status.between?(200, 299)
|
|
166
|
+
|
|
167
|
+
data = parse_json(text)
|
|
168
|
+
return nil unless data.is_a?(Hash)
|
|
169
|
+
|
|
170
|
+
jwt = data["jwt"]
|
|
171
|
+
refresh_token = data["refresh_token"]
|
|
172
|
+
return nil if jwt.nil? || jwt.empty? || refresh_token.nil? || refresh_token.empty?
|
|
173
|
+
|
|
174
|
+
HandshakeSession.new(
|
|
175
|
+
jwt: jwt,
|
|
176
|
+
refresh_token: refresh_token,
|
|
177
|
+
session_id: data["session_id"] || "",
|
|
178
|
+
expires_in: (data["expires_in"] || 0).to_i
|
|
179
|
+
)
|
|
180
|
+
rescue StandardError
|
|
181
|
+
# A transport error is indistinguishable, to the user, from a signed-out
|
|
182
|
+
# state — return nil and let the caller fall back to sign-in.
|
|
183
|
+
nil
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Extract the handshake params from a return URL or query string.
|
|
187
|
+
#
|
|
188
|
+
# @param url_or_query [String] a full URL, a query string, or a bare
|
|
189
|
+
# +key=value&...+ fragment.
|
|
190
|
+
# @return [Hash{Symbol=>String}, nil] +{ user_id:, nonce: }+ when
|
|
191
|
+
# +__atlas_hs == "ok"+ and both +__atlas_hu+ and +__atlas_hn+ are present;
|
|
192
|
+
# +nil+ otherwise.
|
|
193
|
+
def read_handshake_params(url_or_query)
|
|
194
|
+
return nil if url_or_query.nil?
|
|
195
|
+
|
|
196
|
+
query = url_or_query.include?("?") ? url_or_query[(url_or_query.index("?") + 1)..] : url_or_query
|
|
197
|
+
query = query.sub(/#.*\z/, "") if query # drop any fragment
|
|
198
|
+
return nil if query.nil? || query.empty?
|
|
199
|
+
|
|
200
|
+
params = {}
|
|
201
|
+
URI.decode_www_form(query).each { |key, value| params[key] = value }
|
|
202
|
+
|
|
203
|
+
return nil unless params["__atlas_hs"] == "ok"
|
|
204
|
+
|
|
205
|
+
user_id = params["__atlas_hu"]
|
|
206
|
+
nonce = params["__atlas_hn"]
|
|
207
|
+
return nil if user_id.nil? || user_id.empty? || nonce.nil? || nonce.empty?
|
|
208
|
+
|
|
209
|
+
{ user_id: user_id, nonce: nonce }
|
|
210
|
+
rescue StandardError
|
|
211
|
+
nil
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def parse_json(text)
|
|
215
|
+
return nil if text.nil? || text.empty?
|
|
216
|
+
|
|
217
|
+
JSON.parse(text)
|
|
218
|
+
rescue JSON::ParserError
|
|
219
|
+
nil
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Convenience mirrors on the top-level module, matching the standalone auth
|
|
224
|
+
# helpers (+Atlas.has_permission?+, +Atlas.protect+, ...).
|
|
225
|
+
module_function
|
|
226
|
+
|
|
227
|
+
# @see Atlas::Handshake.create_pkce_pair
|
|
228
|
+
def create_pkce_pair
|
|
229
|
+
Handshake.create_pkce_pair
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# @see Atlas::Handshake.redeem_handshake
|
|
233
|
+
def redeem_handshake(**kwargs)
|
|
234
|
+
Handshake.redeem_handshake(**kwargs)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# @see Atlas::Handshake.read_handshake_params
|
|
238
|
+
def read_handshake_params(url_or_query)
|
|
239
|
+
Handshake.read_handshake_params(url_or_query)
|
|
240
|
+
end
|
|
241
|
+
end
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "base64"
|
|
5
|
+
require "net/http"
|
|
6
|
+
require "uri"
|
|
7
|
+
|
|
8
|
+
module Atlas
|
|
9
|
+
# §7.3: "JWKS cached in-process with kid-miss refetch (max 1/min) so key
|
|
10
|
+
# rotation needs no deploys."
|
|
11
|
+
#
|
|
12
|
+
# The rate limit on the refetch is the security property, not a politeness.
|
|
13
|
+
# Without it, an attacker sends tokens carrying random +kid+ values and every
|
|
14
|
+
# one forces an outbound request to the JWKS endpoint — turning any
|
|
15
|
+
# unauthenticated caller into a traffic amplifier. The cache's job is as much
|
|
16
|
+
# to refuse to fetch as it is to fetch.
|
|
17
|
+
class JwksCache
|
|
18
|
+
# §7.3: at most one refetch per minute, however many misses arrive.
|
|
19
|
+
REFETCH_INTERVAL_MS = 60_000
|
|
20
|
+
# §7.3 serves +Cache-Control: max-age=3600+; honoured rather than ignored.
|
|
21
|
+
DEFAULT_TTL_MS = 3_600_000
|
|
22
|
+
|
|
23
|
+
# Exposed so a caller can assert the rate limit actually bit.
|
|
24
|
+
# One of :fresh, :cached, :refetched, :throttled, :failed.
|
|
25
|
+
attr_reader :last_outcome
|
|
26
|
+
|
|
27
|
+
# @param url [String] the instance JWKS URL.
|
|
28
|
+
# @param fetch_impl [#call] optional +call(url) -> [status, body]+ for tests.
|
|
29
|
+
# @param now [#call] optional clock returning epoch milliseconds.
|
|
30
|
+
def initialize(url:, fetch_impl: nil, now: nil, ttl_ms: DEFAULT_TTL_MS,
|
|
31
|
+
refetch_interval_ms: REFETCH_INTERVAL_MS)
|
|
32
|
+
@url = url
|
|
33
|
+
@fetch_impl = fetch_impl
|
|
34
|
+
@now = now
|
|
35
|
+
@ttl_ms = ttl_ms
|
|
36
|
+
@refetch_interval_ms = refetch_interval_ms
|
|
37
|
+
@cached = nil
|
|
38
|
+
@fetched_at = 0
|
|
39
|
+
@last_attempt_at = 0
|
|
40
|
+
@last_outcome = :fresh
|
|
41
|
+
@mutex = Mutex.new
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The JWKS to verify against, refetching if this +kid+ is unknown. Returns
|
|
45
|
+
# whatever is cached when a refetch is throttled or fails — a stale JWKS
|
|
46
|
+
# still verifies every token signed by a key it contains.
|
|
47
|
+
#
|
|
48
|
+
# @return [Hash, nil] a +{ "keys" => [...] }+ hash, or nil if never fetched.
|
|
49
|
+
def get(kid = nil)
|
|
50
|
+
@mutex.synchronize do
|
|
51
|
+
now = current_time
|
|
52
|
+
expired = @cached.nil? || (now - @fetched_at) >= @ttl_ms
|
|
53
|
+
kid_miss = !@cached.nil? && !key?(kid)
|
|
54
|
+
|
|
55
|
+
if !expired && !kid_miss
|
|
56
|
+
@last_outcome = :cached
|
|
57
|
+
return @cached
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
if kid_miss && !expired && (now - @last_attempt_at) < @refetch_interval_ms
|
|
61
|
+
@last_outcome = :throttled
|
|
62
|
+
return @cached
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
@last_attempt_at = now
|
|
66
|
+
begin
|
|
67
|
+
body = fetch_jwks
|
|
68
|
+
raise "JWKS is malformed" unless body.is_a?(Hash) && body["keys"].is_a?(Array)
|
|
69
|
+
|
|
70
|
+
@cached = body
|
|
71
|
+
@fetched_at = now
|
|
72
|
+
@last_outcome = kid_miss ? :refetched : :fresh
|
|
73
|
+
@cached
|
|
74
|
+
rescue StandardError
|
|
75
|
+
# Keep serving what we have. A JWKS outage should degrade to "new keys
|
|
76
|
+
# do not work yet", not "nobody can authenticate".
|
|
77
|
+
@last_outcome = :failed
|
|
78
|
+
@cached
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Test and diagnostic surface — never used for a security decision.
|
|
84
|
+
def snapshot
|
|
85
|
+
{ keys: @cached ? @cached["keys"].length : 0, fetched_at: @fetched_at }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def current_time
|
|
91
|
+
@now ? @now.call : (Time.now.to_f * 1000).to_i
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def key?(kid)
|
|
95
|
+
return false if @cached.nil?
|
|
96
|
+
return !@cached["keys"].empty? if kid.nil?
|
|
97
|
+
|
|
98
|
+
@cached["keys"].any? { |k| k["kid"] == kid }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def fetch_jwks
|
|
102
|
+
if @fetch_impl
|
|
103
|
+
status, text = @fetch_impl.call(@url)
|
|
104
|
+
raise "JWKS fetch failed (#{status})" unless status.between?(200, 299)
|
|
105
|
+
|
|
106
|
+
return JSON.parse(text)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
uri = URI.parse(@url)
|
|
110
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
111
|
+
http.use_ssl = uri.scheme == "https"
|
|
112
|
+
request = Net::HTTP::Get.new(uri.request_uri)
|
|
113
|
+
request["accept"] = "application/json"
|
|
114
|
+
response = http.request(request)
|
|
115
|
+
raise "JWKS fetch failed (#{response.code})" unless response.code.to_i.between?(200, 299)
|
|
116
|
+
|
|
117
|
+
JSON.parse(response.body.to_s)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Read the +kid+ from a JWT header without verifying anything.
|
|
122
|
+
def self.read_kid(jwt)
|
|
123
|
+
header = jwt.to_s.split(".").first
|
|
124
|
+
return nil if header.nil? || header.empty?
|
|
125
|
+
|
|
126
|
+
decoded = Base64.urlsafe_decode64(pad_base64(header))
|
|
127
|
+
parsed = JSON.parse(decoded)
|
|
128
|
+
parsed["kid"].is_a?(String) ? parsed["kid"] : nil
|
|
129
|
+
rescue StandardError
|
|
130
|
+
nil
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Base64url strings from JWTs are unpadded; restore padding before decoding.
|
|
134
|
+
def self.pad_base64(str)
|
|
135
|
+
str += "=" * ((4 - (str.length % 4)) % 4)
|
|
136
|
+
str
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Atlas
|
|
4
|
+
# Cursor-pagination helpers.
|
|
5
|
+
#
|
|
6
|
+
# Walk every page of a cursor-paginated BAPI list, yielding items one at a
|
|
7
|
+
# time. Works with any resource +list+ that takes +limit+/+starting_after+
|
|
8
|
+
# (plus any route-specific filters) and returns a
|
|
9
|
+
# +{ "data", "has_more", "next_cursor" }+ page — +users.list+,
|
|
10
|
+
# +organizations.list+, +invitations.list+, +audit_logs.list+, +waitlist.list+.
|
|
11
|
+
#
|
|
12
|
+
# Atlas.paginate(client.users).each { |user| puts user["id"] }
|
|
13
|
+
# Atlas.paginate(client.users, status: "active").to_a
|
|
14
|
+
#
|
|
15
|
+
# Kept as free functions rather than bolted onto every return value: the page
|
|
16
|
+
# is the primitive most callers want, and a caller who needs the whole set
|
|
17
|
+
# opts into the extra round trips explicitly.
|
|
18
|
+
module Pagination
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
# Yield every item across every cursor page. Returns an Enumerator when no
|
|
22
|
+
# block is given, so +.each+, +.map+, +.lazy+, +.first(n)+ all work.
|
|
23
|
+
#
|
|
24
|
+
# @param resource [#list] anything exposing a +list(params)+ method.
|
|
25
|
+
# @param params [Hash] filters passed through on every page request.
|
|
26
|
+
def paginate(resource, params = {}, &block)
|
|
27
|
+
return enum_for(:paginate, resource, params) unless block_given?
|
|
28
|
+
|
|
29
|
+
cursor = params[:starting_after] || params["starting_after"]
|
|
30
|
+
loop do
|
|
31
|
+
page = resource.list(params.merge(starting_after: cursor))
|
|
32
|
+
(page["data"] || []).each(&block)
|
|
33
|
+
cursor = page["next_cursor"]
|
|
34
|
+
break if !page["has_more"] || cursor.nil?
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Collect every page of a cursor-paginated list into a single array.
|
|
39
|
+
def collect(resource, params = {})
|
|
40
|
+
paginate(resource, params).to_a
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Convenience delegators so callers write +Atlas.paginate(...)+.
|
|
45
|
+
def self.paginate(resource, params = {}, &block)
|
|
46
|
+
Pagination.paginate(resource, params, &block)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.collect(resource, params = {})
|
|
50
|
+
Pagination.collect(resource, params)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Atlas
|
|
6
|
+
module Resources
|
|
7
|
+
# +/v1/actions+ — tenant code run in the hardened isolate at auth triggers,
|
|
8
|
+
# plus the per-trigger binding lists.
|
|
9
|
+
class Actions < Base
|
|
10
|
+
def list
|
|
11
|
+
request(method: :get, path: "/v1/actions")
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def create(body, idempotency_key: nil)
|
|
15
|
+
request(method: :post, path: "/v1/actions", body: body, idempotency_key: idempotency_key)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def get(id)
|
|
19
|
+
request(method: :get, path: "/v1/actions/#{enc(id)}")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def update(id, body)
|
|
23
|
+
request(method: :patch, path: "/v1/actions/#{enc(id)}", body: body)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def delete(id)
|
|
27
|
+
request(method: :delete, path: "/v1/actions/#{enc(id)}")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Dry-run against a sample event in the sandbox; nothing is persisted.
|
|
31
|
+
def test(id, event = {})
|
|
32
|
+
request(method: :post, path: "/v1/actions/#{enc(id)}/test", body: { event: event })
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The actions bound to a trigger, in run order.
|
|
36
|
+
def get_bindings(trigger)
|
|
37
|
+
request(method: :get, path: "/v1/actions/bindings/#{enc(trigger)}")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Replace a trigger's ordered binding set.
|
|
41
|
+
def set_bindings(trigger, action_ids)
|
|
42
|
+
request(method: :put, path: "/v1/actions/bindings/#{enc(trigger)}", body: { action_ids: action_ids })
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Atlas
|
|
6
|
+
module Resources
|
|
7
|
+
# +/v1/actor_tokens+ — impersonation tokens (act-as another subject).
|
|
8
|
+
class ActorTokens < Base
|
|
9
|
+
def create(body, idempotency_key: nil)
|
|
10
|
+
request(method: :post, path: "/v1/actor_tokens", body: body, idempotency_key: idempotency_key)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def revoke(id)
|
|
14
|
+
request(method: :post, path: "/v1/actor_tokens/#{enc(id)}/revoke")
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Atlas
|
|
6
|
+
module Resources
|
|
7
|
+
# +/v1/api_keys+ — end-user API keys a tenant mints for its own subjects.
|
|
8
|
+
class ApiKeys < Base
|
|
9
|
+
# Optionally narrowed to one subject via +subject_type+/+subject_id+.
|
|
10
|
+
def list(query = nil)
|
|
11
|
+
request(method: :get, path: "/v1/api_keys", query: query)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Mint a key. The secret is returned once, here.
|
|
15
|
+
def create(body, idempotency_key: nil)
|
|
16
|
+
request(method: :post, path: "/v1/api_keys", body: body, idempotency_key: idempotency_key)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Check a presented secret. Rate-limited — it is an online credential check.
|
|
20
|
+
def verify(secret)
|
|
21
|
+
request(method: :post, path: "/v1/api_keys/verify", body: { secret: secret })
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def get(id)
|
|
25
|
+
request(method: :get, path: "/v1/api_keys/#{enc(id)}")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def update(id, body)
|
|
29
|
+
request(method: :patch, path: "/v1/api_keys/#{enc(id)}", body: body)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Revoke a key. It stays queryable but never authenticates again.
|
|
33
|
+
def delete(id)
|
|
34
|
+
request(method: :delete, path: "/v1/api_keys/#{enc(id)}")
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Atlas
|
|
6
|
+
module Resources
|
|
7
|
+
# +/v1/attack_protection+ — brute-force, breached-password, and related gates.
|
|
8
|
+
class AttackProtection < Base
|
|
9
|
+
def get
|
|
10
|
+
request(method: :get, path: "/v1/attack_protection")
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def update(body)
|
|
14
|
+
request(method: :patch, path: "/v1/attack_protection", body: body)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Atlas
|
|
6
|
+
module Resources
|
|
7
|
+
# +/v1/audit_logs+ — the instance audit trail, cursor-paginated.
|
|
8
|
+
class AuditLogs < Base
|
|
9
|
+
def list(params = {})
|
|
10
|
+
request(method: :get, path: "/v1/audit_logs", query: params)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Atlas
|
|
4
|
+
module Resources
|
|
5
|
+
# Shared base for every resource namespace. Holds the config-bound transport
|
|
6
|
+
# and forwards +#request+, so each namespace method stays a one-liner naming
|
|
7
|
+
# a verb, a path, and its shapes.
|
|
8
|
+
class Base
|
|
9
|
+
def initialize(transport)
|
|
10
|
+
@transport = transport
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
# Forward to the transport. Keeps +Transport::OMIT+ as the default body so
|
|
16
|
+
# a method that names no body sends none (no content-type), matching the
|
|
17
|
+
# reference SDKs.
|
|
18
|
+
def request(method:, path:, query: nil, body: Atlas::Transport::OMIT,
|
|
19
|
+
idempotency_key: nil, raw: false)
|
|
20
|
+
@transport.request(
|
|
21
|
+
method: method, path: path, query: query, body: body,
|
|
22
|
+
idempotency_key: idempotency_key, raw: raw
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Percent-encode a single path segment (an id, slug, provider name).
|
|
27
|
+
def enc(segment)
|
|
28
|
+
URI.encode_www_form_component(segment.to_s)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Atlas
|
|
6
|
+
module Resources
|
|
7
|
+
# +/v1/billing+ — the plans a tenant defines for its users, and the read-only
|
|
8
|
+
# subscription view.
|
|
9
|
+
class Billing < Base
|
|
10
|
+
def list_plans
|
|
11
|
+
request(method: :get, path: "/v1/billing/plans")
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def create_plan(body, idempotency_key: nil)
|
|
15
|
+
request(method: :post, path: "/v1/billing/plans", body: body, idempotency_key: idempotency_key)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def get_plan(id)
|
|
19
|
+
request(method: :get, path: "/v1/billing/plans/#{enc(id)}")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def update_plan(id, body)
|
|
23
|
+
request(method: :patch, path: "/v1/billing/plans/#{enc(id)}", body: body)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def delete_plan(id)
|
|
27
|
+
request(method: :delete, path: "/v1/billing/plans/#{enc(id)}")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def list_subscriptions(params = nil)
|
|
31
|
+
request(method: :get, path: "/v1/billing/subscriptions", query: params)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Atlas
|
|
6
|
+
module Resources
|
|
7
|
+
# +/v1/bot_signals+ — export the anti-bot signal lake and its training labels,
|
|
8
|
+
# and add analyst labels.
|
|
9
|
+
class BotSignals < Base
|
|
10
|
+
# Page the signal lake, newest first, for an incremental pull.
|
|
11
|
+
def list(query = nil)
|
|
12
|
+
request(method: :get, path: "/v1/bot_signals", query: query)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Page the training labels, newest first.
|
|
16
|
+
def list_labels(query = nil)
|
|
17
|
+
request(method: :get, path: "/v1/bot_labels", query: query)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Attach a training label to a user / device / ip_hash / attempt.
|
|
21
|
+
def create_label(body, idempotency_key: nil)
|
|
22
|
+
request(method: :post, path: "/v1/bot_labels", body: body, idempotency_key: idempotency_key)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|