16 Commits

Author SHA1 Message Date
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
Dan Milne
e631f606e7 Better error messages
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-03 12:29:27 +11:00
Dan Milne
f4a697ae9b More OpenID Conformance test fixes - work with POST, correct auth code character set, correct no-store cache headers 2026-01-03 12:28:43 +11:00
Dan Milne
16e34ffaf0 Updates for oidc conformance 2026-01-03 10:11:10 +11:00
27 changed files with 680 additions and 81 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 Certified](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
@@ -52,12 +51,24 @@ module Authentication
# Extract root domain for cross-subdomain cookies (required for forward auth) # Extract root domain for cross-subdomain cookies (required for forward auth)
domain = extract_root_domain(request.host) domain = extract_root_domain(request.host)
cookie_options = { # Set cookie options based on environment
value: session.id, # Production: Use SameSite=None to allow cross-site cookies (needed for OIDC conformance testing)
httponly: true, # Development: Use SameSite=Lax since HTTPS might not be available
same_site: :lax, cookie_options = if Rails.env.production?
secure: Rails.env.production? {
} value: session.id,
httponly: true,
same_site: :none, # Allow cross-site cookies for OIDC testing
secure: true # Required for SameSite=None
}
else
{
value: session.id,
httponly: true,
same_site: :lax,
secure: false
}
end
# Set domain for cross-subdomain authentication if we can extract it # Set domain for cross-subdomain authentication if we can extract it
cookie_options[:domain] = domain if domain.present? cookie_options[:domain] = domain if domain.present?

View File

@@ -1,7 +1,8 @@
class OidcController < ApplicationController class OidcController < ApplicationController
# Discovery and JWKS endpoints are public # Discovery and JWKS endpoints are public
allow_unauthenticated_access only: [:discovery, :jwks, :token, :revoke, :userinfo, :logout] # authorize is also unauthenticated to handle prompt=none and prompt=login specially
skip_before_action :verify_authenticity_token, only: [:token, :revoke, :userinfo, :logout] allow_unauthenticated_access only: [:discovery, :jwks, :token, :revoke, :userinfo, :logout, :authorize]
skip_before_action :verify_authenticity_token, only: [:token, :revoke, :userinfo, :logout, :authorize, :consent]
# Rate limiting to prevent brute force and abuse # Rate limiting to prevent brute force and abuse
rate_limit to: 60, within: 1.minute, only: [:token, :revoke], with: -> { rate_limit to: 60, within: 1.minute, only: [:token, :revoke], with: -> {
@@ -43,7 +44,9 @@ 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,
claims_parameter_supported: true
} }
render json: config render json: config
@@ -119,6 +122,18 @@ class OidcController < ApplicationController
# per OAuth2 RFC 6749 Section 4.1.2.1 # per OAuth2 RFC 6749 Section 4.1.2.1
# ============================================================================ # ============================================================================
# Reject request objects (JWT-encoded authorization parameters)
# Per OIDC Core §3.1.2.6: If request parameter is present and not supported,
# return request_not_supported error
if params[:request].present? || params[:request_uri].present?
Rails.logger.error "OAuth: Request object not supported"
error_uri = "#{redirect_uri}?error=request_not_supported"
error_uri += "&error_description=#{CGI.escape("Request objects are not supported")}"
error_uri += "&state=#{CGI.escape(state)}" if state.present?
redirect_to error_uri, allow_other_host: true
return
end
# Validate response_type (now we can safely redirect with error) # Validate response_type (now we can safely redirect with error)
unless response_type == "code" unless response_type == "code"
Rails.logger.error "OAuth: Invalid response_type: #{response_type}" Rails.logger.error "OAuth: Invalid response_type: #{response_type}"
@@ -151,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}"
@@ -162,7 +206,17 @@ class OidcController < ApplicationController
# Check if user is authenticated # Check if user is authenticated
unless authenticated? unless authenticated?
# Store OAuth parameters in session and redirect to sign in # Handle prompt=none - no UI allowed, return error immediately
# Per OIDC Core spec §3.1.2.6: If prompt=none and user not authenticated,
# return login_required error without showing any UI
if params[:prompt] == "none"
error_uri = "#{redirect_uri}?error=login_required"
error_uri += "&state=#{CGI.escape(state)}" if state.present?
redirect_to error_uri, allow_other_host: true
return
end
# Normal flow: store OAuth parameters and redirect to sign in
session[:oauth_params] = { session[:oauth_params] = {
client_id: client_id, client_id: client_id,
redirect_uri: redirect_uri, redirect_uri: redirect_uri,
@@ -170,12 +224,62 @@ 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
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
end end
# Handle prompt=login - force re-authentication
# Per OIDC Core spec §3.1.2.1: If prompt=login, the Authorization Server MUST prompt
# the End-User for reauthentication, even if the End-User is currently authenticated
if params[:prompt] == "login"
# Destroy current session to force re-authentication
# This creates a fresh authentication event with a new auth_time
Current.session&.destroy!
# Clear the session cookie so the user is truly logged out
cookies.delete(:session_id)
# Store the current URL (which contains all OAuth params) for redirect after login
# Remove prompt=login to prevent infinite re-auth loop
return_url = remove_query_param(request.url, "prompt")
session[:return_to_after_authenticating] = return_url
redirect_to signin_path, alert: "Please sign in to continue"
return
end
# Handle max_age - require re-authentication if session is too old
# Per OIDC Core spec §3.1.2.1: If max_age is provided and the auth time is older,
# the Authorization Server MUST prompt for reauthentication
if params[:max_age].present?
max_age_seconds = params[:max_age].to_i
# Calculate session age
session_age_seconds = Time.current.to_i - Current.session.created_at.to_i
if session_age_seconds >= max_age_seconds
# Session is too old - require re-authentication
# Store the return URL in Rails session, then destroy the Session record
# 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!
cookies.delete(:session_id)
Current.session = nil
redirect_to signin_path, alert: "Please sign in to continue"
return
end
end
# Get the authenticated user # Get the authenticated user
user = Current.session.user user = Current.session.user
@@ -187,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,
@@ -199,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
@@ -219,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
@@ -284,8 +422,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!
@@ -298,6 +439,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
@@ -315,6 +457,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
@@ -457,6 +609,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,
@@ -465,9 +618,14 @@ 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
response.headers["Cache-Control"] = "no-store"
response.headers["Pragma"] = "no-cache"
# Return tokens # Return tokens
render json: { render json: {
access_token: access_token_record.plaintext_token, # Opaque token access_token: access_token_record.plaintext_token, # Opaque token
@@ -587,6 +745,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,
@@ -594,9 +753,14 @@ 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
response.headers["Cache-Control"] = "no-store"
response.headers["Pragma"] = "no-cache"
# Return new tokens # Return new tokens
render json: { render json: {
access_token: new_access_token.plaintext_token, # Opaque token access_token: new_access_token.plaintext_token, # Opaque token
@@ -654,33 +818,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
@@ -695,6 +871,16 @@ 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
response.headers["Cache-Control"] = "no-store"
response.headers["Pragma"] = "no-cache"
render json: claims render json: claims
end end
@@ -839,12 +1025,12 @@ class OidcController < ApplicationController
} }
end end
# Validate code verifier format (base64url-encoded, 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",
error_description: "Invalid code_verifier format. Must be 43-128 characters of base64url encoding", error_description: "Invalid code_verifier format. Must be 43-128 characters [A-Z/a-z/0-9/-/./_/~]",
status: :bad_request status: :bad_request
} }
end end
@@ -960,4 +1146,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

@@ -14,6 +14,20 @@ class SessionsController < ApplicationController
return return
end end
# Extract login_hint from the return URL for pre-filling the email field (OIDC spec)
@login_hint = nil
if session[:return_to_after_authenticating].present?
begin
uri = URI.parse(session[:return_to_after_authenticating])
if uri.query.present?
query_params = CGI.parse(uri.query)
@login_hint = query_params["login_hint"]&.first
end
rescue URI::InvalidURIError
# Ignore parsing errors
end
end
respond_to do |format| respond_to do |format|
format.html # render HTML login page format.html # render HTML login page
format.json { render json: {error: "Authentication required"}, status: :unauthorized } format.json { render json: {error: "Authentication required"}, status: :unauthorized }
@@ -73,7 +87,10 @@ class SessionsController < ApplicationController
# Sign in successful (password only) # Sign in successful (password only)
start_new_session_for user, acr: "1" start_new_session_for user, acr: "1"
redirect_to after_authentication_url, notice: "Signed in successfully.", allow_other_host: true
# Use status: :see_other to ensure browser makes a GET request
# This prevents Turbo from converting it to a TURBO_STREAM request
redirect_to after_authentication_url, notice: "Signed in successfully.", allow_other_host: true, status: :see_other
end end
def verify_totp def verify_totp

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

@@ -12,7 +12,7 @@
autofocus: true, autofocus: true,
autocomplete: "username", autocomplete: "username",
placeholder: "your@email.com", placeholder: "your@email.com",
value: params[:email_address], value: @login_hint || params[:email_address],
data: { action: "blur->webauthn#checkWebAuthnSupport change->webauthn#checkWebAuthnSupport" }, data: { action: "blur->webauthn#checkWebAuthnSupport change->webauthn#checkWebAuthnSupport" },
class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %> class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
</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

@@ -26,7 +26,7 @@ Rails.application.routes.draw do
# OIDC (OpenID Connect) routes # OIDC (OpenID Connect) routes
get "/.well-known/openid-configuration", to: "oidc#discovery" get "/.well-known/openid-configuration", to: "oidc#discovery"
get "/.well-known/jwks.json", to: "oidc#jwks" get "/.well-known/jwks.json", to: "oidc#jwks"
get "/oauth/authorize", to: "oidc#authorize" match "/oauth/authorize", to: "oidc#authorize", via: [:get, :post]
post "/oauth/authorize/consent", to: "oidc#consent", as: :oauth_consent post "/oauth/authorize/consent", to: "oidc#consent", as: :oauth_consent
post "/oauth/token", to: "oidc#token" post "/oauth/token", to: "oidc#token"
post "/oauth/revoke", to: "oidc#revoke" post "/oauth/revoke", to: "oidc#revoke"

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

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

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