From 4a48012a8264f18583cf72cc7b16598375fd46af Mon Sep 17 00:00:00 2001 From: Dan Milne Date: Mon, 5 Jan 2026 12:00:29 +1100 Subject: [PATCH] Add claims support --- app/controllers/oidc_controller.rb | 224 +++++++++++++++++++++++--- app/models/oidc_authorization_code.rb | 6 + app/models/oidc_user_consent.rb | 6 + app/services/oidc_jwt_service.rb | 107 +++++++++++- config/initializers/version.rb | 2 +- db/schema.rb | 4 +- 6 files changed, 322 insertions(+), 27 deletions(-) diff --git a/app/controllers/oidc_controller.rb b/app/controllers/oidc_controller.rb index 436cb71..1cacf69 100644 --- a/app/controllers/oidc_controller.rb +++ b/app/controllers/oidc_controller.rb @@ -45,7 +45,8 @@ class OidcController < ApplicationController code_challenge_methods_supported: ["plain", "S256"], backchannel_logout_supported: true, backchannel_logout_session_supported: true, - request_parameter_supported: false + request_parameter_supported: false, + claims_parameter_supported: true } render json: config @@ -165,6 +166,35 @@ class OidcController < ApplicationController end end + # Parse claims parameter (JSON string) for OIDC claims request + # Per OIDC Core §5.5: The claims parameter is a JSON object that requests + # specific claims to be returned in the id_token and/or userinfo + claims_parameter = params[:claims] + parsed_claims = parse_claims_parameter(claims_parameter) if claims_parameter.present? + + # Validate claims parameter format if present + if claims_parameter.present? && parsed_claims.nil? + Rails.logger.error "OAuth: Invalid claims parameter format" + error_uri = "#{redirect_uri}?error=invalid_request" + error_uri += "&error_description=#{CGI.escape("Invalid claims parameter: must be valid JSON")}" + error_uri += "&state=#{CGI.escape(state)}" if state.present? + redirect_to error_uri, allow_other_host: true + return + end + + # Validate that requested claims are covered by granted scopes + if parsed_claims.present? + validation_result = validate_claims_against_scopes(parsed_claims, requested_scopes) + unless validation_result[:valid] + Rails.logger.error "OAuth: Claims parameter requests claims not covered by scopes: #{validation_result[:errors]}" + error_uri = "#{redirect_uri}?error=invalid_scope" + error_uri += "&error_description=#{CGI.escape("Claims parameter requests claims not covered by granted scopes")}" + error_uri += "&state=#{CGI.escape(state)}" if state.present? + redirect_to error_uri, allow_other_host: true + return + end + end + # Check if application is active (now we can safely redirect with error) unless @application.active? Rails.logger.error "OAuth: Application is not active: #{@application.name}" @@ -194,7 +224,8 @@ class OidcController < ApplicationController nonce: nonce, scope: scope, code_challenge: code_challenge, - code_challenge_method: code_challenge_method + code_challenge_method: code_challenge_method, + claims_requests: parsed_claims&.to_json } # Store the current URL (with all OAuth params) for redirect after authentication session[:return_to_after_authenticating] = request.url @@ -260,7 +291,7 @@ class OidcController < ApplicationController # Check if user has already granted consent for these scopes existing_consent = user.has_oidc_consent?(@application, requested_scopes) - if existing_consent + if existing_consent && claims_match_consent?(parsed_claims, existing_consent) # User has already consented, generate authorization code directly auth_code = OidcAuthorizationCode.create!( application: @application, @@ -270,6 +301,7 @@ class OidcController < ApplicationController nonce: nonce, code_challenge: code_challenge, code_challenge_method: code_challenge_method, + claims_requests: parsed_claims || {}, auth_time: Current.session.created_at.to_i, acr: Current.session.acr, expires_at: 10.minutes.from_now @@ -290,7 +322,8 @@ class OidcController < ApplicationController nonce: nonce, scope: scope, code_challenge: code_challenge, - code_challenge_method: code_challenge_method + code_challenge_method: code_challenge_method, + claims_requests: parsed_claims&.to_json } # Render consent page with dynamic CSP for OAuth redirect @@ -355,8 +388,11 @@ class OidcController < ApplicationController # Record user consent requested_scopes = oauth_params["scope"].split(" ") + parsed_claims = JSON.parse(oauth_params["claims_requests"]) rescue {} + consent = OidcUserConsent.find_or_initialize_by(user: user, application: application) consent.scopes_granted = requested_scopes.join(" ") + consent.claims_requests = parsed_claims consent.granted_at = Time.current consent.save! @@ -369,6 +405,7 @@ class OidcController < ApplicationController nonce: oauth_params["nonce"], code_challenge: oauth_params["code_challenge"], code_challenge_method: oauth_params["code_challenge_method"], + claims_requests: parsed_claims, auth_time: Current.session.created_at.to_i, acr: Current.session.acr, expires_at: 10.minutes.from_now @@ -528,6 +565,7 @@ class OidcController < ApplicationController # Generate ID token (JWT) with pairwise SID, at_hash, auth_time, and acr # auth_time and acr come from the authorization code (captured at /authorize time) # scopes determine which claims are included (per OIDC Core spec) + # claims_requests parameter filters which claims are included id_token = OidcJwtService.generate_id_token( user, application, @@ -536,7 +574,8 @@ class OidcController < ApplicationController access_token: access_token_record.plaintext_token, auth_time: auth_code.auth_time, acr: auth_code.acr, - scopes: auth_code.scope + scopes: auth_code.scope, + claims_requests: auth_code.parsed_claims_requests ) # RFC6749-5.1: Token endpoint MUST return Cache-Control: no-store @@ -662,6 +701,7 @@ class OidcController < ApplicationController # Generate new ID token (JWT with pairwise SID, at_hash, auth_time, acr; no nonce for refresh grants) # auth_time and acr come from the original refresh token (carried over from initial auth) # scopes determine which claims are included (per OIDC Core spec) + # claims_requests parameter filters which claims are included (from original consent) id_token = OidcJwtService.generate_id_token( user, application, @@ -669,7 +709,8 @@ class OidcController < ApplicationController access_token: new_access_token.plaintext_token, auth_time: refresh_token_record.auth_time, acr: refresh_token_record.acr, - scopes: refresh_token_record.scope + scopes: refresh_token_record.scope, + claims_requests: consent.parsed_claims_requests ) # RFC6749-5.1: Token endpoint MUST return Cache-Control: no-store @@ -733,33 +774,45 @@ class OidcController < ApplicationController # Parse scopes from access token (space-separated string) requested_scopes = access_token.scope.to_s.split + # Get claims_requests from consent (if available) for UserInfo context + userinfo_claims = consent&.parsed_claims_requests&.dig("userinfo") || {} + # Return user claims (filter by scope per OIDC Core spec) - # Required claims (always included) + # Required claims (always included - cannot be filtered by claims parameter) claims = { sub: subject } - # Email claims (only if 'email' scope requested) + # Email claims (only if 'email' scope requested AND requested in claims parameter) if requested_scopes.include?("email") - claims[:email] = user.email_address - claims[:email_verified] = true + if should_include_claim_for_userinfo?("email", userinfo_claims) + claims[:email] = user.email_address + end + if should_include_claim_for_userinfo?("email_verified", userinfo_claims) + claims[:email_verified] = true + end end # Profile claims (only if 'profile' scope requested) # Per OIDC Core spec section 5.4, include available profile claims # Only include claims we have data for - omit unknown claims rather than returning null if requested_scopes.include?("profile") - # Use username if available, otherwise email as preferred_username - claims[:preferred_username] = user.username.presence || user.email_address - # Name: use stored name or fall back to email - claims[:name] = user.name.presence || user.email_address - # Time the user's information was last updated - claims[:updated_at] = user.updated_at.to_i + if should_include_claim_for_userinfo?("preferred_username", userinfo_claims) + claims[:preferred_username] = user.username.presence || user.email_address + end + if should_include_claim_for_userinfo?("name", userinfo_claims) + claims[:name] = user.name.presence || user.email_address + end + if should_include_claim_for_userinfo?("updated_at", userinfo_claims) + claims[:updated_at] = user.updated_at.to_i + end end - # Groups claim (only if 'groups' scope requested) + # Groups claim (only if 'groups' scope requested AND requested in claims parameter) if requested_scopes.include?("groups") && user.groups.any? - claims[:groups] = user.groups.pluck(:name) + if should_include_claim_for_userinfo?("groups", userinfo_claims) + claims[:groups] = user.groups.pluck(:name) + end end # Merge custom claims from groups @@ -774,6 +827,12 @@ class OidcController < ApplicationController application = access_token.application claims.merge!(application.custom_claims_for_user(user)) + # Filter custom claims based on claims parameter + # If claims parameter is present, only include requested custom claims + if userinfo_claims.any? + claims = filter_custom_claims_for_userinfo(claims, userinfo_claims) + end + # Security: Don't cache user data responses response.headers["Cache-Control"] = "no-store" response.headers["Pragma"] = "no-cache" @@ -1043,4 +1102,133 @@ class OidcController < ApplicationController # Log error but don't block logout Rails.logger.error "OidcController: Failed to enqueue backchannel logout: #{e.class} - #{e.message}" end + + # Parse claims parameter JSON string + # Per OIDC Core §5.5: The claims parameter is a JSON object containing + # id_token and/or userinfo keys, each mapping to claim requests + def parse_claims_parameter(claims_string) + return {} if claims_string.blank? + + parsed = JSON.parse(claims_string) + return nil unless parsed.is_a?(Hash) + + # Validate structure: can have id_token, userinfo, or both + valid_keys = parsed.keys & ["id_token", "userinfo"] + return nil if valid_keys.empty? + + # Validate each claim request has proper structure + valid_keys.each do |key| + next unless parsed[key].is_a?(Hash) + + parsed[key].each do |_claim_name, claim_spec| + # Claim spec can be null (requested), true (essential), or a hash with specific keys + next if claim_spec.nil? || claim_spec == true || claim_spec == false + next if claim_spec.is_a?(Hash) && claim_spec.keys.all? { |k| ["essential", "value", "values"].include?(k) } + + # Invalid claim specification + return nil + end + end + + parsed + rescue JSON::ParserError + nil + end + + # Validate that requested claims are covered by granted scopes + # Per OIDC Core §5.5: Claims can only be requested if the corresponding scope is granted + def validate_claims_against_scopes(parsed_claims, granted_scopes) + granted = Array(granted_scopes).map(&:to_s) + errors = [] + + # Standard claim-to-scope mapping + claim_scope_mapping = { + "email" => "email", + "email_verified" => "email", + "preferred_username" => "profile", + "name" => "profile", + "updated_at" => "profile", + "groups" => "groups" + } + + # Check both id_token and userinfo claims + ["id_token", "userinfo"].each do |context| + next unless parsed_claims[context]&.is_a?(Hash) + + parsed_claims[context].each do |claim_name, _claim_spec| + # Skip custom claims (not in standard mapping) + # Custom claims are allowed since they're configured in the IdP + next unless claim_scope_mapping.key?(claim_name) + + required_scope = claim_scope_mapping[claim_name] + unless granted.include?(required_scope) + errors << "#{claim_name} requires #{required_scope} scope" + end + end + end + + if errors.any? + {valid: false, errors: errors} + else + {valid: true} + end + end + + # Check if claims match existing consent + # For MVP: treat any claims request as requiring new consent if consent has no claims stored + def claims_match_consent?(parsed_claims, consent) + return true if parsed_claims.nil? || parsed_claims.empty? + + # If consent has no claims stored, this is a new claims request + # Require fresh consent + return false if consent.parsed_claims_requests.empty? + + # If both have claims, they must match exactly + consent.parsed_claims_requests == parsed_claims + end + + # Check if a claim should be included in UserInfo response + # Returns true if no claims filtering or claim is explicitly requested + def should_include_claim_for_userinfo?(claim_name, userinfo_claims) + return true if userinfo_claims.empty? + userinfo_claims.key?(claim_name) + end + + # Filter custom claims for UserInfo endpoint + # Removes claims not explicitly requested + # Applies value/values filtering if specified + def filter_custom_claims_for_userinfo(claims, userinfo_claims) + # Get all claim names that are NOT standard OIDC claims + standard_claims = %w[sub email email_verified name preferred_username updated_at groups] + custom_claim_names = claims.keys.map(&:to_s) - standard_claims + + filtered = claims.dup + + custom_claim_names.each do |claim_name| + claim_sym = claim_name.to_sym + + unless userinfo_claims.key?(claim_name) || userinfo_claims.key?(claim_sym) + filtered.delete(claim_sym) + next + end + + # Apply value/values filtering if specified + claim_spec = userinfo_claims[claim_name] || userinfo_claims[claim_sym] + next unless claim_spec.is_a?(Hash) + + current_value = filtered[claim_sym] + + # Check value constraint + if claim_spec["value"].present? + filtered.delete(claim_sym) unless current_value == claim_spec["value"] + end + + # Check values constraint (array of allowed values) + if claim_spec["values"].is_a?(Array) + filtered.delete(claim_sym) unless claim_spec["values"].include?(current_value) + end + end + + filtered + end end diff --git a/app/models/oidc_authorization_code.rb b/app/models/oidc_authorization_code.rb index df3e791..825cdd8 100644 --- a/app/models/oidc_authorization_code.rb +++ b/app/models/oidc_authorization_code.rb @@ -44,6 +44,12 @@ class OidcAuthorizationCode < ApplicationRecord code_challenge.present? end + # Parse claims_requests JSON field + def parsed_claims_requests + return {} if claims_requests.blank? + claims_requests.is_a?(Hash) ? claims_requests : {} + end + private def generate_code diff --git a/app/models/oidc_user_consent.rb b/app/models/oidc_user_consent.rb index ebb3c3f..f6031e3 100644 --- a/app/models/oidc_user_consent.rb +++ b/app/models/oidc_user_consent.rb @@ -50,6 +50,12 @@ class OidcUserConsent < ApplicationRecord find_by(sid: sid) end + # Parse claims_requests JSON field + def parsed_claims_requests + return {} if claims_requests.blank? + claims_requests.is_a?(Hash) ? claims_requests : {} + end + private def set_granted_at diff --git a/app/services/oidc_jwt_service.rb b/app/services/oidc_jwt_service.rb index 3415d63..534f8f0 100644 --- a/app/services/oidc_jwt_service.rb +++ b/app/services/oidc_jwt_service.rb @@ -3,7 +3,7 @@ class OidcJwtService class << self # Generate an ID token (JWT) for the user - def generate_id_token(user, application, consent: nil, nonce: nil, access_token: nil, auth_time: nil, acr: nil, scopes: "openid") + def generate_id_token(user, application, consent: nil, nonce: nil, access_token: nil, auth_time: nil, acr: nil, scopes: "openid", claims_requests: {}) now = Time.current.to_i # Use application's configured ID token TTL (defaults to 1 hour) ttl = application.id_token_expiry_seconds @@ -14,6 +14,9 @@ class OidcJwtService # Parse scopes (space-separated string) requested_scopes = scopes.to_s.split + # Parse claims_requests parameter for id_token context + id_token_claims = claims_requests["id_token"] || {} + # Required claims (always included per OIDC Core spec) payload = { iss: issuer_url, @@ -23,10 +26,28 @@ class OidcJwtService iat: now } - # NOTE: Email and profile claims are NOT included in the ID token for authorization code flow - # Per OIDC Core spec §5.4, these claims should only be returned via the UserInfo endpoint - # For implicit flow (response_type=id_token), claims would be included here, but we only - # support authorization code flow, so these claims are omitted from the ID token. + # Email claims (only if 'email' scope requested AND either no claims filter OR email requested) + if requested_scopes.include?("email") + if should_include_claim?("email", id_token_claims) + payload[:email] = user.email_address + end + if should_include_claim?("email_verified", id_token_claims) + payload[:email_verified] = true + end + end + + # Profile claims (only if 'profile' scope requested) + if requested_scopes.include?("profile") + if should_include_claim?("preferred_username", id_token_claims) + payload[:preferred_username] = user.username.presence || user.email_address + end + if should_include_claim?("name", id_token_claims) + payload[:name] = user.name.presence || user.email_address + end + if should_include_claim?("updated_at", id_token_claims) + payload[:updated_at] = user.updated_at.to_i + end + end # Add nonce if provided (OIDC requires this for implicit flow) payload[:nonce] = nonce if nonce.present? @@ -49,9 +70,11 @@ class OidcJwtService payload[:at_hash] = at_hash end - # Groups claims (only if 'groups' scope requested) + # Groups claims (only if 'groups' scope requested AND requested in claims parameter) if requested_scopes.include?("groups") && user.groups.any? - payload[:groups] = user.groups.pluck(:name) + if should_include_claim?("groups", id_token_claims) + payload[:groups] = user.groups.pluck(:name) + end end # Merge custom claims from groups (arrays are combined, not overwritten) @@ -66,6 +89,12 @@ class OidcJwtService # Merge app-specific custom claims (highest priority, arrays are combined) payload = deep_merge_claims(payload, application.custom_claims_for_user(user)) + # Filter custom claims based on claims parameter + # If claims parameter is present, only include requested custom claims + if id_token_claims.any? + payload = filter_custom_claims(payload, id_token_claims) + end + JWT.encode(payload, private_key, "RS256", {kid: key_id, typ: "JWT"}) end @@ -178,5 +207,69 @@ class OidcJwtService def key_id @key_id ||= Digest::SHA256.hexdigest(public_key.to_pem)[0..15] end + + # Check if a claim should be included based on claims parameter + # Returns true if: + # - No claims parameter specified (include all scope-based claims) + # - Claim is explicitly requested (even with null spec or essential: true) + def should_include_claim?(claim_name, id_token_claims) + # No claims parameter = include all scope-based claims + return true if id_token_claims.empty? + + # Check if claim is requested + return false unless id_token_claims.key?(claim_name) + + # Claim specification can be: + # - null (requested) + # - true (essential, requested) + # - false (not requested) + # - Hash with essential/value/values + + claim_spec = id_token_claims[claim_name] + return true if claim_spec.nil? || claim_spec == true + return false if claim_spec == false + + # If it's a hash, the claim is requested (filtering happens later) + true if claim_spec.is_a?(Hash) + end + + # Filter custom claims based on claims parameter + # Removes claims not explicitly requested + # Applies value/values filtering if specified + def filter_custom_claims(payload, id_token_claims) + # Get all claim names that are NOT standard OIDC claims + standard_claims = %w[iss sub aud exp iat nbf jti nonce azp at_hash auth_time acr email email_verified name preferred_username updated_at groups] + custom_claim_names = payload.keys.map(&:to_s) - standard_claims + + filtered = payload.dup + + custom_claim_names.each do |claim_name| + claim_sym = claim_name.to_sym + + # If claim is not requested, remove it + unless id_token_claims.key?(claim_name) || id_token_claims.key?(claim_sym) + filtered.delete(claim_sym) + next + end + + # Apply value/values filtering if specified + claim_spec = id_token_claims[claim_name] || id_token_claims[claim_sym] + next unless claim_spec.is_a?(Hash) + + current_value = filtered[claim_sym] + + # Check value constraint + if claim_spec["value"].present? + filtered.delete(claim_sym) unless current_value == claim_spec["value"] + end + + # Check values constraint (array of allowed values) + if claim_spec["values"].is_a?(Array) + filtered.delete(claim_sym) unless claim_spec["values"].include?(current_value) + end + end + + filtered + end end end diff --git a/config/initializers/version.rb b/config/initializers/version.rb index f2bd951..75858ab 100644 --- a/config/initializers/version.rb +++ b/config/initializers/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Clinch - VERSION = "0.8.4" + VERSION = "0.8.6" end diff --git a/db/schema.rb b/db/schema.rb index 9a41079..d716a7e 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2025_12_31_060112) do +ActiveRecord::Schema[8.1].define(version: 2026_01_05_000809) do create_table "active_storage_attachments", force: :cascade do |t| t.bigint "blob_id", null: false t.datetime "created_at", null: false @@ -116,6 +116,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_12_31_060112) do t.string "acr" t.integer "application_id", null: false t.integer "auth_time" + t.json "claims_requests", default: {}, null: false t.string "code_challenge" t.string "code_challenge_method" t.string "code_hmac", null: false @@ -160,6 +161,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_12_31_060112) do create_table "oidc_user_consents", force: :cascade do |t| t.integer "application_id", null: false + t.json "claims_requests", default: {}, null: false t.datetime "created_at", null: false t.datetime "granted_at", null: false t.text "scopes_granted", null: false