verikloak-bff 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 +126 -0
- data/lib/verikloak/bff/configuration.rb +64 -0
- data/lib/verikloak/bff/consistency_checks.rb +112 -0
- data/lib/verikloak/bff/errors.rb +50 -0
- data/lib/verikloak/bff/forwarded_token.rb +104 -0
- data/lib/verikloak/bff/header_guard.rb +291 -0
- data/lib/verikloak/bff/proxy_trust.rb +126 -0
- data/lib/verikloak/bff/version.rb +10 -0
- data/lib/verikloak/bff.rb +29 -0
- data/lib/verikloak-bff.rb +20 -0
- metadata +110 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 3034b92c69f7147be681038b8f91a5a0c4ec1d577144a4372cad33b6645adb3a
|
|
4
|
+
data.tar.gz: a52b1ccc320fee7d5526f612fa509218a5b2232ce678d6d6e03a42d5c0122df0
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: a9e31d538da66f06b1f18412b41520a3c1a5f0f5e3adf88af19f4aa68451420717002c11dc639a2f1dbc9441b64b68b74de2729d2ef8a5af805d862b7568dd75
|
|
7
|
+
data.tar.gz: cb2f118b465d7cb4a64f0aa9eb77cb81ec0bd5a82ae2b33c1e9992b1b518e5690dee84f7c5ae69189b83e0394de784d559c57daef41154c24f396390cb0d8bab
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be 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
|
+
---
|
|
9
|
+
|
|
10
|
+
## [0.1.0] - 2025-09-14
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- Rack middleware `Verikloak::BFF::HeaderGuard`
|
|
14
|
+
- Bearer normalization (`ensure_bearer`), Authorization seeding (`token_header_priority`)
|
|
15
|
+
- Trust evaluation: REMOTE_ADDR first, XFF fallback (`peer_preference`, `xff_strategy`)
|
|
16
|
+
- Config keys: `forwarded_header_name`, `auth_request_headers`, `log_with`
|
|
17
|
+
- Claims/header consistency checks、`X-Auth-Request-*` stripping
|
|
18
|
+
- Env passthrough: `verikloak.bff.token`, `verikloak.bff.selected_peer`
|
|
19
|
+
- Docs: README、ERRORS、Rails guide (`docs/rails.md`)
|
|
20
|
+
- RSpec coverage for trust/consistency/seeding/env
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 taiyaky
|
|
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,126 @@
|
|
|
1
|
+
# verikloak-bff
|
|
2
|
+
|
|
3
|
+
[](https://github.com/taiyaky/verikloak-bff/actions/workflows/ci.yml)
|
|
4
|
+
[](https://rubygems.org/gems/verikloak-bff)
|
|
5
|
+

|
|
6
|
+
[](https://rubygems.org/gems/verikloak-bff)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
A framework-agnostic Rack middleware that hardens the **BFF / reverse-auth** boundary (oauth2-proxy, Nginx `auth_request`, etc.). It **normalizes** access tokens from `X-Forwarded-Access-Token` into `Authorization` and optionally checks **consistency** between `X-Auth-Request-*` headers and JWT claims. Signature and `iss/aud/exp` validation are performed by the core `verikloak` middleware placed after this middleware.
|
|
10
|
+
|
|
11
|
+
## Features
|
|
12
|
+
- Prefer / require `X-Forwarded-Access-Token` (configurable)
|
|
13
|
+
- Trust-boundary checking for proxy IPs (X-Forwarded-For parsing)
|
|
14
|
+
- Header consistency enforcement (Authorization vs Forwarded)
|
|
15
|
+
- Claims consistency checks (e.g., email/sub/groups) against `X-Auth-Request-*`
|
|
16
|
+
- Strip suspicious/forged headers before downstream
|
|
17
|
+
- Logging hooks for `request_id`, `sub`, `kid`, `iss/aud`
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
```bash
|
|
21
|
+
bundle add verikloak-bff
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Usage
|
|
25
|
+
|
|
26
|
+
- Rack-only apps: `use Verikloak::BFF::HeaderGuard` before your core Verikloak middleware.
|
|
27
|
+
- Rails apps: see the full guide in [docs/rails.md](docs/rails.md) (Gemfile, middleware order、initializer、proxy examples)。
|
|
28
|
+
|
|
29
|
+
## Consistency mapping
|
|
30
|
+
|
|
31
|
+
| Key | Header | JWT claim/path | Rule |
|
|
32
|
+
|----------|---------------------------|-------------------------|-------------|
|
|
33
|
+
| `email` | `X-Auth-Request-Email` | `email` | equality |
|
|
34
|
+
| `user` | `X-Auth-Request-User` | `sub` | equality |
|
|
35
|
+
| `groups` | `X-Auth-Request-Groups` | `realm_access.roles` | subset |
|
|
36
|
+
|
|
37
|
+
Use `enforce_claims_consistency: { email: :email, user: :sub, groups: :realm_roles }` to enable. Header names can be remapped via `auth_request_headers`.
|
|
38
|
+
|
|
39
|
+
See `examples/rack.ru` for a tiny Rack app demo.
|
|
40
|
+
|
|
41
|
+
## Configuration
|
|
42
|
+
|
|
43
|
+
| Key | Type | Default | Description |
|
|
44
|
+
|----------------------------- |--------------------------------------|--------------|-------------|
|
|
45
|
+
| `trusted_proxies` | Array[String/Regexp/Proc] | `[]` | Allowlist for proxy peers (by IP/CIDR/regex/proc). |
|
|
46
|
+
| `prefer_forwarded` | Boolean | `true` | Prefer `X-Forwarded-Access-Token` over `Authorization`. |
|
|
47
|
+
| `require_forwarded_header` | Boolean | `false` | Reject when no `X-Forwarded-Access-Token` (blocks direct access). |
|
|
48
|
+
| `enforce_header_consistency` | Boolean | `true` | If both headers exist, require identical token values. |
|
|
49
|
+
| `enforce_claims_consistency` | Hash | `{}` | Mapping of header→claim to compare (e.g., `{ email: :email, user: :sub, groups: :realm_roles }`). |
|
|
50
|
+
| `strip_suspicious_headers` | Boolean | `true` | Remove external `X-Auth-Request-*` before passing downstream. |
|
|
51
|
+
| `xff_strategy` | Symbol (`:rightmost`/`:leftmost`) | `:rightmost` | Which peer to pick from `X-Forwarded-For`. |
|
|
52
|
+
| `peer_preference` | Symbol (`:remote_then_xff`/`:xff_only`) | `:remote_then_xff` | Whether to prefer `REMOTE_ADDR` before falling back to XFF. |
|
|
53
|
+
| `clock_skew_leeway` | Integer (seconds) | `30` | Reserved for small exp/nbf skew handled by core verifier. |
|
|
54
|
+
| `logger` | `Logger` or `nil` | `nil` | Logger for audit tags (`rid`, `sub`, `kid`, `iss/aud`). |
|
|
55
|
+
| `token_header_priority` | Array[String] | see code | When Authorization is empty and no token chosen, seed it from these env headers in order. `HTTP_AUTHORIZATION` is ignored as a source; forwarded header is considered only from trusted peers. |
|
|
56
|
+
| `forwarded_header_name` | String | `HTTP_X_FORWARDED_ACCESS_TOKEN` | Env key for forwarded access token. |
|
|
57
|
+
| `auth_request_headers` | Hash | see code | Mapping for `X-Auth-Request-*` env keys: `{ email, user, groups }`. |
|
|
58
|
+
|
|
59
|
+
## Errors
|
|
60
|
+
This gem returns concise, RFC 6750–style error responses with stable codes. See [ERRORS.md](ERRORS.md) for details and examples.
|
|
61
|
+
|
|
62
|
+
**Note:** This middleware does not verify JWT signatures or `iss/aud/exp` itself; it normalizes and guards headers so the core `verikloak` middleware always performs final verification.
|
|
63
|
+
|
|
64
|
+
For full reverse proxy examples (Nginx auth_request / oauth2-proxy), see [docs/rails.md](docs/rails.md).
|
|
65
|
+
|
|
66
|
+
## Tips & Advanced Usage
|
|
67
|
+
|
|
68
|
+
- Peer preference: prefer `REMOTE_ADDR` before XFF
|
|
69
|
+
- Set `peer_preference: :remote_then_xff` (default) to evaluate trust using the direct peer first, then fall back to the nearest (rightmost) `X-Forwarded-For` value.
|
|
70
|
+
- If you run only behind a single, known proxy chain and want to rely solely on XFF ordering, use `peer_preference: :xff_only` and control position with `xff_strategy`.
|
|
71
|
+
|
|
72
|
+
- Header name customization
|
|
73
|
+
- Forwarded-access-token header can be changed via:
|
|
74
|
+
- `forwarded_header_name: 'HTTP_X_CUSTOM_FORWARD_TOKEN'`
|
|
75
|
+
- X-Auth-Request-* header names can be remapped via:
|
|
76
|
+
- `auth_request_headers: { email: 'HTTP_X_EMAIL', user: 'HTTP_X_USER', groups: 'HTTP_X_GROUPS' }`
|
|
77
|
+
|
|
78
|
+
- Authorization seeding from priority headers
|
|
79
|
+
- When no token is chosen and `HTTP_AUTHORIZATION` is empty, the middleware consults `token_header_priority` to seed Authorization.
|
|
80
|
+
- `HTTP_AUTHORIZATION` itself is never used as a source; forwarded headers are considered only from trusted peers.
|
|
81
|
+
|
|
82
|
+
- Observability helpers
|
|
83
|
+
- Downstream can inspect `env['verikloak.bff.token']` (chosen token, unverified) and `env['verikloak.bff.selected_peer']` (peer IP selected for trust decisions).
|
|
84
|
+
- Provide a structured log hook with `log_with: ->(payload) { logger.info(payload.to_json) }` to consume the same fields emitted to `logger`.
|
|
85
|
+
- Caution: avoid logging the entire Rack `env` in application logs. Treat `env['verikloak.bff.token']` as sensitive; never emit raw tokens or PII (e.g., emails) to logs.
|
|
86
|
+
|
|
87
|
+
## Development (for contributors)
|
|
88
|
+
Clone and install dependencies:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
git clone https://github.com/taiyaky/verikloak-bff.git
|
|
92
|
+
cd verikloak-bff
|
|
93
|
+
bundle install
|
|
94
|
+
```
|
|
95
|
+
See **Testing** below to run specs and RuboCop. For releasing, see **Publishing**.
|
|
96
|
+
|
|
97
|
+
## Testing
|
|
98
|
+
All pull requests and pushes are automatically tested with [RSpec](https://rspec.info/) and [RuboCop](https://rubocop.org/) via GitHub Actions.
|
|
99
|
+
See the CI badge at the top for current build status.
|
|
100
|
+
|
|
101
|
+
To run the test suite locally:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
docker compose run --rm dev rspec
|
|
105
|
+
docker compose run --rm dev rubocop -a
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Contributing
|
|
109
|
+
Bug reports and pull requests are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for details.
|
|
110
|
+
|
|
111
|
+
## Security
|
|
112
|
+
If you find a security vulnerability, please follow the instructions in [SECURITY.md](SECURITY.md).
|
|
113
|
+
|
|
114
|
+
## License
|
|
115
|
+
This project is licensed under the [MIT License](LICENSE).
|
|
116
|
+
|
|
117
|
+
## Publishing (for maintainers)
|
|
118
|
+
Gem release instructions are documented separately in [MAINTAINERS.md](MAINTAINERS.md).
|
|
119
|
+
|
|
120
|
+
## Changelog
|
|
121
|
+
See [CHANGELOG.md](CHANGELOG.md) for release history.
|
|
122
|
+
|
|
123
|
+
## References
|
|
124
|
+
- Verikloak (core): https://github.com/taiyaky/verikloak
|
|
125
|
+
- verikloak-rails (Rails integration): https://github.com/taiyaky/verikloak-rails
|
|
126
|
+
- verikloak-bff on RubyGems: https://rubygems.org/gems/verikloak-bff
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Configuration object for verikloak-bff. Holds middleware settings such as
|
|
4
|
+
# proxy trust rules, header consistency policies, and logging options.
|
|
5
|
+
#
|
|
6
|
+
# @!attribute [rw] trusted_proxies
|
|
7
|
+
# @return [Array<String, Regexp, Proc>] allowlist of trusted proxy peers
|
|
8
|
+
# @!attribute [rw] prefer_forwarded
|
|
9
|
+
# @return [Boolean] prefer X-Forwarded-Access-Token over Authorization
|
|
10
|
+
# @!attribute [rw] require_forwarded_header
|
|
11
|
+
# @return [Boolean] require X-Forwarded-Access-Token to be present
|
|
12
|
+
# @!attribute [rw] enforce_header_consistency
|
|
13
|
+
# @return [Boolean] when both headers exist, require equality
|
|
14
|
+
# @!attribute [rw] enforce_claims_consistency
|
|
15
|
+
# @return [Hash] mapping of header keys to claim keys (e.g., { email: :email })
|
|
16
|
+
# @!attribute [rw] strip_suspicious_headers
|
|
17
|
+
# @return [Boolean] strip X-Auth-Request-* headers before downstream
|
|
18
|
+
# @!attribute [rw] xff_strategy
|
|
19
|
+
# @return [Symbol] :rightmost or :leftmost peer selection from XFF
|
|
20
|
+
# @!attribute [rw] clock_skew_leeway
|
|
21
|
+
# @return [Integer] reserved leeway (seconds) for exp/nbf (handled in core)
|
|
22
|
+
# @!attribute [rw] logger
|
|
23
|
+
# @return [Logger, nil] optional logger for audit tags
|
|
24
|
+
|
|
25
|
+
module Verikloak
|
|
26
|
+
module BFF
|
|
27
|
+
# Configuration for Verikloak::BFF middleware (trusted proxies, header policies, logging, etc.).
|
|
28
|
+
class Configuration
|
|
29
|
+
attr_accessor :trusted_proxies, :prefer_forwarded, :require_forwarded_header,
|
|
30
|
+
:enforce_header_consistency, :enforce_claims_consistency,
|
|
31
|
+
:strip_suspicious_headers, :xff_strategy, :clock_skew_leeway,
|
|
32
|
+
:logger, :token_header_priority, :peer_preference,
|
|
33
|
+
:forwarded_header_name, :auth_request_headers, :log_with
|
|
34
|
+
|
|
35
|
+
# enforce_claims_consistency example:
|
|
36
|
+
# { email: :email, user: :sub, groups: :realm_roles }
|
|
37
|
+
def initialize
|
|
38
|
+
@trusted_proxies = []
|
|
39
|
+
@prefer_forwarded = true
|
|
40
|
+
@require_forwarded_header = false
|
|
41
|
+
@enforce_header_consistency = true
|
|
42
|
+
@enforce_claims_consistency = {}
|
|
43
|
+
@strip_suspicious_headers = true
|
|
44
|
+
@xff_strategy = :rightmost
|
|
45
|
+
@peer_preference = :remote_then_xff
|
|
46
|
+
@clock_skew_leeway = 30
|
|
47
|
+
@logger = nil
|
|
48
|
+
@log_with = nil
|
|
49
|
+
@forwarded_header_name = 'HTTP_X_FORWARDED_ACCESS_TOKEN'
|
|
50
|
+
@auth_request_headers = {
|
|
51
|
+
email: 'HTTP_X_AUTH_REQUEST_EMAIL',
|
|
52
|
+
user: 'HTTP_X_AUTH_REQUEST_USER',
|
|
53
|
+
groups: 'HTTP_X_AUTH_REQUEST_GROUPS'
|
|
54
|
+
}
|
|
55
|
+
# When Authorization is empty and no chosen token exists, try these env headers (in order)
|
|
56
|
+
# to seed Authorization, similar to verikloak-rails behavior. HTTP_AUTHORIZATION is ignored as a source.
|
|
57
|
+
@token_header_priority = %w[
|
|
58
|
+
HTTP_X_FORWARDED_ACCESS_TOKEN
|
|
59
|
+
HTTP_AUTHORIZATION
|
|
60
|
+
]
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Helpers to compare X-Auth-Request-* headers with JWT claims according to a
|
|
4
|
+
# provided mapping. Intended for lightweight, unverified JWT parsing only.
|
|
5
|
+
#
|
|
6
|
+
# @see .enforce!
|
|
7
|
+
|
|
8
|
+
require 'json'
|
|
9
|
+
require 'jwt' # used only to parse segments safely without verify
|
|
10
|
+
|
|
11
|
+
module Verikloak
|
|
12
|
+
module BFF
|
|
13
|
+
# Compares X-Auth-Request-* headers with JWT claims according to a mapping.
|
|
14
|
+
module ConsistencyChecks
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
# Parse JWT payload without verifying (verikloak core will verify later).
|
|
18
|
+
# Decode JWT payload without verification.
|
|
19
|
+
#
|
|
20
|
+
# @param token [String, nil]
|
|
21
|
+
# @return [Hash] claims or empty hash on error
|
|
22
|
+
def decode_claims(token)
|
|
23
|
+
return {} unless token
|
|
24
|
+
|
|
25
|
+
parts = token.split('.')
|
|
26
|
+
return {} unless parts.size >= 2
|
|
27
|
+
|
|
28
|
+
payload = JWT::Base64.url_decode(parts[1])
|
|
29
|
+
JSON.parse(payload)
|
|
30
|
+
rescue StandardError
|
|
31
|
+
{}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# mapping: { email: :email, user: :sub, groups: :realm_roles }
|
|
35
|
+
# Enforce consistency according to the provided mapping.
|
|
36
|
+
#
|
|
37
|
+
# @param env [Hash]
|
|
38
|
+
# @param token [String, nil]
|
|
39
|
+
# @param mapping [Hash] e.g., { email: :email, user: :sub, groups: :realm_roles }
|
|
40
|
+
# @return [true, Array(:error, Symbol)] true or error tuple with failing field
|
|
41
|
+
def enforce!(env, token, mapping, headers_map = nil)
|
|
42
|
+
return true if mapping.nil? || mapping.empty?
|
|
43
|
+
|
|
44
|
+
claims = decode_claims(token)
|
|
45
|
+
|
|
46
|
+
mapping.each do |header_key, claim_key|
|
|
47
|
+
hdr_val = extract_header_value(env, header_key, headers_map)
|
|
48
|
+
next if hdr_val.nil? # no header → skip comparison
|
|
49
|
+
|
|
50
|
+
case claim_key
|
|
51
|
+
when :realm_roles
|
|
52
|
+
roles = Array((claims['realm_access'] || {})['roles'] || [])
|
|
53
|
+
hdr_list = split_list(hdr_val)
|
|
54
|
+
return error(:groups) unless (hdr_list - roles).empty?
|
|
55
|
+
else
|
|
56
|
+
claim_val = dig_claim(claims, claim_key)
|
|
57
|
+
return error(header_key) unless claim_val && hdr_val.to_s == claim_val.to_s
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
true
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Extract an X-Auth-Request-* header value mapped from a symbolic key.
|
|
64
|
+
#
|
|
65
|
+
# @param env [Hash]
|
|
66
|
+
# @param key [Symbol]
|
|
67
|
+
# @return [String, nil]
|
|
68
|
+
def extract_header_value(env, key, headers_map = nil)
|
|
69
|
+
return env[headers_map[key]] if headers_map && headers_map[key]
|
|
70
|
+
|
|
71
|
+
case key
|
|
72
|
+
when :email
|
|
73
|
+
env['HTTP_X_AUTH_REQUEST_EMAIL']
|
|
74
|
+
when :user
|
|
75
|
+
env['HTTP_X_AUTH_REQUEST_USER']
|
|
76
|
+
when :groups
|
|
77
|
+
env['HTTP_X_AUTH_REQUEST_GROUPS']
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Split a comma-separated list into an array.
|
|
82
|
+
#
|
|
83
|
+
# @param val [String]
|
|
84
|
+
# @return [Array<String>]
|
|
85
|
+
def split_list(val)
|
|
86
|
+
Array(val.to_s.split(',').map(&:strip).reject(&:empty?))
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Dig into JWT claims by a symbol/string key or an array path.
|
|
90
|
+
#
|
|
91
|
+
# @param claims [Hash]
|
|
92
|
+
# @param key [Symbol, String, Array<String,Symbol>]
|
|
93
|
+
# @return [Object, nil]
|
|
94
|
+
def dig_claim(claims, key)
|
|
95
|
+
case key
|
|
96
|
+
when Symbol, String
|
|
97
|
+
claims[key.to_s]
|
|
98
|
+
when Array
|
|
99
|
+
key.reduce(claims) { |acc, k| acc.is_a?(Hash) ? acc[k.to_s] : nil }
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Build an error tuple for a failed field.
|
|
104
|
+
#
|
|
105
|
+
# @param field [Symbol]
|
|
106
|
+
# @return [Array(:error, Symbol)]
|
|
107
|
+
def error(field)
|
|
108
|
+
[:error, field]
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Error types emitted by verikloak-bff. These map to RFC6750-style responses
|
|
4
|
+
# with stable error codes for easy handling at clients and logs.
|
|
5
|
+
|
|
6
|
+
module Verikloak
|
|
7
|
+
module BFF
|
|
8
|
+
# Base error class with HTTP status and short code.
|
|
9
|
+
#
|
|
10
|
+
# @attr_reader [String] code
|
|
11
|
+
# @attr_reader [Integer] http_status
|
|
12
|
+
class Error < StandardError
|
|
13
|
+
attr_reader :code, :http_status
|
|
14
|
+
|
|
15
|
+
def initialize(message = nil, code: 'bff_error', http_status: 401)
|
|
16
|
+
super(message || code)
|
|
17
|
+
@code = code
|
|
18
|
+
@http_status = http_status
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Raised when a request did not pass through a trusted proxy peer.
|
|
23
|
+
class UntrustedProxyError < Error
|
|
24
|
+
def initialize(msg = 'request did not pass through a trusted proxy')
|
|
25
|
+
super(msg, code: 'untrusted_proxy', http_status: 401)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Raised when require_forwarded_header is enabled but the forwarded token is absent.
|
|
30
|
+
class MissingForwardedTokenError < Error
|
|
31
|
+
def initialize(msg = 'missing X-Forwarded-Access-Token')
|
|
32
|
+
super(msg, code: 'missing_forwarded_token', http_status: 401)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Raised when Authorization and X-Forwarded-Access-Token both exist and differ.
|
|
37
|
+
class HeaderMismatchError < Error
|
|
38
|
+
def initialize(msg = 'authorization and forwarded token mismatch')
|
|
39
|
+
super(msg, code: 'header_mismatch', http_status: 401)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Raised when X-Auth-Request-* headers conflict with JWT claims.
|
|
44
|
+
class ClaimsMismatchError < Error
|
|
45
|
+
def initialize(field, msg = nil)
|
|
46
|
+
super(msg || "claims/header mismatch for #{field}", code: 'claims_mismatch', http_status: 403)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Utilities to extract, normalize, and manipulate forwarded/auth headers.
|
|
4
|
+
#
|
|
5
|
+
# @see .extract
|
|
6
|
+
|
|
7
|
+
module Verikloak
|
|
8
|
+
module BFF
|
|
9
|
+
# Helpers to extract and normalize forwarded/access token headers.
|
|
10
|
+
module ForwardedToken
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
FORWARDED_HEADER = 'HTTP_X_FORWARDED_ACCESS_TOKEN'
|
|
14
|
+
AUTH_HEADER = 'HTTP_AUTHORIZATION'
|
|
15
|
+
|
|
16
|
+
# Extract normalized tokens from the Rack env.
|
|
17
|
+
#
|
|
18
|
+
# @param env [Hash]
|
|
19
|
+
# @return [Array(String, String)] [auth_token, forwarded_token]
|
|
20
|
+
def extract(env, forwarded_header_name = FORWARDED_HEADER, auth_header_name = AUTH_HEADER)
|
|
21
|
+
fwd_raw = env[forwarded_header_name]
|
|
22
|
+
auth_raw = env[auth_header_name]
|
|
23
|
+
[normalize_auth(auth_raw), normalize_forwarded(fwd_raw)]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Only accept Bearer scheme for Authorization header.
|
|
27
|
+
#
|
|
28
|
+
# @param raw [String, nil]
|
|
29
|
+
# @return [String, nil] token or nil when not Bearer
|
|
30
|
+
def normalize_auth(raw)
|
|
31
|
+
return nil unless raw
|
|
32
|
+
|
|
33
|
+
token = raw.to_s.strip
|
|
34
|
+
return ::Regexp.last_match(1) if token =~ /^Bearer\s+(.+)$/i
|
|
35
|
+
return token[6..] if token =~ /^Bearer(?!\s)/i
|
|
36
|
+
|
|
37
|
+
nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Accept either bare token or Bearer for forwarded header.
|
|
41
|
+
#
|
|
42
|
+
# @param raw [String, nil]
|
|
43
|
+
# @return [String, nil] token or nil
|
|
44
|
+
def normalize_forwarded(raw)
|
|
45
|
+
return nil unless raw
|
|
46
|
+
|
|
47
|
+
token = raw.to_s.strip
|
|
48
|
+
return ::Regexp.last_match(1) if token =~ /^Bearer\s+(.+)$/i
|
|
49
|
+
|
|
50
|
+
token.empty? ? nil : token
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Normalize to a proper 'Bearer <token>' header value.
|
|
54
|
+
# - Detects scheme case-insensitively
|
|
55
|
+
# - Inserts a missing space (e.g., 'BearerXYZ' => 'Bearer XYZ')
|
|
56
|
+
# - Collapses multiple spaces/tabs after the scheme to a single space
|
|
57
|
+
# @param token [String]
|
|
58
|
+
# @return [String]
|
|
59
|
+
def ensure_bearer(token)
|
|
60
|
+
s = token.to_s.strip
|
|
61
|
+
# Case-insensitive 'Bearer' with spaces/tabs after
|
|
62
|
+
if s =~ /\ABearer[ \t]+/i
|
|
63
|
+
rest = s.sub(/\ABearer[ \t]+/i, '')
|
|
64
|
+
return "Bearer #{rest}"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Case-insensitive 'Bearer' with no separator (e.g., 'BearerXYZ')
|
|
68
|
+
if s =~ /\ABearer(?![ \t])/i
|
|
69
|
+
rest = s[6..] || ''
|
|
70
|
+
return "Bearer #{rest}"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# No scheme present; add it
|
|
74
|
+
"Bearer #{s}"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Set Authorization header to a normalized Bearer value (no overwrite when present).
|
|
78
|
+
#
|
|
79
|
+
# @param env [Hash]
|
|
80
|
+
# @param token [String]
|
|
81
|
+
def set_authorization!(env, token)
|
|
82
|
+
existing = env[AUTH_HEADER].to_s
|
|
83
|
+
# Overwrite only if Authorization is empty or not a valid Bearer value
|
|
84
|
+
return unless existing.empty? || normalize_auth(existing).nil?
|
|
85
|
+
|
|
86
|
+
env[AUTH_HEADER] = ensure_bearer(token)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Remove potentially forged X-Auth-Request-* headers before passing
|
|
90
|
+
# downstream when not emitted by a trusted proxy.
|
|
91
|
+
#
|
|
92
|
+
# @param env [Hash]
|
|
93
|
+
def strip_suspicious!(env, headers = nil)
|
|
94
|
+
if headers.is_a?(Hash)
|
|
95
|
+
headers.each_value { |h| env.delete(h) }
|
|
96
|
+
return
|
|
97
|
+
end
|
|
98
|
+
env.delete('HTTP_X_AUTH_REQUEST_EMAIL')
|
|
99
|
+
env.delete('HTTP_X_AUTH_REQUEST_USER')
|
|
100
|
+
env.delete('HTTP_X_AUTH_REQUEST_GROUPS')
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Rack middleware that hardens the BFF boundary by normalizing tokens and
|
|
4
|
+
# enforcing header/claims consistency before the core verifier runs.
|
|
5
|
+
#
|
|
6
|
+
# Typical usage (Rack/Rails):
|
|
7
|
+
# use Verikloak::BFF::HeaderGuard, trusted_proxies: ['127.0.0.1']
|
|
8
|
+
#
|
|
9
|
+
# The middleware prefers `X-Forwarded-Access-Token` (configurable), enforces
|
|
10
|
+
# header equality when both tokens exist, optionally checks `X-Auth-Request-*`
|
|
11
|
+
# headers against JWT claims, and normalizes the request into
|
|
12
|
+
# `HTTP_AUTHORIZATION: Bearer <token>` for the downstream verifier.
|
|
13
|
+
require 'rack'
|
|
14
|
+
require 'json'
|
|
15
|
+
require 'jwt'
|
|
16
|
+
require 'verikloak/bff/configuration'
|
|
17
|
+
require 'verikloak/bff/errors'
|
|
18
|
+
require 'verikloak/bff/proxy_trust'
|
|
19
|
+
require 'verikloak/bff/forwarded_token'
|
|
20
|
+
require 'verikloak/bff/consistency_checks'
|
|
21
|
+
|
|
22
|
+
module Verikloak
|
|
23
|
+
module BFF
|
|
24
|
+
# Rack middleware that enforces BFF boundary and header/claims consistency.
|
|
25
|
+
class HeaderGuard
|
|
26
|
+
# Accept both Rack 2 and Rack 3 builder call styles:
|
|
27
|
+
# - new(app, key: val)
|
|
28
|
+
# - new(app, { key: val })
|
|
29
|
+
#
|
|
30
|
+
# @param app [#call]
|
|
31
|
+
# @param opts [Hash, nil]
|
|
32
|
+
# @param opts_kw [Hash]
|
|
33
|
+
def initialize(app, opts = nil, **opts_kw)
|
|
34
|
+
@app = app
|
|
35
|
+
# Use a per-instance copy of global config to avoid cross-request/test side effects
|
|
36
|
+
@config = Verikloak::BFF.config.dup
|
|
37
|
+
combined = {}
|
|
38
|
+
combined.merge!(opts) if opts.is_a?(Hash)
|
|
39
|
+
combined.merge!(opts_kw) if opts_kw && !opts_kw.empty?
|
|
40
|
+
apply_overrides!(combined)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Process a Rack request.
|
|
44
|
+
#
|
|
45
|
+
# @param env [Hash]
|
|
46
|
+
# @return [Array(Integer, Hash, Array<#to_s>)] Rack response triple
|
|
47
|
+
def call(env)
|
|
48
|
+
ensure_trusted_proxy!(env)
|
|
49
|
+
auth_token, fwd_token = ForwardedToken.extract(env, @config.forwarded_header_name)
|
|
50
|
+
ensure_forwarded_if_required!(fwd_token)
|
|
51
|
+
chosen = choose_token(auth_token, fwd_token)
|
|
52
|
+
chosen = seed_authorization_if_needed(env, chosen)
|
|
53
|
+
|
|
54
|
+
enforce_header_consistency!(env, auth_token, fwd_token)
|
|
55
|
+
enforce_claims_consistency!(env, chosen)
|
|
56
|
+
ForwardedToken.strip_suspicious!(env, @config.auth_request_headers) if @config.strip_suspicious_headers
|
|
57
|
+
normalize_authorization!(env, chosen, auth_token, fwd_token)
|
|
58
|
+
expose_env_hints(env, chosen)
|
|
59
|
+
@app.call(env)
|
|
60
|
+
rescue Verikloak::BFF::Error => e
|
|
61
|
+
respond_with_error(env, e)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
# Apply per-instance configuration overrides.
|
|
67
|
+
#
|
|
68
|
+
# @param opts [Hash]
|
|
69
|
+
def apply_overrides!(opts)
|
|
70
|
+
cfg = @config
|
|
71
|
+
opts.each do |k, v|
|
|
72
|
+
cfg.public_send("#{k}=", v) if cfg.respond_to?("#{k}=")
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Choose a token based on preference and presence.
|
|
77
|
+
#
|
|
78
|
+
# @param auth_token [String, nil]
|
|
79
|
+
# @param fwd_token [String, nil]
|
|
80
|
+
# @return [String, nil]
|
|
81
|
+
def choose_token(auth_token, fwd_token)
|
|
82
|
+
return fwd_token if @config.prefer_forwarded && fwd_token
|
|
83
|
+
|
|
84
|
+
auth_token || fwd_token
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Describe the source of the chosen token for logging purposes.
|
|
88
|
+
#
|
|
89
|
+
# @param auth_token [String, nil]
|
|
90
|
+
# @param fwd_token [String, nil]
|
|
91
|
+
# @return [String] "authorization" or "forwarded"
|
|
92
|
+
def token_source(auth_token, fwd_token)
|
|
93
|
+
return 'forwarded' if @config.prefer_forwarded && fwd_token
|
|
94
|
+
|
|
95
|
+
if auth_token && fwd_token
|
|
96
|
+
'authorization'
|
|
97
|
+
else
|
|
98
|
+
(auth_token ? 'authorization' : 'forwarded')
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Extract selected JWT tags for audit logging (best-effort, unverified).
|
|
103
|
+
#
|
|
104
|
+
# @param token [String, nil]
|
|
105
|
+
# @return [Hash] subset of {sub, iss, aud, kid}
|
|
106
|
+
def token_tags(token)
|
|
107
|
+
return {} unless token
|
|
108
|
+
|
|
109
|
+
payload, header = decode_unverified(token)
|
|
110
|
+
{
|
|
111
|
+
sub: payload['sub'],
|
|
112
|
+
iss: payload['iss'],
|
|
113
|
+
aud: (payload['aud'].is_a?(Array) ? payload['aud'].join(' ') : payload['aud']).to_s,
|
|
114
|
+
kid: header['kid']
|
|
115
|
+
}.compact
|
|
116
|
+
rescue StandardError
|
|
117
|
+
{}
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Decode JWT header/payload without validation (for logging only).
|
|
121
|
+
#
|
|
122
|
+
# @param token [String]
|
|
123
|
+
# @return [Array(Hash, Hash)] [payload, header]
|
|
124
|
+
def decode_unverified(token)
|
|
125
|
+
parts = token.to_s.split('.')
|
|
126
|
+
return [{}, {}] unless parts.size >= 2
|
|
127
|
+
|
|
128
|
+
payload = begin
|
|
129
|
+
JSON.parse(::JWT::Base64.url_decode(parts[1]))
|
|
130
|
+
rescue StandardError
|
|
131
|
+
{}
|
|
132
|
+
end
|
|
133
|
+
header = begin
|
|
134
|
+
JSON.parse(::JWT::Base64.url_decode(parts[0]))
|
|
135
|
+
rescue StandardError
|
|
136
|
+
{}
|
|
137
|
+
end
|
|
138
|
+
[payload, header]
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Extract request id for logging from common headers.
|
|
142
|
+
#
|
|
143
|
+
# @param env [Hash]
|
|
144
|
+
# @return [String, nil]
|
|
145
|
+
def request_id(env)
|
|
146
|
+
env['HTTP_X_REQUEST_ID'] || env['action_dispatch.request_id']
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Resolve logger to use (config-provided or rack.logger).
|
|
150
|
+
#
|
|
151
|
+
# @param env [Hash]
|
|
152
|
+
# @return [Logger, nil]
|
|
153
|
+
def logger(env)
|
|
154
|
+
@config.logger || env['rack.logger']
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Emit a structured log line if a logger is present.
|
|
158
|
+
#
|
|
159
|
+
# @param env [Hash]
|
|
160
|
+
# @param kind [Symbol] :ok, :mismatch, :claims_mismatch, :error
|
|
161
|
+
# @param attrs [Hash]
|
|
162
|
+
def log_event(env, kind, **attrs)
|
|
163
|
+
lg = logger(env)
|
|
164
|
+
payload = { event: 'bff.header_guard', kind: kind, rid: request_id(env) }.merge(attrs).compact
|
|
165
|
+
if @config.log_with.respond_to?(:call)
|
|
166
|
+
begin
|
|
167
|
+
@config.log_with.call(payload)
|
|
168
|
+
rescue StandardError
|
|
169
|
+
# ignore log hook failures
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
return unless lg
|
|
173
|
+
|
|
174
|
+
msg = payload.map { |k, v| v.nil? || v.to_s.empty? ? nil : "#{k}=#{v}" }.compact.join(' ')
|
|
175
|
+
level = (kind == :ok ? :info : :warn)
|
|
176
|
+
lg.public_send(level, msg)
|
|
177
|
+
rescue StandardError
|
|
178
|
+
# no-op on logging errors
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Raise when the request did not come through a trusted proxy.
|
|
182
|
+
#
|
|
183
|
+
# @param env [Hash]
|
|
184
|
+
def ensure_trusted_proxy!(env)
|
|
185
|
+
return if ProxyTrust.trusted?(env, @config.trusted_proxies, @config.xff_strategy)
|
|
186
|
+
|
|
187
|
+
raise UntrustedProxyError
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Enforce presence of forwarded token when required.
|
|
191
|
+
#
|
|
192
|
+
# @param fwd_token [String, nil]
|
|
193
|
+
def ensure_forwarded_if_required!(fwd_token)
|
|
194
|
+
return unless @config.require_forwarded_header
|
|
195
|
+
raise MissingForwardedTokenError if fwd_token.nil? || fwd_token.to_s.strip.empty?
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Enforce equality when both Authorization and Forwarded tokens exist.
|
|
199
|
+
#
|
|
200
|
+
# @param env [Hash]
|
|
201
|
+
# @param auth_token [String, nil]
|
|
202
|
+
# @param fwd_token [String, nil]
|
|
203
|
+
def enforce_header_consistency!(env, auth_token, fwd_token)
|
|
204
|
+
return unless @config.enforce_header_consistency
|
|
205
|
+
return unless auth_token && fwd_token
|
|
206
|
+
return if auth_token == fwd_token
|
|
207
|
+
|
|
208
|
+
log_event(env, :mismatch, reason: 'authorization_vs_forwarded')
|
|
209
|
+
raise HeaderMismatchError
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Enforce X-Auth-Request-* ↔ JWT claims mapping when configured.
|
|
213
|
+
#
|
|
214
|
+
# @param env [Hash]
|
|
215
|
+
# @param chosen [String, nil]
|
|
216
|
+
def enforce_claims_consistency!(env, chosen)
|
|
217
|
+
res = ConsistencyChecks.enforce!(env, chosen, @config.enforce_claims_consistency, @config.auth_request_headers)
|
|
218
|
+
return unless res.is_a?(Array) && res.first == :error
|
|
219
|
+
|
|
220
|
+
field = res.last
|
|
221
|
+
log_event(env, :claims_mismatch, field: field.to_s)
|
|
222
|
+
raise ClaimsMismatchError, field
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Set normalized Authorization header and emit success log.
|
|
226
|
+
#
|
|
227
|
+
# @param env [Hash]
|
|
228
|
+
# @param chosen [String, nil]
|
|
229
|
+
# @param auth_token [String, nil]
|
|
230
|
+
# @param fwd_token [String, nil]
|
|
231
|
+
def normalize_authorization!(env, chosen, auth_token, fwd_token)
|
|
232
|
+
return unless chosen
|
|
233
|
+
|
|
234
|
+
ForwardedToken.set_authorization!(env, chosen)
|
|
235
|
+
log_event(env, :ok, source: token_source(auth_token, fwd_token), **token_tags(chosen))
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Build a minimal RFC6750-style error response.
|
|
239
|
+
#
|
|
240
|
+
# @param env [Hash]
|
|
241
|
+
# @param error [Verikloak::BFF::Error]
|
|
242
|
+
# @return [Array(Integer, Hash, Array<String>)]
|
|
243
|
+
def respond_with_error(env, error)
|
|
244
|
+
log_event(env, :error, code: error.code)
|
|
245
|
+
body = { error: error.code, message: error.message }.to_json
|
|
246
|
+
headers = { 'Content-Type' => 'application/json',
|
|
247
|
+
'WWW-Authenticate' => %(Bearer error="#{error.code}", error_description="#{error.message}") }
|
|
248
|
+
[error.http_status, headers, [body]]
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Resolve the first env header from which to source a bearer token.
|
|
252
|
+
# Forwarded is considered only when the peer is trusted; HTTP_AUTHORIZATION is never a source.
|
|
253
|
+
# @param env [Hash]
|
|
254
|
+
# @return [String, nil]
|
|
255
|
+
def resolve_first_token_header(env)
|
|
256
|
+
candidates = Array(@config.token_header_priority).dup
|
|
257
|
+
candidates -= ['HTTP_AUTHORIZATION']
|
|
258
|
+
fwd_key = 'HTTP_X_FORWARDED_ACCESS_TOKEN'
|
|
259
|
+
if candidates.include?(fwd_key) && !ProxyTrust.from_trusted_proxy?(env, @config.trusted_proxies)
|
|
260
|
+
candidates -= [fwd_key]
|
|
261
|
+
end
|
|
262
|
+
candidates.find { |k| (v = env[k]) && !v.to_s.empty? }
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Seed Authorization from priority headers if nothing chosen and empty Authorization
|
|
266
|
+
# @param env [Hash]
|
|
267
|
+
# @param chosen [String, nil]
|
|
268
|
+
# @return [String, nil] possibly updated chosen token
|
|
269
|
+
def seed_authorization_if_needed(env, chosen)
|
|
270
|
+
return chosen unless chosen.nil? && env['HTTP_AUTHORIZATION'].to_s.empty?
|
|
271
|
+
return chosen unless Array(@config.token_header_priority).any?
|
|
272
|
+
|
|
273
|
+
seeded = resolve_first_token_header(env)
|
|
274
|
+
if seeded
|
|
275
|
+
ForwardedToken.set_authorization!(env, env[seeded])
|
|
276
|
+
return ForwardedToken.normalize_forwarded(env[seeded]) || env[seeded].to_s
|
|
277
|
+
end
|
|
278
|
+
chosen
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# Expose hints to downstream
|
|
282
|
+
# @param env [Hash]
|
|
283
|
+
# @param chosen [String, nil]
|
|
284
|
+
def expose_env_hints(env, chosen)
|
|
285
|
+
env['verikloak.bff.token'] = chosen if chosen
|
|
286
|
+
env['verikloak.bff.selected_peer'] =
|
|
287
|
+
ProxyTrust.selected_peer(env, @config.peer_preference, @config.xff_strategy)
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Utilities to determine whether a request peer (via REMOTE_ADDR / XFF) is
|
|
4
|
+
# allow‑listed as a trusted proxy.
|
|
5
|
+
require 'ipaddr'
|
|
6
|
+
|
|
7
|
+
module Verikloak
|
|
8
|
+
module BFF
|
|
9
|
+
# Determines whether the selected peer (via XFF/REMOTE_ADDR) is a trusted proxy.
|
|
10
|
+
module ProxyTrust
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# Determine if the immediate peer (based on REMOTE_ADDR / X-Forwarded-For) is trusted.
|
|
14
|
+
# strategy :rightmost (typical when proxy appends client IP to the right)
|
|
15
|
+
#
|
|
16
|
+
# @param env [Hash] Rack environment
|
|
17
|
+
# @param trusted [Array<String, Regexp, Proc>, nil] Allowlist of proxy peers.
|
|
18
|
+
# - String: exact IP (e.g. "127.0.0.1") or CIDR (e.g. "10.0.0.0/8")
|
|
19
|
+
# - Regexp: matched against the selected peer IP
|
|
20
|
+
# - Proc: called as `->(ip, env) { ... }` and returns truthy when trusted
|
|
21
|
+
# @param strategy [Symbol, String] `:rightmost` (default) or `:leftmost` for XFF parsing
|
|
22
|
+
# @return [Boolean] true if the selected peer is trusted
|
|
23
|
+
# @example CIDR + Regex allowlist
|
|
24
|
+
# ProxyTrust.trusted?(env, ["10.0.0.0/8", /^192\.168\./], :rightmost)
|
|
25
|
+
def trusted?(env, trusted, strategy = :rightmost)
|
|
26
|
+
return true if trusted.nil? || trusted.empty?
|
|
27
|
+
|
|
28
|
+
# Rails-aligned: prefer REMOTE_ADDR; fallback to nearest XFF entry
|
|
29
|
+
remote = (env['REMOTE_ADDR'] || '').to_s.strip
|
|
30
|
+
remote = extract_peer_ip(env, strategy) if remote.empty?
|
|
31
|
+
return false unless remote
|
|
32
|
+
|
|
33
|
+
remote_ip = ip_or_nil(remote)
|
|
34
|
+
trusted.any? { |rule| rule_trusts?(rule, remote, remote_ip, env) }
|
|
35
|
+
rescue StandardError
|
|
36
|
+
false
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Select the peer IP from X-Forwarded-For according to strategy or fall back to REMOTE_ADDR.
|
|
40
|
+
#
|
|
41
|
+
# @param env [Hash] Rack environment
|
|
42
|
+
# @param strategy [Symbol, String] `:rightmost` (default) or `:leftmost`
|
|
43
|
+
# @return [String, nil] Selected peer IP, or nil if not determinable
|
|
44
|
+
def extract_peer_ip(env, strategy)
|
|
45
|
+
mode = strategy.to_s.to_sym
|
|
46
|
+
xff = env['HTTP_X_FORWARDED_FOR']
|
|
47
|
+
if xff && !xff.strip.empty?
|
|
48
|
+
parts = xff.split(',').map(&:strip)
|
|
49
|
+
ip = mode == :leftmost ? parts.first : parts.last
|
|
50
|
+
return ip
|
|
51
|
+
end
|
|
52
|
+
env['REMOTE_ADDR']
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Return the selected peer IP according to preference and strategy.
|
|
56
|
+
# @param env [Hash]
|
|
57
|
+
# @param preference [Symbol] :remote_then_xff or :xff_only
|
|
58
|
+
# @param strategy [Symbol] :rightmost or :leftmost
|
|
59
|
+
# @return [String, nil]
|
|
60
|
+
def selected_peer(env, preference, strategy)
|
|
61
|
+
case preference.to_s.to_sym
|
|
62
|
+
when :remote_then_xff
|
|
63
|
+
ip = (env['REMOTE_ADDR'] || '').to_s.strip
|
|
64
|
+
return ip unless ip.nil? || ip.empty?
|
|
65
|
+
|
|
66
|
+
extract_peer_ip(env, :rightmost) # nearest by default
|
|
67
|
+
else
|
|
68
|
+
extract_peer_ip(env, strategy)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Parse string to IPAddr or nil on failure.
|
|
73
|
+
# @param str [String]
|
|
74
|
+
# @return [IPAddr, nil]
|
|
75
|
+
def ip_or_nil(str)
|
|
76
|
+
IPAddr.new(str)
|
|
77
|
+
rescue StandardError
|
|
78
|
+
nil
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Check whether a single rule trusts the selected remote.
|
|
82
|
+
# @param rule [String, Regexp, Proc]
|
|
83
|
+
# @param remote [String]
|
|
84
|
+
# @param remote_ip [IPAddr, nil]
|
|
85
|
+
# @param env [Hash]
|
|
86
|
+
# @return [Boolean]
|
|
87
|
+
def rule_trusts?(rule, remote, remote_ip, env)
|
|
88
|
+
case rule
|
|
89
|
+
when String
|
|
90
|
+
if rule.include?('/') # CIDR
|
|
91
|
+
cidr = IPAddr.new(rule)
|
|
92
|
+
remote_ip ? cidr.include?(remote_ip) : false
|
|
93
|
+
else
|
|
94
|
+
remote == rule
|
|
95
|
+
end
|
|
96
|
+
when Regexp
|
|
97
|
+
remote =~ rule
|
|
98
|
+
when Proc
|
|
99
|
+
rule.call(remote, env)
|
|
100
|
+
else
|
|
101
|
+
false
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Determine if the request originates from a trusted proxy subnet.
|
|
106
|
+
# Rails-aligned behavior: prefer REMOTE_ADDR, fallback to nearest (rightmost) X-Forwarded-For.
|
|
107
|
+
# @param env [Hash]
|
|
108
|
+
# @param trusted [Array<String, Regexp, Proc>, nil]
|
|
109
|
+
# @return [Boolean]
|
|
110
|
+
def self.from_trusted_proxy?(env, trusted)
|
|
111
|
+
return true if trusted.nil? || trusted.empty?
|
|
112
|
+
|
|
113
|
+
ip = (env['REMOTE_ADDR'] || '').to_s.strip
|
|
114
|
+
ip = env['HTTP_X_FORWARDED_FOR'].to_s.split(',').last.to_s.strip if ip.empty? && env['HTTP_X_FORWARDED_FOR']
|
|
115
|
+
return false if ip.empty?
|
|
116
|
+
|
|
117
|
+
remote_ip = ip_or_nil(ip)
|
|
118
|
+
return false unless remote_ip
|
|
119
|
+
|
|
120
|
+
trusted.any? { |rule| rule_trusts?(rule, ip, remote_ip, env) }
|
|
121
|
+
rescue StandardError
|
|
122
|
+
false
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Namespace for BFF-related configuration and helpers used by verikloak-bff.
|
|
4
|
+
#
|
|
5
|
+
# @see Verikloak::BFF::Configuration
|
|
6
|
+
|
|
7
|
+
module Verikloak
|
|
8
|
+
# Top-level namespace for verikloak-bff features and configuration.
|
|
9
|
+
module BFF
|
|
10
|
+
class << self
|
|
11
|
+
# Configure global settings for the BFF middleware.
|
|
12
|
+
#
|
|
13
|
+
# @yieldparam config [Verikloak::BFF::Configuration]
|
|
14
|
+
# @return [Verikloak::BFF::Configuration]
|
|
15
|
+
def configure
|
|
16
|
+
@config ||= Configuration.new
|
|
17
|
+
yield @config if block_given?
|
|
18
|
+
@config
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Current global configuration.
|
|
22
|
+
#
|
|
23
|
+
# @return [Verikloak::BFF::Configuration]
|
|
24
|
+
def config
|
|
25
|
+
@config ||= Configuration.new
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# verikloak-bff — library entrypoint.
|
|
4
|
+
#
|
|
5
|
+
# Requiring this file loads the BFF namespace, configuration, middleware, and
|
|
6
|
+
# supporting utilities. Applications typically need only:
|
|
7
|
+
#
|
|
8
|
+
# require 'verikloak-bff'
|
|
9
|
+
# use Verikloak::BFF::HeaderGuard, trusted_proxies: ['127.0.0.1']
|
|
10
|
+
#
|
|
11
|
+
# @see Verikloak::BFF::HeaderGuard
|
|
12
|
+
# @see Verikloak::BFF::Configuration
|
|
13
|
+
require 'verikloak/bff'
|
|
14
|
+
require 'verikloak/bff/version'
|
|
15
|
+
require 'verikloak/bff/configuration'
|
|
16
|
+
require 'verikloak/bff/errors'
|
|
17
|
+
require 'verikloak/bff/proxy_trust'
|
|
18
|
+
require 'verikloak/bff/forwarded_token'
|
|
19
|
+
require 'verikloak/bff/consistency_checks'
|
|
20
|
+
require 'verikloak/bff/header_guard'
|
metadata
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: verikloak-bff
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- taiyaky
|
|
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.7'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '2.7'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: rack
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '2.2'
|
|
33
|
+
- - "<"
|
|
34
|
+
- !ruby/object:Gem::Version
|
|
35
|
+
version: '4.0'
|
|
36
|
+
type: :runtime
|
|
37
|
+
prerelease: false
|
|
38
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
39
|
+
requirements:
|
|
40
|
+
- - ">="
|
|
41
|
+
- !ruby/object:Gem::Version
|
|
42
|
+
version: '2.2'
|
|
43
|
+
- - "<"
|
|
44
|
+
- !ruby/object:Gem::Version
|
|
45
|
+
version: '4.0'
|
|
46
|
+
- !ruby/object:Gem::Dependency
|
|
47
|
+
name: verikloak
|
|
48
|
+
requirement: !ruby/object:Gem::Requirement
|
|
49
|
+
requirements:
|
|
50
|
+
- - ">="
|
|
51
|
+
- !ruby/object:Gem::Version
|
|
52
|
+
version: 0.1.2
|
|
53
|
+
- - "<"
|
|
54
|
+
- !ruby/object:Gem::Version
|
|
55
|
+
version: '0.2'
|
|
56
|
+
type: :runtime
|
|
57
|
+
prerelease: false
|
|
58
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
59
|
+
requirements:
|
|
60
|
+
- - ">="
|
|
61
|
+
- !ruby/object:Gem::Version
|
|
62
|
+
version: 0.1.2
|
|
63
|
+
- - "<"
|
|
64
|
+
- !ruby/object:Gem::Version
|
|
65
|
+
version: '0.2'
|
|
66
|
+
description: Framework-agnostic Rack middleware that normalizes forwarded tokens,
|
|
67
|
+
enforces trust boundaries, and checks header/claims consistency before verikloak.
|
|
68
|
+
executables: []
|
|
69
|
+
extensions: []
|
|
70
|
+
extra_rdoc_files: []
|
|
71
|
+
files:
|
|
72
|
+
- CHANGELOG.md
|
|
73
|
+
- LICENSE
|
|
74
|
+
- README.md
|
|
75
|
+
- lib/verikloak-bff.rb
|
|
76
|
+
- lib/verikloak/bff.rb
|
|
77
|
+
- lib/verikloak/bff/configuration.rb
|
|
78
|
+
- lib/verikloak/bff/consistency_checks.rb
|
|
79
|
+
- lib/verikloak/bff/errors.rb
|
|
80
|
+
- lib/verikloak/bff/forwarded_token.rb
|
|
81
|
+
- lib/verikloak/bff/header_guard.rb
|
|
82
|
+
- lib/verikloak/bff/proxy_trust.rb
|
|
83
|
+
- lib/verikloak/bff/version.rb
|
|
84
|
+
homepage: https://github.com/taiyaky/verikloak-bff
|
|
85
|
+
licenses:
|
|
86
|
+
- MIT
|
|
87
|
+
metadata:
|
|
88
|
+
source_code_uri: https://github.com/taiyaky/verikloak-bff
|
|
89
|
+
changelog_uri: https://github.com/taiyaky/verikloak-bff/blob/main/CHANGELOG.md
|
|
90
|
+
bug_tracker_uri: https://github.com/taiyaky/verikloak-bff/issues
|
|
91
|
+
documentation_uri: https://rubydoc.info/gems/verikloak-bff/0.1.0
|
|
92
|
+
rubygems_mfa_required: 'true'
|
|
93
|
+
rdoc_options: []
|
|
94
|
+
require_paths:
|
|
95
|
+
- lib
|
|
96
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
97
|
+
requirements:
|
|
98
|
+
- - ">="
|
|
99
|
+
- !ruby/object:Gem::Version
|
|
100
|
+
version: '3.1'
|
|
101
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
102
|
+
requirements:
|
|
103
|
+
- - ">="
|
|
104
|
+
- !ruby/object:Gem::Version
|
|
105
|
+
version: '0'
|
|
106
|
+
requirements: []
|
|
107
|
+
rubygems_version: 3.6.9
|
|
108
|
+
specification_version: 4
|
|
109
|
+
summary: BFF header guard for verikloak (oauth2-proxy / auth_request integration)
|
|
110
|
+
test_files: []
|