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.
Files changed (58) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +132 -0
  4. data/lib/atlas/authorization.rb +78 -0
  5. data/lib/atlas/client.rb +124 -0
  6. data/lib/atlas/error.rb +92 -0
  7. data/lib/atlas/handshake.rb +241 -0
  8. data/lib/atlas/jwks_cache.rb +138 -0
  9. data/lib/atlas/pagination.rb +52 -0
  10. data/lib/atlas/resources/actions.rb +46 -0
  11. data/lib/atlas/resources/actor_tokens.rb +18 -0
  12. data/lib/atlas/resources/api_keys.rb +38 -0
  13. data/lib/atlas/resources/attack_protection.rb +18 -0
  14. data/lib/atlas/resources/audit_logs.rb +14 -0
  15. data/lib/atlas/resources/base.rb +32 -0
  16. data/lib/atlas/resources/billing.rb +35 -0
  17. data/lib/atlas/resources/bot_signals.rb +26 -0
  18. data/lib/atlas/resources/branding.rb +24 -0
  19. data/lib/atlas/resources/data_subject_requests.rb +28 -0
  20. data/lib/atlas/resources/domains.rb +26 -0
  21. data/lib/atlas/resources/email_templates.rb +29 -0
  22. data/lib/atlas/resources/fga.rb +146 -0
  23. data/lib/atlas/resources/import_export.rb +35 -0
  24. data/lib/atlas/resources/instance.rb +18 -0
  25. data/lib/atlas/resources/instance_security.rb +46 -0
  26. data/lib/atlas/resources/invitations.rb +22 -0
  27. data/lib/atlas/resources/jwt_templates.rb +32 -0
  28. data/lib/atlas/resources/localizations.rb +30 -0
  29. data/lib/atlas/resources/log_streams.rb +35 -0
  30. data/lib/atlas/resources/lti_platforms.rb +30 -0
  31. data/lib/atlas/resources/managed_waf.rb +35 -0
  32. data/lib/atlas/resources/messaging.rb +32 -0
  33. data/lib/atlas/resources/network_acls.rb +30 -0
  34. data/lib/atlas/resources/oauth_clients.rb +55 -0
  35. data/lib/atlas/resources/oauth_providers.rb +44 -0
  36. data/lib/atlas/resources/organizations.rb +140 -0
  37. data/lib/atlas/resources/radius_clients.rb +30 -0
  38. data/lib/atlas/resources/rate_limit.rb +18 -0
  39. data/lib/atlas/resources/resource_servers.rb +30 -0
  40. data/lib/atlas/resources/restrictions.rb +29 -0
  41. data/lib/atlas/resources/risk_based_mfa.rb +18 -0
  42. data/lib/atlas/resources/roles.rb +55 -0
  43. data/lib/atlas/resources/scim_provisioning.rb +46 -0
  44. data/lib/atlas/resources/scim_tokens.rb +23 -0
  45. data/lib/atlas/resources/sessions.rb +29 -0
  46. data/lib/atlas/resources/sign_in_tokens.rb +17 -0
  47. data/lib/atlas/resources/sms_templates.rb +37 -0
  48. data/lib/atlas/resources/sso_connections.rb +35 -0
  49. data/lib/atlas/resources/sso_onboarding.rb +37 -0
  50. data/lib/atlas/resources/tokens.rb +15 -0
  51. data/lib/atlas/resources/users.rb +121 -0
  52. data/lib/atlas/resources/waitlist.rb +19 -0
  53. data/lib/atlas/resources/webhooks.rb +35 -0
  54. data/lib/atlas/transport.rb +158 -0
  55. data/lib/atlas/verifier.rb +227 -0
  56. data/lib/atlas/version.rb +6 -0
  57. data/lib/atlas.rb +41 -0
  58. 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
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Atlas
4
+ # The gem version. Tracks the TypeScript/Python backend SDK line.
5
+ VERSION = "0.1.0"
6
+ 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