cloudflare_access_gate 0.2.1

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: b6ca1922f843d5b954725992cecf9898d6b073f62a8c2f51c189c9c4082aea07
4
+ data.tar.gz: 81850fd90a1bcf4484e0666ed7ef82497f00507554ff61572e52cca93093f45f
5
+ SHA512:
6
+ metadata.gz: 71baf0701840236e6dbaa3a49a18f850426d13eb2da81479963bb620892e82be2dcafb86aedf7e432a8aeac1547702b68c512a05f233d98ed47940273773d543
7
+ data.tar.gz: 0ff41256842e598670d5e6d051abebdddbfffe2935a7be7f5ca7baf8fdf6d00472b39650e24de87123467a9e19cf7fa7cd4167b8cebf27c178cd0ca6633a9e73
data/CHANGELOG.md ADDED
@@ -0,0 +1,155 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.2.1] - 2026-08-09
9
+
10
+ First release published to RubyGems. The library is unchanged from 0.2.0 — that
11
+ version was tagged in the repository but never pushed to rubygems.org, so 0.2.1
12
+ is the first version installable with `gem install cloudflare_access_gate`.
13
+
14
+ ### Added
15
+ - A `Release` workflow that builds the gem and publishes it to RubyGems via
16
+ [trusted publishing](https://guides.rubygems.org/trusted-publishing/) whenever
17
+ a `v*` tag is pushed, so a release needs no long-lived API key on anyone's
18
+ machine. It refuses to publish when the tag and `VERSION` disagree, since a
19
+ yanked version number can never be re-pushed.
20
+
21
+ ## [0.2.0] - 2026-08-05
22
+
23
+ Public-release preparation on top of 0.1.1. Everything 0.1.1 added is kept: the
24
+ `ensure`-logged audit record, `.protect` idempotency, the plain-Ruby blank
25
+ check, and the fail-closed flag specs.
26
+
27
+ ### Security
28
+
29
+ - **Verify the `iss` claim.** Tokens are now checked against
30
+ `https://<team>.cloudflareaccess.com`, so a valid Access JWT minted by a
31
+ *different* Access team is rejected instead of accepted.
32
+ - **Deny when the team domain is unset**, as an explicit fail-closed check
33
+ rather than a side effect of the JWKS fetch failing.
34
+ - **Require `exp`, `iss`, and `aud` to be present.** The `jwt` gem's `verify_*`
35
+ options only check a claim when it exists, so a correctly signed token with the
36
+ right issuer and audience but *no* `exp` was accepted indefinitely. Now passed
37
+ as `required_claims`.
38
+ - **Reject an unusable JWKS.** A `{"keys": []}` response — or one whose entries
39
+ carry no key type, e.g. `{"keys":[{}]}` — is treated as a failed fetch. The
40
+ latter previously cached as good, evicting the last known-good key set and then
41
+ failing every signature check for a full TTL instead of falling back to it.
42
+ - **Synchronize the JwksCache construction.** `JwksCache` guards its own fetches,
43
+ but `Gate` memoized it with an unsynchronized `||=`, so concurrent cold-start
44
+ requests could each build a separate cache and run a separate 6-second fetch —
45
+ defeating the backoff this release added.
46
+
47
+ ### Fixed
48
+
49
+ - **Rack 3 SPEC compliance.** The 403 response used a `Content-Type` header,
50
+ which Rack 3 rejects outright (`Rack::Lint::LintError: uppercase character in
51
+ header name`). Header names are now lowercase, valid on both Rack 2 and Rack 3.
52
+ The response also carries an explicit `content-length`, and its headers hash is
53
+ a fresh mutable hash per response, so downstream middleware can add to it.
54
+ `spec/rack_compliance_spec.rb` runs every response through `Rack::Lint`, and CI
55
+ exercises Rack 2.2 and 3.x across the whole Ruby matrix.
56
+
57
+ ### Added
58
+
59
+ - Clock-drift leeway of 60s for `exp`/`nbf` (configurable via `leeway:`),
60
+ matching the tolerance Cloudflare itself applies. Fixes spurious 403s from
61
+ minor clock skew. (`iat` is deliberately not covered: ruby-jwt leaves
62
+ `verify_iat` off and its `iat` check does not consume this leeway.)
63
+ - `CloudflareAccessGate::JwksCache`, extracted from `Gate`, with two new
64
+ availability behaviors:
65
+ - a 30-second backoff after a failed fetch, so a JWKS outage no longer queues
66
+ every request behind its own (up to 6s) HTTP timeout while holding the cache
67
+ mutex;
68
+ - a staleness grace window (`jwks_stale_grace:`, default 300s) during which a
69
+ previously-good key set is reused if a refresh fails. Set to `0` for strict
70
+ behavior.
71
+ - `CloudflareAccessGate::StructuredLogger` and `CloudflareAccessGate.logger`, so
72
+ any logger works: SemanticLogger gets native structured payloads and named
73
+ tags, and everything else gets the payload appended as `key=value` pairs.
74
+ - `logger:` option on both middlewares; `message:` and `tag_key:` on
75
+ `AuditLogger`; `team_domain:`/`leeway:`/`jwks_stale_grace:` on `Gate`.
76
+ - `.protect` forwards extra keyword arguments to `Gate`.
77
+ - `spec/no_rails_spec.rb`, which runs the middlewares in a subprocess where
78
+ requiring Rails, ActiveSupport, or SemanticLogger raises — the 0.1.1 blank-check
79
+ fix is now guarded against regression rather than asserted in prose.
80
+ - `SECURITY.md` with a private vulnerability reporting path, and
81
+ `CONTRIBUTING.md`.
82
+ - CI pins `ruby/setup-ruby` to a commit SHA rather than the mutable `v1` tag,
83
+ clearing the CodeQL unpinned-third-party-action finding.
84
+
85
+ ### Changed (breaking)
86
+
87
+ - **Gem renamed** from `cloudflare-access-gate` to `cloudflare_access_gate`, so
88
+ `require:` no longer has to be spelled out in a `Gemfile`. The library name and
89
+ `CloudflareAccessGate` namespace are unchanged.
90
+ - **`semantic_logger` is no longer a runtime dependency** — it is used when the
91
+ host app has it loaded and ignored otherwise, which supersedes 0.1.1's
92
+ `>= 4, < 6` runtime bound (that bound now applies to the development
93
+ dependency). `jwt` is the only runtime dependency.
94
+ - **`required_ruby_version` lowered back to `>= 3.1`** from 0.1.1's `>= 3.4`. A
95
+ public gem shouldn't demand the newest Ruby, and CI now proves 3.1 works by
96
+ testing the full 3.1-3.4 range instead of 3.4 alone.
97
+ - Removed the `allowed_push_host` gemspec pin to a private registry.
98
+ - Internal application names, ticket IDs, and the Access team slug are out of the
99
+ README, CHANGELOG, gemspec, and specs ahead of the repository going public.
100
+
101
+ ## [0.1.1] - 2026-07-24
102
+
103
+ ### Fixed
104
+ - `Gate` no longer depends on ActiveSupport: the `.blank?` calls on the token,
105
+ audience, and team domain are replaced with a plain-Ruby check, so the
106
+ advertised "standalone / plain Rack, no Rails dependency" claim actually holds
107
+ for a non-Rails consumer (previously raised `NoMethodError`).
108
+ - `AuditLogger` now emits the access line in an `ensure` block, so a request
109
+ that raises (e.g. Redis down mid-POST) is still audited (status best-effort
110
+ `nil`) before the error propagates — previously the record vanished exactly
111
+ when it mattered.
112
+ - `.protect` is now idempotent: repeated calls for the same app (including
113
+ a development route-reload) no longer re-append the class-level middleware
114
+ stack. Returns the app.
115
+
116
+ ### Changed
117
+ - `semantic_logger` dependency is bounded (`>= 4, < 6`) so a future major can't
118
+ break consumers whose lockfile has resolved past the pinned revision.
119
+ - `required_ruby_version` raised to `>= 3.4` to match what CI actually tests
120
+ (the RuboCop `TargetRubyVersion` and CI matrix are aligned at 3.4).
121
+
122
+ ### Added
123
+ - Specs for the fail-closed flag semantics (non-`'false'` and unset both stay
124
+ enabled), the team-domain-blank fail-closed path, the 403 response body /
125
+ `Content-Type`, `.protect` idempotency + return value, and `ensure`-logging on
126
+ a raising inner app.
127
+
128
+ ### Docs
129
+ - Corrected the provenance: one consumer held the vendored copy; the other two
130
+ migrated from HTTP Basic Auth / a Devise session.
131
+ - README: `github:` install shorthand, a per-app `session_key` example,
132
+ `CLOUDFLARE_ACCESS_SIDEKIQ_AUD` in the ENV table, a
133
+ local-dev (`ENABLE_CLOUDFLARE_GATE=false`) note, a `session_key` rationale, a
134
+ Releasing section, and both wiring placements (initializer or inline routes).
135
+
136
+ ## [0.1.0] - 2026-07-24
137
+
138
+ ### Added
139
+ - Initial extraction of the Cloudflare Access Sidekiq gate middleware
140
+ consolidated from three internal Rails applications.
141
+ - `CloudflareAccessGate::Gate` — Rack middleware that validates the
142
+ `Cf-Access-Jwt-Assertion` header against the Cloudflare Access team JWKS and a
143
+ configured audience. Fails closed: gate + JWT validation stay on unless a flag
144
+ is explicitly set to `'false'`, and a blank audience or unavailable JWKS
145
+ yields a 403. Hardened with JWKS HTTP timeouts, TLS peer verification, a
146
+ mutex-guarded JWKS cache, and denial logging (path + user).
147
+ - `CloudflareAccessGate::AuditLogger` — Rack middleware that logs
148
+ method/path/user/status for every dashboard request via SemanticLogger.
149
+ - `CloudflareAccessGate.protect(app, audience:, session_key:)` — installer
150
+ helper that wires Gate, AuditLogger, `ActionDispatch::Cookies`, and
151
+ `ActionDispatch::Session::CookieStore` onto a Rack app in the canonical order.
152
+
153
+ ### Notes
154
+ - The `BLOCKED_METHODS` HTTP-method blocklist present in earlier copies was
155
+ intentionally dropped and is not carried by this gem.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Homebot, 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
13
+ all 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
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,280 @@
1
+ # cloudflare_access_gate
2
+
3
+ Rack middleware that gates a Sidekiq (or any Rack) dashboard behind
4
+ [Cloudflare Access](https://developers.cloudflare.com/cloudflare-one/policies/access/),
5
+ plus an audit logger for every request that gets through.
6
+
7
+ It **fails closed** and has exactly one runtime dependency
8
+ ([`jwt`](https://github.com/jwt/ruby-jwt)) — no Rails, no ActiveSupport, and no
9
+ logging library required. It works on **Rack 2 and Rack 3**, and it doesn't even
10
+ require `rack` itself: it calls no Rack APIs, only the SPEC's `call(env)`
11
+ contract.
12
+
13
+ It ships two plain-Rack middlewares and one Rails installer helper:
14
+
15
+ - `CloudflareAccessGate::Gate` — validates the `Cf-Access-Jwt-Assertion` header
16
+ (injected by the Cloudflare Access application in front of your host) against
17
+ your Access team's JWKS, issuer, and audience. **Fails closed.**
18
+ - `CloudflareAccessGate::AuditLogger` — logs `method` / `path` / `user` /
19
+ `status` for GET, POST, PUT, PATCH, and DELETE dashboard requests, in an
20
+ `ensure` so the record is emitted even if the request raises.
21
+ - `CloudflareAccessGate.protect(app, audience:, session_key:)` — wires both
22
+ middlewares plus the ActionDispatch cookie/session store onto a Rack app in
23
+ the canonical order (Rails hosts only). Idempotent.
24
+
25
+ ## Installation
26
+
27
+ ```ruby
28
+ gem 'cloudflare_access_gate'
29
+ ```
30
+
31
+ Or straight from git, pinned to a tag:
32
+
33
+ ```ruby
34
+ gem 'cloudflare_access_gate', github: 'homebotapp/cloudflare_access_gate',
35
+ tag: 'v0.2.1'
36
+ ```
37
+
38
+ ## Usage
39
+
40
+ ### Rails + Sidekiq
41
+
42
+ Mount `Sidekiq::Web` **outside** your authenticated routes and register the gate
43
+ on it. Two placements are both valid — pick whichever your app already uses.
44
+
45
+ **A. Initializer** (e.g. `config/initializers/sidekiq_web.rb`), wired in an
46
+ `after_initialize` hook so the autoloader/eager-load have finished:
47
+
48
+ ```ruby
49
+ require 'sidekiq/web'
50
+
51
+ Rails.application.config.after_initialize do
52
+ CloudflareAccessGate.protect(
53
+ Sidekiq::Web,
54
+ audience: ENV.fetch('CLOUDFLARE_ACCESS_SIDEKIQ_AUD', nil),
55
+ session_key: '_myapp_sidekiq_session'
56
+ )
57
+ end
58
+ ```
59
+
60
+ **B. Inline in `config/routes.rb`**, immediately before you `mount Sidekiq::Web`:
61
+
62
+ ```ruby
63
+ CloudflareAccessGate.protect(
64
+ Sidekiq::Web,
65
+ audience: ENV.fetch('CLOUDFLARE_ACCESS_SIDEKIQ_AUD', nil),
66
+ session_key: '_myapp_sidekiq_session'
67
+ )
68
+ mount Sidekiq::Web => '/sidekiq'
69
+ ```
70
+
71
+ `.protect` installs, in order:
72
+
73
+ 1. `CloudflareAccessGate::Gate` (with the given `audience`)
74
+ 2. `CloudflareAccessGate::AuditLogger`
75
+ 3. `ActionDispatch::Cookies`
76
+ 4. `ActionDispatch::Session::CookieStore` (keyed by `session_key`)
77
+
78
+ It is **idempotent** per app: `Sidekiq::Web`'s middleware stack is class-level,
79
+ so calling `.protect` twice — or on a development route-reload — would otherwise
80
+ re-append the whole stack. Repeated calls for the same app are a no-op, and the
81
+ app is returned either way.
82
+
83
+ Extra keyword arguments are forwarded to `Gate`, so
84
+ `CloudflareAccessGate.protect(app, audience: …, session_key: …, jwks_stale_grace: 0)`
85
+ works.
86
+
87
+ `ActionDispatch` must already be loaded, so `.protect` is for Rails hosts.
88
+
89
+ ### `session_key`
90
+
91
+ Pass a **unique** `session_key` per app so the Sidekiq dashboard's session
92
+ cookie doesn't collide with another app's cookie when several dashboards share a
93
+ parent domain.
94
+
95
+ The cookie/session pair isn't for the gate — the gate is stateless, since
96
+ Cloudflare Access appends `Cf-Access-Jwt-Assertion` to every proxied request.
97
+ It's for Sidekiq's own CSRF protection, which hard-fails any non-safe request
98
+ without a `rack.session`. An `api_only` Rails app has no Cookies/Session
99
+ middleware of its own, so without this pair every dashboard POST (retry, kill,
100
+ queue pause/delete) would raise.
101
+
102
+ ### Any other Rack app
103
+
104
+ `Gate` and `AuditLogger` are plain Rack — install them directly:
105
+
106
+ ```ruby
107
+ use CloudflareAccessGate::Gate, audience: ENV['CLOUDFLARE_ACCESS_AUD']
108
+ use CloudflareAccessGate::AuditLogger
109
+ ```
110
+
111
+ ## Configuration
112
+
113
+ ### ENV contract
114
+
115
+ | Variable | Default | Effect |
116
+ | --- | --- | --- |
117
+ | `ENABLE_CLOUDFLARE_GATE` | `true` | Set to exactly `'false'` to disable the gate entirely. Any other value — including `'0'` — or unset keeps it **on**. |
118
+ | `ENABLE_CLOUDFLARE_JWT_VALIDATION` | `true` | Set to exactly `'false'` to skip signature/issuer/audience/expiry validation (header-presence check only). Any other value keeps validation **on**. |
119
+ | `CLOUDFLARE_ACCESS_TEAM_DOMAIN` | _(unset)_ | Your Access team slug, used to derive the issuer `https://<team>.cloudflareaccess.com` and fetch its `/cdn-cgi/access/certs`. If blank while validation is on, requests are denied. |
120
+ | `CLOUDFLARE_ACCESS_SIDEKIQ_AUD` | _(unset)_ | Conventional name for the Access application AUD tag. **Not read by the gem** — your app reads it and passes it as `audience:`. |
121
+
122
+ ### Gate options
123
+
124
+ | Option | Default | Meaning |
125
+ | --- | --- | --- |
126
+ | `audience:` | _(none)_ | The Access application AUD tag. A blank audience denies every request rather than skipping audience verification. |
127
+ | `team_domain:` | `ENV['CLOUDFLARE_ACCESS_TEAM_DOMAIN']` | Access team slug. |
128
+ | `leeway:` | `60` | Clock-drift allowance in seconds for `exp`/`nbf`, matching the 60s Cloudflare itself allows. |
129
+ | `jwks_stale_grace:` | `300` | How long a previously-good JWKS may still be used when a refresh fails. `0` denies as soon as the cache expires. |
130
+ | `logger:` | _(global)_ | Per-instance logger override. |
131
+
132
+ ### AuditLogger options
133
+
134
+ | Option | Default | Meaning |
135
+ | --- | --- | --- |
136
+ | `message:` | `'Sidekiq dashboard access'` | Log message for each audited request. |
137
+ | `tag_key:` | `:sidekiq_user` | SemanticLogger named-tag key for the authenticated user. |
138
+ | `logger:` | _(global)_ | Per-instance logger override. |
139
+
140
+ ### Logging
141
+
142
+ Both middlewares log through `CloudflareAccessGate::StructuredLogger`, which
143
+ takes any logger you give it:
144
+
145
+ ```ruby
146
+ CloudflareAccessGate.logger = Rails.logger
147
+ ```
148
+
149
+ If you don't set one, [SemanticLogger](https://github.com/reidmorrison/semantic_logger)
150
+ is used when your app already has it loaded (giving per-class named loggers,
151
+ structured payloads, and a `sidekiq_user` named tag around each request), and a
152
+ stdlib `Logger.new($stdout)` otherwise. For non-SemanticLogger loggers the
153
+ structured payload is appended to the message as `key=value` pairs rather than
154
+ dropped, and tagging is skipped.
155
+
156
+ ### Local development
157
+
158
+ There is no Cloudflare Access in front of your local dashboard, so no
159
+ `Cf-Access-Jwt-Assertion` header is present and the fail-closed gate returns
160
+ **403** for every `/sidekiq` request. To use the dashboard locally, set:
161
+
162
+ ```sh
163
+ ENABLE_CLOUDFLARE_GATE=false
164
+ ```
165
+
166
+ (Only the literal string `false` disables it.)
167
+
168
+ ## Security behavior
169
+
170
+ ### Fail-closed
171
+
172
+ - Missing/blank `Cf-Access-Jwt-Assertion` header → **403**.
173
+ - JWT validation on + blank audience → **403** (never verifies "no audience").
174
+ - JWT validation on + blank team domain → **403** (no issuer to verify against).
175
+ - JWKS endpoint unavailable, non-JSON, or containing no usable keys → **403**.
176
+ - Invalid signature, wrong issuer, wrong audience, expired token, or algorithm
177
+ confusion (e.g. HS256 with the public key) → **403**.
178
+ - A token **missing** `exp`, `iss`, or `aud` → **403**. The `jwt` gem's
179
+ `verify_*` options only check a claim that is present, so these are required
180
+ explicitly; otherwise a correctly signed token with no `exp` would never
181
+ expire.
182
+ - Flags are strict: only the literal string `'false'` disables the gate / JWT
183
+ validation. Any other value — including `'0'` or an unset var — leaves them on.
184
+
185
+ ### Hardening
186
+
187
+ - JWKS fetches use short open/read timeouts and `OpenSSL::SSL::VERIFY_PEER`.
188
+ - The JWKS cache is mutex-guarded and refreshed after a 10-minute TTL.
189
+ - A failed JWKS fetch starts a 30-second backoff, so a JWKS outage can't queue
190
+ every request behind its own HTTP timeout.
191
+ - Denials are logged with the request path and the Cloudflare-authenticated user.
192
+
193
+ ### The staleness trade-off
194
+
195
+ If a JWKS refresh fails, the last known-good key set is reused for up to
196
+ `jwks_stale_grace` seconds (default 300) past the cache TTL. Access signing keys
197
+ rotate on the order of weeks, so a few minutes of staleness is a much smaller
198
+ risk than locking every operator out of the dashboard during a transient
199
+ failure. If you would rather deny than serve stale keys, set
200
+ `jwks_stale_grace: 0`.
201
+
202
+ ### What this middleware does *not* do
203
+
204
+ It verifies that a request carries a valid Access JWT for **your** application.
205
+ It does not implement per-user authorization — which identities may reach the
206
+ app is decided by your Cloudflare Access policy, not by this gem. It is also not
207
+ a substitute for keeping the origin unreachable except through Cloudflare; an
208
+ attacker who can reach your origin directly bypasses Access entirely, so pair
209
+ this with origin locking (Tunnel, mTLS, or IP allowlisting).
210
+
211
+ To report a vulnerability, see [SECURITY.md](SECURITY.md).
212
+
213
+ ## Rack compatibility
214
+
215
+ Supported on **Rack 2.x and Rack 3.x**, and on plain Rack-compatible servers with
216
+ no `rack` gem loaded at all — the middlewares implement `call(env)` and use
217
+ nothing from Rack's API.
218
+
219
+ Responses use **lowercase header names** (`content-type`, not `Content-Type`).
220
+ Rack 3 requires this — an uppercase header name is a `Rack::Lint::LintError`
221
+ there — and Rack 2 accepts it, since HTTP header names are case-insensitive. The
222
+ 403 also carries an explicit `content-length`, and its headers hash is a fresh
223
+ mutable hash per response so downstream middleware can add to it.
224
+
225
+ `spec/rack_compliance_spec.rb` runs every response this gem produces through
226
+ `Rack::Lint`, the SPEC in executable form. CI runs it against both major Rack
227
+ versions across Ruby 3.1–3.4.
228
+
229
+ ## Development
230
+
231
+ ```sh
232
+ bundle install
233
+ bundle exec rspec # test suite
234
+ bundle exec rubocop # lint
235
+ bundle exec rake # both (default task)
236
+ ```
237
+
238
+ To test against a specific Rack major version, as CI does:
239
+
240
+ ```sh
241
+ RACK_VERSION=2.2 bundle install && RACK_VERSION=2.2 bundle exec rspec
242
+ RACK_VERSION=3 bundle install && RACK_VERSION=3 bundle exec rspec
243
+ ```
244
+
245
+ `spec/no_rails_spec.rb` runs the middlewares in a subprocess where requiring
246
+ Rails, ActiveSupport, or SemanticLogger raises, which is what keeps the
247
+ "no Rails dependency" claim honest. CI runs rspec on Ruby 3.1–3.4 against Rack
248
+ 2.2 and 3.x, plus rubocop.
249
+
250
+ Contributions welcome — see [CONTRIBUTING.md](CONTRIBUTING.md).
251
+
252
+ ## Releasing
253
+
254
+ `.github/workflows/release.yml` publishes to RubyGems over
255
+ [trusted publishing](https://guides.rubygems.org/trusted-publishing/), so no API
256
+ key is stored in the repository or on a maintainer's machine.
257
+
258
+ 1. Open a PR bumping `CloudflareAccessGate::VERSION` in
259
+ `lib/cloudflare_access_gate/version.rb`, updating the version assertion in
260
+ `spec/cloudflare_access_gate_spec.rb`, and adding a `CHANGELOG.md` entry.
261
+ `main` is protected, so this can't be pushed directly.
262
+ 2. Merge it once CI is green.
263
+ 3. Create and push an annotated tag on the merge commit:
264
+ `git tag -a vX.Y.Z -m "…" && git push origin vX.Y.Z`.
265
+ 4. The Release workflow verifies the tag matches `VERSION`, builds the gem,
266
+ pushes it to RubyGems, and creates the GitHub Release. A mismatch fails the
267
+ job before anything is published — a yanked version number can never be
268
+ reused.
269
+ 5. For consumers pinned by git ref rather than the published gem, update the
270
+ `tag:` in their `Gemfile` and re-resolve:
271
+ `bundle update cloudflare_access_gate`.
272
+
273
+ Don't run `rake release` yourself: it pushes the release commit and tag straight
274
+ to `main`, which branch protection rejects. The workflow does invoke it, but
275
+ because the tag already exists when it runs, Bundler skips its source-control
276
+ push and goes straight to publishing.
277
+
278
+ ## License
279
+
280
+ [MIT](LICENSE.txt) © Homebot, Inc.
data/SECURITY.md ADDED
@@ -0,0 +1,43 @@
1
+ # Security Policy
2
+
3
+ ## Supported versions
4
+
5
+ Security fixes are released for the latest minor version. Until 1.0, that means
6
+ the most recent `0.x` release.
7
+
8
+ ## Reporting a vulnerability
9
+
10
+ **Please do not open a public GitHub issue for a security vulnerability.**
11
+
12
+ Report it privately through either channel:
13
+
14
+ - GitHub's [private vulnerability reporting](https://github.com/homebotapp/cloudflare_access_gate/security/advisories/new)
15
+ (Security → Report a vulnerability), or
16
+ - email **engineering@homebot.ai** with `cloudflare_access_gate` in the subject.
17
+
18
+ Please include the affected version, a description of the impact, and enough
19
+ detail to reproduce — a failing spec or a minimal Rack app is ideal.
20
+
21
+ We aim to acknowledge a report within 3 business days and to ship a fix or share
22
+ a mitigation timeline within 30 days. We'll credit you in the advisory and
23
+ CHANGELOG unless you'd rather stay anonymous.
24
+
25
+ ## Scope
26
+
27
+ In scope — anything that lets a request reach the protected app without a valid
28
+ Cloudflare Access JWT for the configured audience, for example:
29
+
30
+ - accepting a token with a wrong or absent signature, issuer, audience, or expiry
31
+ - a configuration or failure mode that opens the gate instead of closing it
32
+ - cache poisoning of the JWKS, or accepting keys from another Access team
33
+
34
+ Out of scope:
35
+
36
+ - Cloudflare Access policy misconfiguration (which identities are allowed is
37
+ decided in your Access policy, not by this gem)
38
+ - an origin reachable without going through Cloudflare — see "What this
39
+ middleware does *not* do" in the README
40
+ - setting `ENABLE_CLOUDFLARE_GATE=false` or `ENABLE_CLOUDFLARE_JWT_VALIDATION=false`,
41
+ which are documented, deliberate off switches
42
+ - vulnerabilities in the `jwt` gem itself (report those to
43
+ [ruby-jwt](https://github.com/jwt/ruby-jwt/security))
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CloudflareAccessGate
4
+ # Rack middleware that audit-logs every request to the gated dashboard,
5
+ # recording the HTTP method, path, Cloudflare-authenticated user email, and
6
+ # response status. Install it behind Gate so only authorized requests are
7
+ # logged.
8
+ #
9
+ # Like Gate, this is plain Rack: no Rails, ActiveSupport, or logging library is
10
+ # required at runtime.
11
+ class AuditLogger
12
+ LOGGED_METHODS = %w[GET POST PUT PATCH DELETE].freeze
13
+
14
+ DEFAULT_MESSAGE = 'Sidekiq dashboard access'
15
+ DEFAULT_TAG_KEY = :sidekiq_user
16
+
17
+ # Options:
18
+ # logger: per-instance logger override
19
+ # message: log message for each audited request
20
+ # tag_key: SemanticLogger named-tag key for the authenticated user
21
+ def initialize(app, options = {})
22
+ @app = app
23
+ @logger = options[:logger] && StructuredLogger.wrap(options[:logger])
24
+ @message = options.fetch(:message, DEFAULT_MESSAGE)
25
+ @tag_key = options.fetch(:tag_key, DEFAULT_TAG_KEY)
26
+ end
27
+
28
+ def call(env)
29
+ request_method = env['REQUEST_METHOD']
30
+
31
+ return @app.call(env) unless LOGGED_METHODS.include?(request_method)
32
+
33
+ email = env['HTTP_CF_ACCESS_AUTHENTICATED_USER_EMAIL'] || 'unknown'
34
+ status = nil
35
+
36
+ # Logged in an `ensure` so a raising request is still audited.
37
+ logger.tagged(@tag_key => email) do
38
+ response = @app.call(env)
39
+ status = response.first
40
+ response
41
+ ensure
42
+ logger.info(
43
+ @message,
44
+ method: request_method,
45
+ path: env['PATH_INFO'],
46
+ user: email,
47
+ status: status
48
+ )
49
+ end
50
+ end
51
+
52
+ private
53
+
54
+ def logger
55
+ @logger ||= CloudflareAccessGate.logger_for('CloudflareAccessGate::AuditLogger')
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'jwt'
4
+
5
+ module CloudflareAccessGate
6
+ # Rack middleware that gates a dashboard (typically Sidekiq::Web) behind
7
+ # Cloudflare Access. It reads the `Cf-Access-Jwt-Assertion` header injected by
8
+ # the Cloudflare Access application in front of the host and, when JWT
9
+ # validation is enabled, verifies the token's signature (against the team's
10
+ # JWKS), issuer, audience, and expiry before letting the request through.
11
+ #
12
+ # Everything is fail-closed: the gate stays on and rejects requests unless a
13
+ # flag is explicitly set to the string 'false', and a missing audience, missing
14
+ # team domain, or unavailable JWKS results in a 403 rather than an open door.
15
+ #
16
+ # ENV contract:
17
+ # ENABLE_CLOUDFLARE_GATE - 'false' disables the gate entirely
18
+ # ENABLE_CLOUDFLARE_JWT_VALIDATION - 'false' skips signature/iss/aud checks
19
+ # CLOUDFLARE_ACCESS_TEAM_DOMAIN - your Cloudflare Access team slug
20
+ # The audience is passed as a constructor option, not read from ENV.
21
+ #
22
+ # This class is plain Rack: no Rails, ActiveSupport, or logging library is
23
+ # required at runtime.
24
+ class Gate
25
+ # Clock drift allowance for exp/nbf, matching the 60s Cloudflare itself
26
+ # allows when validating tokens. (`iat` is not verified: ruby-jwt leaves
27
+ # verify_iat off by default and its iat check ignores this leeway.)
28
+ DEFAULT_LEEWAY = 60 # seconds
29
+
30
+ # `verify_*` options only check a claim when it is present, so a correctly
31
+ # signed token that simply omits `exp` would otherwise never expire.
32
+ REQUIRED_CLAIMS = %w[exp iss aud].freeze
33
+
34
+ FORBIDDEN_BODY = 'Forbidden'
35
+
36
+ # Header names are lowercase because the Rack 3 SPEC requires it ("uppercase
37
+ # character in header name" is a Rack::Lint error there). Rack 2 accepts
38
+ # lowercase too — HTTP header names are case-insensitive — so one spelling
39
+ # is correct on both. `content-length` is set explicitly so the response is
40
+ # complete for servers that don't add it.
41
+ FORBIDDEN_HEADERS = {
42
+ 'content-type' => 'text/plain',
43
+ 'content-length' => FORBIDDEN_BODY.bytesize.to_s
44
+ }.freeze
45
+
46
+ attr_reader :audience, :team_domain
47
+
48
+ # Options:
49
+ # audience: (required in practice) the Access application AUD tag
50
+ # team_domain: Access team slug; defaults to ENV
51
+ # leeway: clock drift allowance in seconds
52
+ # jwks_stale_grace: seconds a stale JWKS may be reused on refresh failure
53
+ # logger: per-instance logger override
54
+ # clock: callable returning monotonic seconds (for tests)
55
+ def initialize(app, options = {})
56
+ @app = app
57
+ @audience = options[:audience]
58
+ @team_domain = options.fetch(:team_domain) { ENV.fetch('CLOUDFLARE_ACCESS_TEAM_DOMAIN', nil) }
59
+ @leeway = options.fetch(:leeway, DEFAULT_LEEWAY)
60
+ @logger = options[:logger] && StructuredLogger.wrap(options[:logger])
61
+ @jwks_cache_mutex = Mutex.new
62
+ @jwks_options = {
63
+ stale_grace: options.fetch(:jwks_stale_grace, JwksCache::DEFAULT_STALE_GRACE),
64
+ clock: options.fetch(:clock, JwksCache::MONOTONIC_CLOCK)
65
+ }
66
+ end
67
+
68
+ def call(env)
69
+ return @app.call(env) unless self.class.enabled?
70
+
71
+ token = env['HTTP_CF_ACCESS_JWT_ASSERTION']
72
+ return forbidden('Missing access token', env) if blank?(token)
73
+
74
+ return forbidden('Invalid access token', env) unless token_acceptable?(token)
75
+
76
+ @app.call(env)
77
+ end
78
+
79
+ # Fail closed: the gate stays ON unless explicitly disabled with 'false'.
80
+ def self.enabled?
81
+ ENV.fetch('ENABLE_CLOUDFLARE_GATE', 'true') != 'false'
82
+ end
83
+
84
+ # Fail closed: JWT validation stays ON unless explicitly disabled with 'false'.
85
+ def self.jwt_validation_enabled?
86
+ ENV.fetch('ENABLE_CLOUDFLARE_JWT_VALIDATION', 'true') != 'false'
87
+ end
88
+
89
+ private
90
+
91
+ # When JWT validation is switched off, the presence of the header (already
92
+ # checked by the caller) is all that is required.
93
+ def token_acceptable?(token)
94
+ return true unless self.class.jwt_validation_enabled?
95
+
96
+ !verify_token(token).nil?
97
+ end
98
+
99
+ # Returns the decoded payload, or nil if the token must be rejected.
100
+ def verify_token(token)
101
+ return nil unless verifiable?
102
+
103
+ jwks = jwks_cache.fetch
104
+ return nil unless jwks
105
+
106
+ decode(token, jwks)
107
+ rescue JWT::DecodeError => e
108
+ logger.warn('Cloudflare Access JWT verification failed', error: e.message, error_class: e.class.name)
109
+ nil
110
+ end
111
+
112
+ def decode(token, jwks)
113
+ JWT.decode(
114
+ token,
115
+ nil,
116
+ true,
117
+ algorithms: ['RS256'],
118
+ jwks: jwks,
119
+ aud: audience,
120
+ verify_aud: true,
121
+ iss: issuer,
122
+ verify_iss: true,
123
+ verify_expiration: true,
124
+ required_claims: REQUIRED_CLAIMS,
125
+ leeway: @leeway
126
+ ).first
127
+ end
128
+
129
+ # Both of these must be configured for verification to mean anything, so a
130
+ # blank value denies rather than silently skipping a check.
131
+ def verifiable?
132
+ # A nil/blank audience makes the jwt gem skip aud verification, which would
133
+ # let any valid Cloudflare Access JWT (from any app) through.
134
+ if blank?(audience)
135
+ logger.error('Cloudflare Access JWT not verified: audience not configured')
136
+ return false
137
+ end
138
+
139
+ # The issuer and the JWKS URL are both derived from the team domain.
140
+ if blank?(team_domain)
141
+ logger.error('Cloudflare Access JWT not verified: CLOUDFLARE_ACCESS_TEAM_DOMAIN unset')
142
+ return false
143
+ end
144
+
145
+ true
146
+ end
147
+
148
+ def issuer
149
+ "https://#{team_domain}.cloudflareaccess.com"
150
+ end
151
+
152
+ # Synchronized: JwksCache guards its own fetches, but an unsynchronized
153
+ # `||=` here lets concurrent cold-start requests each build their own cache
154
+ # and each run a separate fetch, which is what the backoff exists to prevent.
155
+ def jwks_cache
156
+ @jwks_cache_mutex.synchronize do
157
+ @jwks_cache ||= JwksCache.new(
158
+ certs_url: "#{issuer}/cdn-cgi/access/certs",
159
+ logger: logger,
160
+ **@jwks_options
161
+ )
162
+ end
163
+ end
164
+
165
+ def forbidden(message, env)
166
+ logger.warn('Cloudflare Access gate denied request',
167
+ reason: message,
168
+ path: env['PATH_INFO'],
169
+ user: env['HTTP_CF_ACCESS_AUTHENTICATED_USER_EMAIL'])
170
+ [403, FORBIDDEN_HEADERS.dup, [FORBIDDEN_BODY]]
171
+ end
172
+
173
+ # Stands in for ActiveSupport's `Object#blank?` so the middleware works in a
174
+ # plain Rack app with no Rails loaded.
175
+ def blank?(value)
176
+ value.nil? || value.to_s.strip.empty?
177
+ end
178
+
179
+ def logger
180
+ @logger ||= CloudflareAccessGate.logger_for('CloudflareAccessGate::Gate')
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'net/http'
5
+ require 'openssl'
6
+
7
+ module CloudflareAccessGate
8
+ # Fetches and caches the Cloudflare Access team's JWKS (public signing keys).
9
+ #
10
+ # `#fetch` returns a usable key set or nil; nil means the caller must deny the
11
+ # request. Three behaviors matter for a gate that has to stay both closed and
12
+ # available:
13
+ #
14
+ # * **TTL** — a good key set is served from memory for CACHE_TTL seconds.
15
+ # * **Error backoff** — fetches happen under a mutex, so without a cooldown
16
+ # every request during a JWKS outage would queue behind its own (up to 6s)
17
+ # HTTP timeout, hanging the dashboard. After a failure, Cloudflare isn't
18
+ # contacted again for ERROR_BACKOFF seconds.
19
+ # * **Staleness grace** — if a refresh fails, the last known-good key set is
20
+ # reused for up to `stale_grace` seconds past the TTL. Access signing keys
21
+ # rotate on the order of weeks, so a few minutes of staleness is a far
22
+ # smaller risk than locking every operator out during a transient failure.
23
+ # Pass `stale_grace: 0` for strict behavior.
24
+ class JwksCache
25
+ CACHE_TTL = 600 # seconds a successfully fetched JWKS is served as fresh
26
+ ERROR_BACKOFF = 30 # seconds to wait after a failed fetch before retrying
27
+ DEFAULT_STALE_GRACE = 300 # seconds a stale JWKS may be reused past the TTL
28
+ HTTP_OPEN_TIMEOUT = 3 # seconds
29
+ HTTP_READ_TIMEOUT = 3 # seconds
30
+
31
+ MONOTONIC_CLOCK = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
32
+
33
+ def initialize(certs_url:, logger:, stale_grace: DEFAULT_STALE_GRACE, clock: MONOTONIC_CLOCK)
34
+ @certs_url = certs_url
35
+ @logger = logger
36
+ @stale_grace = stale_grace
37
+ @clock = clock
38
+
39
+ @jwks = nil
40
+ @fetched_at = nil
41
+ @failed_at = nil
42
+ @mutex = Mutex.new
43
+ end
44
+
45
+ # Returns the JWKS to verify against, or nil if none can be obtained.
46
+ def fetch
47
+ @mutex.synchronize do
48
+ return @jwks if fresh?
49
+ return stale if backing_off?
50
+
51
+ jwks = request
52
+ return remember(jwks) if jwks
53
+
54
+ @failed_at = now
55
+ stale
56
+ end
57
+ end
58
+
59
+ private
60
+
61
+ attr_reader :logger
62
+
63
+ def remember(jwks)
64
+ @jwks = jwks
65
+ @fetched_at = now
66
+ @failed_at = nil
67
+ @jwks
68
+ end
69
+
70
+ def fresh?
71
+ !@jwks.nil? && !@fetched_at.nil? && (now - @fetched_at) < CACHE_TTL
72
+ end
73
+
74
+ # True while the JWKS endpoint is in its post-failure cooldown.
75
+ def backing_off?
76
+ !@failed_at.nil? && (now - @failed_at) < ERROR_BACKOFF
77
+ end
78
+
79
+ # The last known-good JWKS, if it is still inside the staleness grace window.
80
+ def stale
81
+ return nil if @jwks.nil? || @fetched_at.nil?
82
+
83
+ age = now - @fetched_at
84
+ if age > (CACHE_TTL + @stale_grace)
85
+ logger.error('Cloudflare JWKS stale beyond grace period; denying requests', age_seconds: age.round)
86
+ return nil
87
+ end
88
+
89
+ logger.warn('Serving stale Cloudflare JWKS after refresh failure', age_seconds: age.round)
90
+ @jwks
91
+ end
92
+
93
+ # Returns the parsed JWKS on success, nil on any failure.
94
+ def request
95
+ uri = URI(@certs_url)
96
+ response = http_get(uri)
97
+
98
+ unless response.is_a?(Net::HTTPSuccess)
99
+ logger.error('Cloudflare JWKS fetch failed', http_status: response&.code)
100
+ return nil
101
+ end
102
+
103
+ parse(response.body)
104
+ rescue StandardError => e
105
+ logger.error('Failed to fetch Cloudflare JWKS', error: e.message, error_class: e.class.name)
106
+ nil
107
+ end
108
+
109
+ def http_get(uri)
110
+ http = Net::HTTP.new(uri.host, uri.port)
111
+ http.use_ssl = true
112
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
113
+ http.open_timeout = HTTP_OPEN_TIMEOUT
114
+ http.read_timeout = HTTP_READ_TIMEOUT
115
+ http.get(uri.request_uri)
116
+ end
117
+
118
+ def parse(body)
119
+ jwks = JSON.parse(body)
120
+ keys = jwks.is_a?(Hash) ? jwks['keys'] : nil
121
+
122
+ # A key set that is empty — or whose entries carry no key type — would be
123
+ # cached as good, discarding the last known-good set and then failing every
124
+ # signature check for a full TTL. Treat it as a failed fetch instead, so the
125
+ # staleness grace window can do its job.
126
+ unless keys.is_a?(Array) && keys.any? { |key| usable_key?(key) }
127
+ logger.error('Cloudflare JWKS response contained no usable keys')
128
+ return nil
129
+ end
130
+
131
+ jwks
132
+ rescue JSON::ParserError => e
133
+ logger.error('Cloudflare JWKS response was not valid JSON', error: e.message)
134
+ nil
135
+ end
136
+
137
+ # Minimum bar for a JWKS entry the jwt gem could actually verify against.
138
+ def usable_key?(key)
139
+ key.is_a?(Hash) && !key['kty'].to_s.strip.empty?
140
+ end
141
+
142
+ def now
143
+ @clock.call
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger'
4
+
5
+ module CloudflareAccessGate
6
+ # Thin adapter that lets this gem emit structured key/value log payloads
7
+ # without depending on any particular logging library.
8
+ #
9
+ # SemanticLogger accepts a payload natively (`logger.warn('msg', key: 'v')`);
10
+ # the stdlib `Logger`, `Rails.logger`, lograge, and friends do not. This
11
+ # wrapper detects a SemanticLogger target and passes the payload through,
12
+ # and otherwise appends a `key=value` suffix to the message so no log data is
13
+ # lost.
14
+ #
15
+ # Wrap any logger-ish object: it only needs to respond to the standard
16
+ # `debug`/`info`/`warn`/`error` severity methods.
17
+ class StructuredLogger
18
+ LEVELS = %i[debug info warn error].freeze
19
+
20
+ attr_reader :target
21
+
22
+ # Wraps `logger` unless it is already wrapped.
23
+ def self.wrap(logger)
24
+ logger.is_a?(self) ? logger : new(logger)
25
+ end
26
+
27
+ def initialize(target)
28
+ @target = target
29
+ end
30
+
31
+ LEVELS.each do |level|
32
+ define_method(level) do |message, **payload|
33
+ emit(level, message, payload)
34
+ end
35
+ end
36
+
37
+ # Attaches `tags` to every log line emitted inside the block.
38
+ #
39
+ # Only SemanticLogger supports this; for every other logger the block is
40
+ # simply yielded (the same key/values are still emitted in the payload of
41
+ # the log line itself, so nothing is silently dropped).
42
+ def tagged(**tags, &block)
43
+ return block.call if tags.empty? || !structured?
44
+
45
+ target.tagged(**tags, &block)
46
+ end
47
+
48
+ private
49
+
50
+ def emit(level, message, payload)
51
+ if structured?
52
+ target.public_send(level, message, payload)
53
+ else
54
+ target.public_send(level, merged_message(message, payload))
55
+ end
56
+ end
57
+
58
+ def structured?
59
+ defined?(::SemanticLogger::Base) && target.is_a?(::SemanticLogger::Base)
60
+ end
61
+
62
+ def merged_message(message, payload)
63
+ return message if payload.empty?
64
+
65
+ "#{message} (#{payload.map { |key, value| "#{key}=#{value.inspect}" }.join(' ')})"
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CloudflareAccessGate
4
+ VERSION = '0.2.1'
5
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cloudflare_access_gate/version'
4
+ require 'cloudflare_access_gate/structured_logger'
5
+ require 'cloudflare_access_gate/jwks_cache'
6
+ require 'cloudflare_access_gate/gate'
7
+ require 'cloudflare_access_gate/audit_logger'
8
+
9
+ # Rack middleware that gates a dashboard (typically Sidekiq::Web) behind
10
+ # Cloudflare Access, plus an audit logger for the requests that get through.
11
+ # See the README for the ENV contract and security notes.
12
+ module CloudflareAccessGate
13
+ @installed = {}
14
+ @install_mutex = Mutex.new
15
+
16
+ class << self
17
+ # Logger used by both middlewares. Assign anything that responds to
18
+ # `debug`/`info`/`warn`/`error` — SemanticLogger, a stdlib `Logger`,
19
+ # `Rails.logger`, and so on:
20
+ #
21
+ # CloudflareAccessGate.logger = Rails.logger
22
+ #
23
+ # When left unset (`nil`), SemanticLogger is used if the host app has it
24
+ # loaded (giving per-class named loggers and tagged output), and a stdlib
25
+ # `Logger.new($stdout)` otherwise.
26
+ attr_accessor :logger
27
+
28
+ # Returns the logger to use for `name`, wrapped so structured payloads work
29
+ # regardless of the underlying logging library.
30
+ def logger_for(name)
31
+ StructuredLogger.wrap(@logger || default_logger(name))
32
+ end
33
+
34
+ private
35
+
36
+ def default_logger(name)
37
+ return ::SemanticLogger[name] if defined?(::SemanticLogger)
38
+
39
+ stdout_logger
40
+ end
41
+
42
+ def stdout_logger
43
+ @stdout_logger ||= ::Logger.new($stdout)
44
+ end
45
+ end
46
+
47
+ # Wire the full Cloudflare Access protection stack onto a Rack app in the
48
+ # canonical order. Typically called on Sidekiq::Web from an initializer:
49
+ #
50
+ # CloudflareAccessGate.protect(
51
+ # Sidekiq::Web,
52
+ # audience: ENV['CLOUDFLARE_ACCESS_SIDEKIQ_AUD'],
53
+ # session_key: '_myapp_sidekiq_session'
54
+ # )
55
+ #
56
+ # This installs, in order:
57
+ # 1. Gate - rejects requests without a valid Cf-Access JWT
58
+ # 2. AuditLogger - logs who accessed the dashboard
59
+ # 3. ActionDispatch::Cookies
60
+ # 4. ActionDispatch::Session::CookieStore(key: session_key)
61
+ #
62
+ # ActionDispatch (from Rails/actionpack) must be loaded before this is
63
+ # called, so `.protect` is for Rails hosts only. Gate and AuditLogger
64
+ # themselves are plain Rack and carry no Rails dependency — outside Rails,
65
+ # install them directly with `app.use`.
66
+ #
67
+ # Extra keyword arguments are forwarded to Gate (e.g. `team_domain:`,
68
+ # `jwks_stale_grace:`).
69
+ #
70
+ # Idempotent: the middleware stack is class-level, so re-calling would
71
+ # otherwise re-append it. Returns the app.
72
+ def self.protect(app, audience:, session_key:, **gate_options)
73
+ @install_mutex.synchronize do
74
+ return app if @installed[app]
75
+
76
+ app.use Gate, **gate_options, audience: audience
77
+ app.use AuditLogger
78
+ app.use ActionDispatch::Cookies
79
+ app.use ActionDispatch::Session::CookieStore, key: session_key
80
+
81
+ @installed[app] = true
82
+ end
83
+
84
+ app
85
+ end
86
+ end
metadata ADDED
@@ -0,0 +1,190 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: cloudflare_access_gate
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.1
5
+ platform: ruby
6
+ authors:
7
+ - Homebot Infrastructure
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: jwt
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '2'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '2'
26
+ - !ruby/object:Gem::Dependency
27
+ name: actionpack
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '6.0'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '6.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: rack
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '2.0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '2.0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: rake
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '13.0'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '13.0'
68
+ - !ruby/object:Gem::Dependency
69
+ name: rspec
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '3.0'
75
+ type: :development
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '3.0'
82
+ - !ruby/object:Gem::Dependency
83
+ name: rubocop
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '1.0'
89
+ type: :development
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '1.0'
96
+ - !ruby/object:Gem::Dependency
97
+ name: semantic_logger
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - ">="
101
+ - !ruby/object:Gem::Version
102
+ version: '4'
103
+ - - "<"
104
+ - !ruby/object:Gem::Version
105
+ version: '6'
106
+ type: :development
107
+ prerelease: false
108
+ version_requirements: !ruby/object:Gem::Requirement
109
+ requirements:
110
+ - - ">="
111
+ - !ruby/object:Gem::Version
112
+ version: '4'
113
+ - - "<"
114
+ - !ruby/object:Gem::Version
115
+ version: '6'
116
+ - !ruby/object:Gem::Dependency
117
+ name: timecop
118
+ requirement: !ruby/object:Gem::Requirement
119
+ requirements:
120
+ - - "~>"
121
+ - !ruby/object:Gem::Version
122
+ version: '0.9'
123
+ type: :development
124
+ prerelease: false
125
+ version_requirements: !ruby/object:Gem::Requirement
126
+ requirements:
127
+ - - "~>"
128
+ - !ruby/object:Gem::Version
129
+ version: '0.9'
130
+ - !ruby/object:Gem::Dependency
131
+ name: webmock
132
+ requirement: !ruby/object:Gem::Requirement
133
+ requirements:
134
+ - - "~>"
135
+ - !ruby/object:Gem::Version
136
+ version: '3.0'
137
+ type: :development
138
+ prerelease: false
139
+ version_requirements: !ruby/object:Gem::Requirement
140
+ requirements:
141
+ - - "~>"
142
+ - !ruby/object:Gem::Version
143
+ version: '3.0'
144
+ description: |
145
+ Rack middleware that gates a Sidekiq (or any Rack) dashboard behind
146
+ Cloudflare Access: it validates the Cf-Access-Jwt-Assertion JWT against your
147
+ team's JWKS, issuer, and audience (fail-closed) and audit-logs every request
148
+ that gets through. No Rails or logging-library dependency at runtime.
149
+ email:
150
+ - engineering@homebot.ai
151
+ executables: []
152
+ extensions: []
153
+ extra_rdoc_files: []
154
+ files:
155
+ - CHANGELOG.md
156
+ - LICENSE.txt
157
+ - README.md
158
+ - SECURITY.md
159
+ - lib/cloudflare_access_gate.rb
160
+ - lib/cloudflare_access_gate/audit_logger.rb
161
+ - lib/cloudflare_access_gate/gate.rb
162
+ - lib/cloudflare_access_gate/jwks_cache.rb
163
+ - lib/cloudflare_access_gate/structured_logger.rb
164
+ - lib/cloudflare_access_gate/version.rb
165
+ homepage: https://github.com/homebotapp/cloudflare_access_gate
166
+ licenses:
167
+ - MIT
168
+ metadata:
169
+ homepage_uri: https://github.com/homebotapp/cloudflare_access_gate
170
+ changelog_uri: https://github.com/homebotapp/cloudflare_access_gate/blob/main/CHANGELOG.md
171
+ bug_tracker_uri: https://github.com/homebotapp/cloudflare_access_gate/issues
172
+ rubygems_mfa_required: 'true'
173
+ rdoc_options: []
174
+ require_paths:
175
+ - lib
176
+ required_ruby_version: !ruby/object:Gem::Requirement
177
+ requirements:
178
+ - - ">="
179
+ - !ruby/object:Gem::Version
180
+ version: '3.1'
181
+ required_rubygems_version: !ruby/object:Gem::Requirement
182
+ requirements:
183
+ - - ">="
184
+ - !ruby/object:Gem::Version
185
+ version: '0'
186
+ requirements: []
187
+ rubygems_version: 3.6.9
188
+ specification_version: 4
189
+ summary: Cloudflare Access JWT gate + audit logger Rack middleware.
190
+ test_files: []