zevauth 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.
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "uri"
5
+
6
+ module ZevAuth
7
+ # The ZevAuth backend API, called with your secret key.
8
+ #
9
+ # client = ZevAuth::Client.new(secret_key: ENV.fetch("ZEVAUTH_SECRET_KEY"))
10
+ # client.users.get("user_123")
11
+ # client.users.each { |user| ... } # every page, followed for you
12
+ #
13
+ # Server-side only. A secret key in a browser is a key anyone can read.
14
+ class Client
15
+ RETRYABLE = [429, 502, 503, 504].freeze
16
+ DEFAULT_CODES = {
17
+ 400 => "invalid_request", 401 => "unauthorized", 403 => "forbidden", 404 => "not_found",
18
+ 409 => "conflict", 429 => "rate_limited"
19
+ }.freeze
20
+
21
+ attr_reader :users, :organizations
22
+
23
+ # secret_key: sk_test_… or sk_live_…
24
+ # api_url: default https://api.zevauth.net
25
+ # timeout: seconds per attempt, default 15
26
+ # max_retries: default 2
27
+ # transport: see Transport; injectable for tests
28
+ # sleeper: -> (seconds) {}; injectable so tests do not wait
29
+ def initialize(secret_key: nil, api_url: nil, timeout: 15, max_retries: 2, transport: nil, sleeper: nil)
30
+ secret_key ||= ZevAuth.config.secret_key
31
+ if secret_key.nil? || secret_key.to_s.empty?
32
+ raise ConfigurationError.new("The ZevAuth client needs your secret key (sk_test_… or sk_live_…).", code: "missing_secret_key")
33
+ end
34
+ if secret_key.start_with?("pk_")
35
+ raise ConfigurationError.new(
36
+ "That is a publishable key. The backend API takes your secret key (sk_…), which must never reach a browser.",
37
+ code: "wrong_key_type"
38
+ )
39
+ end
40
+
41
+ @secret_key = secret_key
42
+ @api_url = (api_url || ZevAuth.config.api_url || ZevAuth::DEFAULT_API_URL).chomp("/")
43
+ @timeout = timeout
44
+ @max_retries = max_retries
45
+ @transport = transport || Transport.new
46
+ @sleeper = sleeper || ->(seconds) { sleep(seconds) }
47
+ @users = Resources::Users.new(self)
48
+ @organizations = Resources::Organizations.new(self)
49
+ end
50
+
51
+ def get(path, query = nil) = request(:get, path, query: query)
52
+ def post(path, body = nil) = request(:post, path, body: body)
53
+ def patch(path, body = nil) = request(:patch, path, body: body)
54
+ def delete(path, query = nil) = request(:delete, path, query: query)
55
+
56
+ # Retries only what certainly did not run: 429, 502, 503, 504 and
57
+ # network failures. Never a 500: the server ran and failed, possibly
58
+ # after writing, and retrying is how one create becomes two.
59
+ def request(method, path, query: nil, body: nil)
60
+ url = "#{@api_url}#{path}#{query_string(query)}"
61
+ payload = body.nil? ? nil : JSON.generate(body)
62
+ attempt = 0
63
+ wait = nil
64
+
65
+ loop do
66
+ @sleeper.call(wait || backoff(attempt)) if attempt.positive?
67
+ wait = nil
68
+
69
+ begin
70
+ response = @transport.call(method: method, url: url, headers: headers, body: payload, timeout: @timeout)
71
+ rescue Transport::Failure => e
72
+ raise APIError.new("Could not reach ZevAuth: #{e.message}", code: "network_error", status: 0) if attempt >= @max_retries
73
+
74
+ attempt += 1
75
+ next
76
+ end
77
+
78
+ return parse_success(response) if response.status < 400
79
+
80
+ error = Client.error_from(response)
81
+ raise error unless RETRYABLE.include?(response.status) && attempt < @max_retries
82
+
83
+ wait = Client.retry_after(response.header("retry-after"))
84
+ attempt += 1
85
+ end
86
+ end
87
+
88
+ # Turns an error response into an APIError. Public so the conformance
89
+ # fixtures can check it directly.
90
+ def self.error_from(response)
91
+ parsed = begin
92
+ JSON.parse(response.body.to_s)
93
+ rescue JSON::ParserError
94
+ nil
95
+ end
96
+ envelope = parsed.is_a?(Hash) ? parsed["error"] : nil
97
+ code = envelope.is_a?(Hash) ? envelope["code"] : nil
98
+ message = (envelope.is_a?(Hash) && envelope["message"]) || "ZevAuth returned #{response.status}."
99
+ APIError.new(message, code: code || "request_failed", status: response.status, request_id: response.header("x-request-id"))
100
+ end
101
+
102
+ # Seconds from a Retry-After header, capped at 10, or nil.
103
+ def self.retry_after(value)
104
+ return nil if value.nil?
105
+
106
+ seconds = Float(value, exception: false)
107
+ return nil unless seconds && seconds >= 0
108
+
109
+ [seconds, 10].min
110
+ end
111
+
112
+ def self.retryable?(status) = RETRYABLE.include?(status)
113
+
114
+ private
115
+
116
+ def headers
117
+ {
118
+ "authorization" => "Bearer #{@secret_key}",
119
+ "content-type" => "application/json",
120
+ "accept" => "application/json",
121
+ "user-agent" => "zevauth-ruby/#{ZevAuth::VERSION}"
122
+ }
123
+ end
124
+
125
+ # 200 ms, 400 ms, 800 ms … capped at 4 s.
126
+ def backoff(attempt)
127
+ [0.2 * (2**(attempt - 1)), 4.0].min
128
+ end
129
+
130
+ def parse_success(response)
131
+ return nil if response.status == 204 || response.body.to_s.empty?
132
+
133
+ JSON.parse(response.body)
134
+ end
135
+
136
+ def query_string(query)
137
+ return "" if query.nil?
138
+
139
+ pairs = query.compact.flat_map do |key, value|
140
+ if value.is_a?(Hash)
141
+ value.map { |k, v| ["#{key}[#{k}]", v.to_s] }
142
+ else
143
+ [[key.to_s, value.to_s]]
144
+ end
145
+ end
146
+ pairs.empty? ? "" : "?#{URI.encode_www_form(pairs)}"
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ZevAuth
4
+ # Base class for everything this gem raises.
5
+ class Error < StandardError
6
+ # A stable, machine-readable reason, the same string in every ZevAuth
7
+ # SDK (see the server SDK contract). Match on this, not the message.
8
+ attr_reader :code
9
+
10
+ def initialize(message = nil, code: nil)
11
+ super(message)
12
+ @code = code
13
+ end
14
+ end
15
+
16
+ # Something about the setup, not the request: no environment id, a
17
+ # publishable key where a secret key belongs. Raised loudly on purpose,
18
+ # because getting it wrong is a deploy bug, not a signed-out visitor.
19
+ class ConfigurationError < Error; end
20
+
21
+ # A session token was refused. `code` is one of VerifyError::CODES.
22
+ class VerifyError < Error
23
+ CODES = %w[
24
+ malformed_token
25
+ unsupported_algorithm
26
+ unknown_key
27
+ invalid_signature
28
+ invalid_issuer
29
+ invalid_audience
30
+ token_expired
31
+ token_not_yet_valid
32
+ jwks_unavailable
33
+ ].freeze
34
+ end
35
+
36
+ # A request refused by `require_auth`: not signed in, a pending task,
37
+ # or two-step verification required. Carries the HTTP status and the
38
+ # error envelope the framework integrations turn into a response.
39
+ class AuthRefused < Error
40
+ attr_reader :status, :pending_tasks
41
+
42
+ def initialize(message, code:, status:, pending_tasks: nil)
43
+ super(message, code: code)
44
+ @status = status
45
+ @pending_tasks = pending_tasks
46
+ end
47
+
48
+ # `{ "error": { "code": …, "message": … } }`, the envelope the ZevAuth
49
+ # API itself uses, plus `pendingTasks` for a pending-task refusal.
50
+ def body
51
+ error = { "code" => code, "message" => message }
52
+ body = { "error" => error }
53
+ body["pendingTasks"] = pending_tasks if pending_tasks
54
+ body
55
+ end
56
+
57
+ def to_rack
58
+ [status, { "content-type" => "application/json", "cache-control" => "no-store" }, [JSON.generate(body)]]
59
+ end
60
+ end
61
+
62
+ # The backend API answered with an error, or could not be reached.
63
+ class APIError < Error
64
+ # HTTP status, or 0 when the request never got an answer.
65
+ attr_reader :status
66
+ # The `x-request-id` the API sent, for support conversations.
67
+ attr_reader :request_id
68
+
69
+ def initialize(message, code:, status:, request_id: nil)
70
+ super(message, code: code)
71
+ @status = status
72
+ @request_id = request_id
73
+ end
74
+ end
75
+
76
+ # A webhook delivery whose signature did not check out. `code` is one of
77
+ # `webhook_malformed_signature`, `webhook_timestamp_out_of_range`,
78
+ # `webhook_signature_mismatch`.
79
+ class WebhookError < Error; end
80
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "jwt"
5
+
6
+ module ZevAuth
7
+ # The environment's signing keys, by `kid`.
8
+ #
9
+ # Two kinds: a local set handed in (tests, air-gapped servers), or a
10
+ # remote one found through the discovery document and cached.
11
+ class KeySet
12
+ def self.local(jwks)
13
+ LocalKeySet.new(jwks)
14
+ end
15
+
16
+ # JWK hash -> OpenSSL public key. Only RSA signing keys are kept; a key
17
+ # set that also publishes something else must not make RS256 tokens
18
+ # verifiable with it.
19
+ def self.public_keys_from(jwks)
20
+ jwks = JSON.parse(jwks) if jwks.is_a?(String)
21
+ keys = jwks.fetch("keys") { jwks.fetch(:keys, []) }
22
+ keys.each_with_object({}) do |jwk, out|
23
+ jwk = jwk.transform_keys(&:to_s)
24
+ next unless jwk["kty"] == "RSA" && jwk["kid"]
25
+ next if jwk["use"] && jwk["use"] != "sig"
26
+
27
+ imported = JWT::JWK.import(jwk)
28
+ out[jwk["kid"]] = imported.respond_to?(:verify_key) ? imported.verify_key : imported.public_key
29
+ end
30
+ end
31
+ end
32
+
33
+ class LocalKeySet
34
+ def initialize(jwks)
35
+ @keys = KeySet.public_keys_from(jwks)
36
+ end
37
+
38
+ def find(kid)
39
+ @keys[kid]
40
+ end
41
+ end
42
+
43
+ # Discovery once per process, then the key set cached for at most ten
44
+ # minutes. A token whose `kid` is not in the cache forces one refetch,
45
+ # at most once per cooldown (30 seconds by default), so a token with a
46
+ # made-up `kid` cannot turn every request into a fetch.
47
+ class RemoteKeySet
48
+ CACHE_SECONDS = 600
49
+
50
+ attr_reader :issuer
51
+
52
+ def initialize(api_url:, environment_id:, transport:, clock:, cooldown_seconds: 30, timeout: 15)
53
+ @api_url = api_url.chomp("/")
54
+ @environment_id = environment_id
55
+ @transport = transport
56
+ @clock = clock
57
+ @cooldown = cooldown_seconds
58
+ @timeout = timeout
59
+ @lock = Mutex.new
60
+ @keys = nil
61
+ @fetched_at = nil
62
+ @last_forced_at = nil
63
+ end
64
+
65
+ # The issuer is read from discovery, never built from the API URL:
66
+ # self-hosted and local setups are allowed to have them differ.
67
+ def discovered_issuer
68
+ discover
69
+ @issuer
70
+ end
71
+
72
+ def find(kid)
73
+ # One lock around fetching means concurrent first requests share a
74
+ # single fetch: the second waits, then finds the cache filled.
75
+ @lock.synchronize do
76
+ refresh if @keys.nil? || stale?
77
+ return @keys[kid] if @keys.key?(kid)
78
+ return nil if cooling_down?
79
+
80
+ @last_forced_at = now
81
+ refresh
82
+ @keys[kid]
83
+ end
84
+ end
85
+
86
+ private
87
+
88
+ def now
89
+ @clock.call.to_f
90
+ end
91
+
92
+ def stale?
93
+ now - @fetched_at > CACHE_SECONDS
94
+ end
95
+
96
+ def cooling_down?
97
+ @last_forced_at && now - @last_forced_at < @cooldown
98
+ end
99
+
100
+ def discover
101
+ return if @jwks_uri
102
+
103
+ @lock.synchronize { discover_unlocked }
104
+ end
105
+
106
+ def refresh
107
+ discover_unlocked
108
+ @keys = KeySet.public_keys_from(get_json(@jwks_uri, "key set"))
109
+ @fetched_at = now
110
+ end
111
+
112
+ # Callers hold the lock (Mutex is not re-entrant). Both values are
113
+ # assigned only after the document is read, so a failed fetch is not
114
+ # cached and the next request tries again.
115
+ def discover_unlocked
116
+ return if @jwks_uri
117
+
118
+ document = get_json("#{@api_url}/v1/#{@environment_id}/.well-known/openid-configuration", "discovery document")
119
+ issuer = document.fetch("issuer")
120
+ jwks_uri = document.fetch("jwks_uri")
121
+ @issuer = issuer
122
+ @jwks_uri = jwks_uri
123
+ end
124
+
125
+ def get_json(url, what)
126
+ response = @transport.call(method: :get, url: url, headers: { "accept" => "application/json" }, timeout: @timeout)
127
+ unless response.status == 200
128
+ raise VerifyError.new(
129
+ "Could not read the ZevAuth #{what} for #{@environment_id} (#{response.status}). " \
130
+ "Check the environment id and api_url.",
131
+ code: "jwks_unavailable"
132
+ )
133
+ end
134
+ JSON.parse(response.body)
135
+ rescue Transport::Failure, JSON::ParserError, KeyError => e
136
+ raise VerifyError.new("Could not read the ZevAuth #{what}: #{e.message}", code: "jwks_unavailable")
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ZevAuth
4
+ module Rack
5
+ # Puts who is signed in on every request, at `env["zevauth.auth"]`.
6
+ #
7
+ # use ZevAuth::Rack::Middleware
8
+ #
9
+ # It also serves the session bridge (POST /zevauth/session) and the
10
+ # handshake page (GET /zevauth/handshake), and sends a page request
11
+ # through the handshake when the session cookie lapsed. So a Sinatra,
12
+ # Roda or Hanami app gets server-rendered sessions from this one line.
13
+ # It never refuses a request; protect routes with RequireAuth or
14
+ # `ZevAuth::Rack.refusal`.
15
+ class Middleware
16
+ ENV_KEY = "zevauth.auth"
17
+
18
+ # handshake: send lapsed-cookie page requests through the handshake
19
+ # serve_bridge: answer the bridge and handshake paths itself
20
+ # bridge_path: default ZevAuth.config.bridge_path (/zevauth/session)
21
+ def initialize(app, authenticator: nil, handshake: true, serve_bridge: true, bridge_path: nil, handshake_page: nil)
22
+ @app = app
23
+ @authenticator = authenticator
24
+ @handshake = handshake
25
+ @serve_bridge = serve_bridge
26
+ @bridge_path = bridge_path
27
+ @handshake_page = handshake_page
28
+ end
29
+
30
+ def call(env)
31
+ authenticator = @authenticator || ZevAuth.authenticator
32
+
33
+ if @serve_bridge
34
+ path = env["PATH_INFO"]
35
+ return Bridge::Endpoint.new(authenticator).call(env) if path == bridge_path
36
+ return handshake_page.call(env) if path == authenticator.bridge.handshake_path && env["REQUEST_METHOD"] == "GET"
37
+ end
38
+
39
+ auth, handshake = @handshake ? authenticator.authenticate_request(env) : [authenticator.get_auth(env), nil]
40
+ return handshake if handshake
41
+
42
+ env[ENV_KEY] = auth
43
+ @app.call(env)
44
+ end
45
+
46
+ private
47
+
48
+ def bridge_path
49
+ @bridge_path || ZevAuth.config.bridge_path
50
+ end
51
+
52
+ def handshake_page
53
+ @handshake_page ||= Bridge::HandshakePage.new(bridge_path: bridge_path)
54
+ end
55
+ end
56
+
57
+ # Refuses requests that do not pass `require_auth`, with the shared
58
+ # JSON envelope (401 / 403). Put it in front of what it protects:
59
+ #
60
+ # map "/api" do
61
+ # use ZevAuth::Rack::RequireAuth, require_mfa: true
62
+ # run Api
63
+ # end
64
+ class RequireAuth
65
+ def initialize(app, allow_pending_tasks: false, require_mfa: false, authenticator: nil)
66
+ @app = app
67
+ @options = { allow_pending_tasks: allow_pending_tasks, require_mfa: require_mfa }
68
+ @authenticator = authenticator
69
+ end
70
+
71
+ def call(env)
72
+ refused = Rack.refusal(env, authenticator: @authenticator, **@options)
73
+ refused || @app.call(env)
74
+ end
75
+ end
76
+
77
+ # nil when the request passes, else the Rack response to send:
78
+ #
79
+ # refused = ZevAuth::Rack.refusal(env, require_mfa: true)
80
+ # halt(*refused) if refused # Sinatra
81
+ def self.refusal(env, allow_pending_tasks: false, require_mfa: false, authenticator: nil)
82
+ auth = env[Middleware::ENV_KEY] || (authenticator || ZevAuth.authenticator).get_auth(env)
83
+ env[Middleware::ENV_KEY] ||= auth
84
+ ZevAuth.check!(auth, allow_pending_tasks: allow_pending_tasks, require_mfa: require_mfa)
85
+ nil
86
+ rescue AuthRefused => e
87
+ e.to_rack
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Mounted with `mount ZevAuth::Engine => "/zevauth"`, these become
4
+ # POST /zevauth/session (the bridge) and GET /zevauth/handshake.
5
+ ZevAuth::Engine.routes.draw do
6
+ # Rack apps, not controllers: they behave the same under Rails as under
7
+ # plain Rack, and need nothing from ActionController.
8
+ post "session", to: ZevAuth::Bridge::Endpoint.new
9
+ match "session", to: ZevAuth::Bridge::Endpoint.new, via: %i[get put patch delete]
10
+ get "handshake", to: ->(env) { ZevAuth::Rails.handshake_page.call(env) }
11
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+
5
+ module ZevAuth
6
+ module Rails
7
+ # Included into every controller by the engine.
8
+ #
9
+ # before_action :require_zevauth! # signed in
10
+ # require_zevauth only: :destroy, require_mfa: true # with options
11
+ #
12
+ # zevauth_auth.user_id
13
+ # zevauth_auth.has?(permission: "org:members:manage")
14
+ #
15
+ # Views get `zevauth_auth`, `zevauth_signed_in?` and `zevauth_user_id`.
16
+ module Controller
17
+ extend ActiveSupport::Concern
18
+
19
+ included do
20
+ helper_method :zevauth_auth, :zevauth_signed_in?, :zevauth_user_id if respond_to?(:helper_method)
21
+ rescue_from ZevAuth::AuthRefused, with: :zevauth_refused
22
+ end
23
+
24
+ class_methods do
25
+ # `before_action` with options:
26
+ # require_zevauth except: :index, require_mfa: true
27
+ def require_zevauth(require_mfa: false, allow_pending_tasks: false, **filter_options)
28
+ before_action(**filter_options) do
29
+ require_zevauth!(require_mfa: require_mfa, allow_pending_tasks: allow_pending_tasks)
30
+ end
31
+ end
32
+ end
33
+
34
+ # Who is signed in on this request. Never raises.
35
+ def zevauth_auth
36
+ @zevauth_auth ||= request.env[ZevAuth::Rack::Middleware::ENV_KEY] || ZevAuth.get_auth(request)
37
+ end
38
+
39
+ def zevauth_signed_in?
40
+ zevauth_auth.signed_in?
41
+ end
42
+
43
+ def zevauth_user_id
44
+ zevauth_auth.user_id
45
+ end
46
+
47
+ # Refuses the request unless it passes (401 not signed in, 403 a
48
+ # pending task, 403 two-step required). A page whose session cookie
49
+ # lapsed goes through the handshake first, so the browser can refresh
50
+ # it and come straight back.
51
+ def require_zevauth!(require_mfa: false, allow_pending_tasks: false)
52
+ auth, handshake = ZevAuth.authenticate_request(request)
53
+ if handshake
54
+ status, headers, = handshake
55
+ return redirect_to(headers["location"], status: status) if status == 302
56
+
57
+ return render(json: JSON.parse(handshake[2].join), status: status)
58
+ end
59
+
60
+ @zevauth_auth = auth
61
+ ZevAuth.check!(auth, require_mfa: require_mfa, allow_pending_tasks: allow_pending_tasks)
62
+ end
63
+
64
+ # The verified event from a webhook delivery. Reads the raw body:
65
+ # never verify `params`, which is parsed and re-encoded.
66
+ #
67
+ # def create
68
+ # event = zevauth_webhook_event
69
+ # ...
70
+ # rescue ZevAuth::WebhookError
71
+ # head :bad_request
72
+ # end
73
+ def zevauth_webhook_event(secret: nil, tolerance_seconds: ZevAuth::Webhooks::DEFAULT_TOLERANCE)
74
+ ZevAuth::Webhooks.construct_event(
75
+ payload: request.raw_post,
76
+ signature: request.headers[ZevAuth::Webhooks::SIGNATURE_HEADER],
77
+ secret: secret,
78
+ tolerance_seconds: tolerance_seconds
79
+ )
80
+ end
81
+
82
+ private
83
+
84
+ def zevauth_refused(error)
85
+ render json: error.body, status: error.status
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/engine"
4
+ require_relative "controller"
5
+
6
+ module ZevAuth
7
+ # Rails integration. Loaded only when Rails is present; the gem itself
8
+ # never depends on Rails.
9
+ #
10
+ # # config/routes.rb
11
+ # mount ZevAuth::Engine => "/zevauth" # bridge + handshake
12
+ #
13
+ # class ApplicationController < ActionController::Base
14
+ # before_action :require_zevauth!
15
+ # end
16
+ class Engine < ::Rails::Engine
17
+ isolate_namespace ZevAuth
18
+ # The engine's routes live in rails/config/routes.rb next to this file,
19
+ # so Rails loads (and reloads) them like any engine's.
20
+ config.root = File.expand_path(__dir__)
21
+ end
22
+
23
+ # Every controller gets the helpers (`zevauth_auth`, `require_zevauth!`
24
+ # and friends). Nothing is enforced until a controller asks. Registered
25
+ # when this file loads rather than in an initializer, so a controller
26
+ # class loaded early (a gem's, or one defined during boot) has them too.
27
+ ActiveSupport.on_load(:action_controller_base) { include ZevAuth::Rails::Controller }
28
+ ActiveSupport.on_load(:action_controller_api) { include ZevAuth::Rails::Controller }
29
+
30
+ module Rails
31
+ # The handshake page posts to wherever the engine is mounted, so its
32
+ # bridge path follows `ZevAuth.config.bridge_path`.
33
+ def self.handshake_page
34
+ Bridge::HandshakePage.new(bridge_path: ZevAuth.config.bridge_path)
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Explicit entry point for apps that load this gem before Rails:
4
+ # gem "zevauth", require: "zevauth/rails"
5
+ require_relative "../zevauth"
6
+ require_relative "rails/railtie"