Add claims support
This commit is contained in:
@@ -45,7 +45,8 @@ class OidcController < ApplicationController
|
|||||||
code_challenge_methods_supported: ["plain", "S256"],
|
code_challenge_methods_supported: ["plain", "S256"],
|
||||||
backchannel_logout_supported: true,
|
backchannel_logout_supported: true,
|
||||||
backchannel_logout_session_supported: true,
|
backchannel_logout_session_supported: true,
|
||||||
request_parameter_supported: false
|
request_parameter_supported: false,
|
||||||
|
claims_parameter_supported: true
|
||||||
}
|
}
|
||||||
|
|
||||||
render json: config
|
render json: config
|
||||||
@@ -165,6 +166,35 @@ class OidcController < ApplicationController
|
|||||||
end
|
end
|
||||||
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)
|
# Check if application is active (now we can safely redirect with error)
|
||||||
unless @application.active?
|
unless @application.active?
|
||||||
Rails.logger.error "OAuth: Application is not active: #{@application.name}"
|
Rails.logger.error "OAuth: Application is not active: #{@application.name}"
|
||||||
@@ -194,7 +224,8 @@ class OidcController < ApplicationController
|
|||||||
nonce: nonce,
|
nonce: nonce,
|
||||||
scope: scope,
|
scope: scope,
|
||||||
code_challenge: code_challenge,
|
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
|
# Store the current URL (with all OAuth params) for redirect after authentication
|
||||||
session[:return_to_after_authenticating] = request.url
|
session[:return_to_after_authenticating] = request.url
|
||||||
@@ -260,7 +291,7 @@ class OidcController < ApplicationController
|
|||||||
|
|
||||||
# Check if user has already granted consent for these scopes
|
# Check if user has already granted consent for these scopes
|
||||||
existing_consent = user.has_oidc_consent?(@application, requested_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
|
# User has already consented, generate authorization code directly
|
||||||
auth_code = OidcAuthorizationCode.create!(
|
auth_code = OidcAuthorizationCode.create!(
|
||||||
application: @application,
|
application: @application,
|
||||||
@@ -270,6 +301,7 @@ class OidcController < ApplicationController
|
|||||||
nonce: nonce,
|
nonce: nonce,
|
||||||
code_challenge: code_challenge,
|
code_challenge: code_challenge,
|
||||||
code_challenge_method: code_challenge_method,
|
code_challenge_method: code_challenge_method,
|
||||||
|
claims_requests: parsed_claims || {},
|
||||||
auth_time: Current.session.created_at.to_i,
|
auth_time: Current.session.created_at.to_i,
|
||||||
acr: Current.session.acr,
|
acr: Current.session.acr,
|
||||||
expires_at: 10.minutes.from_now
|
expires_at: 10.minutes.from_now
|
||||||
@@ -290,7 +322,8 @@ class OidcController < ApplicationController
|
|||||||
nonce: nonce,
|
nonce: nonce,
|
||||||
scope: scope,
|
scope: scope,
|
||||||
code_challenge: code_challenge,
|
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
|
# Render consent page with dynamic CSP for OAuth redirect
|
||||||
@@ -355,8 +388,11 @@ class OidcController < ApplicationController
|
|||||||
|
|
||||||
# Record user consent
|
# Record user consent
|
||||||
requested_scopes = oauth_params["scope"].split(" ")
|
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 = OidcUserConsent.find_or_initialize_by(user: user, application: application)
|
||||||
consent.scopes_granted = requested_scopes.join(" ")
|
consent.scopes_granted = requested_scopes.join(" ")
|
||||||
|
consent.claims_requests = parsed_claims
|
||||||
consent.granted_at = Time.current
|
consent.granted_at = Time.current
|
||||||
consent.save!
|
consent.save!
|
||||||
|
|
||||||
@@ -369,6 +405,7 @@ class OidcController < ApplicationController
|
|||||||
nonce: oauth_params["nonce"],
|
nonce: oauth_params["nonce"],
|
||||||
code_challenge: oauth_params["code_challenge"],
|
code_challenge: oauth_params["code_challenge"],
|
||||||
code_challenge_method: oauth_params["code_challenge_method"],
|
code_challenge_method: oauth_params["code_challenge_method"],
|
||||||
|
claims_requests: parsed_claims,
|
||||||
auth_time: Current.session.created_at.to_i,
|
auth_time: Current.session.created_at.to_i,
|
||||||
acr: Current.session.acr,
|
acr: Current.session.acr,
|
||||||
expires_at: 10.minutes.from_now
|
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
|
# 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)
|
# auth_time and acr come from the authorization code (captured at /authorize time)
|
||||||
# scopes determine which claims are included (per OIDC Core spec)
|
# scopes determine which claims are included (per OIDC Core spec)
|
||||||
|
# claims_requests parameter filters which claims are included
|
||||||
id_token = OidcJwtService.generate_id_token(
|
id_token = OidcJwtService.generate_id_token(
|
||||||
user,
|
user,
|
||||||
application,
|
application,
|
||||||
@@ -536,7 +574,8 @@ class OidcController < ApplicationController
|
|||||||
access_token: access_token_record.plaintext_token,
|
access_token: access_token_record.plaintext_token,
|
||||||
auth_time: auth_code.auth_time,
|
auth_time: auth_code.auth_time,
|
||||||
acr: auth_code.acr,
|
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
|
# 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)
|
# 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)
|
# 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)
|
# 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(
|
id_token = OidcJwtService.generate_id_token(
|
||||||
user,
|
user,
|
||||||
application,
|
application,
|
||||||
@@ -669,7 +709,8 @@ class OidcController < ApplicationController
|
|||||||
access_token: new_access_token.plaintext_token,
|
access_token: new_access_token.plaintext_token,
|
||||||
auth_time: refresh_token_record.auth_time,
|
auth_time: refresh_token_record.auth_time,
|
||||||
acr: refresh_token_record.acr,
|
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
|
# RFC6749-5.1: Token endpoint MUST return Cache-Control: no-store
|
||||||
@@ -733,34 +774,46 @@ class OidcController < ApplicationController
|
|||||||
# Parse scopes from access token (space-separated string)
|
# Parse scopes from access token (space-separated string)
|
||||||
requested_scopes = access_token.scope.to_s.split
|
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)
|
# 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 = {
|
claims = {
|
||||||
sub: subject
|
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")
|
if requested_scopes.include?("email")
|
||||||
|
if should_include_claim_for_userinfo?("email", userinfo_claims)
|
||||||
claims[:email] = user.email_address
|
claims[:email] = user.email_address
|
||||||
|
end
|
||||||
|
if should_include_claim_for_userinfo?("email_verified", userinfo_claims)
|
||||||
claims[:email_verified] = true
|
claims[:email_verified] = true
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# Profile claims (only if 'profile' scope requested)
|
# Profile claims (only if 'profile' scope requested)
|
||||||
# Per OIDC Core spec section 5.4, include available profile claims
|
# 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
|
# Only include claims we have data for - omit unknown claims rather than returning null
|
||||||
if requested_scopes.include?("profile")
|
if requested_scopes.include?("profile")
|
||||||
# Use username if available, otherwise email as preferred_username
|
if should_include_claim_for_userinfo?("preferred_username", userinfo_claims)
|
||||||
claims[:preferred_username] = user.username.presence || user.email_address
|
claims[:preferred_username] = user.username.presence || user.email_address
|
||||||
# Name: use stored name or fall back to email
|
end
|
||||||
|
if should_include_claim_for_userinfo?("name", userinfo_claims)
|
||||||
claims[:name] = user.name.presence || user.email_address
|
claims[:name] = user.name.presence || user.email_address
|
||||||
# Time the user's information was last updated
|
end
|
||||||
|
if should_include_claim_for_userinfo?("updated_at", userinfo_claims)
|
||||||
claims[:updated_at] = user.updated_at.to_i
|
claims[:updated_at] = user.updated_at.to_i
|
||||||
end
|
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?
|
if requested_scopes.include?("groups") && user.groups.any?
|
||||||
|
if should_include_claim_for_userinfo?("groups", userinfo_claims)
|
||||||
claims[:groups] = user.groups.pluck(:name)
|
claims[:groups] = user.groups.pluck(:name)
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# Merge custom claims from groups
|
# Merge custom claims from groups
|
||||||
user.groups.each do |group|
|
user.groups.each do |group|
|
||||||
@@ -774,6 +827,12 @@ class OidcController < ApplicationController
|
|||||||
application = access_token.application
|
application = access_token.application
|
||||||
claims.merge!(application.custom_claims_for_user(user))
|
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
|
# Security: Don't cache user data responses
|
||||||
response.headers["Cache-Control"] = "no-store"
|
response.headers["Cache-Control"] = "no-store"
|
||||||
response.headers["Pragma"] = "no-cache"
|
response.headers["Pragma"] = "no-cache"
|
||||||
@@ -1043,4 +1102,133 @@ class OidcController < ApplicationController
|
|||||||
# Log error but don't block logout
|
# Log error but don't block logout
|
||||||
Rails.logger.error "OidcController: Failed to enqueue backchannel logout: #{e.class} - #{e.message}"
|
Rails.logger.error "OidcController: Failed to enqueue backchannel logout: #{e.class} - #{e.message}"
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
@@ -44,6 +44,12 @@ class OidcAuthorizationCode < ApplicationRecord
|
|||||||
code_challenge.present?
|
code_challenge.present?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Parse claims_requests JSON field
|
||||||
|
def parsed_claims_requests
|
||||||
|
return {} if claims_requests.blank?
|
||||||
|
claims_requests.is_a?(Hash) ? claims_requests : {}
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def generate_code
|
def generate_code
|
||||||
|
|||||||
@@ -50,6 +50,12 @@ class OidcUserConsent < ApplicationRecord
|
|||||||
find_by(sid: sid)
|
find_by(sid: sid)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Parse claims_requests JSON field
|
||||||
|
def parsed_claims_requests
|
||||||
|
return {} if claims_requests.blank?
|
||||||
|
claims_requests.is_a?(Hash) ? claims_requests : {}
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def set_granted_at
|
def set_granted_at
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ class OidcJwtService
|
|||||||
|
|
||||||
class << self
|
class << self
|
||||||
# Generate an ID token (JWT) for the user
|
# 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
|
now = Time.current.to_i
|
||||||
# Use application's configured ID token TTL (defaults to 1 hour)
|
# Use application's configured ID token TTL (defaults to 1 hour)
|
||||||
ttl = application.id_token_expiry_seconds
|
ttl = application.id_token_expiry_seconds
|
||||||
@@ -14,6 +14,9 @@ class OidcJwtService
|
|||||||
# Parse scopes (space-separated string)
|
# Parse scopes (space-separated string)
|
||||||
requested_scopes = scopes.to_s.split
|
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)
|
# Required claims (always included per OIDC Core spec)
|
||||||
payload = {
|
payload = {
|
||||||
iss: issuer_url,
|
iss: issuer_url,
|
||||||
@@ -23,10 +26,28 @@ class OidcJwtService
|
|||||||
iat: now
|
iat: now
|
||||||
}
|
}
|
||||||
|
|
||||||
# NOTE: Email and profile claims are NOT included in the ID token for authorization code flow
|
# Email claims (only if 'email' scope requested AND either no claims filter OR email requested)
|
||||||
# Per OIDC Core spec §5.4, these claims should only be returned via the UserInfo endpoint
|
if requested_scopes.include?("email")
|
||||||
# For implicit flow (response_type=id_token), claims would be included here, but we only
|
if should_include_claim?("email", id_token_claims)
|
||||||
# support authorization code flow, so these claims are omitted from the ID token.
|
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)
|
# Add nonce if provided (OIDC requires this for implicit flow)
|
||||||
payload[:nonce] = nonce if nonce.present?
|
payload[:nonce] = nonce if nonce.present?
|
||||||
@@ -49,10 +70,12 @@ class OidcJwtService
|
|||||||
payload[:at_hash] = at_hash
|
payload[:at_hash] = at_hash
|
||||||
end
|
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?
|
if requested_scopes.include?("groups") && user.groups.any?
|
||||||
|
if should_include_claim?("groups", id_token_claims)
|
||||||
payload[:groups] = user.groups.pluck(:name)
|
payload[:groups] = user.groups.pluck(:name)
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# Merge custom claims from groups (arrays are combined, not overwritten)
|
# Merge custom claims from groups (arrays are combined, not overwritten)
|
||||||
# Note: Custom claims from groups are always merged (not scope-dependent)
|
# Note: Custom claims from groups are always merged (not scope-dependent)
|
||||||
@@ -66,6 +89,12 @@ class OidcJwtService
|
|||||||
# Merge app-specific custom claims (highest priority, arrays are combined)
|
# Merge app-specific custom claims (highest priority, arrays are combined)
|
||||||
payload = deep_merge_claims(payload, application.custom_claims_for_user(user))
|
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"})
|
JWT.encode(payload, private_key, "RS256", {kid: key_id, typ: "JWT"})
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -178,5 +207,69 @@ class OidcJwtService
|
|||||||
def key_id
|
def key_id
|
||||||
@key_id ||= Digest::SHA256.hexdigest(public_key.to_pem)[0..15]
|
@key_id ||= Digest::SHA256.hexdigest(public_key.to_pem)[0..15]
|
||||||
end
|
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
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
module Clinch
|
module Clinch
|
||||||
VERSION = "0.8.4"
|
VERSION = "0.8.6"
|
||||||
end
|
end
|
||||||
|
|||||||
4
db/schema.rb
generated
4
db/schema.rb
generated
@@ -10,7 +10,7 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# 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|
|
create_table "active_storage_attachments", force: :cascade do |t|
|
||||||
t.bigint "blob_id", null: false
|
t.bigint "blob_id", null: false
|
||||||
t.datetime "created_at", 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.string "acr"
|
||||||
t.integer "application_id", null: false
|
t.integer "application_id", null: false
|
||||||
t.integer "auth_time"
|
t.integer "auth_time"
|
||||||
|
t.json "claims_requests", default: {}, null: false
|
||||||
t.string "code_challenge"
|
t.string "code_challenge"
|
||||||
t.string "code_challenge_method"
|
t.string "code_challenge_method"
|
||||||
t.string "code_hmac", null: false
|
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|
|
create_table "oidc_user_consents", force: :cascade do |t|
|
||||||
t.integer "application_id", null: false
|
t.integer "application_id", null: false
|
||||||
|
t.json "claims_requests", default: {}, null: false
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.datetime "granted_at", null: false
|
t.datetime "granted_at", null: false
|
||||||
t.text "scopes_granted", null: false
|
t.text "scopes_granted", null: false
|
||||||
|
|||||||
Reference in New Issue
Block a user