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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: c1c4f39c408d4398f5f65cd0a1d1b23e56f959dee0c25e8e82889ca80cb967f4
|
|
4
|
+
data.tar.gz: 97dc9e1a1614a986a40489838bfbb1b145a848c1ec9d8301abc40e7b1d371905
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 5ac39af131e5d96b2074ccec8a57241fe25b3b497c86a43c41891f8b6a91de1c9d61079090dc5972ebc7a8c51e7d951eb9ebad525889f5599613d5dff2555c94
|
|
7
|
+
data.tar.gz: f28f89f5a54a8a11d6c28af5d8a08942d2155ec8fd929bb1942829c011a9c26664690d716957fc17b16442a6b041f4c02f9d5ffa8c914fe5b1acf36c5e48ae2f
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 (unreleased)
|
|
4
|
+
|
|
5
|
+
First release.
|
|
6
|
+
|
|
7
|
+
- Session verification: RS256 only, issuer from discovery, audience
|
|
8
|
+
enforced, 5 s clock skew, keys cached 10 minutes with an unknown-kid
|
|
9
|
+
refetch at most every 30 s. Passes every shared conformance fixture.
|
|
10
|
+
- `ZevAuth.get_auth` / `ZevAuth.require_auth`, with `has?`,
|
|
11
|
+
`mfa_verified?` and pending tasks.
|
|
12
|
+
- Backend API client: users and organizations, cursor iteration with
|
|
13
|
+
`each`, retries on 429/502/503/504 and network failures (never on 500),
|
|
14
|
+
`Retry-After`, `request_id`.
|
|
15
|
+
- Webhook verification over the raw body, in constant time.
|
|
16
|
+
- Session bridge endpoint and handshake page, as Rack apps.
|
|
17
|
+
- Rack middleware (`ZevAuth::Rack::Middleware`, `ZevAuth::Rack::RequireAuth`).
|
|
18
|
+
- Rails engine: `mount ZevAuth::Engine`, `require_zevauth!`,
|
|
19
|
+
`require_zevauth only: …, require_mfa: true`, view helpers,
|
|
20
|
+
`zevauth_webhook_event`.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ZevOp Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# ZevAuth for Ruby
|
|
2
|
+
|
|
3
|
+
Verify ZevAuth sessions, call the ZevAuth backend API and check webhook
|
|
4
|
+
signatures from Rails, Rack apps (Sinatra, Roda, Hanami) or plain Ruby.
|
|
5
|
+
|
|
6
|
+
```ruby
|
|
7
|
+
gem "zevauth"
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
Ruby 3.1 or later. One runtime dependency (`jwt`, to read published keys);
|
|
11
|
+
Rack and Rails are used when your app has them and never required.
|
|
12
|
+
|
|
13
|
+
## Configure
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
ZevAuth.configure do |c|
|
|
17
|
+
c.environment_id = ENV.fetch("ZEVAUTH_ENVIRONMENT_ID") # env_live_…
|
|
18
|
+
c.secret_key = ENV.fetch("ZEVAUTH_SECRET_KEY") # sk_live_…, backend API only
|
|
19
|
+
c.publishable_key = ENV.fetch("ZEVAUTH_PUBLISHABLE_KEY") # pk_live_…, for the handshake page
|
|
20
|
+
c.webhook_secret = ENV.fetch("ZEVAUTH_WEBHOOK_SECRET") # whsec_…
|
|
21
|
+
end
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Who is signed in
|
|
25
|
+
|
|
26
|
+
```ruby
|
|
27
|
+
auth = ZevAuth.get_auth(request) # never raises for a missing or bad token
|
|
28
|
+
auth.signed_in?
|
|
29
|
+
auth.user_id # "user_…"
|
|
30
|
+
auth.organization_id
|
|
31
|
+
auth.has?(permission: "org:members:manage")
|
|
32
|
+
auth.mfa_verified?
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
`require_auth` refuses instead, with the same responses in every ZevAuth SDK:
|
|
36
|
+
401 `unauthorized`, 403 `session_task_pending`, 403 `mfa_required`.
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
ZevAuth.require_auth(request, require_mfa: true) # raises ZevAuth::AuthRefused
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Tokens are checked locally against your environment's published keys (RS256
|
|
43
|
+
only, keys cached and rotated for you), so this costs no network call per
|
|
44
|
+
request.
|
|
45
|
+
|
|
46
|
+
## Rails
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
# config/routes.rb
|
|
50
|
+
mount ZevAuth::Engine => "/zevauth"
|
|
51
|
+
|
|
52
|
+
# app/controllers/application_controller.rb
|
|
53
|
+
class ApplicationController < ActionController::Base
|
|
54
|
+
before_action :require_zevauth!
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
class BillingController < ApplicationController
|
|
58
|
+
require_zevauth only: :update, require_mfa: true
|
|
59
|
+
end
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Views get `zevauth_signed_in?`, `zevauth_user_id` and `zevauth_auth`.
|
|
63
|
+
|
|
64
|
+
## Rack
|
|
65
|
+
|
|
66
|
+
```ruby
|
|
67
|
+
use ZevAuth::Rack::Middleware # env["zevauth.auth"] on every request
|
|
68
|
+
|
|
69
|
+
map "/api" do
|
|
70
|
+
use ZevAuth::Rack::RequireAuth # 401 / 403 with the JSON envelope
|
|
71
|
+
run Api
|
|
72
|
+
end
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Backend API
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
client = ZevAuth.client
|
|
79
|
+
client.users.get("user_123")
|
|
80
|
+
client.users.each(metadata: { plan: "pro" }) { |user| puts user["email"] }
|
|
81
|
+
client.organizations.add_member("org_123", user_id: "user_123", role: "org:admin")
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Errors are `ZevAuth::APIError` with `code`, `status` and `request_id`.
|
|
85
|
+
Rate limits and brief outages are retried for you; a 500 never is, so a
|
|
86
|
+
create cannot run twice.
|
|
87
|
+
|
|
88
|
+
## Webhooks
|
|
89
|
+
|
|
90
|
+
Verify the raw body, never parsed params:
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
event = ZevAuth::Webhooks.construct_event(
|
|
94
|
+
payload: request.raw_post,
|
|
95
|
+
signature: request.headers["zevauth-signature"]
|
|
96
|
+
)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
In a Rails controller, `zevauth_webhook_event` does the same.
|
|
100
|
+
|
|
101
|
+
## Server-rendered pages
|
|
102
|
+
|
|
103
|
+
With the session bridge, the browser SDK hands each new access token to your
|
|
104
|
+
server, which keeps it in its own HttpOnly cookie, so a page knows who is
|
|
105
|
+
signed in before it renders. Mount the engine (Rails) or the middleware
|
|
106
|
+
(Rack), and set `sessionBridge: { url: "/zevauth/session" }` in the browser
|
|
107
|
+
SDK. Your server never refreshes a token; the browser does.
|
|
108
|
+
|
|
109
|
+
## Documentation
|
|
110
|
+
|
|
111
|
+
- Ruby: <https://docs.zevauth.com/sdk/ruby>
|
|
112
|
+
- Rails: <https://docs.zevauth.com/sdk/rails>
|
|
113
|
+
- The contract every ZevAuth server SDK follows:
|
|
114
|
+
<https://github.com/zevop/zevauth-sdk/tree/main/spec>
|
|
115
|
+
|
|
116
|
+
## Development
|
|
117
|
+
|
|
118
|
+
```sh
|
|
119
|
+
bundle install
|
|
120
|
+
bundle exec rake test # includes the shared conformance fixtures
|
|
121
|
+
bundle exec rubocop
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
`test/fixtures` is a copy of the shared conformance fixtures from
|
|
125
|
+
[zevauth-sdk](https://github.com/zevop/zevauth-sdk/tree/main/spec/fixtures).
|
|
126
|
+
Never edit it by hand: change the fixture in zevauth-sdk, then run
|
|
127
|
+
`script/sync-fixtures` here.
|
|
128
|
+
|
|
129
|
+
## Support
|
|
130
|
+
|
|
131
|
+
Questions and bug reports: open an issue here, or contact ZevAuth support.
|
|
132
|
+
|
|
133
|
+
## License
|
|
134
|
+
|
|
135
|
+
[MIT](LICENSE). Copyright (c) 2026 ZevOp Inc.
|
data/lib/zevauth/auth.rb
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ZevAuth
|
|
4
|
+
# Who is signed in on this request, or that nobody is.
|
|
5
|
+
#
|
|
6
|
+
# A signed-out Auth is a real object with the same methods, not nil, so a
|
|
7
|
+
# view can ask `auth.signed_in?` without guarding against nil first.
|
|
8
|
+
class Auth
|
|
9
|
+
attr_reader :user_id, :session_id, :organization_id, :organization_role, :claims, :pending_tasks
|
|
10
|
+
|
|
11
|
+
def self.signed_out(needs_handshake: false)
|
|
12
|
+
new(nil, needs_handshake: needs_handshake)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.from_claims(claims)
|
|
16
|
+
new(claims)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def initialize(claims, needs_handshake: false)
|
|
20
|
+
@claims = claims&.freeze
|
|
21
|
+
@user_id = claims && claims["sub"]
|
|
22
|
+
@session_id = claims && claims["sid"]
|
|
23
|
+
@organization_id = claims && claims["org_id"]
|
|
24
|
+
@organization_role = claims && claims["org_role"]
|
|
25
|
+
# Absent means empty, never nil: code written before a claim existed
|
|
26
|
+
# keeps working when it starts appearing.
|
|
27
|
+
@pending_tasks = Array(claims && claims["pending_tasks"]).freeze
|
|
28
|
+
@permissions = Array(claims && claims["org_permissions"]).freeze
|
|
29
|
+
@mfa = Array(claims && claims["amr"]).include?("mfa")
|
|
30
|
+
@needs_handshake = needs_handshake
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def signed_in?
|
|
34
|
+
!@claims.nil?
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# The session passed a second factor at sign-in (`amr` contains `mfa`).
|
|
38
|
+
def mfa_verified?
|
|
39
|
+
@mfa
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# True when the session cookie lapsed but the browser still holds a
|
|
43
|
+
# session: a page request should go through the handshake so the
|
|
44
|
+
# browser can refresh it. See ZevAuth::Bridge.
|
|
45
|
+
def needs_handshake?
|
|
46
|
+
@needs_handshake
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Role and permission checks against the active organization.
|
|
50
|
+
#
|
|
51
|
+
# auth.has?(permission: "org:members:manage")
|
|
52
|
+
# auth.has?(role: "org:admin")
|
|
53
|
+
#
|
|
54
|
+
# False with no active organization, and false when neither is given:
|
|
55
|
+
# an empty check granting access would be a bug waiting to be written.
|
|
56
|
+
def has?(role: nil, permission: nil)
|
|
57
|
+
return false unless @organization_id
|
|
58
|
+
return false if role.nil? && permission.nil?
|
|
59
|
+
return false if role && @organization_role != role
|
|
60
|
+
return false if permission && !@permissions.include?(permission)
|
|
61
|
+
|
|
62
|
+
true
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def to_h
|
|
66
|
+
{
|
|
67
|
+
signed_in: signed_in?,
|
|
68
|
+
user_id: user_id,
|
|
69
|
+
session_id: session_id,
|
|
70
|
+
organization_id: organization_id,
|
|
71
|
+
organization_role: organization_role,
|
|
72
|
+
pending_tasks: pending_tasks,
|
|
73
|
+
mfa_verified: mfa_verified?
|
|
74
|
+
}
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module ZevAuth
|
|
6
|
+
# Reads and checks the session on a request: the `Authorization` header
|
|
7
|
+
# first, then the session-bridge cookie for server-rendered pages.
|
|
8
|
+
#
|
|
9
|
+
# Works with a Rack env Hash, a Rack::Request or an ActionDispatch::Request.
|
|
10
|
+
class Authenticator
|
|
11
|
+
HANDSHAKE_MARKER = "__zevauth_handshake"
|
|
12
|
+
|
|
13
|
+
attr_reader :verifier, :bridge
|
|
14
|
+
|
|
15
|
+
def initialize(verifier, bridge: Bridge::Config.new)
|
|
16
|
+
@verifier = verifier
|
|
17
|
+
@bridge = bridge
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Who is signed in. Never raises for a missing, expired or bad token:
|
|
21
|
+
# each of those means "nobody", and a page deciding what to render must
|
|
22
|
+
# not crash because a session lapsed.
|
|
23
|
+
def get_auth(request)
|
|
24
|
+
env = ZevAuth.env_of(request)
|
|
25
|
+
header = ZevAuth.bearer_from(env["HTTP_AUTHORIZATION"])
|
|
26
|
+
cookies = ZevAuth.parse_cookies(env["HTTP_COOKIE"])
|
|
27
|
+
names = @bridge.cookie_names
|
|
28
|
+
token = header || present(cookies[names[:session]])
|
|
29
|
+
has_client = cookies[names[:client]] == "1"
|
|
30
|
+
|
|
31
|
+
return Auth.signed_out(needs_handshake: header.nil? && has_client) unless token
|
|
32
|
+
|
|
33
|
+
Auth.from_claims(@verifier.verify(token))
|
|
34
|
+
rescue VerifyError => e
|
|
35
|
+
Auth.signed_out(needs_handshake: header.nil? && (e.code == "token_expired" || has_client))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The same, but refuses with AuthRefused: 401 `unauthorized`, 403
|
|
39
|
+
# `session_task_pending`, then 403 `mfa_required`, in that order.
|
|
40
|
+
#
|
|
41
|
+
# allow_pending_tasks: serve a session that still has a task to finish
|
|
42
|
+
# (only for the routes that help finish it)
|
|
43
|
+
# require_mfa: refuse unless the session passed a second factor
|
|
44
|
+
def require_auth(request, allow_pending_tasks: false, require_mfa: false)
|
|
45
|
+
auth = request.is_a?(Auth) ? request : get_auth(request)
|
|
46
|
+
ZevAuth.check!(auth, allow_pending_tasks: allow_pending_tasks, require_mfa: require_mfa)
|
|
47
|
+
auth
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# For server-rendered pages: the auth, plus a Rack response to send
|
|
51
|
+
# instead when the session cookie lapsed and the browser should refresh
|
|
52
|
+
# it first. `handshake` is nil when the page can render as it is.
|
|
53
|
+
def authenticate_request(request)
|
|
54
|
+
env = ZevAuth.env_of(request)
|
|
55
|
+
auth = get_auth(env)
|
|
56
|
+
return [auth, nil] unless auth.needs_handshake?
|
|
57
|
+
|
|
58
|
+
query = env["QUERY_STRING"].to_s
|
|
59
|
+
# Back from a handshake already: render signed out rather than loop.
|
|
60
|
+
return [auth, nil] if URI.decode_www_form(query).any? { |key, _| key == HANDSHAKE_MARKER }
|
|
61
|
+
|
|
62
|
+
method = env["REQUEST_METHOD"]
|
|
63
|
+
navigation = %w[GET HEAD].include?(method) && env["HTTP_ACCEPT"].to_s.include?("text/html")
|
|
64
|
+
unless navigation
|
|
65
|
+
body = {
|
|
66
|
+
"error" => {
|
|
67
|
+
"code" => "session_refresh_required",
|
|
68
|
+
"message" => "The session cookie expired. Send the SDK token in Authorization, or reload the page."
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return [auth, [401, { "content-type" => "application/json", "cache-control" => "no-store" }, [JSON.generate(body)]]]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
path = env["SCRIPT_NAME"].to_s + env["PATH_INFO"].to_s
|
|
75
|
+
target = path + (query.empty? ? "" : "?#{query}")
|
|
76
|
+
location = "#{@bridge.handshake_path}?#{URI.encode_www_form('redirect_url' => target)}"
|
|
77
|
+
[auth, [302, { "location" => location, "cache-control" => "no-store" }, []]]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def present(value)
|
|
83
|
+
value.nil? || value.empty? ? nil : value
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
class << self
|
|
88
|
+
# Raises AuthRefused unless the auth passes; see Authenticator#require_auth.
|
|
89
|
+
def check!(auth, allow_pending_tasks: false, require_mfa: false)
|
|
90
|
+
raise AuthRefused.new("Sign in to continue.", code: "unauthorized", status: 401) unless auth.signed_in?
|
|
91
|
+
|
|
92
|
+
if !allow_pending_tasks && !auth.pending_tasks.empty?
|
|
93
|
+
raise AuthRefused.new(
|
|
94
|
+
"Finish setting up your account to continue.",
|
|
95
|
+
code: "session_task_pending", status: 403, pending_tasks: auth.pending_tasks
|
|
96
|
+
)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
return unless require_mfa && !auth.mfa_verified?
|
|
100
|
+
|
|
101
|
+
raise AuthRefused.new("Two-step verification is required for this.", code: "mfa_required", status: 403)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# `Authorization: Bearer <token>` -> the token, or nil. The scheme is
|
|
105
|
+
# case-sensitive, whitespace is trimmed, and an API key in the header
|
|
106
|
+
# (`pk_…`, `sk_…`) is not a session, so it reads as none.
|
|
107
|
+
def bearer_from(header)
|
|
108
|
+
return nil unless header.is_a?(String) && header.start_with?("Bearer ")
|
|
109
|
+
|
|
110
|
+
token = header.delete_prefix("Bearer ").strip
|
|
111
|
+
return nil if token.empty? || token.start_with?("pk_", "sk_")
|
|
112
|
+
|
|
113
|
+
token
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# The Rack env behind whatever request object a framework hands us.
|
|
117
|
+
def env_of(request)
|
|
118
|
+
return request if request.is_a?(Hash)
|
|
119
|
+
return request.env if request.respond_to?(:env)
|
|
120
|
+
|
|
121
|
+
raise ArgumentError, "Expected a Rack env or a request object, got #{request.class}."
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def parse_cookies(header)
|
|
125
|
+
header.to_s.split(";").each_with_object({}) do |part, out|
|
|
126
|
+
at = part.index("=")
|
|
127
|
+
next unless at&.positive?
|
|
128
|
+
|
|
129
|
+
out[part[0...at].strip] = part[(at + 1)..].strip
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module ZevAuth
|
|
7
|
+
# The session bridge, for server-rendered apps.
|
|
8
|
+
#
|
|
9
|
+
# The browser SDK keeps the session (and its refresh token) and posts
|
|
10
|
+
# each new access token to an endpoint on your own site, which stores it
|
|
11
|
+
# in your own HttpOnly cookie. Your pages then read who is signed in from
|
|
12
|
+
# that cookie, before they render.
|
|
13
|
+
#
|
|
14
|
+
# Your server never refreshes a token. Refresh tokens rotate on every use
|
|
15
|
+
# and a refresh token used twice ends the whole session, so a server
|
|
16
|
+
# refreshing alongside the browser would sign the person out. Only the
|
|
17
|
+
# browser refreshes; when the cookie lapses, the handshake page lets it
|
|
18
|
+
# do so and sends the visitor straight back.
|
|
19
|
+
module Bridge
|
|
20
|
+
HEADER = "HTTP_X_ZEVAUTH_BRIDGE"
|
|
21
|
+
CLIENT_COOKIE_SECONDS = 30 * 24 * 60 * 60
|
|
22
|
+
DEFAULT_UI_SCRIPT = "https://cdn.jsdelivr.net/npm/@zevauth/ui@0/dist/zevauth-ui.global.js"
|
|
23
|
+
|
|
24
|
+
# allowed_origins: origins allowed to post sessions; default the
|
|
25
|
+
# request's own origin
|
|
26
|
+
# cookie_domain: share the cookies with subdomains (`.example.com`)
|
|
27
|
+
# secure: false only for plain-http local development
|
|
28
|
+
# handshake_path: where the handshake page is served
|
|
29
|
+
Config = Struct.new(:allowed_origins, :cookie_domain, :secure, :handshake_path, keyword_init: true) do
|
|
30
|
+
def initialize(allowed_origins: nil, cookie_domain: nil, secure: true, handshake_path: "/zevauth/handshake")
|
|
31
|
+
super
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# `__Host-` cookies by default: no Domain, Secure, Path=/, so nothing
|
|
35
|
+
# on another subdomain can set or read them. `__Secure-` when shared
|
|
36
|
+
# across subdomains, and no prefix for plain-http development.
|
|
37
|
+
def cookie_names
|
|
38
|
+
prefix = if secure == false then ""
|
|
39
|
+
elsif cookie_domain then "__Secure-"
|
|
40
|
+
else "__Host-"
|
|
41
|
+
end
|
|
42
|
+
{ session: "#{prefix}zevauth_session", client: "#{prefix}zevauth_client" }
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# POST {token} -> sets or clears the cookies. Mount it at the URL you
|
|
47
|
+
# give the browser SDK as `sessionBridge: { url }`.
|
|
48
|
+
class Endpoint
|
|
49
|
+
def initialize(authenticator = nil)
|
|
50
|
+
@authenticator = authenticator
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def call(env)
|
|
54
|
+
authenticator = @authenticator || ZevAuth.authenticator
|
|
55
|
+
config = authenticator.bridge
|
|
56
|
+
|
|
57
|
+
return error(405, "method_not_allowed", "The session bridge accepts POST only.", { "allow" => "POST" }) unless env["REQUEST_METHOD"] == "POST"
|
|
58
|
+
|
|
59
|
+
unless env[HEADER] == "1"
|
|
60
|
+
return error(403, "bridge_header_missing", "Send the x-zevauth-bridge: 1 header. The ZevAuth browser SDK does this for you.")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# With the header, this stops login CSRF: a page elsewhere posting
|
|
64
|
+
# its own valid token to sign the visitor in as someone else.
|
|
65
|
+
origin = env["HTTP_ORIGIN"]
|
|
66
|
+
allowed = config.allowed_origins || [own_origin(env)]
|
|
67
|
+
if origin && !allowed.include?(origin)
|
|
68
|
+
return error(403, "bridge_origin_not_allowed", "#{origin} may not post sessions here. Add it to allowed_origins.")
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
body = read_json(env)
|
|
72
|
+
return error(400, "invalid_request", 'Expected a JSON body: { "token": string | null }.') unless body.is_a?(Hash)
|
|
73
|
+
|
|
74
|
+
names = config.cookie_names
|
|
75
|
+
token = body["token"]
|
|
76
|
+
cleared = [Bridge.cookie(names[:session], "", 0, config), Bridge.cookie(names[:client], "", 0, config)]
|
|
77
|
+
return empty(204, cleared) if token.nil? || token == ""
|
|
78
|
+
return error(400, "invalid_request", "`token` must be a string or null.") unless token.is_a?(String)
|
|
79
|
+
|
|
80
|
+
begin
|
|
81
|
+
claims = authenticator.verifier.verify(token)
|
|
82
|
+
rescue VerifyError => e
|
|
83
|
+
return error(401, "unauthorized", "That session token was refused.", {}, cleared, reason: e.code)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
max_age = [claims["exp"].to_i - authenticator.verifier.now_seconds.floor, 0].max
|
|
87
|
+
empty(204, [
|
|
88
|
+
Bridge.cookie(names[:session], token, max_age, config),
|
|
89
|
+
Bridge.cookie(names[:client], "1", CLIENT_COOKIE_SECONDS, config)
|
|
90
|
+
])
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def own_origin(env)
|
|
96
|
+
scheme = first_value(env["HTTP_X_FORWARDED_PROTO"]) || env["rack.url_scheme"] || "http"
|
|
97
|
+
host = first_value(env["HTTP_X_FORWARDED_HOST"]) || env["HTTP_HOST"] || env["SERVER_NAME"]
|
|
98
|
+
"#{scheme}://#{host}"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Proxies append to these headers; the first value is the client's.
|
|
102
|
+
def first_value(header)
|
|
103
|
+
header.nil? ? nil : header.split(",").first&.strip
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def read_json(env)
|
|
107
|
+
input = env["rack.input"]
|
|
108
|
+
raw = input ? input.read.to_s : ""
|
|
109
|
+
input.rewind if input.respond_to?(:rewind)
|
|
110
|
+
JSON.parse(raw)
|
|
111
|
+
rescue JSON::ParserError
|
|
112
|
+
nil
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def error(status, code, message, headers = {}, cookies = [], reason: nil)
|
|
116
|
+
error = { "code" => code, "message" => message }
|
|
117
|
+
error["reason"] = reason if reason
|
|
118
|
+
response(status, JSON.generate("error" => error), { "content-type" => "application/json" }.merge(headers), cookies)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def empty(status, cookies)
|
|
122
|
+
response(status, nil, {}, cookies)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def response(status, body, headers, cookies)
|
|
126
|
+
headers = { "cache-control" => "no-store" }.merge(headers)
|
|
127
|
+
headers["set-cookie"] = Bridge.set_cookie_value(cookies) unless cookies.empty?
|
|
128
|
+
[status, headers, body ? [body] : []]
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# GET {redirect_url} -> a tiny page that lets the browser SDK refresh the
|
|
133
|
+
# session, post it to the bridge, and go back. Plain HTML and one script
|
|
134
|
+
# tag, so it works with any framework or none.
|
|
135
|
+
class HandshakePage
|
|
136
|
+
def initialize(publishable_key: nil, bridge_path: "/zevauth/session", api_url: nil, ui_script_url: nil)
|
|
137
|
+
@publishable_key = publishable_key
|
|
138
|
+
@bridge_path = bridge_path
|
|
139
|
+
@api_url = api_url
|
|
140
|
+
@ui_script_url = ui_script_url || DEFAULT_UI_SCRIPT
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def call(env)
|
|
144
|
+
publishable_key = @publishable_key || ZevAuth.config.publishable_key
|
|
145
|
+
raise ConfigurationError.new("The handshake page needs your publishable key (pk_…).", code: "missing_publishable_key") unless publishable_key
|
|
146
|
+
|
|
147
|
+
requested = URI.decode_www_form(env["QUERY_STRING"].to_s).to_h["redirect_url"] || "/"
|
|
148
|
+
# Same-site paths only: anything else is an open redirect.
|
|
149
|
+
safe = requested.match?(%r{\A/(?![/\\])}) ? requested : "/"
|
|
150
|
+
back = "#{safe}#{safe.include?('?') ? '&' : '?'}#{Authenticator::HANDSHAKE_MARKER}=1"
|
|
151
|
+
|
|
152
|
+
config = JSON.generate(
|
|
153
|
+
"publishableKey" => publishable_key,
|
|
154
|
+
"apiUrl" => @api_url || ZevAuth.config.frontend_api_url,
|
|
155
|
+
"bridge" => @bridge_path,
|
|
156
|
+
"back" => back
|
|
157
|
+
).gsub("<", "\\u003c")
|
|
158
|
+
|
|
159
|
+
html = <<~HTML
|
|
160
|
+
<!doctype html><meta charset="utf-8"><meta name="robots" content="noindex"><title>Signing in…</title>
|
|
161
|
+
<script src="#{escape_attribute(@ui_script_url)}"></script>
|
|
162
|
+
<script>
|
|
163
|
+
(async function () {
|
|
164
|
+
var c = #{config};
|
|
165
|
+
try {
|
|
166
|
+
window.ZevAuthUI.init({ publishableKey: c.publishableKey, apiUrl: c.apiUrl || undefined, sessionBridge: { url: c.bridge } });
|
|
167
|
+
var client = window.ZevAuthUI.getClient();
|
|
168
|
+
await client.load();
|
|
169
|
+
await client.syncSessionBridge();
|
|
170
|
+
} catch (e) {}
|
|
171
|
+
location.replace(c.back);
|
|
172
|
+
})();
|
|
173
|
+
</script>
|
|
174
|
+
HTML
|
|
175
|
+
|
|
176
|
+
[200, { "content-type" => "text/html; charset=utf-8", "cache-control" => "no-store", "referrer-policy" => "same-origin" }, [html]]
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
private
|
|
180
|
+
|
|
181
|
+
def escape_attribute(value)
|
|
182
|
+
value.gsub(/[&"<>]/, "&" => "&", '"' => """, "<" => "<", ">" => ">")
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Rack 3 takes several Set-Cookie values as an Array; Rack 2 as one
|
|
187
|
+
# newline-joined String (and Rack 3's linter refuses newlines).
|
|
188
|
+
def self.set_cookie_value(cookies)
|
|
189
|
+
rack3 = defined?(::Rack::RELEASE) && Gem::Version.new(::Rack::RELEASE) >= Gem::Version.new("3")
|
|
190
|
+
rack3 || !defined?(::Rack::RELEASE) ? cookies : cookies.join("\n")
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def self.cookie(name, value, max_age, config)
|
|
194
|
+
parts = ["#{name}=#{value}", "Path=/", "Max-Age=#{max_age}", "HttpOnly", "SameSite=Lax"]
|
|
195
|
+
parts << "Secure" unless config.secure == false
|
|
196
|
+
parts << "Domain=#{config.cookie_domain}" if config.cookie_domain
|
|
197
|
+
parts.join("; ")
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|