12 Commits

Author SHA1 Message Date
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
23 changed files with 658 additions and 76 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.
#### 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:
- `/.well-known/openid-configuration` - Discovery endpoint
- `/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"
# 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
def revoke_all_consents

View File

@@ -104,7 +104,7 @@ module Admin
permitted = params.require(:application).permit(
:name, :slug, :app_type, :active, :redirect_uris, :description, :metadata,
: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

View File

@@ -9,4 +9,33 @@ class ApplicationController < ActionController::Base
# CSRF protection
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

View File

@@ -40,7 +40,6 @@ module Authentication
end
def after_authentication_url
session[:return_to_after_authenticating]
session.delete(:return_to_after_authenticating) || root_url
end
@@ -52,12 +51,24 @@ module Authentication
# Extract root domain for cross-subdomain cookies (required for forward auth)
domain = extract_root_domain(request.host)
cookie_options = {
# Set cookie options based on environment
# Production: Use SameSite=None to allow cross-site cookies (needed for OIDC conformance testing)
# Development: Use SameSite=Lax since HTTPS might not be available
cookie_options = if 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: Rails.env.production?
secure: false
}
end
# Set domain for cross-subdomain authentication if we can extract it
cookie_options[:domain] = domain if domain.present?

View File

@@ -1,7 +1,8 @@
class OidcController < ApplicationController
# Discovery and JWKS endpoints are public
allow_unauthenticated_access only: [:discovery, :jwks, :token, :revoke, :userinfo, :logout]
skip_before_action :verify_authenticity_token, only: [:token, :revoke, :userinfo, :logout]
# authorize is also unauthenticated to handle prompt=none and prompt=login specially
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_limit to: 60, within: 1.minute, only: [:token, :revoke], with: -> {
@@ -43,7 +44,9 @@ class OidcController < ApplicationController
],
code_challenge_methods_supported: ["plain", "S256"],
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
@@ -119,6 +122,18 @@ class OidcController < ApplicationController
# 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)
unless response_type == "code"
Rails.logger.error "OAuth: Invalid response_type: #{response_type}"
@@ -151,6 +166,35 @@ class OidcController < ApplicationController
end
end
# Parse claims parameter (JSON string) for OIDC claims request
# Per OIDC Core §5.5: The claims parameter is a JSON object that requests
# specific claims to be returned in the id_token and/or userinfo
claims_parameter = params[:claims]
parsed_claims = parse_claims_parameter(claims_parameter) if claims_parameter.present?
# Validate claims parameter format if present
if claims_parameter.present? && parsed_claims.nil?
Rails.logger.error "OAuth: Invalid claims parameter format"
error_uri = "#{redirect_uri}?error=invalid_request"
error_uri += "&error_description=#{CGI.escape("Invalid claims parameter: must be valid JSON")}"
error_uri += "&state=#{CGI.escape(state)}" if state.present?
redirect_to error_uri, allow_other_host: true
return
end
# Validate that requested claims are covered by granted scopes
if parsed_claims.present?
validation_result = validate_claims_against_scopes(parsed_claims, requested_scopes)
unless validation_result[:valid]
Rails.logger.error "OAuth: Claims parameter requests claims not covered by scopes: #{validation_result[:errors]}"
error_uri = "#{redirect_uri}?error=invalid_scope"
error_uri += "&error_description=#{CGI.escape("Claims parameter requests claims not covered by granted scopes")}"
error_uri += "&state=#{CGI.escape(state)}" if state.present?
redirect_to error_uri, allow_other_host: true
return
end
end
# Check if application is active (now we can safely redirect with error)
unless @application.active?
Rails.logger.error "OAuth: Application is not active: #{@application.name}"
@@ -162,7 +206,17 @@ class OidcController < ApplicationController
# Check if user is 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] = {
client_id: client_id,
redirect_uri: redirect_uri,
@@ -170,12 +224,62 @@ class OidcController < ApplicationController
nonce: nonce,
scope: scope,
code_challenge: code_challenge,
code_challenge_method: code_challenge_method
code_challenge_method: code_challenge_method,
claims_requests: parsed_claims&.to_json
}
# Store the current URL (with all OAuth params) for redirect after authentication
session[:return_to_after_authenticating] = request.url
redirect_to signin_path, alert: "Please sign in to continue"
return
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
user = Current.session.user
@@ -187,9 +291,41 @@ class OidcController < ApplicationController
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
existing_consent = user.has_oidc_consent?(@application, requested_scopes)
if existing_consent
if existing_consent && claims_match_consent?(parsed_claims, existing_consent)
# User has already consented, generate authorization code directly
auth_code = OidcAuthorizationCode.create!(
application: @application,
@@ -199,6 +335,7 @@ class OidcController < ApplicationController
nonce: nonce,
code_challenge: code_challenge,
code_challenge_method: code_challenge_method,
claims_requests: parsed_claims || {},
auth_time: Current.session.created_at.to_i,
acr: Current.session.acr,
expires_at: 10.minutes.from_now
@@ -219,7 +356,8 @@ class OidcController < ApplicationController
nonce: nonce,
scope: scope,
code_challenge: code_challenge,
code_challenge_method: code_challenge_method
code_challenge_method: code_challenge_method,
claims_requests: parsed_claims&.to_json
}
# Render consent page with dynamic CSP for OAuth redirect
@@ -284,8 +422,11 @@ class OidcController < ApplicationController
# Record user consent
requested_scopes = oauth_params["scope"].split(" ")
parsed_claims = JSON.parse(oauth_params["claims_requests"]) rescue {}
consent = OidcUserConsent.find_or_initialize_by(user: user, application: application)
consent.scopes_granted = requested_scopes.join(" ")
consent.claims_requests = parsed_claims
consent.granted_at = Time.current
consent.save!
@@ -298,6 +439,7 @@ class OidcController < ApplicationController
nonce: oauth_params["nonce"],
code_challenge: oauth_params["code_challenge"],
code_challenge_method: oauth_params["code_challenge_method"],
claims_requests: parsed_claims,
auth_time: Current.session.created_at.to_i,
acr: Current.session.acr,
expires_at: 10.minutes.from_now
@@ -457,6 +599,7 @@ class OidcController < ApplicationController
# Generate ID token (JWT) with pairwise SID, at_hash, auth_time, and acr
# auth_time and acr come from the authorization code (captured at /authorize time)
# scopes determine which claims are included (per OIDC Core spec)
# claims_requests parameter filters which claims are included
id_token = OidcJwtService.generate_id_token(
user,
application,
@@ -465,9 +608,14 @@ class OidcController < ApplicationController
access_token: access_token_record.plaintext_token,
auth_time: auth_code.auth_time,
acr: auth_code.acr,
scopes: auth_code.scope
scopes: auth_code.scope,
claims_requests: auth_code.parsed_claims_requests
)
# RFC6749-5.1: Token endpoint MUST return Cache-Control: no-store
response.headers["Cache-Control"] = "no-store"
response.headers["Pragma"] = "no-cache"
# Return tokens
render json: {
access_token: access_token_record.plaintext_token, # Opaque token
@@ -587,6 +735,7 @@ class OidcController < ApplicationController
# Generate new ID token (JWT with pairwise SID, at_hash, auth_time, acr; no nonce for refresh grants)
# auth_time and acr come from the original refresh token (carried over from initial auth)
# scopes determine which claims are included (per OIDC Core spec)
# claims_requests parameter filters which claims are included (from original consent)
id_token = OidcJwtService.generate_id_token(
user,
application,
@@ -594,9 +743,14 @@ class OidcController < ApplicationController
access_token: new_access_token.plaintext_token,
auth_time: refresh_token_record.auth_time,
acr: refresh_token_record.acr,
scopes: refresh_token_record.scope
scopes: refresh_token_record.scope,
claims_requests: consent.parsed_claims_requests
)
# RFC6749-5.1: Token endpoint MUST return Cache-Control: no-store
response.headers["Cache-Control"] = "no-store"
response.headers["Pragma"] = "no-cache"
# Return new tokens
render json: {
access_token: new_access_token.plaintext_token, # Opaque token
@@ -654,34 +808,46 @@ class OidcController < ApplicationController
# Parse scopes from access token (space-separated string)
requested_scopes = access_token.scope.to_s.split
# Get claims_requests from consent (if available) for UserInfo context
userinfo_claims = consent&.parsed_claims_requests&.dig("userinfo") || {}
# Return user claims (filter by scope per OIDC Core spec)
# Required claims (always included)
# Required claims (always included - cannot be filtered by claims parameter)
claims = {
sub: subject
}
# Email claims (only if 'email' scope requested)
# Email claims (only if 'email' scope requested AND requested in claims parameter)
if requested_scopes.include?("email")
if should_include_claim_for_userinfo?("email", userinfo_claims)
claims[:email] = user.email_address
end
if should_include_claim_for_userinfo?("email_verified", userinfo_claims)
claims[:email_verified] = true
end
end
# Profile claims (only if 'profile' scope requested)
# Per OIDC Core spec section 5.4, include available profile claims
# Only include claims we have data for - omit unknown claims rather than returning null
if requested_scopes.include?("profile")
# Use username if available, otherwise email as preferred_username
if should_include_claim_for_userinfo?("preferred_username", userinfo_claims)
claims[:preferred_username] = user.username.presence || user.email_address
# Name: use stored name or fall back to email
end
if should_include_claim_for_userinfo?("name", userinfo_claims)
claims[:name] = user.name.presence || user.email_address
# Time the user's information was last updated
end
if should_include_claim_for_userinfo?("updated_at", userinfo_claims)
claims[:updated_at] = user.updated_at.to_i
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 should_include_claim_for_userinfo?("groups", userinfo_claims)
claims[:groups] = user.groups.pluck(:name)
end
end
# Merge custom claims from groups
user.groups.each do |group|
@@ -695,6 +861,16 @@ class OidcController < ApplicationController
application = access_token.application
claims.merge!(application.custom_claims_for_user(user))
# Filter custom claims based on claims parameter
# If claims parameter is present, only include requested custom claims
if userinfo_claims.any?
claims = filter_custom_claims_for_userinfo(claims, userinfo_claims)
end
# Security: Don't cache user data responses
response.headers["Cache-Control"] = "no-store"
response.headers["Pragma"] = "no-cache"
render json: claims
end
@@ -839,12 +1015,12 @@ class OidcController < ApplicationController
}
end
# Validate code verifier format (base64url-encoded, 43-128 characters)
unless code_verifier.match?(/\A[A-Za-z0-9\-_]{43,128}\z/)
# 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/)
return {
valid: false,
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
}
end
@@ -960,4 +1136,133 @@ class OidcController < ApplicationController
# Log error but don't block logout
Rails.logger.error "OidcController: Failed to enqueue backchannel logout: #{e.class} - #{e.message}"
end
# Parse claims parameter JSON string
# Per OIDC Core §5.5: The claims parameter is a JSON object containing
# id_token and/or userinfo keys, each mapping to claim requests
def parse_claims_parameter(claims_string)
return {} if claims_string.blank?
parsed = JSON.parse(claims_string)
return nil unless parsed.is_a?(Hash)
# Validate structure: can have id_token, userinfo, or both
valid_keys = parsed.keys & ["id_token", "userinfo"]
return nil if valid_keys.empty?
# Validate each claim request has proper structure
valid_keys.each do |key|
next unless parsed[key].is_a?(Hash)
parsed[key].each do |_claim_name, claim_spec|
# Claim spec can be null (requested), true (essential), or a hash with specific keys
next if claim_spec.nil? || claim_spec == true || claim_spec == false
next if claim_spec.is_a?(Hash) && claim_spec.keys.all? { |k| ["essential", "value", "values"].include?(k) }
# Invalid claim specification
return nil
end
end
parsed
rescue JSON::ParserError
nil
end
# Validate that requested claims are covered by granted scopes
# Per OIDC Core §5.5: Claims can only be requested if the corresponding scope is granted
def validate_claims_against_scopes(parsed_claims, granted_scopes)
granted = Array(granted_scopes).map(&:to_s)
errors = []
# Standard claim-to-scope mapping
claim_scope_mapping = {
"email" => "email",
"email_verified" => "email",
"preferred_username" => "profile",
"name" => "profile",
"updated_at" => "profile",
"groups" => "groups"
}
# Check both id_token and userinfo claims
["id_token", "userinfo"].each do |context|
next unless parsed_claims[context]&.is_a?(Hash)
parsed_claims[context].each do |claim_name, _claim_spec|
# Skip custom claims (not in standard mapping)
# Custom claims are allowed since they're configured in the IdP
next unless claim_scope_mapping.key?(claim_name)
required_scope = claim_scope_mapping[claim_name]
unless granted.include?(required_scope)
errors << "#{claim_name} requires #{required_scope} scope"
end
end
end
if errors.any?
{valid: false, errors: errors}
else
{valid: true}
end
end
# Check if claims match existing consent
# For MVP: treat any claims request as requiring new consent if consent has no claims stored
def claims_match_consent?(parsed_claims, consent)
return true if parsed_claims.nil? || parsed_claims.empty?
# If consent has no claims stored, this is a new claims request
# Require fresh consent
return false if consent.parsed_claims_requests.empty?
# If both have claims, they must match exactly
consent.parsed_claims_requests == parsed_claims
end
# Check if a claim should be included in UserInfo response
# Returns true if no claims filtering or claim is explicitly requested
def should_include_claim_for_userinfo?(claim_name, userinfo_claims)
return true if userinfo_claims.empty?
userinfo_claims.key?(claim_name)
end
# Filter custom claims for UserInfo endpoint
# Removes claims not explicitly requested
# Applies value/values filtering if specified
def filter_custom_claims_for_userinfo(claims, userinfo_claims)
# Get all claim names that are NOT standard OIDC claims
standard_claims = %w[sub email email_verified name preferred_username updated_at groups]
custom_claim_names = claims.keys.map(&:to_s) - standard_claims
filtered = claims.dup
custom_claim_names.each do |claim_name|
claim_sym = claim_name.to_sym
unless userinfo_claims.key?(claim_name) || userinfo_claims.key?(claim_sym)
filtered.delete(claim_sym)
next
end
# Apply value/values filtering if specified
claim_spec = userinfo_claims[claim_name] || userinfo_claims[claim_sym]
next unless claim_spec.is_a?(Hash)
current_value = filtered[claim_sym]
# Check value constraint
if claim_spec["value"].present?
filtered.delete(claim_sym) unless current_value == claim_spec["value"]
end
# Check values constraint (array of allowed values)
if claim_spec["values"].is_a?(Array)
filtered.delete(claim_sym) unless claim_spec["values"].include?(current_value)
end
end
filtered
end
end

View File

@@ -14,6 +14,20 @@ class SessionsController < ApplicationController
return
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|
format.html # render HTML login page
format.json { render json: {error: "Authentication required"}, status: :unauthorized }
@@ -73,7 +87,10 @@ class SessionsController < ApplicationController
# Sign in successful (password only)
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
def verify_totp

View File

@@ -5,6 +5,23 @@ class Application < ApplicationRecord
# When true, no client_secret will be generated (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
# Fix SVG content type after attachment
@@ -39,7 +56,7 @@ class Application < ApplicationRecord
# 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 :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
normalizes :slug, with: ->(slug) { slug.strip.downcase }

View File

@@ -44,6 +44,12 @@ class OidcAuthorizationCode < ApplicationRecord
code_challenge.present?
end
# Parse claims_requests JSON field
def parsed_claims_requests
return {} if claims_requests.blank?
claims_requests.is_a?(Hash) ? claims_requests : {}
end
private
def generate_code

View File

@@ -50,6 +50,12 @@ class OidcUserConsent < ApplicationRecord
find_by(sid: sid)
end
# Parse claims_requests JSON field
def parsed_claims_requests
return {} if claims_requests.blank?
claims_requests.is_a?(Hash) ? claims_requests : {}
end
private
def set_granted_at

View File

@@ -3,7 +3,7 @@ class OidcJwtService
class << self
# Generate an ID token (JWT) for the user
def generate_id_token(user, application, consent: nil, nonce: nil, access_token: nil, auth_time: nil, acr: nil, scopes: "openid")
def generate_id_token(user, application, consent: nil, nonce: nil, access_token: nil, auth_time: nil, acr: nil, scopes: "openid", claims_requests: {})
now = Time.current.to_i
# Use application's configured ID token TTL (defaults to 1 hour)
ttl = application.id_token_expiry_seconds
@@ -14,6 +14,9 @@ class OidcJwtService
# Parse scopes (space-separated string)
requested_scopes = scopes.to_s.split
# Parse claims_requests parameter for id_token context
id_token_claims = claims_requests["id_token"] || {}
# Required claims (always included per OIDC Core spec)
payload = {
iss: issuer_url,
@@ -23,10 +26,28 @@ class OidcJwtService
iat: now
}
# NOTE: Email and profile claims are NOT included in the ID token for authorization code flow
# Per OIDC Core spec §5.4, these claims should only be returned via the UserInfo endpoint
# For implicit flow (response_type=id_token), claims would be included here, but we only
# support authorization code flow, so these claims are omitted from the ID token.
# Email claims (only if 'email' scope requested AND either no claims filter OR email requested)
if requested_scopes.include?("email")
if should_include_claim?("email", id_token_claims)
payload[:email] = user.email_address
end
if should_include_claim?("email_verified", id_token_claims)
payload[:email_verified] = true
end
end
# Profile claims (only if 'profile' scope requested)
if requested_scopes.include?("profile")
if should_include_claim?("preferred_username", id_token_claims)
payload[:preferred_username] = user.username.presence || user.email_address
end
if should_include_claim?("name", id_token_claims)
payload[:name] = user.name.presence || user.email_address
end
if should_include_claim?("updated_at", id_token_claims)
payload[:updated_at] = user.updated_at.to_i
end
end
# Add nonce if provided (OIDC requires this for implicit flow)
payload[:nonce] = nonce if nonce.present?
@@ -49,10 +70,12 @@ class OidcJwtService
payload[:at_hash] = at_hash
end
# Groups claims (only if 'groups' scope requested)
# Groups claims (only if 'groups' scope requested AND requested in claims parameter)
if requested_scopes.include?("groups") && user.groups.any?
if should_include_claim?("groups", id_token_claims)
payload[:groups] = user.groups.pluck(:name)
end
end
# Merge custom claims from groups (arrays are combined, not overwritten)
# Note: Custom claims from groups are always merged (not scope-dependent)
@@ -66,6 +89,12 @@ class OidcJwtService
# Merge app-specific custom claims (highest priority, arrays are combined)
payload = deep_merge_claims(payload, application.custom_claims_for_user(user))
# Filter custom claims based on claims parameter
# If claims parameter is present, only include requested custom claims
if id_token_claims.any?
payload = filter_custom_claims(payload, id_token_claims)
end
JWT.encode(payload, private_key, "RS256", {kid: key_id, typ: "JWT"})
end
@@ -178,5 +207,69 @@ class OidcJwtService
def key_id
@key_id ||= Digest::SHA256.hexdigest(public_key.to_pem)[0..15]
end
# Check if a claim should be included based on claims parameter
# Returns true if:
# - No claims parameter specified (include all scope-based claims)
# - Claim is explicitly requested (even with null spec or essential: true)
def should_include_claim?(claim_name, id_token_claims)
# No claims parameter = include all scope-based claims
return true if id_token_claims.empty?
# Check if claim is requested
return false unless id_token_claims.key?(claim_name)
# Claim specification can be:
# - null (requested)
# - true (essential, requested)
# - false (not requested)
# - Hash with essential/value/values
claim_spec = id_token_claims[claim_name]
return true if claim_spec.nil? || claim_spec == true
return false if claim_spec == false
# If it's a hash, the claim is requested (filtering happens later)
true if claim_spec.is_a?(Hash)
end
# Filter custom claims based on claims parameter
# Removes claims not explicitly requested
# Applies value/values filtering if specified
def filter_custom_claims(payload, id_token_claims)
# Get all claim names that are NOT standard OIDC claims
standard_claims = %w[iss sub aud exp iat nbf jti nonce azp at_hash auth_time acr email email_verified name preferred_username updated_at groups]
custom_claim_names = payload.keys.map(&:to_s) - standard_claims
filtered = payload.dup
custom_claim_names.each do |claim_name|
claim_sym = claim_name.to_sym
# If claim is not requested, remove it
unless id_token_claims.key?(claim_name) || id_token_claims.key?(claim_sym)
filtered.delete(claim_sym)
next
end
# Apply value/values filtering if specified
claim_spec = id_token_claims[claim_name] || id_token_claims[claim_sym]
next unless claim_spec.is_a?(Hash)
current_value = filtered[claim_sym]
# Check value constraint
if claim_spec["value"].present?
filtered.delete(claim_sym) unless current_value == claim_spec["value"]
end
# Check values constraint (array of allowed values)
if claim_spec["values"].is_a?(Array)
filtered.delete(claim_sym) unless claim_spec["values"].include?(current_value)
end
end
filtered
end
end
end

View File

@@ -153,6 +153,26 @@
</div>
<% 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) -->
<div id="pkce-options" data-application-form-target="pkceOptions" class="<%= 'hidden' if application.persisted? && application.public_client? %>">
<div class="flex items-center">
@@ -165,6 +185,16 @@
</p>
</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>
<%= 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" %>
@@ -187,43 +217,90 @@
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<%= form.label :access_token_ttl, "Access Token TTL (seconds)", 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.label :access_token_ttl, "Access Token TTL", class: "block text-sm font-medium text-gray-700" %>
<%= 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">
Range: 5 min - 24 hours
<br>Default: 1 hour (3600s)
<br>Current: <span class="font-medium"><%= application.access_token_ttl_human || "1 hour" %></span>
Range: 5m - 24h
<br>Default: 1h
<% 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>
</div>
<div>
<%= form.label :refresh_token_ttl, "Refresh Token TTL (seconds)", 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.label :refresh_token_ttl, "Refresh Token TTL", class: "block text-sm font-medium text-gray-700" %>
<%= 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">
Range: 1 day - 90 days
<br>Default: 30 days (2592000s)
<br>Current: <span class="font-medium"><%= application.refresh_token_ttl_human || "30 days" %></span>
Range: 5m - 90d
<br>Default: 30d
<% 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>
</div>
<div>
<%= form.label :id_token_ttl, "ID Token TTL (seconds)", 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.label :id_token_ttl, "ID Token TTL", class: "block text-sm font-medium text-gray-700" %>
<%= 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">
Range: 5 min - 24 hours
<br>Default: 1 hour (3600s)
<br>Current: <span class="font-medium"><%= application.id_token_ttl_human || "1 hour" %></span>
Range: 5m - 24h
<br>Default: 1h
<% 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>
</div>
</div>
<details class="mt-3">
<summary class="cursor-pointer text-sm text-blue-600 hover:text-blue-800">Understanding Token Types</summary>
<div class="mt-2 ml-4 space-y-2 text-sm text-gray-600">
<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-3 text-sm text-gray-600">
<div>
<p class="font-medium text-gray-900 mb-1">Token Types:</p>
<p><strong>Access Token:</strong> Used to access protected resources (APIs). Shorter lifetime = more secure. Users won't notice automatic refreshes.</p>
<p><strong>Refresh Token:</strong> Used to get new access tokens without re-authentication. Longer lifetime = better UX (less re-logins).</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>
<p class="text-xs italic mt-2">💡 Tip: Banking apps use 5-15 min access tokens. Internal tools use 1-4 hours.</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>
</details>
</div>

View File

@@ -147,9 +147,9 @@
<% end %>
<% 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",
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 %>
</div>
</div>

View File

@@ -12,7 +12,7 @@
autofocus: true,
autocomplete: "username",
placeholder: "your@email.com",
value: params[:email_address],
value: @login_hint || params[:email_address],
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" %>
</div>

View File

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

View File

@@ -26,7 +26,7 @@ Rails.application.routes.draw do
# OIDC (OpenID Connect) routes
get "/.well-known/openid-configuration", to: "oidc#discovery"
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/token", to: "oidc#token"
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.
ActiveRecord::Schema[8.1].define(version: 2025_12_31_060112) do
ActiveRecord::Schema[8.1].define(version: 2026_01_05_000809) do
create_table "active_storage_attachments", force: :cascade do |t|
t.bigint "blob_id", null: false
t.datetime "created_at", null: false
@@ -78,6 +78,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_12_31_060112) do
t.text "redirect_uris"
t.integer "refresh_token_ttl", default: 2592000
t.boolean "require_pkce", default: true, null: false
t.boolean "skip_consent", default: false, null: false
t.string "slug", null: false
t.datetime "updated_at", null: false
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.integer "application_id", null: false
t.integer "auth_time"
t.json "claims_requests", default: {}, null: false
t.string "code_challenge"
t.string "code_challenge_method"
t.string "code_hmac", null: false
@@ -160,6 +162,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_12_31_060112) do
create_table "oidc_user_consents", force: :cascade do |t|
t.integer "application_id", null: false
t.json "claims_requests", default: {}, null: false
t.datetime "created_at", null: false
t.datetime "granted_at", null: false
t.text "scopes_granted", null: false

View File

@@ -279,7 +279,7 @@ module Api
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
refute_match evil_url, response.location, "Should not redirect to evil URL after authentication"
# Should redirect to the legitimate URL (not the evil one)

View File

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

View File

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