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 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.
@@ -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(/[&"<>]/, "&" => "&amp;", '"' => "&quot;", "<" => "&lt;", ">" => "&gt;")
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