16 Commits

Author SHA1 Message Date
Dan Milne
444ae6291c Add missing files, fix formatting
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / scan_container (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2026-01-05 23:34:11 +11:00
Dan Milne
233fb723d5 More accurate language around passing the OpenID Conformance tests
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / scan_container (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2026-01-05 23:32:34 +11:00
Dan Milne
cc6d4fcc65 Add test files, update checklist
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / scan_container (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2026-01-05 23:28:55 +11:00
Dan Milne
5268f10eb3 Don't allow claim escalation
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / scan_container (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2026-01-05 16:40:11 +11:00
Dan Milne
5c5662eaab Expose 'username' via forward auth headers 2026-01-05 15:12:24 +11:00
Dan Milne
27d77ebf47 Expose 'username' via forward auth headers 2026-01-05 15:12:02 +11:00
Dan Milne
ba08158c85 Bug fix for background jobs 2026-01-05 14:43:06 +11:00
Dan Milne
a6480b0860 Verion Bump
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / scan_container (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2026-01-05 13:08:22 +11:00
Dan Milne
75cc223329 303 is the correct response 2026-01-05 13:05:24 +11:00
Dan Milne
46ae65f4d2 Move the 'remove_query_param' to the application controller 2026-01-05 13:03:03 +11:00
Dan Milne
95d0d844e9 Add a method to remove parameters from urls, so we can redirect without risk of infinite redirect. Fix a bunch of redirects to login afer being foced to log out. Add missing migrations 2026-01-05 13:01:32 +11:00
Dan Milne
524a7719c3 Merge branch 'main' into feature/claims 2026-01-05 12:11:53 +11:00
Dan Milne
8110d547dd Fix bug with session deletion when logout forced and we have a redirect to follow 2026-01-05 12:11:52 +11:00
Dan Milne
25e1043312 Add skip-consent, correctly use 303, rather than 302, actually rename per app 'logout' to 'require re-auth'. Add helper methods for token lifetime - allowing 10d for 10days for example. 2026-01-05 12:03:01 +11:00
Dan Milne
074a734c0c Accidentally added skip-consent to this branch 2026-01-05 12:01:04 +11:00
Dan Milne
4a48012a82 Add claims support 2026-01-05 12:00:29 +11:00
29 changed files with 1493 additions and 82 deletions

View File

@@ -75,6 +75,9 @@ Apps that speak OIDC use the OIDC flow.
Apps that only need "who is it?", or you want available from the internet behind authentication (MeTube, Jellyfin) use ForwardAuth. Apps that only need "who is it?", or you want available from the internet behind authentication (MeTube, Jellyfin) use ForwardAuth.
#### OpenID Connect (OIDC) #### OpenID Connect (OIDC)
**[OpenID Connect Conformance](https://www.certification.openid.net/plan-detail.html?plan=FbQNTJuYVzrzs&public=true)** - Clinch passes the official OpenID Connect conformance tests (valid as of [v0.8.6](https://github.com/dkam/clinch/releases/tag/0.8.6)).
Standard OAuth2/OIDC provider with endpoints: Standard OAuth2/OIDC provider with endpoints:
- `/.well-known/openid-configuration` - Discovery endpoint - `/.well-known/openid-configuration` - Discovery endpoint
- `/authorize` - Authorization endpoint with PKCE support - `/authorize` - Authorization endpoint with PKCE support

View File

@@ -71,7 +71,7 @@ class ActiveSessionsController < ApplicationController
Rails.logger.info "ActiveSessionsController: Logged out from #{application.name} - revoked #{revoked_access_tokens} access tokens and #{revoked_refresh_tokens} refresh tokens" Rails.logger.info "ActiveSessionsController: Logged out from #{application.name} - revoked #{revoked_access_tokens} access tokens and #{revoked_refresh_tokens} refresh tokens"
# Keep the consent intact - this is the key difference from revoke_consent # Keep the consent intact - this is the key difference from revoke_consent
redirect_to root_path, notice: "Successfully logged out of #{application.name}." redirect_to root_path, notice: "Revoked access tokens for #{application.name}. Re-authentication will be required on next use."
end end
def revoke_all_consents def revoke_all_consents

View File

@@ -104,7 +104,7 @@ module Admin
permitted = params.require(:application).permit( permitted = params.require(:application).permit(
:name, :slug, :app_type, :active, :redirect_uris, :description, :metadata, :name, :slug, :app_type, :active, :redirect_uris, :description, :metadata,
:domain_pattern, :landing_url, :access_token_ttl, :refresh_token_ttl, :id_token_ttl, :domain_pattern, :landing_url, :access_token_ttl, :refresh_token_ttl, :id_token_ttl,
:icon, :backchannel_logout_uri, :is_public_client, :require_pkce :icon, :backchannel_logout_uri, :is_public_client, :require_pkce, :skip_consent
) )
# Handle headers_config - it comes as a JSON string from the text area # Handle headers_config - it comes as a JSON string from the text area

View File

@@ -88,6 +88,8 @@ module Api
case key case key
when :user, :email, :name when :user, :email, :name
[header_name, user.email_address] [header_name, user.email_address]
when :username
[header_name, user.username] if user.username.present?
when :groups when :groups
user.groups.any? ? [header_name, user.groups.pluck(:name).join(",")] : nil user.groups.any? ? [header_name, user.groups.pluck(:name).join(",")] : nil
when :admin when :admin

View File

@@ -9,4 +9,33 @@ class ApplicationController < ActionController::Base
# CSRF protection # CSRF protection
protect_from_forgery with: :exception protect_from_forgery with: :exception
helper_method :remove_query_param
private
# Remove a query parameter from a URL using proper URI parsing
# More robust than regex - handles URL encoding, edge cases, etc.
#
# @param url [String] The URL to modify
# @param param_name [String] The query parameter name to remove
# @return [String] The URL with the parameter removed
#
# @example
# remove_query_param("https://example.com?foo=bar&baz=qux", "foo")
# # => "https://example.com?baz=qux"
def remove_query_param(url, param_name)
uri = URI.parse(url)
return url unless uri.query
# Parse query string into hash
params = CGI.parse(uri.query)
params.delete(param_name)
# Rebuild query string (empty string if no params left)
uri.query = params.any? ? URI.encode_www_form(params) : nil
uri.to_s
rescue URI::InvalidURIError
url
end
end end

View File

@@ -40,7 +40,6 @@ module Authentication
end end
def after_authentication_url def after_authentication_url
session[:return_to_after_authenticating]
session.delete(:return_to_after_authenticating) || root_url session.delete(:return_to_after_authenticating) || root_url
end end

View File

@@ -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
@@ -215,9 +246,7 @@ class OidcController < ApplicationController
# Store the current URL (which contains all OAuth params) for redirect after login # Store the current URL (which contains all OAuth params) for redirect after login
# Remove prompt=login to prevent infinite re-auth loop # Remove prompt=login to prevent infinite re-auth loop
return_url = request.url.sub(/&prompt=login(?=&|$)|\?prompt=login&?/, '\1') return_url = remove_query_param(request.url, "prompt")
# Fix any resulting URL issues (like ?& or & at end)
return_url = return_url.gsub("?&", "?").gsub(/[?&]$/, "")
session[:return_to_after_authenticating] = return_url session[:return_to_after_authenticating] = return_url
redirect_to signin_path, alert: "Please sign in to continue" redirect_to signin_path, alert: "Please sign in to continue"
@@ -232,15 +261,19 @@ class OidcController < ApplicationController
# Calculate session age # Calculate session age
session_age_seconds = Time.current.to_i - Current.session.created_at.to_i session_age_seconds = Time.current.to_i - Current.session.created_at.to_i
if session_age_seconds > max_age_seconds if session_age_seconds >= max_age_seconds
# Session is too old - require re-authentication # Session is too old - require re-authentication
# Store return URL in session (creates new session cookie) # Store the return URL in Rails session, then destroy the Session record
# Destroy session and clear cookie to force fresh login # Store return URL before destroying anything
# Remove max_age from return URL to prevent infinite re-auth loop
return_url = remove_query_param(request.url, "max_age")
session[:return_to_after_authenticating] = return_url
# Destroy the Session record and clear its cookie
Current.session&.destroy! Current.session&.destroy!
cookies.delete(:session_id) cookies.delete(:session_id)
Current.session = nil
session[:return_to_after_authenticating] = request.url
redirect_to signin_path, alert: "Please sign in to continue" redirect_to signin_path, alert: "Please sign in to continue"
return return
@@ -258,9 +291,41 @@ class OidcController < ApplicationController
requested_scopes = scope.split(" ") requested_scopes = scope.split(" ")
# Check if application is configured to skip consent
# If so, automatically create consent and proceed without showing consent screen
if @application.skip_consent?
# Create or update consent record automatically for trusted applications
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!
# Generate authorization code directly
auth_code = OidcAuthorizationCode.create!(
application: @application,
user: user,
redirect_uri: redirect_uri,
scope: scope,
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
)
# Redirect back to client with authorization code (plaintext)
redirect_uri = "#{redirect_uri}?code=#{auth_code.plaintext_code}"
redirect_uri += "&state=#{CGI.escape(state)}" if state.present?
redirect_to redirect_uri, allow_other_host: true
return
end
# 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 +335,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 +356,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 +422,15 @@ class OidcController < ApplicationController
# Record user consent # Record user consent
requested_scopes = oauth_params["scope"].split(" ") requested_scopes = oauth_params["scope"].split(" ")
parsed_claims = begin
JSON.parse(oauth_params["claims_requests"])
rescue
{}
end
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 +443,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
@@ -386,6 +461,16 @@ class OidcController < ApplicationController
# POST /oauth/token # POST /oauth/token
def token def token
# Reject claims parameter - per OIDC security, claims parameter is only valid
# in authorization requests, not at the token endpoint
if params[:claims].present?
render json: {
error: "invalid_request",
error_description: "claims parameter is not allowed at the token endpoint"
}, status: :bad_request
return
end
grant_type = params[:grant_type] grant_type = params[:grant_type]
case grant_type case grant_type
@@ -528,6 +613,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 +622,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 +749,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 +757,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
@@ -695,10 +784,10 @@ class OidcController < ApplicationController
# Extract access token from Authorization header or POST body # Extract access token from Authorization header or POST body
# RFC 6750: Bearer token can be in Authorization header, request body, or query string # RFC 6750: Bearer token can be in Authorization header, request body, or query string
token = if request.headers["Authorization"]&.start_with?("Bearer ") token = if request.headers["Authorization"]&.start_with?("Bearer ")
request.headers["Authorization"].sub("Bearer ", "") request.headers["Authorization"].sub("Bearer ", "")
elsif request.params["access_token"].present? elsif request.params["access_token"].present?
request.params["access_token"] request.params["access_token"]
end end
unless token unless token
head :unauthorized head :unauthorized
@@ -733,33 +822,45 @@ 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")
claims[:email] = user.email_address if should_include_claim_for_userinfo?("email", userinfo_claims)
claims[:email_verified] = true claims[:email] = user.email_address
end
if should_include_claim_for_userinfo?("email_verified", userinfo_claims)
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
claims[:name] = user.name.presence || user.email_address if should_include_claim_for_userinfo?("name", userinfo_claims)
# Time the user's information was last updated claims[:name] = user.name.presence || user.email_address
claims[:updated_at] = user.updated_at.to_i end
if should_include_claim_for_userinfo?("updated_at", userinfo_claims)
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?
claims[:groups] = user.groups.pluck(:name) if should_include_claim_for_userinfo?("groups", userinfo_claims)
claims[:groups] = user.groups.pluck(:name)
end
end end
# Merge custom claims from groups # Merge custom claims from groups
@@ -774,6 +875,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"
@@ -923,7 +1030,7 @@ class OidcController < ApplicationController
end end
# Validate code verifier format (per RFC 7636: [A-Za-z0-9\-._~], 43-128 characters) # Validate code verifier format (per RFC 7636: [A-Za-z0-9\-._~], 43-128 characters)
unless code_verifier.match?(/\A[A-Za-z0-9\.\-_~]{43,128}\z/) unless code_verifier.match?(/\A[A-Za-z0-9.\-_~]{43,128}\z/)
return { return {
valid: false, valid: false,
error: "invalid_request", error: "invalid_request",
@@ -1043,4 +1150,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

View File

@@ -0,0 +1,45 @@
class DurationParser
UNITS = {
"s" => 1, # seconds
"m" => 60, # minutes
"h" => 3600, # hours
"d" => 86400, # days
"w" => 604800, # weeks
"M" => 2592000, # months (30 days)
"y" => 31536000 # years (365 days)
}
# Parse a duration string into seconds
# Accepts formats: "1h", "30m", "1d", "1M" (month), "3600" (plain number)
# Returns integer seconds or nil if invalid
# Case-sensitive: 1s, 1m, 1h, 1d, 1w, 1M (month), 1y
def self.parse(input)
# Handle integers directly
return input if input.is_a?(Integer)
# Convert to string and strip whitespace
str = input.to_s.strip
# Return nil for blank input
return nil if str.blank?
# Try to parse as plain number (already in seconds)
if str.match?(/^\d+$/)
return str.to_i
end
# Try to parse with unit (e.g., "1h", "30m", "1M")
# Allow optional space between number and unit
# Case-sensitive to avoid confusion (1m = minute, 1M = month)
match = str.match(/^(\d+)\s*([smhdwMy])$/)
return nil unless match
number = match[1].to_i
unit = match[2]
multiplier = UNITS[unit]
return nil unless multiplier
number * multiplier
end
end

View File

@@ -5,6 +5,23 @@ class Application < ApplicationRecord
# When true, no client_secret will be generated (public client) # When true, no client_secret will be generated (public client)
attr_accessor :is_public_client attr_accessor :is_public_client
# Virtual setters for TTL fields - accept human-friendly durations
# e.g., "1h", "30m", "1d", or plain numbers "3600"
def access_token_ttl=(value)
parsed = DurationParser.parse(value)
super(parsed)
end
def refresh_token_ttl=(value)
parsed = DurationParser.parse(value)
super(parsed)
end
def id_token_ttl=(value)
parsed = DurationParser.parse(value)
super(parsed)
end
has_one_attached :icon has_one_attached :icon
# Fix SVG content type after attachment # Fix SVG content type after attachment
@@ -39,7 +56,7 @@ class Application < ApplicationRecord
# Token TTL validations (for OIDC apps) # Token TTL validations (for OIDC apps)
validates :access_token_ttl, numericality: {greater_than_or_equal_to: 300, less_than_or_equal_to: 86400}, if: :oidc? # 5 min - 24 hours validates :access_token_ttl, numericality: {greater_than_or_equal_to: 300, less_than_or_equal_to: 86400}, if: :oidc? # 5 min - 24 hours
validates :refresh_token_ttl, numericality: {greater_than_or_equal_to: 86400, less_than_or_equal_to: 7776000}, if: :oidc? # 1 day - 90 days validates :refresh_token_ttl, numericality: {greater_than_or_equal_to: 300, less_than_or_equal_to: 7776000}, if: :oidc? # 5 min - 90 days
validates :id_token_ttl, numericality: {greater_than_or_equal_to: 300, less_than_or_equal_to: 86400}, if: :oidc? # 5 min - 24 hours validates :id_token_ttl, numericality: {greater_than_or_equal_to: 300, less_than_or_equal_to: 86400}, if: :oidc? # 5 min - 24 hours
normalizes :slug, with: ->(slug) { slug.strip.downcase } normalizes :slug, with: ->(slug) { slug.strip.downcase }
@@ -59,6 +76,7 @@ class Application < ApplicationRecord
user: "X-Remote-User", user: "X-Remote-User",
email: "X-Remote-Email", email: "X-Remote-Email",
name: "X-Remote-Name", name: "X-Remote-Name",
username: "X-Remote-Username",
groups: "X-Remote-Groups", groups: "X-Remote-Groups",
admin: "X-Remote-Admin" admin: "X-Remote-Admin"
}.freeze }.freeze
@@ -178,6 +196,8 @@ class Application < ApplicationRecord
headers[header_name] = user.email_address headers[header_name] = user.email_address
when :name when :name
headers[header_name] = user.name.presence || user.email_address headers[header_name] = user.name.presence || user.email_address
when :username
headers[header_name] = user.username if user.username.present?
when :groups when :groups
headers[header_name] = user.groups.pluck(:name).join(",") if user.groups.any? headers[header_name] = user.groups.pluck(:name).join(",") if user.groups.any?
when :admin when :admin

View File

@@ -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

View File

@@ -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

View File

@@ -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,9 +70,11 @@ 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?
payload[:groups] = user.groups.pluck(:name) if should_include_claim?("groups", id_token_claims)
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)
@@ -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

View File

@@ -153,6 +153,26 @@
</div> </div>
<% end %> <% end %>
<!-- OAuth2/OIDC Flow Information -->
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 space-y-3">
<div>
<h4 class="text-sm font-semibold text-gray-900 mb-2">OAuth2 Flow</h4>
<p class="text-sm text-gray-700">
Clinch uses the <code class="bg-white px-1.5 py-0.5 rounded text-xs font-mono">authorization_code</code> flow with <code class="bg-white px-1.5 py-0.5 rounded text-xs font-mono">response_type=code</code> (the modern, secure standard).
</p>
<p class="text-sm text-gray-600 mt-1">
Deprecated flows like Implicit (<code class="bg-white px-1 rounded text-xs font-mono">id_token</code>, <code class="bg-white px-1 rounded text-xs font-mono">token</code>) are not supported for security reasons.
</p>
</div>
<div class="border-t border-blue-200 pt-3">
<h4 class="text-sm font-semibold text-gray-900 mb-2">Client Authentication</h4>
<p class="text-sm text-gray-700">
Clinch supports both <code class="bg-white px-1.5 py-0.5 rounded text-xs font-mono">client_secret_basic</code> (HTTP Basic Auth) and <code class="bg-white px-1.5 py-0.5 rounded text-xs font-mono">client_secret_post</code> (POST parameters) authentication methods.
</p>
</div>
</div>
<!-- PKCE Requirement (only for confidential clients) --> <!-- PKCE Requirement (only for confidential clients) -->
<div id="pkce-options" data-application-form-target="pkceOptions" class="<%= 'hidden' if application.persisted? && application.public_client? %>"> <div id="pkce-options" data-application-form-target="pkceOptions" class="<%= 'hidden' if application.persisted? && application.public_client? %>">
<div class="flex items-center"> <div class="flex items-center">
@@ -165,6 +185,16 @@
</p> </p>
</div> </div>
<!-- Skip Consent -->
<div class="flex items-center">
<%= form.check_box :skip_consent, class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" %>
<%= form.label :skip_consent, "Skip Consent Screen", class: "ml-2 block text-sm font-medium text-gray-900" %>
</div>
<p class="ml-6 text-sm text-gray-500">
Automatically grant consent for all users. Useful for first-party or trusted applications.
<br><span class="text-xs text-amber-600">Only enable for applications you fully trust. Consent is still recorded in the database.</span>
</p>
<div> <div>
<%= form.label :redirect_uris, "Redirect URIs", class: "block text-sm font-medium text-gray-700" %> <%= form.label :redirect_uris, "Redirect URIs", class: "block text-sm font-medium text-gray-700" %>
<%= form.text_area :redirect_uris, rows: 4, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono", placeholder: "https://example.com/callback\nhttps://app.example.com/auth/callback" %> <%= form.text_area :redirect_uris, rows: 4, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono", placeholder: "https://example.com/callback\nhttps://app.example.com/auth/callback" %>
@@ -187,43 +217,90 @@
<div class="grid grid-cols-1 md:grid-cols-3 gap-4"> <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div> <div>
<%= form.label :access_token_ttl, "Access Token TTL (seconds)", class: "block text-sm font-medium text-gray-700" %> <%= form.label :access_token_ttl, "Access Token TTL", class: "block text-sm font-medium text-gray-700" %>
<%= form.number_field :access_token_ttl, value: application.access_token_ttl || 3600, min: 300, max: 86400, step: 60, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %> <%= form.text_field :access_token_ttl,
value: application.access_token_ttl || "1h",
placeholder: "e.g., 1h, 30m, 3600",
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono" %>
<p class="mt-1 text-xs text-gray-500"> <p class="mt-1 text-xs text-gray-500">
Range: 5 min - 24 hours Range: 5m - 24h
<br>Default: 1 hour (3600s) <br>Default: 1h
<br>Current: <span class="font-medium"><%= application.access_token_ttl_human || "1 hour" %></span> <% if application.access_token_ttl.present? %>
<br>Current: <span class="font-medium"><%= application.access_token_ttl_human %> (<%= application.access_token_ttl %>s)</span>
<% end %>
</p> </p>
</div> </div>
<div> <div>
<%= form.label :refresh_token_ttl, "Refresh Token TTL (seconds)", class: "block text-sm font-medium text-gray-700" %> <%= form.label :refresh_token_ttl, "Refresh Token TTL", class: "block text-sm font-medium text-gray-700" %>
<%= form.number_field :refresh_token_ttl, value: application.refresh_token_ttl || 2592000, min: 86400, max: 7776000, step: 86400, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %> <%= form.text_field :refresh_token_ttl,
value: application.refresh_token_ttl || "30d",
placeholder: "e.g., 30d, 1M, 2592000",
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono" %>
<p class="mt-1 text-xs text-gray-500"> <p class="mt-1 text-xs text-gray-500">
Range: 1 day - 90 days Range: 5m - 90d
<br>Default: 30 days (2592000s) <br>Default: 30d
<br>Current: <span class="font-medium"><%= application.refresh_token_ttl_human || "30 days" %></span> <% if application.refresh_token_ttl.present? %>
<br>Current: <span class="font-medium"><%= application.refresh_token_ttl_human %> (<%= application.refresh_token_ttl %>s)</span>
<% end %>
</p> </p>
</div> </div>
<div> <div>
<%= form.label :id_token_ttl, "ID Token TTL (seconds)", class: "block text-sm font-medium text-gray-700" %> <%= form.label :id_token_ttl, "ID Token TTL", class: "block text-sm font-medium text-gray-700" %>
<%= form.number_field :id_token_ttl, value: application.id_token_ttl || 3600, min: 300, max: 86400, step: 60, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %> <%= form.text_field :id_token_ttl,
value: application.id_token_ttl || "1h",
placeholder: "e.g., 1h, 30m, 3600",
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono" %>
<p class="mt-1 text-xs text-gray-500"> <p class="mt-1 text-xs text-gray-500">
Range: 5 min - 24 hours Range: 5m - 24h
<br>Default: 1 hour (3600s) <br>Default: 1h
<br>Current: <span class="font-medium"><%= application.id_token_ttl_human || "1 hour" %></span> <% if application.id_token_ttl.present? %>
<br>Current: <span class="font-medium"><%= application.id_token_ttl_human %> (<%= application.id_token_ttl %>s)</span>
<% end %>
</p> </p>
</div> </div>
</div> </div>
<details class="mt-3"> <details class="mt-3">
<summary class="cursor-pointer text-sm text-blue-600 hover:text-blue-800">Understanding Token Types</summary> <summary class="cursor-pointer text-sm text-blue-600 hover:text-blue-800">Understanding Token Types & Session Length</summary>
<div class="mt-2 ml-4 space-y-2 text-sm text-gray-600"> <div class="mt-2 ml-4 space-y-3 text-sm text-gray-600">
<p><strong>Access Token:</strong> Used to access protected resources (APIs). Shorter lifetime = more secure. Users won't notice automatic refreshes.</p> <div>
<p><strong>Refresh Token:</strong> Used to get new access tokens without re-authentication. Longer lifetime = better UX (less re-logins).</p> <p class="font-medium text-gray-900 mb-1">Token Types:</p>
<p><strong>ID Token:</strong> Contains user identity information (JWT). Should match access token lifetime in most cases.</p> <p><strong>Access Token:</strong> Used to access protected resources (APIs). Shorter lifetime = more secure. Users won't notice automatic refreshes.</p>
<p class="text-xs italic mt-2">💡 Tip: Banking apps use 5-15 min access tokens. Internal tools use 1-4 hours.</p> <p><strong>Refresh Token:</strong> Used to get new access tokens without re-authentication. Each refresh issues a new refresh token (token rotation).</p>
<p><strong>ID Token:</strong> Contains user identity information (JWT). Should match access token lifetime in most cases.</p>
</div>
<div class="border-t border-gray-200 pt-2">
<p class="font-medium text-gray-900 mb-1">How Session Length Works:</p>
<p><strong>Refresh Token TTL = Maximum Inactivity Period</strong></p>
<p class="ml-3">Because refresh tokens are automatically rotated (new token = new expiry), active users can stay logged in indefinitely. The TTL controls how long they can be <em>inactive</em> before requiring re-authentication.</p>
<p class="mt-2"><strong>Example:</strong> Refresh TTL = 30 days</p>
<ul class="ml-6 list-disc space-y-1 text-xs">
<li>User logs in on Day 0, uses app daily → stays logged in forever (tokens keep rotating)</li>
<li>User logs in on Day 0, stops using app → must re-login after 30 days of inactivity</li>
</ul>
</div>
<div class="border-t border-gray-200 pt-2">
<p class="font-medium text-gray-900 mb-1">Forcing Re-Authentication:</p>
<p class="ml-3 text-xs">Because of token rotation, there's no way to force periodic re-authentication using TTL settings alone. Active users can stay logged in indefinitely by refreshing tokens before they expire.</p>
<p class="mt-2 ml-3 text-xs"><strong>To enforce absolute session limits:</strong> Clients can include the <code class="bg-gray-100 px-1 rounded">max_age</code> parameter in their authorization requests to require re-authentication after a specific time, regardless of token rotation.</p>
<p class="mt-2 ml-3 text-xs"><strong>Example:</strong> A banking app might set <code class="bg-gray-100 px-1 rounded">max_age=900</code> (15 minutes) in the authorization request to force re-authentication every 15 minutes, even if refresh tokens are still valid.</p>
</div>
<div class="border-t border-gray-200 pt-2">
<p class="font-medium text-gray-900 mb-1">Common Configurations:</p>
<ul class="ml-3 space-y-1 text-xs">
<li><strong>Banking/High Security:</strong> Access TTL = <code class="bg-gray-100 px-1 rounded">5m</code>, Refresh TTL = <code class="bg-gray-100 px-1 rounded">5m</code> → Re-auth every 5 minutes</li>
<li><strong>Corporate Tools:</strong> Access TTL = <code class="bg-gray-100 px-1 rounded">1h</code>, Refresh TTL = <code class="bg-gray-100 px-1 rounded">8h</code> → Re-auth after 8 hours inactive</li>
<li><strong>Personal Apps:</strong> Access TTL = <code class="bg-gray-100 px-1 rounded">1h</code>, Refresh TTL = <code class="bg-gray-100 px-1 rounded">30d</code> → Re-auth after 30 days inactive</li>
</ul>
</div>
</div> </div>
</details> </details>
</div> </div>
@@ -253,10 +330,10 @@
<p class="font-medium">Optional: Customize header names sent to your application.</p> <p class="font-medium">Optional: Customize header names sent to your application.</p>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<button type="button" data-action="json-validator#format" class="text-xs bg-gray-100 hover:bg-gray-200 px-2 py-1 rounded">Format JSON</button> <button type="button" data-action="json-validator#format" class="text-xs bg-gray-100 hover:bg-gray-200 px-2 py-1 rounded">Format JSON</button>
<button type="button" data-action="json-validator#insertSample" data-json-sample='{"user": "Remote-User", "groups": "Remote-Groups", "email": "Remote-Email", "name": "Remote-Name", "admin": "Remote-Admin"}' class="text-xs bg-blue-100 hover:bg-blue-200 text-blue-700 px-2 py-1 rounded">Insert Example</button> <button type="button" data-action="json-validator#insertSample" data-json-sample='{"user": "Remote-User", "groups": "Remote-Groups", "email": "Remote-Email", "name": "Remote-Name", "username": "Remote-Username", "admin": "Remote-Admin"}' class="text-xs bg-blue-100 hover:bg-blue-200 text-blue-700 px-2 py-1 rounded">Insert Example</button>
</div> </div>
</div> </div>
<p><strong>Default headers:</strong> X-Remote-User, X-Remote-Email, X-Remote-Name, X-Remote-Groups, X-Remote-Admin</p> <p><strong>Default headers:</strong> X-Remote-User, X-Remote-Email, X-Remote-Name, X-Remote-Username, X-Remote-Groups, X-Remote-Admin</p>
<div data-json-validator-target="status" class="text-xs font-medium"></div> <div data-json-validator-target="status" class="text-xs font-medium"></div>
<details class="mt-2"> <details class="mt-2">
<summary class="cursor-pointer text-blue-600 hover:text-blue-800">Show available header keys and what data they send</summary> <summary class="cursor-pointer text-blue-600 hover:text-blue-800">Show available header keys and what data they send</summary>
@@ -264,9 +341,10 @@
<p><code class="bg-gray-100 px-1 rounded">user</code> - User's email address</p> <p><code class="bg-gray-100 px-1 rounded">user</code> - User's email address</p>
<p><code class="bg-gray-100 px-1 rounded">email</code> - User's email address</p> <p><code class="bg-gray-100 px-1 rounded">email</code> - User's email address</p>
<p><code class="bg-gray-100 px-1 rounded">name</code> - User's display name (falls back to email if not set)</p> <p><code class="bg-gray-100 px-1 rounded">name</code> - User's display name (falls back to email if not set)</p>
<p><code class="bg-gray-100 px-1 rounded">username</code> - User's login username (only sent if set)</p>
<p><code class="bg-gray-100 px-1 rounded">groups</code> - Comma-separated list of group names (e.g., "admin,developers")</p> <p><code class="bg-gray-100 px-1 rounded">groups</code> - Comma-separated list of group names (e.g., "admin,developers")</p>
<p><code class="bg-gray-100 px-1 rounded">admin</code> - "true" or "false" indicating admin status</p> <p><code class="bg-gray-100 px-1 rounded">admin</code> - "true" or "false" indicating admin status</p>
<p class="mt-2 italic">Example: <code class="bg-gray-100 px-1 rounded">{"user": "Remote-User", "groups": "Remote-Groups"}</code></p> <p class="mt-2 italic">Example: <code class="bg-gray-100 px-1 rounded">{"user": "Remote-User", "groups": "Remote-Groups", "username": "Remote-Username"}</code></p>
<p class="italic">Need custom user fields? Add them to user's custom_claims for OIDC tokens</p> <p class="italic">Need custom user fields? Add them to user's custom_claims for OIDC tokens</p>
</div> </div>
</details> </details>

View File

@@ -215,7 +215,7 @@
<code class="block bg-gray-100 px-3 py-2 rounded font-mono text-xs whitespace-pre-wrap"><%= JSON.pretty_generate(@application.headers_config) %></code> <code class="block bg-gray-100 px-3 py-2 rounded font-mono text-xs whitespace-pre-wrap"><%= JSON.pretty_generate(@application.headers_config) %></code>
<% else %> <% else %>
<div class="bg-gray-100 px-3 py-2 rounded text-xs text-gray-500"> <div class="bg-gray-100 px-3 py-2 rounded text-xs text-gray-500">
Using default headers: X-Remote-User, X-Remote-Email, X-Remote-Name, X-Remote-Groups, X-Remote-Admin Using default headers: X-Remote-User, X-Remote-Email, X-Remote-Name, X-Remote-Username, X-Remote-Groups, X-Remote-Admin
</div> </div>
<% end %> <% end %>
</dd> </dd>

View File

@@ -147,9 +147,9 @@
<% end %> <% end %>
<% if app.user_has_active_session?(@user) %> <% if app.user_has_active_session?(@user) %>
<%= button_to "Logout", logout_from_app_active_sessions_path(application_id: app.id), method: :delete, <%= button_to "Require Re-Auth", logout_from_app_active_sessions_path(application_id: app.id), method: :delete,
class: "w-full flex justify-center items-center px-4 py-2 border border-orange-300 text-sm font-medium rounded-md text-orange-700 bg-white hover:bg-orange-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-orange-500 transition", class: "w-full flex justify-center items-center px-4 py-2 border border-orange-300 text-sm font-medium rounded-md text-orange-700 bg-white hover:bg-orange-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-orange-500 transition",
form: { data: { turbo_confirm: "This will log you out of #{app.name}. You can sign back in without re-authorizing. Continue?" } } %> form: { data: { turbo_confirm: "This will revoke #{app.name}'s access tokens. The next time #{app.name} needs to authenticate, you'll sign in again (no re-authorization needed). Continue?" } } %>
<% end %> <% end %>
</div> </div>
</div> </div>

View File

@@ -59,6 +59,7 @@ Rails.application.configure do
# Use Solid Queue for background jobs # Use Solid Queue for background jobs
config.active_job.queue_adapter = :solid_queue config.active_job.queue_adapter = :solid_queue
config.solid_queue.connects_to = {database: {writing: :queue}}
# Ignore bad email addresses and do not raise email delivery errors. # Ignore bad email addresses and do not raise email delivery errors.
# Set this to true and configure the email server for immediate delivery to raise delivery errors. # Set this to true and configure the email server for immediate delivery to raise delivery errors.

View File

@@ -1,5 +1,5 @@
# frozen_string_literal: true # frozen_string_literal: true
module Clinch module Clinch
VERSION = "0.8.4" VERSION = "0.8.7"
end end

View File

@@ -0,0 +1,5 @@
class AddSkipConsentToApplications < ActiveRecord::Migration[8.1]
def change
add_column :applications, :skip_consent, :boolean, default: false, null: false
end
end

View File

@@ -0,0 +1,5 @@
class AddClaimsRequestsToOidcUserConsents < ActiveRecord::Migration[8.1]
def change
add_column :oidc_user_consents, :claims_requests, :json, default: {}, null: false
end
end

View File

@@ -0,0 +1,5 @@
class AddClaimsRequestsToOidcAuthorizationCodes < ActiveRecord::Migration[8.1]
def change
add_column :oidc_authorization_codes, :claims_requests, :json, default: {}, null: false
end
end

5
db/schema.rb generated
View File

@@ -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
@@ -78,6 +78,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_12_31_060112) do
t.text "redirect_uris" t.text "redirect_uris"
t.integer "refresh_token_ttl", default: 2592000 t.integer "refresh_token_ttl", default: 2592000
t.boolean "require_pkce", default: true, null: false t.boolean "require_pkce", default: true, null: false
t.boolean "skip_consent", default: false, null: false
t.string "slug", null: false t.string "slug", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.index ["active"], name: "index_applications_on_active" t.index ["active"], name: "index_applications_on_active"
@@ -116,6 +117,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 +162,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

View File

@@ -56,7 +56,8 @@ This checklist ensures Clinch meets security, quality, and documentation standar
- [x] Authorization code flow with PKCE support - [x] Authorization code flow with PKCE support
- [x] Refresh token rotation - [x] Refresh token rotation
- [x] Token family tracking (detects replay attacks) - [x] Token family tracking (detects replay attacks)
- [x] All tokens HMAC-SHA256 hashed in database - [x] All tokens and authorization codes HMAC-SHA256 hashed in database
- [x] TOTP secrets AES-256-GCM encrypted at rest (Rails credentials)
- [x] Configurable token expiry (access, refresh, ID) - [x] Configurable token expiry (access, refresh, ID)
- [x] One-time use authorization codes - [x] One-time use authorization codes
- [x] Pairwise subject identifiers (privacy) - [x] Pairwise subject identifiers (privacy)
@@ -130,8 +131,7 @@ This checklist ensures Clinch meets security, quality, and documentation standar
## Code Quality ## Code Quality
- [x] **RuboCop** - Code style and linting - [x] **StandardRB** - Code style and linting
- Configuration: Rails Omakase
- CI: Runs on every PR and push to main - CI: Runs on every PR and push to main
- [x] **Documentation** - Comprehensive README - [x] **Documentation** - Comprehensive README
@@ -158,7 +158,7 @@ This checklist ensures Clinch meets security, quality, and documentation standar
### Performance ### Performance
- [ ] Review N+1 queries - [ ] Review N+1 queries
- [ ] Add database indexes where needed - [x] Add database indexes where needed
- [ ] Test with realistic data volumes - [ ] Test with realistic data volumes
- [ ] Review token cleanup job performance - [ ] Review token cleanup job performance

View File

@@ -279,7 +279,7 @@ module Api
rd: evil_url # Ensure the rd parameter is preserved in login rd: evil_url # Ensure the rd parameter is preserved in login
} }
assert_response 302 assert_response 303
# Should NOT redirect to evil URL after successful authentication # Should NOT redirect to evil URL after successful authentication
refute_match evil_url, response.location, "Should not redirect to evil URL after authentication" refute_match evil_url, response.location, "Should not redirect to evil URL after authentication"
# Should redirect to the legitimate URL (not the evil one) # Should redirect to the legitimate URL (not the evil one)

View File

@@ -0,0 +1,394 @@
require "test_helper"
class OidcClaimsSecurityTest < ActionDispatch::IntegrationTest
setup do
@user = User.create!(email_address: "claims_security_test@example.com", password: "password123")
@application = Application.create!(
name: "Claims Security Test App",
slug: "claims-security-test-app",
app_type: "oidc",
redirect_uris: ["http://localhost:4000/callback"].to_json,
active: true,
require_pkce: false
)
# Store the plain text client secret for testing
@application.generate_new_client_secret!
@plain_client_secret = @application.client_secret
@application.save!
end
def teardown
# Delete in correct order to avoid foreign key constraints
OidcRefreshToken.where(application: @application).delete_all
OidcAccessToken.where(application: @application).delete_all
OidcAuthorizationCode.where(application: @application).delete_all
OidcUserConsent.where(application: @application).delete_all
@user.destroy
@application.destroy
end
# ====================
# CLAIMS PARAMETER ESCALATION ATTACKS
# ====================
test "rejects claims parameter during authorization code exchange" do
# Create consent with minimal scopes (no profile, email, or admin access)
OidcUserConsent.create!(
user: @user,
application: @application,
scopes_granted: "openid",
granted_at: Time.current,
sid: "test-sid-123"
)
auth_code = OidcAuthorizationCode.create!(
application: @application,
user: @user,
redirect_uri: "http://localhost:4000/callback",
scope: "openid",
expires_at: 10.minutes.from_now
)
# ATTEMPT: Inject claims parameter during token exchange (ATTACK!)
# The client is trying to request 'admin' claim that they never got consent for
post "/oauth/token", params: {
grant_type: "authorization_code",
code: auth_code.plaintext_code,
redirect_uri: "http://localhost:4000/callback",
claims: '{"id_token":{"admin":{"essential":true}}}' # ← ATTACK!
}, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
}
# SHOULD: Reject the claims parameter - it's only allowed in authorization requests
assert_response :bad_request
error = JSON.parse(response.body)
assert_equal "invalid_request", error["error"], "Should reject claims parameter at token endpoint"
assert_match(/claims.*not allowed|unsupported parameter/i, error["error_description"], "Error should mention claims parameter not allowed")
end
test "rejects claims parameter during authorization code exchange with profile escalation" do
# Create consent with ONLY openid scope (no profile scope)
OidcUserConsent.create!(
user: @user,
application: @application,
scopes_granted: "openid",
granted_at: Time.current,
sid: "test-sid-123"
)
auth_code = OidcAuthorizationCode.create!(
application: @application,
user: @user,
redirect_uri: "http://localhost:4000/callback",
scope: "openid",
expires_at: 10.minutes.from_now
)
# ATTEMPT: Try to get profile claims via claims parameter without profile scope
post "/oauth/token", params: {
grant_type: "authorization_code",
code: auth_code.plaintext_code,
redirect_uri: "http://localhost:4000/callback",
claims: '{"id_token":{"name":null,"email":{"essential":true}}}'
}, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
}
# SHOULD: Reject the claims parameter
assert_response :bad_request
error = JSON.parse(response.body)
assert_equal "invalid_request", error["error"]
end
test "rejects claims parameter during refresh token grant" do
access_token = OidcAccessToken.create!(
application: @application,
user: @user,
scope: "openid"
)
refresh_token = OidcRefreshToken.create!(
application: @application,
user: @user,
oidc_access_token: access_token,
scope: "openid"
)
plaintext_refresh_token = refresh_token.token
# ATTEMPT: Inject claims parameter during refresh (ATTACK!)
# Trying to escalate to admin claims during refresh
post "/oauth/token", params: {
grant_type: "refresh_token",
refresh_token: plaintext_refresh_token,
claims: '{"id_token":{"admin":true,"role":{"essential":true}}}' # ← ATTACK!
}, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
}
# SHOULD: Reject the claims parameter
assert_response :bad_request
error = JSON.parse(response.body)
assert_equal "invalid_request", error["error"], "Should reject claims parameter at refresh token endpoint"
assert_match(/claims.*not allowed|unsupported parameter/i, error["error_description"])
end
test "rejects claims parameter during refresh token grant with custom claims escalation" do
# Setup: User has a custom claim at user level
@user.update!(custom_claims: {"role" => "user"})
access_token = OidcAccessToken.create!(
application: @application,
user: @user,
scope: "openid"
)
refresh_token = OidcRefreshToken.create!(
application: @application,
user: @user,
oidc_access_token: access_token,
scope: "openid"
)
plaintext_refresh_token = refresh_token.token
# ATTEMPT: Try to escalate role to admin via claims parameter
post "/oauth/token", params: {
grant_type: "refresh_token",
refresh_token: plaintext_refresh_token,
claims: '{"id_token":{"role":{"value":"admin"}}}' # ← ATTACK! Trying to override role value
}, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
}
# SHOULD: Reject the claims parameter
assert_response :bad_request
error = JSON.parse(response.body)
assert_equal "invalid_request", error["error"]
end
test "allows token exchange without claims parameter" do
# Create consent
OidcUserConsent.create!(
user: @user,
application: @application,
scopes_granted: "openid profile",
granted_at: Time.current,
sid: "test-sid-123"
)
auth_code = OidcAuthorizationCode.create!(
application: @application,
user: @user,
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
expires_at: 10.minutes.from_now
)
# Normal token exchange WITHOUT claims parameter should work fine
post "/oauth/token", params: {
grant_type: "authorization_code",
code: auth_code.plaintext_code,
redirect_uri: "http://localhost:4000/callback"
}, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
}
assert_response :success
response_body = JSON.parse(response.body)
assert response_body.key?("access_token")
assert response_body.key?("id_token")
end
test "allows refresh without claims parameter" do
# Create consent for this application
OidcUserConsent.create!(
user: @user,
application: @application,
scopes_granted: "openid profile",
granted_at: Time.current,
sid: "test-sid-refresh-456"
)
access_token = OidcAccessToken.create!(
application: @application,
user: @user,
scope: "openid profile"
)
refresh_token = OidcRefreshToken.create!(
application: @application,
user: @user,
oidc_access_token: access_token,
scope: "openid profile"
)
plaintext_refresh_token = refresh_token.token
# Normal refresh WITHOUT claims parameter should work fine
post "/oauth/token", params: {
grant_type: "refresh_token",
refresh_token: plaintext_refresh_token
}, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
}
assert_response :success
response_body = JSON.parse(response.body)
assert response_body.key?("access_token")
assert response_body.key?("id_token")
end
# ====================
# CLAIMS PARAMETER IS AUTHORIZATION-ONLY
# ====================
test "claims parameter is only valid in authorization request per OIDC spec" do
# Per OIDC Core spec section 18.2.1, claims parameter usage location is "Authorization Request"
# This test verifies that claims parameter cannot be used at token endpoint
OidcUserConsent.create!(
user: @user,
application: @application,
scopes_granted: "openid",
granted_at: Time.current,
sid: "test-sid-123"
)
auth_code = OidcAuthorizationCode.create!(
application: @application,
user: @user,
redirect_uri: "http://localhost:4000/callback",
scope: "openid",
expires_at: 10.minutes.from_now
)
# Test various attempts to inject claims parameter
malicious_claims = [
'{"id_token":{"admin":true}}',
'{"id_token":{"email":{"essential":true}}}',
'{"userinfo":{"groups":{"values":["admin"]}}}',
'{"id_token":{"custom_claim":"custom_value"}}',
"invalid-json"
]
malicious_claims.each do |claims_value|
post "/oauth/token", params: {
grant_type: "authorization_code",
code: auth_code.plaintext_code,
redirect_uri: "http://localhost:4000/callback",
claims: claims_value
}, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
}
# All should be rejected
assert_response :bad_request, "Claims parameter '#{claims_value}' should be rejected"
error = JSON.parse(response.body)
assert_equal "invalid_request", error["error"]
end
end
# ====================
# VERIFY CONSENT-BASED ACCESS IS ENFORCED
# ====================
test "token endpoint respects scopes granted during authorization" do
# Create consent with ONLY openid scope (no email, profile, etc.)
OidcUserConsent.create!(
user: @user,
application: @application,
scopes_granted: "openid",
granted_at: Time.current,
sid: "test-sid-123"
)
auth_code = OidcAuthorizationCode.create!(
application: @application,
user: @user,
redirect_uri: "http://localhost:4000/callback",
scope: "openid",
expires_at: 10.minutes.from_now
)
# Exchange code for tokens
post "/oauth/token", params: {
grant_type: "authorization_code",
code: auth_code.plaintext_code,
redirect_uri: "http://localhost:4000/callback"
}, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
}
assert_response :success
response_body = JSON.parse(response.body)
id_token = response_body["id_token"]
# Decode ID token to check claims
decoded = JWT.decode(id_token, nil, false).first
# Should only have required claims, not email/profile
assert_includes decoded.keys, "iss"
assert_includes decoded.keys, "sub"
assert_includes decoded.keys, "aud"
assert_includes decoded.keys, "exp"
assert_includes decoded.keys, "iat"
# Should NOT have claims that weren't consented to
refute_includes decoded.keys, "email", "Should not include email without email scope"
refute_includes decoded.keys, "email_verified", "Should not include email_verified without email scope"
refute_includes decoded.keys, "name", "Should not include name without profile scope"
refute_includes decoded.keys, "preferred_username", "Should not include preferred_username without profile scope"
end
test "refresh token preserves original scopes granted during authorization" do
# Create consent with specific scopes
OidcUserConsent.create!(
user: @user,
application: @application,
scopes_granted: "openid email",
granted_at: Time.current,
sid: "test-sid-refresh-123"
)
access_token = OidcAccessToken.create!(
application: @application,
user: @user,
scope: "openid email"
)
refresh_token = OidcRefreshToken.create!(
application: @application,
user: @user,
oidc_access_token: access_token,
scope: "openid email"
)
plaintext_refresh_token = refresh_token.token
# Refresh the token
post "/oauth/token", params: {
grant_type: "refresh_token",
refresh_token: plaintext_refresh_token
}, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
}
assert_response :success
response_body = JSON.parse(response.body)
id_token = response_body["id_token"]
# Decode ID token to verify scopes are preserved
decoded = JWT.decode(id_token, nil, false).first
# Should have email claims (from original consent)
assert_includes decoded.keys, "email", "Should preserve email scope from original consent"
assert_includes decoded.keys, "email_verified", "Should preserve email_verified scope from original consent"
# Should NOT have profile claims (not in original consent)
refute_includes decoded.keys, "name", "Should not add profile claims that weren't consented to"
refute_includes decoded.keys, "preferred_username", "Should not add preferred_username that wasn't consented to"
end
end

View File

@@ -0,0 +1,236 @@
require "test_helper"
class OidcPromptLoginTest < ActionDispatch::IntegrationTest
setup do
@user = users(:alice)
@application = applications(:kavita_app)
@client_secret = SecureRandom.urlsafe_base64(48)
@application.client_secret = @client_secret
@application.save!
# Pre-authorize the application so we skip consent screen
consent = OidcUserConsent.find_or_initialize_by(
user: @user,
application: @application
)
consent.scopes_granted ||= "openid profile email"
consent.save!
end
teardown do
# Clean up
OidcAccessToken.where(user: @user, application: @application).destroy_all
OidcAuthorizationCode.where(user: @user, application: @application).destroy_all
end
test "max_age requires re-authentication when session is too old" do
# Sign in to create a session
post "/signin", params: {
email_address: @user.email_address,
password: "password"
}
assert_response :redirect
follow_redirect!
assert_response :success
# Get first auth_time
get "/oauth/authorize", params: {
client_id: @application.client_id,
redirect_uri: @application.parsed_redirect_uris.first,
response_type: "code",
scope: "openid",
state: "first-state",
nonce: "first-nonce"
}
assert_response :redirect
first_redirect_url = response.location
first_code = CGI.parse(URI(first_redirect_url).query)["code"].first
# Exchange for tokens and extract auth_time
post "/oauth/token", params: {
grant_type: "authorization_code",
code: first_code,
redirect_uri: @application.parsed_redirect_uris.first,
client_id: @application.client_id,
client_secret: @client_secret
}
assert_response :success
first_tokens = JSON.parse(response.body)
first_id_token = OidcJwtService.decode_id_token(first_tokens["id_token"])
first_auth_time = first_id_token[0]["auth_time"]
# Wait a bit (simulate time passing - in real scenario this would be actual seconds)
# Then request with max_age=0 (means session must be brand new)
get "/oauth/authorize", params: {
client_id: @application.client_id,
redirect_uri: @application.parsed_redirect_uris.first,
response_type: "code",
scope: "openid",
state: "second-state",
nonce: "second-nonce",
max_age: "0" # Requires session to be 0 seconds old (i.e., brand new)
}
# Should redirect to sign in because session is too old
assert_response :redirect
assert_redirected_to(/signin/)
# Sign in again
post "/signin", params: {
email_address: @user.email_address,
password: "password"
}
assert_response :redirect
follow_redirect!
# Should receive authorization code
assert_response :redirect
second_redirect_url = response.location
second_code = CGI.parse(URI(second_redirect_url).query)["code"].first
assert second_code.present?, "Should receive authorization code after re-authentication"
# Exchange second authorization code for tokens
post "/oauth/token", params: {
grant_type: "authorization_code",
code: second_code,
redirect_uri: @application.parsed_redirect_uris.first,
client_id: @application.client_id,
client_secret: @client_secret
}
assert_response :success
second_tokens = JSON.parse(response.body)
second_id_token = OidcJwtService.decode_id_token(second_tokens["id_token"])
second_auth_time = second_id_token[0]["auth_time"]
# The second auth_time should be >= the first (re-authentication occurred)
# Note: May be equal if both occur in the same second (test timing edge case)
assert second_auth_time >= first_auth_time,
"max_age=0 should result in a re-authentication. " \
"First: #{first_auth_time}, Second: #{second_auth_time}"
end
test "prompt=none returns login_required error when not authenticated" do
# Don't sign in - user is not authenticated
# Request authorization with prompt=none
get "/oauth/authorize", params: {
client_id: @application.client_id,
redirect_uri: @application.parsed_redirect_uris.first,
response_type: "code",
scope: "openid",
state: "test-state",
prompt: "none"
}
# Should redirect with error=login_required (NOT to sign-in page)
assert_response :redirect
redirect_url = response.location
# Parse the redirect URL
uri = URI.parse(redirect_url)
query_params = uri.query ? CGI.parse(uri.query) : {}
assert_equal "login_required", query_params["error"]&.first,
"Should return login_required error for prompt=none when not authenticated"
assert_equal "test-state", query_params["state"]&.first,
"Should return state parameter"
end
test "prompt=login forces re-authentication with new auth_time" do
# First authentication
post "/signin", params: {
email_address: @user.email_address,
password: "password"
}
assert_response :redirect
follow_redirect!
assert_response :success
# Get first authorization code
get "/oauth/authorize", params: {
client_id: @application.client_id,
redirect_uri: @application.parsed_redirect_uris.first,
response_type: "code",
scope: "openid",
state: "first-state",
nonce: "first-nonce"
}
assert_response :redirect
first_redirect_url = response.location
first_code = CGI.parse(URI(first_redirect_url).query)["code"].first
# Exchange for tokens and extract auth_time from ID token
post "/oauth/token", params: {
grant_type: "authorization_code",
code: first_code,
redirect_uri: @application.parsed_redirect_uris.first,
client_id: @application.client_id,
client_secret: @client_secret
}
assert_response :success
first_tokens = JSON.parse(response.body)
first_id_token = OidcJwtService.decode_id_token(first_tokens["id_token"])
first_auth_time = first_id_token[0]["auth_time"]
# Now request authorization again with prompt=login
get "/oauth/authorize", params: {
client_id: @application.client_id,
redirect_uri: @application.parsed_redirect_uris.first,
response_type: "code",
scope: "openid",
state: "second-state",
nonce: "second-nonce",
prompt: "login"
}
# Should redirect to sign in
assert_response :redirect
assert_redirected_to(/signin/)
# Sign in again (simulating user re-authentication)
post "/signin", params: {
email_address: @user.email_address,
password: "password"
}
assert_response :redirect
# Follow redirect to after_authentication_url (which is /oauth/authorize without prompt=login)
follow_redirect!
# Should receive authorization code redirect
assert_response :redirect
second_redirect_url = response.location
second_code = CGI.parse(URI(second_redirect_url).query)["code"].first
assert second_code.present?, "Should receive authorization code after re-authentication"
# Exchange second authorization code for tokens
post "/oauth/token", params: {
grant_type: "authorization_code",
code: second_code,
redirect_uri: @application.parsed_redirect_uris.first,
client_id: @application.client_id,
client_secret: @client_secret
}
assert_response :success
second_tokens = JSON.parse(response.body)
second_id_token = OidcJwtService.decode_id_token(second_tokens["id_token"])
second_auth_time = second_id_token[0]["auth_time"]
# The second auth_time should be >= the first (re-authentication occurred)
# Note: May be equal if both occur in the same second (test timing edge case)
assert second_auth_time >= first_auth_time,
"prompt=login should result in a later auth_time. " \
"First: #{first_auth_time}, Second: #{second_auth_time}"
end
end

View File

@@ -31,7 +31,7 @@ class ForwardAuthAdvancedTest < ActionDispatch::IntegrationTest
# Step 3: Sign in # Step 3: Sign in
post "/signin", params: {email_address: @user.email_address, password: "password"} post "/signin", params: {email_address: @user.email_address, password: "password"}
assert_response 302 assert_response 303
redirect_uri = URI.parse(response.location) redirect_uri = URI.parse(response.location)
assert_equal "https", redirect_uri.scheme assert_equal "https", redirect_uri.scheme
assert_equal "app.example.com", redirect_uri.host assert_equal "app.example.com", redirect_uri.host
@@ -64,7 +64,7 @@ class ForwardAuthAdvancedTest < ActionDispatch::IntegrationTest
# Sign in once # Sign in once
post "/signin", params: {email_address: @user.email_address, password: "password"} post "/signin", params: {email_address: @user.email_address, password: "password"}
assert_response 302 assert_response 303
assert_redirected_to "/" assert_redirected_to "/"
# Test access to different applications # Test access to different applications
@@ -101,7 +101,7 @@ class ForwardAuthAdvancedTest < ActionDispatch::IntegrationTest
# Sign in # Sign in
post "/signin", params: {email_address: @user.email_address, password: "password"} post "/signin", params: {email_address: @user.email_address, password: "password"}
assert_response 302 assert_response 303
# Should have access (in allowed group) # Should have access (in allowed group)
get "/api/verify", headers: {"X-Forwarded-Host" => "admin.example.com"} get "/api/verify", headers: {"X-Forwarded-Host" => "admin.example.com"}
@@ -139,7 +139,7 @@ class ForwardAuthAdvancedTest < ActionDispatch::IntegrationTest
# Sign in # Sign in
post "/signin", params: {email_address: @user.email_address, password: "password"} post "/signin", params: {email_address: @user.email_address, password: "password"}
assert_response 302 assert_response 303
# Should have access (bypass mode) # Should have access (bypass mode)
get "/api/verify", headers: {"X-Forwarded-Host" => "public.example.com"} get "/api/verify", headers: {"X-Forwarded-Host" => "public.example.com"}
@@ -255,7 +255,7 @@ class ForwardAuthAdvancedTest < ActionDispatch::IntegrationTest
# Sign in once # Sign in once
post "/signin", params: {email_address: @user.email_address, password: "password"} post "/signin", params: {email_address: @user.email_address, password: "password"}
assert_response 302 assert_response 303
# Test access to each application # Test access to each application
apps.each do |app| apps.each do |app|

View File

@@ -27,7 +27,7 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
# Step 2: Sign in # Step 2: Sign in
post "/signin", params: {email_address: @user.email_address, password: "password"} post "/signin", params: {email_address: @user.email_address, password: "password"}
assert_response 302 assert_response 303
# Signin now redirects back with fa_token parameter # Signin now redirects back with fa_token parameter
assert_match(/\?fa_token=/, response.location) assert_match(/\?fa_token=/, response.location)
assert cookies[:session_id] assert cookies[:session_id]

View File

@@ -0,0 +1,136 @@
require "test_helper"
class DurationParserTest < ActiveSupport::TestCase
# Valid formats
test "parses seconds" do
assert_equal 1, DurationParser.parse("1s")
assert_equal 30, DurationParser.parse("30s")
assert_equal 3600, DurationParser.parse("3600s")
end
test "parses minutes" do
assert_equal 60, DurationParser.parse("1m")
assert_equal 300, DurationParser.parse("5m")
assert_equal 1800, DurationParser.parse("30m")
end
test "parses hours" do
assert_equal 3600, DurationParser.parse("1h")
assert_equal 7200, DurationParser.parse("2h")
assert_equal 86400, DurationParser.parse("24h")
end
test "parses days" do
assert_equal 86400, DurationParser.parse("1d")
assert_equal 172800, DurationParser.parse("2d")
assert_equal 2592000, DurationParser.parse("30d")
end
test "parses weeks" do
assert_equal 604800, DurationParser.parse("1w")
assert_equal 1209600, DurationParser.parse("2w")
end
test "parses months (30 days)" do
assert_equal 2592000, DurationParser.parse("1M")
assert_equal 5184000, DurationParser.parse("2M")
end
test "parses years (365 days)" do
assert_equal 31536000, DurationParser.parse("1y")
assert_equal 63072000, DurationParser.parse("2y")
end
# Plain numbers
test "parses plain integer as seconds" do
assert_equal 3600, DurationParser.parse(3600)
assert_equal 300, DurationParser.parse(300)
assert_equal 0, DurationParser.parse(0)
end
test "parses plain numeric string as seconds" do
assert_equal 3600, DurationParser.parse("3600")
assert_equal 300, DurationParser.parse("300")
assert_equal 0, DurationParser.parse("0")
end
# Whitespace handling
test "handles leading and trailing whitespace" do
assert_equal 3600, DurationParser.parse(" 1h ")
assert_equal 300, DurationParser.parse(" 5m ")
assert_equal 86400, DurationParser.parse("\t1d\n")
end
test "handles space between number and unit" do
assert_equal 3600, DurationParser.parse("1 h")
assert_equal 300, DurationParser.parse("5 m")
assert_equal 86400, DurationParser.parse("1 d")
end
# Case sensitivity - only lowercase units work (except M for months)
test "lowercase units work" do
assert_equal 1, DurationParser.parse("1s")
assert_equal 60, DurationParser.parse("1m") # minute (lowercase)
assert_equal 3600, DurationParser.parse("1h")
assert_equal 86400, DurationParser.parse("1d")
assert_equal 604800, DurationParser.parse("1w")
assert_equal 31536000, DurationParser.parse("1y")
end
test "uppercase M for months works" do
assert_equal 2592000, DurationParser.parse("1M") # month (uppercase)
end
test "returns nil for wrong case" do
assert_nil DurationParser.parse("1S") # Should be 1s
assert_nil DurationParser.parse("1H") # Should be 1h
assert_nil DurationParser.parse("1D") # Should be 1d
assert_nil DurationParser.parse("1W") # Should be 1w
assert_nil DurationParser.parse("1Y") # Should be 1y
end
# Edge cases
test "handles zero duration" do
assert_equal 0, DurationParser.parse("0s")
assert_equal 0, DurationParser.parse("0m")
assert_equal 0, DurationParser.parse("0h")
end
test "handles large numbers" do
assert_equal 86400000, DurationParser.parse("1000d")
assert_equal 360000, DurationParser.parse("100h")
end
# Invalid formats - should return nil (not raise)
test "returns nil for invalid format" do
assert_nil DurationParser.parse("invalid")
assert_nil DurationParser.parse("1x")
assert_nil DurationParser.parse("abc")
assert_nil DurationParser.parse("1.5h") # No decimals
assert_nil DurationParser.parse("-1h") # No negatives
assert_nil DurationParser.parse("h1") # Wrong order
end
test "returns nil for blank input" do
assert_nil DurationParser.parse("")
assert_nil DurationParser.parse(nil)
assert_nil DurationParser.parse(" ")
end
test "returns nil for multiple units" do
assert_nil DurationParser.parse("1h30m") # Keep it simple, don't support this
assert_nil DurationParser.parse("1d2h")
end
# String coercion
test "handles string input" do
assert_equal 3600, DurationParser.parse("1h")
assert_equal 3600, DurationParser.parse(:"1h") # Symbol
end
# Boundary validation (not parser's job, but good to know)
test "parses values outside typical TTL ranges without error" do
assert_equal 1, DurationParser.parse("1s") # Below min access_token_ttl
assert_equal 315360000, DurationParser.parse("10y") # Above max refresh_token_ttl
end
end

View File

@@ -0,0 +1,109 @@
require "test_helper"
class ApplicationDurationParserTest < ActiveSupport::TestCase
test "access_token_ttl accepts human-friendly durations" do
app = Application.new(access_token_ttl: "1h")
assert_equal 3600, app.access_token_ttl
app.access_token_ttl = "30m"
assert_equal 1800, app.access_token_ttl
app.access_token_ttl = "5m"
assert_equal 300, app.access_token_ttl
end
test "refresh_token_ttl accepts human-friendly durations" do
app = Application.new(refresh_token_ttl: "30d")
assert_equal 2592000, app.refresh_token_ttl
app.refresh_token_ttl = "1M"
assert_equal 2592000, app.refresh_token_ttl
app.refresh_token_ttl = "7d"
assert_equal 604800, app.refresh_token_ttl
end
test "id_token_ttl accepts human-friendly durations" do
app = Application.new(id_token_ttl: "1h")
assert_equal 3600, app.id_token_ttl
app.id_token_ttl = "2h"
assert_equal 7200, app.id_token_ttl
end
test "TTL fields still accept plain numbers" do
app = Application.new(
access_token_ttl: 3600,
refresh_token_ttl: 2592000,
id_token_ttl: 3600
)
assert_equal 3600, app.access_token_ttl
assert_equal 2592000, app.refresh_token_ttl
assert_equal 3600, app.id_token_ttl
end
test "TTL fields accept plain number strings" do
app = Application.new(
access_token_ttl: "3600",
refresh_token_ttl: "2592000",
id_token_ttl: "3600"
)
assert_equal 3600, app.access_token_ttl
assert_equal 2592000, app.refresh_token_ttl
assert_equal 3600, app.id_token_ttl
end
test "invalid TTL values are set to nil" do
app = Application.new(
access_token_ttl: "invalid",
refresh_token_ttl: "bad",
id_token_ttl: "nope"
)
assert_nil app.access_token_ttl
assert_nil app.refresh_token_ttl
assert_nil app.id_token_ttl
end
test "validation still works with parsed values" do
app = Application.new(
name: "Test",
slug: "test",
app_type: "oidc",
redirect_uris: "https://example.com/callback"
)
# Too short (below 5 minutes)
app.access_token_ttl = "1m"
assert_not app.valid?
assert_includes app.errors[:access_token_ttl], "must be greater than or equal to 300"
# Too long (above 24 hours for access token)
app.access_token_ttl = "2d"
assert_not app.valid?
assert_includes app.errors[:access_token_ttl], "must be less than or equal to 86400"
# Just right
app.access_token_ttl = "1h"
app.valid? # Revalidate
assert app.errors[:access_token_ttl].blank?
end
test "can create OIDC app with human-friendly TTL values" do
app = Application.create!(
name: "Test App",
slug: "test-app",
app_type: "oidc",
redirect_uris: "https://example.com/callback",
access_token_ttl: "1h",
refresh_token_ttl: "30d",
id_token_ttl: "2h"
)
assert_equal 3600, app.access_token_ttl
assert_equal 2592000, app.refresh_token_ttl
assert_equal 7200, app.id_token_ttl
end
end