verikloak-audience 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 983947348061c7b7dd379572850ec12de6bddda37b511b03466301b22c15256f
4
+ data.tar.gz: 2bd99a2a76fdc020d4f3a51a1db11151d0b38e9429847294f0431abb7e47dd17
5
+ SHA512:
6
+ metadata.gz: 6bca90d90c7d35408009fcd6007ac317115743eda14dc4e8f1e9853a3fdb9350f4cb5db426e032631faf2e4ef9f96a86885942145bad40d7e809b5eb1a748905
7
+ data.tar.gz: 3bcc1d7a44a094a3e15d8b33e79fe46ac66e21eb84175799d0469f701522949340e82b43df0d6e029a7396febc9c0ab16a196efdc1cce6bb25358b674019463e
data/CHANGELOG.md ADDED
@@ -0,0 +1,17 @@
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-20
11
+
12
+ ### Added
13
+ - Rack middleware for audience validation with configurable profiles (`:strict_single`, `:allow_account`, `:resource_or_aud`).
14
+ - Configuration helpers and profile checker with suggestion logging.
15
+ - Rails railtie for automatic middleware insertion after core Verikloak.
16
+ - Error reference documentation (`ERRORS.md`) describing 403 responses and exception classes.
17
+ - English README with installation, configuration tables, and integration guidance.
data/LICENSE ADDED
@@ -0,0 +1,19 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025
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 do so, subject to the following
10
+ conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
18
+ COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
19
+ ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,85 @@
1
+ # verikloak-audience
2
+
3
+ Rack middleware for validating the `aud` claim of Keycloak-issued tokens on top of the Verikloak stack. It ships with deploy-friendly presets that address common Keycloak patterns such as `account` co-existence and `resource_access`-driven role enforcement.
4
+
5
+ For the full error behaviour (response shapes, exception classes, logging hints), see [ERRORS.md](ERRORS.md).
6
+
7
+ > Insert the middleware immediately **after** `Verikloak::Middleware`. Doing so ensures the token is already verified and the claims are available via `env["verikloak.user"]` (default).
8
+
9
+ ## Why
10
+ - Keycloak often emits multiple audiences (e.g. `["rails-api","account"]`)
11
+ - Some deployments primarily rely on `resource_access[client].roles`
12
+ - Re-implementing permissive/strict `aud` checks per app is a maintenance burden
13
+
14
+ ## Profiles
15
+
16
+ | Profile | Summary | Suggested scenarios / when `suggest_in_logs` points here |
17
+ |---------|---------|-----------------------------------------------------------|
18
+ | `:strict_single` *(recommended)* | Requires `aud` to match `required_aud` exactly (order-insensitive, no extras). | APIs where audiences are cleanly separated. Logged suggestion when the observed `aud` already equals the configured list. |
19
+ | `:allow_account` | Allows `account` in addition to required audiences (e.g. `["rails-api","account"]`). | SPA + API mixes where Keycloak always emits `account`. Suggested when strict mode fails and the log shows `profile=:allow_account`. |
20
+ | `:resource_or_aud` | Passes when `resource_access[client].roles` is present; otherwise falls back to `:allow_account`. | Services relying on resource roles. Suggested when logs output `profile=:resource_or_aud`. |
21
+
22
+ ## Installation
23
+
24
+ ```bash
25
+ bundle add verikloak-audience
26
+ ```
27
+
28
+ ## Rack / Rails usage
29
+
30
+ Insert **after** `Verikloak::Middleware`:
31
+
32
+ ```ruby
33
+ # config/application.rb
34
+ config.middleware.insert_after Verikloak::Middleware, Verikloak::Audience::Middleware,
35
+ profile: :allow_account,
36
+ required_aud: ["rails-api"],
37
+ resource_client: "rails-api",
38
+ env_claims_key: "verikloak.user",
39
+ suggest_in_logs: true
40
+ ```
41
+
42
+ See [`examples/rack.ru`](examples/rack.ru) for a full Rack sample. In Rails, always insert immediately after the core middleware; otherwise `env['verikloak.user']` will be empty and every request will fail with 403.
43
+
44
+ ## Configuration
45
+
46
+ | Option | Type | Default | Description |
47
+ |--------|------|---------|-------------|
48
+ | `profile` | Symbol | `:strict_single` | Profile selector. Accepts `:strict_single`, `:allow_account`, or `:resource_or_aud`. |
49
+ | `required_aud` | Array/String/Symbol | `[]` | Required audience values; coerced to an array internally. |
50
+ | `resource_client` | String | `"rails-api"` | Keycloak client id used to look up `resource_access[client].roles`. |
51
+ | `env_claims_key` | String | `"verikloak.user"` | Rack env key where verified claims are stored. |
52
+ | `suggest_in_logs` | Boolean | `true` | Emits a WARN log with the suggested profile when validation fails. |
53
+
54
+ `env_claims_key` assumes the preceding `Verikloak::Middleware` populates the Rack env. If the middleware order changes, claims will be missing and the audience check will always reject.
55
+
56
+ ## Testing
57
+ All pull requests and pushes are automatically tested with [RSpec](https://rspec.info/) and [RuboCop](https://rubocop.org/) via GitHub Actions.
58
+ See the CI badge at the top for current build status.
59
+
60
+ To run the test suite locally:
61
+
62
+ ```bash
63
+ docker compose run --rm dev rspec
64
+ docker compose run --rm dev rubocop -a
65
+ ```
66
+
67
+ ## Contributing
68
+ Bug reports and pull requests are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for details.
69
+
70
+ ## Security
71
+ If you find a security vulnerability, please follow the instructions in [SECURITY.md](SECURITY.md).
72
+
73
+ ## License
74
+ This project is licensed under the [MIT License](LICENSE).
75
+
76
+ ## Publishing (for maintainers)
77
+ Gem release instructions are documented separately in [MAINTAINERS.md](MAINTAINERS.md).
78
+
79
+ ## Changelog
80
+ See [CHANGELOG.md](CHANGELOG.md) for release history.
81
+
82
+ ## References
83
+ - Verikloak (core): https://github.com/taiyaky/verikloak
84
+ - verikloak-rails (Rails integration): https://github.com/taiyaky/verikloak-rails
85
+ - verikloak-audience on RubyGems: https://rubygems.org/gems/verikloak-audience
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Verikloak
4
+ module Audience
5
+ # Audience profile checker functions.
6
+ #
7
+ # This module provides predicate helpers used by the middleware to decide
8
+ # whether a given set of claims satisfies the configured profile.
9
+ module Checker
10
+ module_function
11
+
12
+ # Returns whether the given claims satisfy the configured profile.
13
+ #
14
+ # @param claims [Hash] OIDC claims (expects keys like "aud", "resource_access")
15
+ # @param cfg [Verikloak::Audience::Configuration]
16
+ # @return [Boolean]
17
+ def ok?(claims, cfg)
18
+ profile = cfg.profile.to_sym
19
+ profile = :strict_single unless %i[strict_single allow_account resource_or_aud].include?(profile)
20
+
21
+ case profile
22
+ when :strict_single
23
+ strict_single?(claims, cfg.required_aud_list)
24
+ when :allow_account
25
+ allow_account?(claims, cfg.required_aud_list)
26
+ when :resource_or_aud
27
+ resource_or_aud?(claims, cfg.resource_client.to_s, cfg.required_aud_list)
28
+ end
29
+ end
30
+
31
+ # Validate that aud matches required exactly (order-insensitive).
32
+ #
33
+ # @param claims [Hash]
34
+ # @param required [Array<String>]
35
+ # @return [Boolean]
36
+ def strict_single?(claims, required)
37
+ aud = Array(claims['aud']).map(&:to_s)
38
+ return false if required.empty?
39
+
40
+ # Must contain all required and have no unexpected extra (order-insensitive)
41
+ (aud.sort == required.map(&:to_s).sort)
42
+ end
43
+
44
+ # Validate aud allowing "account" as an extra value.
45
+ #
46
+ # @param claims [Hash]
47
+ # @param required [Array<String>]
48
+ # @return [Boolean]
49
+ def allow_account?(claims, required)
50
+ aud = Array(claims['aud']).map(&:to_s)
51
+ return false if required.empty?
52
+
53
+ # Permit 'account' extra
54
+ extras = aud - required
55
+ extras.delete('account')
56
+ extras.empty? && (required - aud).empty?
57
+ end
58
+
59
+ # Permit when resource roles exist for the client; otherwise fallback to
60
+ # {#allow_account?}.
61
+ #
62
+ # @param claims [Hash]
63
+ # @param client [String]
64
+ # @param required [Array<String>]
65
+ # @return [Boolean]
66
+ def resource_or_aud?(claims, client, required)
67
+ roles = Array(claims.dig('resource_access', client, 'roles'))
68
+ return true unless roles.empty? # if roles for client exist, pass
69
+
70
+ # otherwise enforce allow_account semantics by default
71
+ allow_account?(claims, required)
72
+ end
73
+
74
+ # Suggest which profile might fit better, for migration aid.
75
+ #
76
+ # @param claims [Hash]
77
+ # @param cfg [Verikloak::Audience::Configuration]
78
+ # @return [:strict_single, :allow_account, :resource_or_aud]
79
+ def suggest(claims, cfg)
80
+ aud = Array(claims['aud']).map(&:to_s)
81
+ req = cfg.required_aud_list
82
+ has_roles = !Array(claims.dig('resource_access', cfg.resource_client.to_s, 'roles')).empty?
83
+
84
+ return :strict_single if aud.sort == req.sort
85
+ return :allow_account if (aud - req) == ['account'] && (req - aud).empty?
86
+ return :resource_or_aud if has_roles
87
+
88
+ :strict_single
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Verikloak
4
+ module Audience
5
+ # Configuration holder for verikloak-audience.
6
+ #
7
+ # @!attribute [rw] profile
8
+ # The enforcement profile to use.
9
+ # @return [:strict_single, :allow_account, :resource_or_aud]
10
+ # @!attribute [rw] required_aud
11
+ # Required audience(s). Can be a String/Symbol or an Array of them.
12
+ # @return [Array<String,Symbol>, String, Symbol]
13
+ # @!attribute [rw] resource_client
14
+ # Client id to use for `resource_access[client].roles` lookup.
15
+ # @return [String]
16
+ # @!attribute [rw] env_claims_key
17
+ # Rack env key from which to read verified claims.
18
+ # @return [String]
19
+ # @!attribute [rw] suggest_in_logs
20
+ # Whether to log a suggestion when audience validation fails.
21
+ # @return [Boolean]
22
+ class Configuration
23
+ attr_accessor :profile, :required_aud, :resource_client,
24
+ :env_claims_key, :suggest_in_logs
25
+
26
+ # Create a configuration with safe defaults.
27
+ #
28
+ # @return [void]
29
+ def initialize
30
+ @profile = :strict_single
31
+ @required_aud = []
32
+ @resource_client = 'rails-api'
33
+ @env_claims_key = 'verikloak.user'
34
+ @suggest_in_logs = true
35
+ end
36
+
37
+ # Coerce `required_aud` into an array of strings.
38
+ #
39
+ # @return [Array<String>]
40
+ def required_aud_list
41
+ Array(required_aud).map(&:to_s)
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Verikloak
4
+ module Audience
5
+ # Base error for audience failures.
6
+ #
7
+ # @!attribute [r] code
8
+ # Machine-friendly error code (e.g. "insufficient_audience").
9
+ # @return [String]
10
+ # @!attribute [r] http_status
11
+ # HTTP status code associated with the error.
12
+ # @return [Integer]
13
+ class Error < StandardError
14
+ attr_reader :code, :http_status
15
+
16
+ # @param msg [String] human-readable error message
17
+ # @param code [String] machine-friendly error code
18
+ # @param http_status [Integer] associated HTTP status
19
+ def initialize(msg = 'audience error', code: 'audience_error', http_status: 403)
20
+ super(msg)
21
+ @code = code
22
+ @http_status = http_status
23
+ end
24
+ end
25
+
26
+ # Raised when audience is insufficient for the configured profile.
27
+ class Forbidden < Error
28
+ # @param msg [String]
29
+ def initialize(msg = 'insufficient audience')
30
+ super(msg, code: 'insufficient_audience', http_status: 403)
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'verikloak/audience'
5
+ require 'verikloak/audience/configuration'
6
+ require 'verikloak/audience/checker'
7
+ require 'verikloak/audience/errors'
8
+
9
+ module Verikloak
10
+ module Audience
11
+ # Rack middleware that validates audience claims according to configured profile.
12
+ #
13
+ # Place this middleware after the core {::Verikloak::Middleware} so that
14
+ # verified token claims are already available in the Rack env.
15
+ class Middleware
16
+ # @param app [#call] next Rack application
17
+ # @param opts [Hash] configuration overrides (see {Configuration})
18
+ # @option opts [Symbol] :profile
19
+ # @option opts [Array<String>,String,Symbol] :required_aud
20
+ # @option opts [String] :resource_client
21
+ # @option opts [String] :env_claims_key
22
+ # @option opts [Boolean] :suggest_in_logs
23
+ def initialize(app, **opts)
24
+ @app = app
25
+ @config = Verikloak::Audience.configure
26
+ apply_overrides!(opts)
27
+ end
28
+
29
+ # Evaluate the request against the audience profile.
30
+ #
31
+ # @param env [Hash] Rack environment
32
+ # @return [Array(Integer, Hash, #each)] Rack response triple
33
+ def call(env)
34
+ claims = env[@config.env_claims_key] || {}
35
+ return @app.call(env) if Checker.ok?(claims, @config)
36
+
37
+ if @config.suggest_in_logs
38
+ suggestion = Checker.suggest(claims, @config)
39
+ aud_view = Array(claims['aud']).inspect
40
+ warn("[verikloak-audience] insufficient_audience; suggestion profile=:#{suggestion} aud=#{aud_view}")
41
+ end
42
+
43
+ body = { error: 'insufficient_audience',
44
+ message: "Audience not acceptable for profile #{@config.profile}" }.to_json
45
+ headers = { 'Content-Type' => 'application/json' }
46
+ [403, headers, [body]]
47
+ end
48
+
49
+ private
50
+
51
+ # Apply provided options to the configuration instance.
52
+ #
53
+ # @param opts [Hash]
54
+ # @return [void]
55
+ def apply_overrides!(opts)
56
+ cfg = @config
57
+ opts.each do |k, v|
58
+ cfg.public_send("#{k}=", v) if cfg.respond_to?("#{k}=")
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/railtie'
4
+
5
+ module Verikloak
6
+ module Audience
7
+ # Rails integration for verikloak-audience.
8
+ #
9
+ # This Railtie automatically inserts {Verikloak::Audience::Middleware}
10
+ # into the Rails middleware stack directly after {::Verikloak::Middleware}
11
+ # when it is present.
12
+ #
13
+ # If the core Verikloak middleware is not available, nothing is inserted.
14
+ # You may still customize the placement in your application via
15
+ # `config.middleware`.
16
+ #
17
+ # @example Configure placement and options
18
+ # # config/application.rb
19
+ # config.middleware.insert_after Verikloak::Middleware,
20
+ # Verikloak::Audience::Middleware,
21
+ # profile: :allow_account,
22
+ # required_aud: ['rails-api'],
23
+ # resource_client: 'rails-api',
24
+ # env_claims_key: 'verikloak.user',
25
+ # suggest_in_logs: true
26
+ class Railtie < ::Rails::Railtie
27
+ # Adds the audience middleware after the core Verikloak middleware
28
+ # when available.
29
+ #
30
+ # @param app [Rails::Application] the Rails application instance
31
+ initializer 'verikloak_audience.middleware' do |app|
32
+ # Insert automatically after core verikloak if present
33
+ self.class.insert_middleware(app)
34
+ end
35
+
36
+ # Performs the insertion into the middleware stack when the core
37
+ # Verikloak middleware is available. Extracted for testability without
38
+ # requiring a full Rails boot process.
39
+ #
40
+ # @param app [#middleware] An object exposing a Rack middleware stack via `#middleware`.
41
+ # @return [void]
42
+ def self.insert_middleware(app)
43
+ return unless defined?(::Verikloak::Middleware)
44
+
45
+ app.middleware.insert_after ::Verikloak::Middleware, ::Verikloak::Audience::Middleware
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Verikloak
4
+ module Audience
5
+ # Current gem version.
6
+ # @return [String]
7
+ VERSION = '0.1.0'
8
+ end
9
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Verikloak::Audience provides Audience integration over Keycloak claims.
4
+ require 'verikloak/audience/version'
5
+ require 'verikloak/audience/configuration'
6
+ require 'verikloak/audience/errors'
7
+ require 'verikloak/audience/checker'
8
+ require 'verikloak/audience/middleware'
9
+ require 'verikloak/audience/railtie' if defined?(Rails::Railtie)
10
+
11
+ module Verikloak
12
+ # Audience configuration entrypoint and helpers.
13
+ # This file also requires the public components of the gem.
14
+ module Audience
15
+ class << self
16
+ # Configure verikloak-audience.
17
+ #
18
+ # When a block is given, the current configuration is yielded so callers
19
+ # can mutate settings in one place.
20
+ #
21
+ # @yield [config] yields the current configuration for mutation
22
+ # @yieldparam config [Verikloak::Audience::Configuration]
23
+ # @return [Verikloak::Audience::Configuration] the resulting configuration
24
+ def configure
25
+ @config ||= Configuration.new
26
+ yield @config if block_given?
27
+ @config
28
+ end
29
+
30
+ # Access the current configuration without mutating it.
31
+ #
32
+ # @return [Verikloak::Audience::Configuration]
33
+ def config
34
+ @config ||= Configuration.new
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Minimal shim to load the namespaced entrypoint.
4
+ #
5
+ # This file preserves compatibility with Bundler's default require
6
+ # (`require 'verikloak-audience'`) by delegating to the real entrypoint
7
+ # under the namespaced path (`verikloak/audience`).
8
+ require 'verikloak/audience'
metadata ADDED
@@ -0,0 +1,96 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: verikloak-audience
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: rack
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '2.2'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '4.0'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '2.2'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '4.0'
32
+ - !ruby/object:Gem::Dependency
33
+ name: verikloak
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - ">="
37
+ - !ruby/object:Gem::Version
38
+ version: 0.1.2
39
+ - - "<"
40
+ - !ruby/object:Gem::Version
41
+ version: '0.2'
42
+ type: :runtime
43
+ prerelease: false
44
+ version_requirements: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: 0.1.2
49
+ - - "<"
50
+ - !ruby/object:Gem::Version
51
+ version: '0.2'
52
+ description: |
53
+ Rack middleware that enforces audience checks with deployable profiles,
54
+ layering on top of Verikloak token verification.
55
+ executables: []
56
+ extensions: []
57
+ extra_rdoc_files: []
58
+ files:
59
+ - CHANGELOG.md
60
+ - LICENSE
61
+ - README.md
62
+ - lib/verikloak-audience.rb
63
+ - lib/verikloak/audience.rb
64
+ - lib/verikloak/audience/checker.rb
65
+ - lib/verikloak/audience/configuration.rb
66
+ - lib/verikloak/audience/errors.rb
67
+ - lib/verikloak/audience/middleware.rb
68
+ - lib/verikloak/audience/railtie.rb
69
+ - lib/verikloak/audience/version.rb
70
+ homepage: https://github.com/taiyaky/verikloak-audience
71
+ licenses:
72
+ - MIT
73
+ metadata:
74
+ source_code_uri: https://github.com/taiyaky/verikloak-audience
75
+ changelog_uri: https://github.com/taiyaky/verikloak-audience/blob/main/CHANGELOG.md
76
+ bug_tracker_uri: https://github.com/taiyaky/verikloak-audience/issues
77
+ documentation_uri: https://rubydoc.info/gems/verikloak-audience/0.1.0
78
+ rubygems_mfa_required: 'true'
79
+ rdoc_options: []
80
+ require_paths:
81
+ - lib
82
+ required_ruby_version: !ruby/object:Gem::Requirement
83
+ requirements:
84
+ - - ">="
85
+ - !ruby/object:Gem::Version
86
+ version: '3.1'
87
+ required_rubygems_version: !ruby/object:Gem::Requirement
88
+ requirements:
89
+ - - ">="
90
+ - !ruby/object:Gem::Version
91
+ version: '0'
92
+ requirements: []
93
+ rubygems_version: 3.6.9
94
+ specification_version: 4
95
+ summary: Audience profiles for Keycloak on top of Verikloak
96
+ test_files: []