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,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Atlas
|
|
6
|
+
module Resources
|
|
7
|
+
# +/v1/users+ — the user directory, plus emails, identities, MFA, sessions,
|
|
8
|
+
# OAuth access tokens, and consent grants.
|
|
9
|
+
class Users < Base
|
|
10
|
+
# +GET /v1/users+ — cursor-paginated.
|
|
11
|
+
def list(params = {})
|
|
12
|
+
request(method: :get, path: "/v1/users", query: params)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def get(id)
|
|
16
|
+
request(method: :get, path: "/v1/users/#{enc(id)}")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def create(body, idempotency_key: nil)
|
|
20
|
+
request(method: :post, path: "/v1/users", body: body, idempotency_key: idempotency_key)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def update(id, body)
|
|
24
|
+
request(method: :patch, path: "/v1/users/#{enc(id)}", body: body)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# +PUT /v1/users/:id/metadata+ — replaces the named bags wholesale.
|
|
28
|
+
def replace_metadata(id, body)
|
|
29
|
+
request(method: :put, path: "/v1/users/#{enc(id)}/metadata", body: body)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def ban(id)
|
|
33
|
+
request(method: :post, path: "/v1/users/#{enc(id)}/ban")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def unban(id)
|
|
37
|
+
request(method: :post, path: "/v1/users/#{enc(id)}/unban")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def lock(id, body = {})
|
|
41
|
+
request(method: :post, path: "/v1/users/#{enc(id)}/lock", body: body)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def unlock(id)
|
|
45
|
+
request(method: :post, path: "/v1/users/#{enc(id)}/unlock")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def delete(id)
|
|
49
|
+
request(method: :delete, path: "/v1/users/#{enc(id)}")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def reset_mfa(id)
|
|
53
|
+
request(method: :post, path: "/v1/users/#{enc(id)}/reset_mfa")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def delete_mfa_factor(id, factor_id)
|
|
57
|
+
request(method: :delete, path: "/v1/users/#{enc(id)}/mfa/#{enc(factor_id)}")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def list_sessions(id)
|
|
61
|
+
request(method: :get, path: "/v1/users/#{enc(id)}/sessions")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def revoke_sessions(id)
|
|
65
|
+
request(method: :post, path: "/v1/users/#{enc(id)}/sessions/revoke")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def add_email(id, body)
|
|
69
|
+
request(method: :post, path: "/v1/users/#{enc(id)}/email_addresses", body: body)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def verify_email(id, email_id)
|
|
73
|
+
request(method: :post, path: "/v1/users/#{enc(id)}/email_addresses/#{enc(email_id)}/verify")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def set_primary_email(id, email_id)
|
|
77
|
+
request(method: :post, path: "/v1/users/#{enc(id)}/email_addresses/#{enc(email_id)}/primary")
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# +GET /v1/users/:id/oauth_access_tokens/:provider+ — a live provider credential.
|
|
81
|
+
def get_oauth_access_token(id, provider)
|
|
82
|
+
request(method: :get, path: "/v1/users/#{enc(id)}/oauth_access_tokens/#{enc(provider)}")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# +GET /v1/users/:id/identities+ — base Atlas identity + one per linked account.
|
|
86
|
+
def list_identities(id)
|
|
87
|
+
request(method: :get, path: "/v1/users/#{enc(id)}/identities")
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# +POST /v1/users/:id/identities+ — merge a secondary user INTO this one.
|
|
91
|
+
def link_identity(id, body, idempotency_key: nil)
|
|
92
|
+
request(method: :post, path: "/v1/users/#{enc(id)}/identities", body: body, idempotency_key: idempotency_key)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# +POST /v1/users/:id/external_accounts/connect+ — backend-initiated connect flow.
|
|
96
|
+
def connect_external_account(id, body, idempotency_key: nil)
|
|
97
|
+
request(method: :post, path: "/v1/users/#{enc(id)}/external_accounts/connect", body: body, idempotency_key: idempotency_key)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# +DELETE /v1/users/:id/identities/:identity_id+ — extract a linked identity into a new user.
|
|
101
|
+
def unlink_identity(id, identity_id)
|
|
102
|
+
request(method: :delete, path: "/v1/users/#{enc(id)}/identities/#{enc(identity_id)}")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# +GET /v1/users/:id/grants+ — the OAuth clients this user has authorized.
|
|
106
|
+
def list_grants(id)
|
|
107
|
+
request(method: :get, path: "/v1/users/#{enc(id)}/grants")
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# +DELETE /v1/users/:id/grants+ — revoke every consent grant the user holds.
|
|
111
|
+
def revoke_all_grants(id)
|
|
112
|
+
request(method: :delete, path: "/v1/users/#{enc(id)}/grants")
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# +DELETE /v1/grants/:id+ — revoke ONE consent grant (instance-scoped).
|
|
116
|
+
def revoke_grant(grant_id)
|
|
117
|
+
request(method: :delete, path: "/v1/grants/#{enc(grant_id)}")
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Atlas
|
|
6
|
+
module Resources
|
|
7
|
+
# +/v1/waitlist_entries+ — the sign-up waitlist and its approve/deny decisions.
|
|
8
|
+
class Waitlist < Base
|
|
9
|
+
def list(params = {})
|
|
10
|
+
request(method: :get, path: "/v1/waitlist_entries", query: params)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Approve or deny a waitlist entry.
|
|
14
|
+
def decide(id, body)
|
|
15
|
+
request(method: :post, path: "/v1/waitlist_entries/#{enc(id)}/decide", body: body)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Atlas
|
|
6
|
+
module Resources
|
|
7
|
+
# +/v1/webhook_endpoints+ — outbound webhook endpoints and their delivery log.
|
|
8
|
+
class Webhooks < Base
|
|
9
|
+
# +webhooks.endpoints+ — CRUD over endpoints.
|
|
10
|
+
def endpoints
|
|
11
|
+
@endpoints ||= Endpoints.new(@transport)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Delivery log for an endpoint.
|
|
15
|
+
def deliveries(id)
|
|
16
|
+
request(method: :get, path: "/v1/webhook_endpoints/#{enc(id)}/deliveries")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
class Endpoints < Base
|
|
20
|
+
def list
|
|
21
|
+
request(method: :get, path: "/v1/webhook_endpoints")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Create reveals the signing secret (+whsec_...+) exactly once.
|
|
25
|
+
def create(body, idempotency_key: nil)
|
|
26
|
+
request(method: :post, path: "/v1/webhook_endpoints", body: body, idempotency_key: idempotency_key)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def delete(id)
|
|
30
|
+
request(method: :delete, path: "/v1/webhook_endpoints/#{enc(id)}")
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
require_relative "error"
|
|
8
|
+
|
|
9
|
+
module Atlas
|
|
10
|
+
# The default BAPI origin, overridable per instance via +api_url+.
|
|
11
|
+
DEFAULT_API_URL = "https://api.atlasauth.net"
|
|
12
|
+
|
|
13
|
+
# The shared HTTP core every resource namespace calls.
|
|
14
|
+
#
|
|
15
|
+
# One place decides how a BAPI request is authenticated, serialized, and how a
|
|
16
|
+
# failure becomes an {Atlas::APIError} — so a namespace method is a one-liner
|
|
17
|
+
# naming a method, a path, and its shapes.
|
|
18
|
+
#
|
|
19
|
+
# The HTTP mechanism is injectable: pass +http:+ any object responding to
|
|
20
|
+
# +call(method, url, headers, body)+ and returning +[status_integer,
|
|
21
|
+
# body_string]+. Tests use this to run with no network; the default is a
|
|
22
|
+
# {Atlas::NetHTTPRequester} built on Ruby's stdlib +Net::HTTP+.
|
|
23
|
+
class Transport
|
|
24
|
+
# Sentinel meaning "no body key was supplied", distinct from an explicit
|
|
25
|
+
# empty hash (which is serialized to +{}+ with a content-type, matching the
|
|
26
|
+
# TypeScript SDK's +body !== undefined+ gate).
|
|
27
|
+
OMIT = :__atlas_omit__
|
|
28
|
+
|
|
29
|
+
def initialize(secret_key:, api_url: DEFAULT_API_URL, http: nil,
|
|
30
|
+
open_timeout: 30, read_timeout: 30)
|
|
31
|
+
raise ConfigurationError, "Atlas::Client requires a secret_key." if secret_key.nil? || secret_key.empty?
|
|
32
|
+
|
|
33
|
+
@secret_key = secret_key
|
|
34
|
+
@base = api_url || DEFAULT_API_URL
|
|
35
|
+
@http = http || NetHTTPRequester.new(open_timeout: open_timeout, read_timeout: read_timeout)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Perform a request and return the parsed JSON (a Hash/Array), the raw text
|
|
39
|
+
# when +raw:+ is true, or +nil+ for an empty/204 body.
|
|
40
|
+
#
|
|
41
|
+
# @raise [Atlas::APIError] on any non-2xx response.
|
|
42
|
+
def request(method:, path:, query: nil, body: OMIT, idempotency_key: nil, raw: false)
|
|
43
|
+
url = join_url(@base, path) + serialize_query(query)
|
|
44
|
+
|
|
45
|
+
headers = {
|
|
46
|
+
"authorization" => "Bearer #{@secret_key}",
|
|
47
|
+
"accept" => "application/json"
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
payload = nil
|
|
51
|
+
unless body.equal?(OMIT)
|
|
52
|
+
headers["content-type"] = "application/json"
|
|
53
|
+
payload = JSON.generate(body)
|
|
54
|
+
end
|
|
55
|
+
headers["idempotency-key"] = idempotency_key if idempotency_key
|
|
56
|
+
|
|
57
|
+
status, text = @http.call(method.to_s.upcase, url, headers, payload)
|
|
58
|
+
|
|
59
|
+
raise to_api_error(status, text) unless status.between?(200, 299)
|
|
60
|
+
|
|
61
|
+
return nil if status == 204 || text.nil? || text.empty?
|
|
62
|
+
return text if raw
|
|
63
|
+
|
|
64
|
+
JSON.parse(text)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def join_url(base, path)
|
|
70
|
+
"#{base.sub(%r{/+\z}, '')}#{path.start_with?('/') ? path : "/#{path}"}"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Serialize a query hash to a string, dropping nil, spreading arrays,
|
|
74
|
+
# rendering booleans as "true"/"false" — mirroring the TS/Python transports.
|
|
75
|
+
def serialize_query(query)
|
|
76
|
+
return "" if query.nil? || query.empty?
|
|
77
|
+
|
|
78
|
+
parts = []
|
|
79
|
+
query.each do |key, value|
|
|
80
|
+
next if value.nil?
|
|
81
|
+
|
|
82
|
+
values = value.is_a?(Array) ? value : [value]
|
|
83
|
+
values.each do |v|
|
|
84
|
+
next if v.nil?
|
|
85
|
+
|
|
86
|
+
rendered = v == true ? "true" : v == false ? "false" : v.to_s
|
|
87
|
+
parts << "#{encode(key.to_s)}=#{encode(rendered)}"
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
parts.empty? ? "" : "?#{parts.join('&')}"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def encode(str)
|
|
94
|
+
URI.encode_www_form_component(str)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Parse a non-2xx body into the §9.1 envelope, or synthesize one, and pick
|
|
98
|
+
# the most specific typed error class for the status.
|
|
99
|
+
def to_api_error(status, text)
|
|
100
|
+
errors = []
|
|
101
|
+
message = nil
|
|
102
|
+
if text && !text.empty?
|
|
103
|
+
begin
|
|
104
|
+
parsed = JSON.parse(text)
|
|
105
|
+
envelope = parsed.is_a?(Hash) ? parsed["errors"] : nil
|
|
106
|
+
if envelope.is_a?(Array) && !envelope.empty?
|
|
107
|
+
errors = envelope
|
|
108
|
+
else
|
|
109
|
+
message = text[0, 500]
|
|
110
|
+
end
|
|
111
|
+
rescue JSON::ParserError
|
|
112
|
+
message = text[0, 500]
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
if errors.empty?
|
|
116
|
+
errors = [{ "code" => "UNKNOWN",
|
|
117
|
+
"message" => message || "Atlas API request failed with status #{status}" }]
|
|
118
|
+
end
|
|
119
|
+
ErrorFactory.build(status, errors)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# The default requester: percent-encodes nothing extra, follows no redirects,
|
|
124
|
+
# and speaks plain +Net::HTTP+. Kept tiny and dependency-free so the gem runs
|
|
125
|
+
# anywhere stdlib does.
|
|
126
|
+
class NetHTTPRequester
|
|
127
|
+
METHODS = {
|
|
128
|
+
"GET" => Net::HTTP::Get,
|
|
129
|
+
"POST" => Net::HTTP::Post,
|
|
130
|
+
"PATCH" => Net::HTTP::Patch,
|
|
131
|
+
"PUT" => Net::HTTP::Put,
|
|
132
|
+
"DELETE" => Net::HTTP::Delete
|
|
133
|
+
}.freeze
|
|
134
|
+
|
|
135
|
+
def initialize(open_timeout: 30, read_timeout: 30)
|
|
136
|
+
@open_timeout = open_timeout
|
|
137
|
+
@read_timeout = read_timeout
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# @return [Array(Integer, String)] the HTTP status and the raw body text.
|
|
141
|
+
def call(method, url, headers, body)
|
|
142
|
+
uri = URI.parse(url)
|
|
143
|
+
klass = METHODS.fetch(method) { raise ArgumentError, "Unsupported HTTP method: #{method}" }
|
|
144
|
+
|
|
145
|
+
request = klass.new(uri.request_uri)
|
|
146
|
+
headers.each { |k, v| request[k] = v }
|
|
147
|
+
request.body = body if body
|
|
148
|
+
|
|
149
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
150
|
+
http.use_ssl = uri.scheme == "https"
|
|
151
|
+
http.open_timeout = @open_timeout
|
|
152
|
+
http.read_timeout = @read_timeout
|
|
153
|
+
|
|
154
|
+
response = http.request(request)
|
|
155
|
+
[response.code.to_i, response.body.to_s]
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "error"
|
|
4
|
+
require_relative "authorization"
|
|
5
|
+
require_relative "jwks_cache"
|
|
6
|
+
|
|
7
|
+
module Atlas
|
|
8
|
+
# §7.3: five seconds either side, matching the server's minting tolerance.
|
|
9
|
+
CLOCK_SKEW_SECONDS = 5
|
|
10
|
+
|
|
11
|
+
# The outcome of a verification. A success carries the claims and the bound
|
|
12
|
+
# +has?+ / +protect+ helpers; a failure carries a coarse +reason+.
|
|
13
|
+
class VerifyResult
|
|
14
|
+
# @return [Boolean]
|
|
15
|
+
attr_reader :ok
|
|
16
|
+
# @return [Hash, nil] the verified claims (string-keyed), on success.
|
|
17
|
+
attr_reader :claims
|
|
18
|
+
# @return [Symbol, nil] :malformed, :invalid, :no_keys, :unauthorized_party.
|
|
19
|
+
attr_reader :reason
|
|
20
|
+
|
|
21
|
+
def self.success(claims)
|
|
22
|
+
new(ok: true, claims: claims)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.failure(reason)
|
|
26
|
+
new(ok: false, reason: reason)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def initialize(ok:, claims: nil, reason: nil)
|
|
30
|
+
@ok = ok
|
|
31
|
+
@claims = claims
|
|
32
|
+
@reason = reason
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def ok?
|
|
36
|
+
@ok
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# True when the claims satisfy the condition (empty condition = "signed in").
|
|
40
|
+
# Always false on a failed verification.
|
|
41
|
+
def has?(condition = {})
|
|
42
|
+
@ok && Authorization.has_from_claims?(@claims, condition)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Assert the condition; returns the claims on success, raises
|
|
46
|
+
# {Atlas::ForbiddenError} otherwise.
|
|
47
|
+
def protect(condition = {})
|
|
48
|
+
raise ForbiddenError.new(:unauthenticated, condition) unless @ok
|
|
49
|
+
|
|
50
|
+
outcome = Authorization.evaluate(@claims, condition)
|
|
51
|
+
raise ForbiddenError.new(outcome[:reason], condition) unless outcome[:allowed]
|
|
52
|
+
|
|
53
|
+
@claims
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# §7.3 verification by customer backends.
|
|
58
|
+
#
|
|
59
|
+
# Verifies signature, exp/nbf with 5s clock-skew tolerance, iss, and optionally
|
|
60
|
+
# azp against an allowlist. The default path is LOCAL, against cached JWKS —
|
|
61
|
+
# it never calls Atlas on the hot path, so Atlas's availability never becomes
|
|
62
|
+
# the customer's. +verify_online+ is the documented slow path.
|
|
63
|
+
class Backend
|
|
64
|
+
# @param jwks_url [String] the instance's JWKS URL.
|
|
65
|
+
# @param issuer [String] expected +iss+. Required.
|
|
66
|
+
# @param authorized_parties [Array<String>, nil] optional +azp+ allowlist.
|
|
67
|
+
# @param secret_key [String, nil] required only for +verify_online+.
|
|
68
|
+
# @param bapi_base_url [String, nil] required only for +verify_online+.
|
|
69
|
+
# @param http [#call, nil] injectable requester for +verify_online+.
|
|
70
|
+
# @param fetch_impl [#call, nil] injectable JWKS fetcher (tests).
|
|
71
|
+
# @param now [#call, nil] clock returning epoch milliseconds (tests).
|
|
72
|
+
def initialize(jwks_url:, issuer:, authorized_parties: nil, secret_key: nil,
|
|
73
|
+
bapi_base_url: nil, http: nil, fetch_impl: nil, now: nil)
|
|
74
|
+
require_jwt!
|
|
75
|
+
@issuer = issuer
|
|
76
|
+
@authorized_parties = authorized_parties
|
|
77
|
+
@secret_key = secret_key
|
|
78
|
+
@bapi_base_url = bapi_base_url
|
|
79
|
+
@http = http
|
|
80
|
+
@jwks = JwksCache.new(url: jwks_url, fetch_impl: fetch_impl, now: now)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Verify locally. No network call unless the +kid+ is unknown, and at most
|
|
84
|
+
# one of those a minute.
|
|
85
|
+
# @return [VerifyResult]
|
|
86
|
+
def verify(token)
|
|
87
|
+
return VerifyResult.failure(:malformed) if token.nil? || token.split(".").length != 3
|
|
88
|
+
|
|
89
|
+
keys = @jwks.get(Atlas.read_kid(token))
|
|
90
|
+
return VerifyResult.failure(:no_keys) if keys.nil? || keys["keys"].nil? || keys["keys"].empty?
|
|
91
|
+
|
|
92
|
+
begin
|
|
93
|
+
jwk_set = JWT::JWK::Set.new(keys)
|
|
94
|
+
payload, = JWT.decode(
|
|
95
|
+
token, nil, true,
|
|
96
|
+
algorithms: ["RS256"],
|
|
97
|
+
iss: @issuer,
|
|
98
|
+
verify_iss: true,
|
|
99
|
+
jwks: jwk_set,
|
|
100
|
+
# exp/nbf tolerance, matching the server's minting skew.
|
|
101
|
+
leeway: CLOCK_SKEW_SECONDS
|
|
102
|
+
)
|
|
103
|
+
claims = payload
|
|
104
|
+
rescue JWT::DecodeError, StandardError
|
|
105
|
+
# One reason for every failure — telling a caller which check failed
|
|
106
|
+
# helps a forger more than a developer.
|
|
107
|
+
return VerifyResult.failure(:invalid)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# §13.1 token-confusion guard: reject an OP access/id token replayed as a
|
|
111
|
+
# session. An absent token_use/aud is a valid (legacy) session.
|
|
112
|
+
token_use = claims["token_use"]
|
|
113
|
+
return VerifyResult.failure(:invalid) if (!token_use.nil? && token_use != "session") || !claims["aud"].nil?
|
|
114
|
+
|
|
115
|
+
if @authorized_parties && !@authorized_parties.empty?
|
|
116
|
+
azp = claims["azp"]
|
|
117
|
+
return VerifyResult.failure(:unauthorized_party) if azp.nil? || !@authorized_parties.include?(azp)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
VerifyResult.success(claims)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# §7.3 the documented slow path: ask Atlas whether the session is still live.
|
|
124
|
+
# Costs a round trip on every call. Fails CLOSED on an outage.
|
|
125
|
+
# @return [VerifyResult]
|
|
126
|
+
def verify_online(token)
|
|
127
|
+
local = verify(token)
|
|
128
|
+
return local unless local.ok?
|
|
129
|
+
|
|
130
|
+
if @secret_key.nil? || @bapi_base_url.nil?
|
|
131
|
+
raise ConfigurationError,
|
|
132
|
+
"verify_online needs secret_key and bapi_base_url. Without them it " \
|
|
133
|
+
"would silently fall back to local verification."
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
begin
|
|
137
|
+
status, text = post_verify(token)
|
|
138
|
+
return VerifyResult.failure(:invalid) unless status.between?(200, 299)
|
|
139
|
+
|
|
140
|
+
body = JSON.parse(text)
|
|
141
|
+
body["verified"] ? local : VerifyResult.failure(:invalid)
|
|
142
|
+
rescue StandardError
|
|
143
|
+
# The caller reached for verify_online precisely because a stale answer
|
|
144
|
+
# was unacceptable, so an outage returns a failure, never the local pass.
|
|
145
|
+
VerifyResult.failure(:invalid)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Verify whatever a request carries. Accepts a Hash of headers (string keys)
|
|
150
|
+
# or any object responding to +get(name)+. The Authorization header wins over
|
|
151
|
+
# a +__session+ cookie.
|
|
152
|
+
# @return [VerifyResult]
|
|
153
|
+
def authenticate_request(headers)
|
|
154
|
+
header = read_header(headers, "authorization")
|
|
155
|
+
cookie = read_header(headers, "cookie")
|
|
156
|
+
|
|
157
|
+
bearer = header&.start_with?("Bearer ") ? header[7..] : nil
|
|
158
|
+
from_cookie = cookie ? read_cookie(cookie, "__session") : nil
|
|
159
|
+
|
|
160
|
+
token = bearer || from_cookie
|
|
161
|
+
return VerifyResult.failure(:malformed) if token.nil?
|
|
162
|
+
|
|
163
|
+
verify(token)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
private
|
|
167
|
+
|
|
168
|
+
def require_jwt!
|
|
169
|
+
require "jwt"
|
|
170
|
+
rescue LoadError
|
|
171
|
+
raise Atlas::Error,
|
|
172
|
+
"Token verification needs the `jwt` gem. Add `gem \"jwt\"` to your Gemfile."
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def post_verify(token)
|
|
176
|
+
url = "#{@bapi_base_url.sub(%r{/+\z}, '')}/v1/tokens/verify"
|
|
177
|
+
headers = {
|
|
178
|
+
"authorization" => "Bearer #{@secret_key}",
|
|
179
|
+
"content-type" => "application/json"
|
|
180
|
+
}
|
|
181
|
+
payload = JSON.generate({ token: token })
|
|
182
|
+
if @http
|
|
183
|
+
@http.call("POST", url, headers, payload)
|
|
184
|
+
else
|
|
185
|
+
NetHTTPRequester.new.call("POST", url, headers, payload)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def read_header(headers, name)
|
|
190
|
+
if headers.respond_to?(:get)
|
|
191
|
+
headers.get(name)
|
|
192
|
+
elsif headers.respond_to?(:[])
|
|
193
|
+
headers[name] || headers[name.downcase] || headers[name.to_sym]
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def read_cookie(header, name)
|
|
198
|
+
header.split(";").each do |part|
|
|
199
|
+
key, *rest = part.strip.split("=")
|
|
200
|
+
return rest.join("=") if key == name
|
|
201
|
+
end
|
|
202
|
+
nil
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# §8.2 standalone helpers for code that already holds a claims hash.
|
|
207
|
+
module_function
|
|
208
|
+
|
|
209
|
+
def has_permission?(claims, permission)
|
|
210
|
+
Authorization.has_from_claims?(claims, permission: permission)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def has_role?(claims, role)
|
|
214
|
+
Authorization.has_from_claims?(claims, role: role)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def has?(claims, condition = {})
|
|
218
|
+
Authorization.has_from_claims?(claims, condition)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def protect(claims, condition = {})
|
|
222
|
+
outcome = Authorization.evaluate(claims, condition)
|
|
223
|
+
raise ForbiddenError.new(outcome[:reason], condition) unless outcome[:allowed]
|
|
224
|
+
|
|
225
|
+
claims
|
|
226
|
+
end
|
|
227
|
+
end
|
data/lib/atlas.rb
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# atlas-auth — the official Ruby backend SDK for Atlas.
|
|
4
|
+
#
|
|
5
|
+
# A typed client over the +sk_+ Backend API (BAPI), the Ruby peer of the
|
|
6
|
+
# TypeScript +@atlasauth/backend+ and Python +atlas-backend+ SDKs, plus §7.3 local
|
|
7
|
+
# session-token verification.
|
|
8
|
+
#
|
|
9
|
+
# require "atlas"
|
|
10
|
+
#
|
|
11
|
+
# atlas = Atlas::Client.new("sk_live_...")
|
|
12
|
+
# user = atlas.users.create(email_address: "ada@example.com")
|
|
13
|
+
# Atlas.paginate(atlas.organizations).each { |org| puts org["name"] }
|
|
14
|
+
#
|
|
15
|
+
# backend = Atlas::Backend.new(
|
|
16
|
+
# jwks_url: "https://api.atlasauth.net/v1/jwks",
|
|
17
|
+
# issuer: "https://your-instance.atlasauth.net",
|
|
18
|
+
# )
|
|
19
|
+
# result = backend.verify(session_jwt)
|
|
20
|
+
# result.protect(permission: "org:billing:manage") if result.ok?
|
|
21
|
+
module Atlas
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
require_relative "atlas/version"
|
|
25
|
+
require_relative "atlas/error"
|
|
26
|
+
require_relative "atlas/transport"
|
|
27
|
+
require_relative "atlas/pagination"
|
|
28
|
+
require_relative "atlas/authorization"
|
|
29
|
+
require_relative "atlas/jwks_cache"
|
|
30
|
+
require_relative "atlas/verifier"
|
|
31
|
+
require_relative "atlas/handshake"
|
|
32
|
+
require_relative "atlas/client"
|
|
33
|
+
|
|
34
|
+
module Atlas
|
|
35
|
+
# Convenience constructor mirroring the TS +createAtlasClient+ factory.
|
|
36
|
+
#
|
|
37
|
+
# atlas = Atlas.client("sk_live_...")
|
|
38
|
+
def self.client(secret_key, **options)
|
|
39
|
+
Client.new(secret_key, **options)
|
|
40
|
+
end
|
|
41
|
+
end
|