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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +20 -0
- data/LICENSE +21 -0
- data/README.md +135 -0
- data/lib/zevauth/auth.rb +77 -0
- data/lib/zevauth/authenticator.rb +133 -0
- data/lib/zevauth/bridge.rb +200 -0
- data/lib/zevauth/client.rb +149 -0
- data/lib/zevauth/errors.rb +80 -0
- data/lib/zevauth/key_set.rb +139 -0
- data/lib/zevauth/rack/middleware.rb +90 -0
- data/lib/zevauth/rails/config/routes.rb +11 -0
- data/lib/zevauth/rails/controller.rb +89 -0
- data/lib/zevauth/rails/railtie.rb +37 -0
- data/lib/zevauth/rails.rb +6 -0
- data/lib/zevauth/resources.rb +122 -0
- data/lib/zevauth/transport.rb +50 -0
- data/lib/zevauth/verifier.rb +166 -0
- data/lib/zevauth/version.rb +5 -0
- data/lib/zevauth/webhooks.rb +79 -0
- data/lib/zevauth.rb +136 -0
- metadata +92 -0
|
@@ -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
|