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,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "erb"
|
|
4
|
+
|
|
5
|
+
module ZevAuth
|
|
6
|
+
module Resources
|
|
7
|
+
# One page of a list, as the API returned it, plus `each` over its items.
|
|
8
|
+
# Responses keep the API's own field names (`"firstName"`), so what you
|
|
9
|
+
# read here matches the API reference exactly.
|
|
10
|
+
class Page
|
|
11
|
+
include Enumerable
|
|
12
|
+
|
|
13
|
+
attr_reader :data, :next_cursor, :total_count, :raw
|
|
14
|
+
|
|
15
|
+
def initialize(raw)
|
|
16
|
+
@raw = raw
|
|
17
|
+
@data = raw.fetch("data", [])
|
|
18
|
+
@has_more = raw.fetch("hasMore", false)
|
|
19
|
+
@next_cursor = raw["next"]
|
|
20
|
+
@total_count = raw["totalCount"]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def has_more?
|
|
24
|
+
@has_more
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def each(&)
|
|
28
|
+
@data.each(&)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
class Base
|
|
33
|
+
def initialize(client)
|
|
34
|
+
@client = client
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def path(*segments)
|
|
40
|
+
"/#{segments.map { |s| ERB::Util.url_encode(s.to_s) }.join('/')}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Ruby-style keys in, the API's camelCase on the wire: `first_name:`
|
|
44
|
+
# is sent as `firstName`. Only the top level is converted; metadata
|
|
45
|
+
# you pass is your data and is sent exactly as given.
|
|
46
|
+
def wire(params)
|
|
47
|
+
params.to_h.each_with_object({}) do |(key, value), out|
|
|
48
|
+
name = key.to_s
|
|
49
|
+
name = name.gsub(/_([a-z])/) { Regexp.last_match(1).upcase } if name.include?("_")
|
|
50
|
+
out[name] = value
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
class Users < Base
|
|
56
|
+
# One page. `metadata:` filters on public metadata, ANDed:
|
|
57
|
+
# users.list(limit: 50, metadata: { plan: "pro" })
|
|
58
|
+
def list(limit: nil, after: nil, query: nil, metadata: nil)
|
|
59
|
+
Page.new(@client.get("/v1/users", { limit: limit, after: after, query: query, metadata: metadata }))
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Every user, page after page, as an Enumerator:
|
|
63
|
+
# client.users.each(metadata: { plan: "pro" }) { |user| ... }
|
|
64
|
+
# client.users.each.lazy.select { ... }.first(10)
|
|
65
|
+
def each(limit: nil, query: nil, metadata: nil, &block)
|
|
66
|
+
enumerator = Enumerator.new do |yielder|
|
|
67
|
+
after = nil
|
|
68
|
+
loop do
|
|
69
|
+
page = list(limit: limit, after: after, query: query, metadata: metadata)
|
|
70
|
+
page.each { |user| yielder << user }
|
|
71
|
+
break unless page.has_more? && page.next_cursor
|
|
72
|
+
|
|
73
|
+
after = page.next_cursor
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
block ? enumerator.each(&block) : enumerator
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def get(user_id) = @client.get(path("v1", "users", user_id))
|
|
80
|
+
def create(**params) = @client.post("/v1/users", wire(params))
|
|
81
|
+
def update(user_id, **params) = @client.patch(path("v1", "users", user_id), wire(params))
|
|
82
|
+
def delete(user_id) = @client.delete(path("v1", "users", user_id))
|
|
83
|
+
|
|
84
|
+
# Blocks or unblocks; blocking ends their sessions.
|
|
85
|
+
# Returns { "user" => …, "sessionsRevoked" => n }.
|
|
86
|
+
def set_blocked(user_id, blocked)
|
|
87
|
+
@client.post("#{path('v1', 'users', user_id)}/blocked", { "blocked" => blocked })
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Ends every session the user has. Returns { "sessionsRevoked" => n }.
|
|
91
|
+
def revoke_sessions(user_id)
|
|
92
|
+
@client.post("#{path('v1', 'users', user_id)}/sessions/revoke")
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Removes their two-step verification, for support. Sessions stay
|
|
96
|
+
# unless `revoke_sessions: true`.
|
|
97
|
+
def reset_mfa(user_id, revoke_sessions: false)
|
|
98
|
+
@client.delete("#{path('v1', 'users', user_id)}/mfa", revoke_sessions ? { revokeSessions: "true" } : nil)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
class Organizations < Base
|
|
103
|
+
def list_for_user(user_id) = @client.get("/v1/organizations", { userId: user_id })
|
|
104
|
+
def get(organization_id) = @client.get(path("v1", "organizations", organization_id))
|
|
105
|
+
|
|
106
|
+
# `created_by:` (a user id) is required; they become the owner.
|
|
107
|
+
def create(**params) = @client.post("/v1/organizations", wire(params))
|
|
108
|
+
|
|
109
|
+
# Includes `require_mfa:` to require two-step for every member.
|
|
110
|
+
def update(organization_id, **params) = @client.patch(path("v1", "organizations", organization_id), wire(params))
|
|
111
|
+
def delete(organization_id) = @client.delete(path("v1", "organizations", organization_id))
|
|
112
|
+
|
|
113
|
+
def add_member(organization_id, user_id:, role: nil)
|
|
114
|
+
@client.post("#{path('v1', 'organizations', organization_id)}/members", { "userId" => user_id, "role" => role }.compact)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def remove_member(organization_id, user_id)
|
|
118
|
+
@client.delete(path("v1", "organizations", organization_id, "members", user_id))
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module ZevAuth
|
|
7
|
+
# What every HTTP call in this gem goes through.
|
|
8
|
+
#
|
|
9
|
+
# Net::HTTP from the standard library, not Faraday: an auth library sits
|
|
10
|
+
# in every request path of an app, and each dependency it pulls in is one
|
|
11
|
+
# more version for the app to reconcile and one more thing to trust. The
|
|
12
|
+
# calls here are a handful of JSON requests, which Net::HTTP does well.
|
|
13
|
+
#
|
|
14
|
+
# Anything responding to `call(method:, url:, headers:, body:, timeout:)`
|
|
15
|
+
# and returning a Transport::Response can be passed instead, which is how
|
|
16
|
+
# the tests run without a network and how an app can route through its
|
|
17
|
+
# own HTTP stack.
|
|
18
|
+
class Transport
|
|
19
|
+
Response = Struct.new(:status, :headers, :body, keyword_init: true) do
|
|
20
|
+
# Header lookup that does not care how the server cased the name.
|
|
21
|
+
def header(name)
|
|
22
|
+
wanted = name.downcase
|
|
23
|
+
headers.each { |key, value| return value if key.to_s.downcase == wanted }
|
|
24
|
+
nil
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Raised for anything that means "no answer": DNS, refused, timeout.
|
|
29
|
+
class Failure < StandardError; end
|
|
30
|
+
|
|
31
|
+
def call(method:, url:, headers:, body: nil, timeout: 15)
|
|
32
|
+
uri = URI.parse(url)
|
|
33
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
34
|
+
http.use_ssl = uri.scheme == "https"
|
|
35
|
+
http.open_timeout = timeout
|
|
36
|
+
http.read_timeout = timeout
|
|
37
|
+
http.write_timeout = timeout if http.respond_to?(:write_timeout=)
|
|
38
|
+
|
|
39
|
+
request = Net::HTTPGenericRequest.new(
|
|
40
|
+
method.to_s.upcase, !body.nil?, method.to_s.upcase != "HEAD", uri.request_uri, headers
|
|
41
|
+
)
|
|
42
|
+
request.body = body unless body.nil?
|
|
43
|
+
|
|
44
|
+
response = http.request(request)
|
|
45
|
+
Response.new(status: response.code.to_i, headers: response.to_hash.transform_values(&:first), body: response.body)
|
|
46
|
+
rescue Timeout::Error, SocketError, SystemCallError, IOError, OpenSSL::SSL::SSLError, Net::ProtocolError => e
|
|
47
|
+
raise Failure, e.message
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "json"
|
|
5
|
+
require "openssl"
|
|
6
|
+
|
|
7
|
+
module ZevAuth
|
|
8
|
+
# Verifies a ZevAuth session token and returns its claims.
|
|
9
|
+
#
|
|
10
|
+
# The checks run in the order the server SDK contract fixes, each with
|
|
11
|
+
# its own code, so a Ruby app and a PHP app refuse the same bad token
|
|
12
|
+
# with the same reason:
|
|
13
|
+
#
|
|
14
|
+
# 1. shape malformed_token
|
|
15
|
+
# 2. alg is RS256 unsupported_algorithm (before any key is touched)
|
|
16
|
+
# 3. key by kid unknown_key (one refetch for rotation)
|
|
17
|
+
# 4. signature invalid_signature
|
|
18
|
+
# 5. iss invalid_issuer (read from discovery)
|
|
19
|
+
# 6. aud invalid_audience (the environment id)
|
|
20
|
+
# 7. exp / nbf token_expired / token_not_yet_valid (5 s skew)
|
|
21
|
+
#
|
|
22
|
+
# The algorithm is never taken from the token. `none` and `HS256` (which
|
|
23
|
+
# would let the public key be used as an HMAC secret) are refused at
|
|
24
|
+
# step 2, whatever key they claim.
|
|
25
|
+
class Verifier
|
|
26
|
+
ALGORITHM = "RS256"
|
|
27
|
+
DEFAULT_CLOCK_SKEW = 5
|
|
28
|
+
|
|
29
|
+
attr_reader :environment_id
|
|
30
|
+
|
|
31
|
+
# environment_id: required; tokens for any other environment are refused
|
|
32
|
+
# api_url: where discovery and keys are fetched from
|
|
33
|
+
# issuer: override the discovered issuer (required with jwks:)
|
|
34
|
+
# jwks: a local key set (Hash or JSON) instead of fetching
|
|
35
|
+
# clock_skew_seconds: tolerance on exp/nbf, default 5
|
|
36
|
+
# clock: -> Time (or Numeric seconds); injectable for tests
|
|
37
|
+
# transport: HTTP for discovery and keys; see Transport
|
|
38
|
+
# jwks_cooldown_seconds: minimum gap between unknown-kid refetches, default 30
|
|
39
|
+
def initialize(environment_id:, api_url: ZevAuth::DEFAULT_API_URL, issuer: nil, jwks: nil,
|
|
40
|
+
clock_skew_seconds: DEFAULT_CLOCK_SKEW, clock: nil, transport: nil, jwks_cooldown_seconds: 30)
|
|
41
|
+
if environment_id.nil? || environment_id.to_s.empty?
|
|
42
|
+
raise ConfigurationError.new(
|
|
43
|
+
"ZevAuth needs an environment id (env_test_… or env_live_…) to verify tokens.",
|
|
44
|
+
code: "missing_environment_id"
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
@environment_id = environment_id
|
|
49
|
+
@skew = clock_skew_seconds
|
|
50
|
+
@clock = clock || -> { Time.now }
|
|
51
|
+
if jwks
|
|
52
|
+
raise ConfigurationError.new("`issuer` is required when a local `jwks` is given.", code: "missing_issuer") unless issuer
|
|
53
|
+
|
|
54
|
+
@keys = KeySet.local(jwks)
|
|
55
|
+
else
|
|
56
|
+
@keys = RemoteKeySet.new(
|
|
57
|
+
api_url: api_url, environment_id: environment_id, transport: transport || Transport.new,
|
|
58
|
+
clock: @clock, cooldown_seconds: jwks_cooldown_seconds
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
@issuer = issuer
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Returns the verified claims as a Hash with String keys. Raises
|
|
65
|
+
# VerifyError with a contract code.
|
|
66
|
+
def verify(token)
|
|
67
|
+
header, payload, signing_input, signature = decode(token)
|
|
68
|
+
|
|
69
|
+
alg = header["alg"]
|
|
70
|
+
raise VerifyError.new("Tokens must be signed with RS256, not #{alg.inspect}.", code: "unsupported_algorithm") unless alg == ALGORITHM
|
|
71
|
+
|
|
72
|
+
key = @keys.find(header["kid"])
|
|
73
|
+
raise VerifyError.new("No signing key matches kid #{header['kid'].inspect}.", code: "unknown_key") unless key
|
|
74
|
+
|
|
75
|
+
unless key.verify(OpenSSL::Digest.new("SHA256"), signature, signing_input)
|
|
76
|
+
raise VerifyError.new("The token signature does not verify.", code: "invalid_signature")
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
check_claims(payload)
|
|
80
|
+
payload
|
|
81
|
+
rescue OpenSSL::PKey::PKeyError
|
|
82
|
+
raise VerifyError.new("The token signature does not verify.", code: "invalid_signature")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# "Now" in seconds, from the injected clock. The bridge uses it to size
|
|
86
|
+
# the session cookie to the token's remaining life.
|
|
87
|
+
def now_seconds
|
|
88
|
+
now = @clock.call
|
|
89
|
+
now.respond_to?(:to_f) ? now.to_f : Float(now)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def decode(token)
|
|
95
|
+
parts = token.to_s.split(".", -1)
|
|
96
|
+
raise malformed("A session token has three dot-separated parts.") unless parts.length == 3 && !parts[0].empty? && !parts[1].empty?
|
|
97
|
+
|
|
98
|
+
header = parse_segment(parts[0])
|
|
99
|
+
payload = parse_segment(parts[1])
|
|
100
|
+
raise malformed("The token header and payload must be JSON objects.") unless header.is_a?(Hash) && payload.is_a?(Hash)
|
|
101
|
+
|
|
102
|
+
# An empty or undecodable signature is decided after the algorithm:
|
|
103
|
+
# an `alg: none` token carries no signature, and must be refused as
|
|
104
|
+
# the wrong algorithm, not as a malformed token.
|
|
105
|
+
signature = parts[2].match?(/\A[A-Za-z0-9_-]+\z/) ? base64url(parts[2]) : ""
|
|
106
|
+
[header, payload, "#{parts[0]}.#{parts[1]}", signature]
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def parse_segment(segment)
|
|
110
|
+
JSON.parse(base64url(segment))
|
|
111
|
+
rescue JSON::ParserError
|
|
112
|
+
raise malformed("A token segment is not valid JSON.")
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def base64url(segment)
|
|
116
|
+
raise malformed("A token segment is not base64url.") unless segment.match?(/\A[A-Za-z0-9_-]+\z/)
|
|
117
|
+
|
|
118
|
+
Base64.urlsafe_decode64(segment + ("=" * ((4 - (segment.length % 4)) % 4)))
|
|
119
|
+
rescue ArgumentError
|
|
120
|
+
raise malformed("A token segment is not base64url.")
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def check_claims(payload)
|
|
124
|
+
issuer = @issuer || @keys.discovered_issuer
|
|
125
|
+
unless payload["iss"] == issuer
|
|
126
|
+
raise VerifyError.new("The token was issued by #{payload['iss'].inspect}, not #{issuer}.",
|
|
127
|
+
code: "invalid_issuer")
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
unless audience_matches?(payload["aud"])
|
|
131
|
+
raise VerifyError.new(
|
|
132
|
+
"The token belongs to another environment (#{payload['aud'].inspect}), not #{@environment_id}.",
|
|
133
|
+
code: "invalid_audience"
|
|
134
|
+
)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
now = now_seconds
|
|
138
|
+
exp = numeric_claim(payload, "exp", required: true)
|
|
139
|
+
nbf = numeric_claim(payload, "nbf")
|
|
140
|
+
raise VerifyError.new("The token expired.", code: "token_expired") if now - @skew >= exp
|
|
141
|
+
raise VerifyError.new("The token is not valid yet.", code: "token_not_yet_valid") if nbf && now + @skew < nbf
|
|
142
|
+
|
|
143
|
+
%w[sub sid].each do |claim|
|
|
144
|
+
raise malformed("The `#{claim}` claim must be a string.") unless payload[claim].is_a?(String)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# The server sends a string; a list is accepted per RFC 7519 as long as
|
|
149
|
+
# it contains the environment.
|
|
150
|
+
def audience_matches?(aud)
|
|
151
|
+
aud.is_a?(Array) ? aud.include?(@environment_id) : aud == @environment_id
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def numeric_claim(payload, name, required: false)
|
|
155
|
+
value = payload[name]
|
|
156
|
+
return nil if value.nil? && !required
|
|
157
|
+
raise malformed("The `#{name}` claim must be a number.") unless value.is_a?(Numeric)
|
|
158
|
+
|
|
159
|
+
value
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def malformed(message)
|
|
163
|
+
VerifyError.new(message, code: "malformed_token")
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "openssl"
|
|
5
|
+
|
|
6
|
+
module ZevAuth
|
|
7
|
+
# Checks that a webhook delivery came from ZevAuth.
|
|
8
|
+
#
|
|
9
|
+
# Verify the RAW request body, exactly as received. A body parsed and
|
|
10
|
+
# re-encoded is different bytes, and correctly fails: in Rails that means
|
|
11
|
+
# `request.raw_post`, in Rack `env["rack.input"].read`, never `params`.
|
|
12
|
+
module Webhooks
|
|
13
|
+
SIGNATURE_HEADER = "zevauth-signature"
|
|
14
|
+
DEFAULT_TOLERANCE = 300
|
|
15
|
+
|
|
16
|
+
Event = Struct.new(:id, :type, :created_at, :data, :raw, keyword_init: true)
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
# Raises WebhookError unless `signature` (the `zevauth-signature`
|
|
21
|
+
# header) is a valid, current signature of `payload` under `secret`
|
|
22
|
+
# (your `whsec_…` endpoint secret, prefix included).
|
|
23
|
+
def verify(payload:, signature:, secret: nil, tolerance_seconds: DEFAULT_TOLERANCE, now: nil)
|
|
24
|
+
secret ||= ZevAuth.config.webhook_secret
|
|
25
|
+
raise ConfigurationError.new("A webhook secret (whsec_…) is required.", code: "missing_webhook_secret") if secret.to_s.empty?
|
|
26
|
+
|
|
27
|
+
timestamp, given = parse(signature)
|
|
28
|
+
current = (now || Time.now).to_i
|
|
29
|
+
if (current - timestamp).abs > tolerance_seconds
|
|
30
|
+
raise WebhookError.new(
|
|
31
|
+
"The webhook timestamp is outside the #{tolerance_seconds}s tolerance (a replay, or a clock far off).",
|
|
32
|
+
code: "webhook_timestamp_out_of_range"
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{payload}")
|
|
37
|
+
return true if secure_compare(expected, given)
|
|
38
|
+
|
|
39
|
+
raise WebhookError.new(
|
|
40
|
+
"The webhook signature does not match. Check the secret, and that you verify the raw body.",
|
|
41
|
+
code: "webhook_signature_mismatch"
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Verifies, then returns the parsed Event.
|
|
46
|
+
def construct_event(payload:, signature:, secret: nil, tolerance_seconds: DEFAULT_TOLERANCE, now: nil)
|
|
47
|
+
verify(payload: payload, signature: signature, secret: secret, tolerance_seconds: tolerance_seconds, now: now)
|
|
48
|
+
event = JSON.parse(payload)
|
|
49
|
+
Event.new(id: event["id"], type: event["type"], created_at: event["createdAt"], data: event["data"], raw: event)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def parse(header)
|
|
53
|
+
parts = header.to_s.split(",").each_with_object({}) do |part, out|
|
|
54
|
+
at = part.index("=")
|
|
55
|
+
next unless at
|
|
56
|
+
|
|
57
|
+
out[part[0...at].strip] = part[(at + 1)..].strip
|
|
58
|
+
end
|
|
59
|
+
timestamp = parts["t"]
|
|
60
|
+
given = parts["v1"]
|
|
61
|
+
unless timestamp&.match?(/\A\d+\z/) && given && !given.empty?
|
|
62
|
+
raise WebhookError.new(
|
|
63
|
+
"The zevauth-signature header is malformed: expected t=<timestamp>,v1=<signature>.",
|
|
64
|
+
code: "webhook_malformed_signature"
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
[timestamp.to_i, given]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Constant time, and exact: no case folding, so an uppercase copy of a
|
|
72
|
+
# valid signature is still refused.
|
|
73
|
+
def secure_compare(expected, given)
|
|
74
|
+
return false unless expected.bytesize == given.bytesize
|
|
75
|
+
|
|
76
|
+
OpenSSL.fixed_length_secure_compare(expected, given)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
data/lib/zevauth.rb
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
require_relative "zevauth/version"
|
|
6
|
+
require_relative "zevauth/errors"
|
|
7
|
+
require_relative "zevauth/transport"
|
|
8
|
+
require_relative "zevauth/key_set"
|
|
9
|
+
require_relative "zevauth/verifier"
|
|
10
|
+
require_relative "zevauth/auth"
|
|
11
|
+
require_relative "zevauth/bridge"
|
|
12
|
+
require_relative "zevauth/authenticator"
|
|
13
|
+
require_relative "zevauth/webhooks"
|
|
14
|
+
require_relative "zevauth/client"
|
|
15
|
+
require_relative "zevauth/resources"
|
|
16
|
+
|
|
17
|
+
# ZevAuth for Ruby.
|
|
18
|
+
#
|
|
19
|
+
# ZevAuth.configure do |c|
|
|
20
|
+
# c.environment_id = ENV.fetch("ZEVAUTH_ENVIRONMENT_ID")
|
|
21
|
+
# c.secret_key = ENV.fetch("ZEVAUTH_SECRET_KEY")
|
|
22
|
+
# end
|
|
23
|
+
#
|
|
24
|
+
# auth = ZevAuth.get_auth(request) # never raises
|
|
25
|
+
# ZevAuth.require_auth(request, require_mfa: true)
|
|
26
|
+
# ZevAuth.client.users.get(auth.user_id)
|
|
27
|
+
module ZevAuth
|
|
28
|
+
DEFAULT_API_URL = "https://api.zevauth.net"
|
|
29
|
+
|
|
30
|
+
class Configuration
|
|
31
|
+
# Required to verify sessions: env_test_… or env_live_…
|
|
32
|
+
attr_accessor :environment_id
|
|
33
|
+
# For the backend API: sk_test_… or sk_live_…
|
|
34
|
+
attr_accessor :secret_key
|
|
35
|
+
# For the handshake page: pk_test_… or pk_live_…
|
|
36
|
+
attr_accessor :publishable_key
|
|
37
|
+
# For webhook verification: whsec_…
|
|
38
|
+
attr_accessor :webhook_secret
|
|
39
|
+
# Where the API lives. Default https://api.zevauth.net
|
|
40
|
+
attr_accessor :api_url
|
|
41
|
+
# What the browser SDK on the handshake page talks to, when it differs
|
|
42
|
+
# from its own default. Usually leave nil.
|
|
43
|
+
attr_accessor :frontend_api_url
|
|
44
|
+
# A local key set and issuer instead of fetching (tests, air-gapped).
|
|
45
|
+
attr_accessor :jwks, :issuer
|
|
46
|
+
# Tolerance on exp/nbf in seconds. Default 5.
|
|
47
|
+
attr_accessor :clock_skew_seconds
|
|
48
|
+
# -> Time; injectable for tests.
|
|
49
|
+
attr_accessor :clock
|
|
50
|
+
# HTTP; see ZevAuth::Transport.
|
|
51
|
+
attr_accessor :transport
|
|
52
|
+
# Session bridge (server-rendered apps); see ZevAuth::Bridge::Config.
|
|
53
|
+
attr_accessor :bridge
|
|
54
|
+
# Where the bridge endpoint is mounted; the handshake page posts here.
|
|
55
|
+
attr_accessor :bridge_path
|
|
56
|
+
|
|
57
|
+
# Each key falls back to the environment variable of the same name, so
|
|
58
|
+
# an app that sets them needs no configuration code at all. The names
|
|
59
|
+
# match every other ZevAuth server SDK.
|
|
60
|
+
def initialize
|
|
61
|
+
@environment_id = ENV.fetch("ZEVAUTH_ENVIRONMENT_ID", nil)
|
|
62
|
+
@secret_key = ENV.fetch("ZEVAUTH_SECRET_KEY", nil)
|
|
63
|
+
@publishable_key = ENV.fetch("ZEVAUTH_PUBLISHABLE_KEY", nil)
|
|
64
|
+
@webhook_secret = ENV.fetch("ZEVAUTH_WEBHOOK_SECRET", nil)
|
|
65
|
+
@api_url = ENV.fetch("ZEVAUTH_API_URL", DEFAULT_API_URL)
|
|
66
|
+
@clock_skew_seconds = Verifier::DEFAULT_CLOCK_SKEW
|
|
67
|
+
@bridge = Bridge::Config.new
|
|
68
|
+
@bridge_path = "/zevauth/session"
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
class << self
|
|
73
|
+
def config
|
|
74
|
+
@config ||= Configuration.new
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def configure
|
|
78
|
+
yield config
|
|
79
|
+
reset!
|
|
80
|
+
config
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Rebuilds the cached verifier and client from the current config.
|
|
84
|
+
def reset!
|
|
85
|
+
@authenticator = nil
|
|
86
|
+
@client = nil
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def authenticator
|
|
90
|
+
@authenticator ||= Authenticator.new(
|
|
91
|
+
Verifier.new(
|
|
92
|
+
environment_id: config.environment_id,
|
|
93
|
+
api_url: config.api_url,
|
|
94
|
+
issuer: config.issuer,
|
|
95
|
+
jwks: config.jwks,
|
|
96
|
+
clock_skew_seconds: config.clock_skew_seconds,
|
|
97
|
+
clock: config.clock,
|
|
98
|
+
transport: config.transport
|
|
99
|
+
),
|
|
100
|
+
bridge: config.bridge
|
|
101
|
+
)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def client
|
|
105
|
+
@client ||= Client.new(
|
|
106
|
+
secret_key: config.secret_key,
|
|
107
|
+
api_url: config.api_url,
|
|
108
|
+
transport: config.transport
|
|
109
|
+
)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Verifies a token and returns its claims; raises VerifyError.
|
|
113
|
+
def verify_token(token)
|
|
114
|
+
authenticator.verifier.verify(token)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Who is signed in on this request (Rack env or request object).
|
|
118
|
+
# Never raises for a missing or bad token.
|
|
119
|
+
def get_auth(request)
|
|
120
|
+
authenticator.get_auth(request)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# The same, but raises AuthRefused (401/403) unless the request passes.
|
|
124
|
+
def require_auth(request, allow_pending_tasks: false, require_mfa: false)
|
|
125
|
+
authenticator.require_auth(request, allow_pending_tasks: allow_pending_tasks, require_mfa: require_mfa)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# [auth, handshake_rack_response_or_nil]; see Authenticator.
|
|
129
|
+
def authenticate_request(request)
|
|
130
|
+
authenticator.authenticate_request(request)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
require_relative "zevauth/rack/middleware"
|
|
136
|
+
require_relative "zevauth/rails/railtie" if defined?(Rails::Railtie)
|
metadata
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: zevauth
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- ZevOp Inc.
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-11-01 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: jwt
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - ">="
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '2.7'
|
|
20
|
+
- - "<"
|
|
21
|
+
- !ruby/object:Gem::Version
|
|
22
|
+
version: '4'
|
|
23
|
+
type: :runtime
|
|
24
|
+
prerelease: false
|
|
25
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
26
|
+
requirements:
|
|
27
|
+
- - ">="
|
|
28
|
+
- !ruby/object:Gem::Version
|
|
29
|
+
version: '2.7'
|
|
30
|
+
- - "<"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '4'
|
|
33
|
+
description: |
|
|
34
|
+
Verifies ZevAuth session tokens (RS256, keys fetched and rotated for you),
|
|
35
|
+
reads who is signed in from a bearer token or the session-bridge cookie,
|
|
36
|
+
calls the ZevAuth backend API with your secret key, and checks webhook
|
|
37
|
+
signatures. Includes Rack middleware and an optional Rails engine.
|
|
38
|
+
email:
|
|
39
|
+
executables: []
|
|
40
|
+
extensions: []
|
|
41
|
+
extra_rdoc_files: []
|
|
42
|
+
files:
|
|
43
|
+
- CHANGELOG.md
|
|
44
|
+
- LICENSE
|
|
45
|
+
- README.md
|
|
46
|
+
- lib/zevauth.rb
|
|
47
|
+
- lib/zevauth/auth.rb
|
|
48
|
+
- lib/zevauth/authenticator.rb
|
|
49
|
+
- lib/zevauth/bridge.rb
|
|
50
|
+
- lib/zevauth/client.rb
|
|
51
|
+
- lib/zevauth/errors.rb
|
|
52
|
+
- lib/zevauth/key_set.rb
|
|
53
|
+
- lib/zevauth/rack/middleware.rb
|
|
54
|
+
- lib/zevauth/rails.rb
|
|
55
|
+
- lib/zevauth/rails/config/routes.rb
|
|
56
|
+
- lib/zevauth/rails/controller.rb
|
|
57
|
+
- lib/zevauth/rails/railtie.rb
|
|
58
|
+
- lib/zevauth/resources.rb
|
|
59
|
+
- lib/zevauth/transport.rb
|
|
60
|
+
- lib/zevauth/verifier.rb
|
|
61
|
+
- lib/zevauth/version.rb
|
|
62
|
+
- lib/zevauth/webhooks.rb
|
|
63
|
+
homepage: https://docs.zevauth.com
|
|
64
|
+
licenses:
|
|
65
|
+
- MIT
|
|
66
|
+
metadata:
|
|
67
|
+
homepage_uri: https://docs.zevauth.com
|
|
68
|
+
documentation_uri: https://docs.zevauth.com/sdk/ruby
|
|
69
|
+
source_code_uri: https://github.com/zevop/zevauth-ruby
|
|
70
|
+
bug_tracker_uri: https://github.com/zevop/zevauth-ruby/issues
|
|
71
|
+
changelog_uri: https://github.com/zevop/zevauth-ruby/blob/main/CHANGELOG.md
|
|
72
|
+
rubygems_mfa_required: 'true'
|
|
73
|
+
post_install_message:
|
|
74
|
+
rdoc_options: []
|
|
75
|
+
require_paths:
|
|
76
|
+
- lib
|
|
77
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - ">="
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '3.1'
|
|
82
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
83
|
+
requirements:
|
|
84
|
+
- - ">="
|
|
85
|
+
- !ruby/object:Gem::Version
|
|
86
|
+
version: '0'
|
|
87
|
+
requirements: []
|
|
88
|
+
rubygems_version: 3.5.3
|
|
89
|
+
signing_key:
|
|
90
|
+
specification_version: 4
|
|
91
|
+
summary: 'ZevAuth for Ruby: verify sessions, call the backend API, check webhooks.'
|
|
92
|
+
test_files: []
|