Compare commits
19 Commits
0bb84f08d6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
444ae6291c | ||
|
|
233fb723d5 | ||
|
|
cc6d4fcc65 | ||
|
|
5268f10eb3 | ||
|
|
5c5662eaab | ||
|
|
27d77ebf47 | ||
|
|
ba08158c85 | ||
|
|
a6480b0860 | ||
|
|
75cc223329 | ||
|
|
46ae65f4d2 | ||
|
|
95d0d844e9 | ||
|
|
524a7719c3 | ||
|
|
8110d547dd | ||
|
|
25e1043312 | ||
|
|
074a734c0c | ||
|
|
4a48012a82 | ||
|
|
e631f606e7 | ||
|
|
f4a697ae9b | ||
|
|
16e34ffaf0 |
@@ -75,6 +75,9 @@ Apps that speak OIDC use the OIDC flow.
|
|||||||
Apps that only need "who is it?", or you want available from the internet behind authentication (MeTube, Jellyfin) use ForwardAuth.
|
Apps that only need "who is it?", or you want available from the internet behind authentication (MeTube, Jellyfin) use ForwardAuth.
|
||||||
|
|
||||||
#### OpenID Connect (OIDC)
|
#### OpenID Connect (OIDC)
|
||||||
|
|
||||||
|
**[OpenID Connect Conformance](https://www.certification.openid.net/plan-detail.html?plan=FbQNTJuYVzrzs&public=true)** - Clinch passes the official OpenID Connect conformance tests (valid as of [v0.8.6](https://github.com/dkam/clinch/releases/tag/0.8.6)).
|
||||||
|
|
||||||
Standard OAuth2/OIDC provider with endpoints:
|
Standard OAuth2/OIDC provider with endpoints:
|
||||||
- `/.well-known/openid-configuration` - Discovery endpoint
|
- `/.well-known/openid-configuration` - Discovery endpoint
|
||||||
- `/authorize` - Authorization endpoint with PKCE support
|
- `/authorize` - Authorization endpoint with PKCE support
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
# 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,
|
value: session.id,
|
||||||
httponly: true,
|
httponly: true,
|
||||||
same_site: :lax,
|
same_site: :lax,
|
||||||
secure: Rails.env.production?
|
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?
|
||||||
|
|||||||
@@ -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,15 @@ class OidcController < ApplicationController
|
|||||||
|
|
||||||
# Record user consent
|
# Record user consent
|
||||||
requested_scopes = oauth_params["scope"].split(" ")
|
requested_scopes = oauth_params["scope"].split(" ")
|
||||||
|
parsed_claims = begin
|
||||||
|
JSON.parse(oauth_params["claims_requests"])
|
||||||
|
rescue
|
||||||
|
{}
|
||||||
|
end
|
||||||
|
|
||||||
consent = OidcUserConsent.find_or_initialize_by(user: user, application: application)
|
consent = OidcUserConsent.find_or_initialize_by(user: user, application: application)
|
||||||
consent.scopes_granted = requested_scopes.join(" ")
|
consent.scopes_granted = requested_scopes.join(" ")
|
||||||
|
consent.claims_requests = parsed_claims
|
||||||
consent.granted_at = Time.current
|
consent.granted_at = Time.current
|
||||||
consent.save!
|
consent.save!
|
||||||
|
|
||||||
@@ -298,6 +443,7 @@ class OidcController < ApplicationController
|
|||||||
nonce: oauth_params["nonce"],
|
nonce: oauth_params["nonce"],
|
||||||
code_challenge: oauth_params["code_challenge"],
|
code_challenge: oauth_params["code_challenge"],
|
||||||
code_challenge_method: oauth_params["code_challenge_method"],
|
code_challenge_method: oauth_params["code_challenge_method"],
|
||||||
|
claims_requests: parsed_claims,
|
||||||
auth_time: Current.session.created_at.to_i,
|
auth_time: Current.session.created_at.to_i,
|
||||||
acr: Current.session.acr,
|
acr: Current.session.acr,
|
||||||
expires_at: 10.minutes.from_now
|
expires_at: 10.minutes.from_now
|
||||||
@@ -315,6 +461,16 @@ class OidcController < ApplicationController
|
|||||||
|
|
||||||
# POST /oauth/token
|
# POST /oauth/token
|
||||||
def token
|
def token
|
||||||
|
# Reject claims parameter - per OIDC security, claims parameter is only valid
|
||||||
|
# in authorization requests, not at the token endpoint
|
||||||
|
if params[:claims].present?
|
||||||
|
render json: {
|
||||||
|
error: "invalid_request",
|
||||||
|
error_description: "claims parameter is not allowed at the token endpoint"
|
||||||
|
}, status: :bad_request
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
grant_type = params[:grant_type]
|
grant_type = params[:grant_type]
|
||||||
|
|
||||||
case grant_type
|
case grant_type
|
||||||
@@ -457,6 +613,7 @@ class OidcController < ApplicationController
|
|||||||
# Generate ID token (JWT) with pairwise SID, at_hash, auth_time, and acr
|
# Generate ID token (JWT) with pairwise SID, at_hash, auth_time, and acr
|
||||||
# auth_time and acr come from the authorization code (captured at /authorize time)
|
# auth_time and acr come from the authorization code (captured at /authorize time)
|
||||||
# scopes determine which claims are included (per OIDC Core spec)
|
# scopes determine which claims are included (per OIDC Core spec)
|
||||||
|
# claims_requests parameter filters which claims are included
|
||||||
id_token = OidcJwtService.generate_id_token(
|
id_token = OidcJwtService.generate_id_token(
|
||||||
user,
|
user,
|
||||||
application,
|
application,
|
||||||
@@ -465,9 +622,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 +749,7 @@ class OidcController < ApplicationController
|
|||||||
# Generate new ID token (JWT with pairwise SID, at_hash, auth_time, acr; no nonce for refresh grants)
|
# Generate new ID token (JWT with pairwise SID, at_hash, auth_time, acr; no nonce for refresh grants)
|
||||||
# auth_time and acr come from the original refresh token (carried over from initial auth)
|
# auth_time and acr come from the original refresh token (carried over from initial auth)
|
||||||
# scopes determine which claims are included (per OIDC Core spec)
|
# scopes determine which claims are included (per OIDC Core spec)
|
||||||
|
# claims_requests parameter filters which claims are included (from original consent)
|
||||||
id_token = OidcJwtService.generate_id_token(
|
id_token = OidcJwtService.generate_id_token(
|
||||||
user,
|
user,
|
||||||
application,
|
application,
|
||||||
@@ -594,9 +757,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,34 +822,46 @@ class OidcController < ApplicationController
|
|||||||
# Parse scopes from access token (space-separated string)
|
# Parse scopes from access token (space-separated string)
|
||||||
requested_scopes = access_token.scope.to_s.split
|
requested_scopes = access_token.scope.to_s.split
|
||||||
|
|
||||||
|
# Get claims_requests from consent (if available) for UserInfo context
|
||||||
|
userinfo_claims = consent&.parsed_claims_requests&.dig("userinfo") || {}
|
||||||
|
|
||||||
# Return user claims (filter by scope per OIDC Core spec)
|
# Return user claims (filter by scope per OIDC Core spec)
|
||||||
# Required claims (always included)
|
# Required claims (always included - cannot be filtered by claims parameter)
|
||||||
claims = {
|
claims = {
|
||||||
sub: subject
|
sub: subject
|
||||||
}
|
}
|
||||||
|
|
||||||
# Email claims (only if 'email' scope requested)
|
# Email claims (only if 'email' scope requested AND requested in claims parameter)
|
||||||
if requested_scopes.include?("email")
|
if requested_scopes.include?("email")
|
||||||
|
if should_include_claim_for_userinfo?("email", userinfo_claims)
|
||||||
claims[:email] = user.email_address
|
claims[:email] = user.email_address
|
||||||
|
end
|
||||||
|
if should_include_claim_for_userinfo?("email_verified", userinfo_claims)
|
||||||
claims[:email_verified] = true
|
claims[:email_verified] = true
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# Profile claims (only if 'profile' scope requested)
|
# Profile claims (only if 'profile' scope requested)
|
||||||
# Per OIDC Core spec section 5.4, include available profile claims
|
# Per OIDC Core spec section 5.4, include available profile claims
|
||||||
# Only include claims we have data for - omit unknown claims rather than returning null
|
# Only include claims we have data for - omit unknown claims rather than returning null
|
||||||
if requested_scopes.include?("profile")
|
if requested_scopes.include?("profile")
|
||||||
# Use username if available, otherwise email as preferred_username
|
if should_include_claim_for_userinfo?("preferred_username", userinfo_claims)
|
||||||
claims[:preferred_username] = user.username.presence || user.email_address
|
claims[:preferred_username] = user.username.presence || user.email_address
|
||||||
# Name: use stored name or fall back to email
|
end
|
||||||
|
if should_include_claim_for_userinfo?("name", userinfo_claims)
|
||||||
claims[:name] = user.name.presence || user.email_address
|
claims[:name] = user.name.presence || user.email_address
|
||||||
# Time the user's information was last updated
|
end
|
||||||
|
if should_include_claim_for_userinfo?("updated_at", userinfo_claims)
|
||||||
claims[:updated_at] = user.updated_at.to_i
|
claims[:updated_at] = user.updated_at.to_i
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# Groups claim (only if 'groups' scope requested)
|
# Groups claim (only if 'groups' scope requested AND requested in claims parameter)
|
||||||
if requested_scopes.include?("groups") && user.groups.any?
|
if requested_scopes.include?("groups") && user.groups.any?
|
||||||
|
if should_include_claim_for_userinfo?("groups", userinfo_claims)
|
||||||
claims[:groups] = user.groups.pluck(:name)
|
claims[:groups] = user.groups.pluck(:name)
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# Merge custom claims from groups
|
# Merge custom claims from groups
|
||||||
user.groups.each do |group|
|
user.groups.each do |group|
|
||||||
@@ -695,6 +875,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 +1029,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 +1150,133 @@ class OidcController < ApplicationController
|
|||||||
# Log error but don't block logout
|
# Log error but don't block logout
|
||||||
Rails.logger.error "OidcController: Failed to enqueue backchannel logout: #{e.class} - #{e.message}"
|
Rails.logger.error "OidcController: Failed to enqueue backchannel logout: #{e.class} - #{e.message}"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Parse claims parameter JSON string
|
||||||
|
# Per OIDC Core §5.5: The claims parameter is a JSON object containing
|
||||||
|
# id_token and/or userinfo keys, each mapping to claim requests
|
||||||
|
def parse_claims_parameter(claims_string)
|
||||||
|
return {} if claims_string.blank?
|
||||||
|
|
||||||
|
parsed = JSON.parse(claims_string)
|
||||||
|
return nil unless parsed.is_a?(Hash)
|
||||||
|
|
||||||
|
# Validate structure: can have id_token, userinfo, or both
|
||||||
|
valid_keys = parsed.keys & ["id_token", "userinfo"]
|
||||||
|
return nil if valid_keys.empty?
|
||||||
|
|
||||||
|
# Validate each claim request has proper structure
|
||||||
|
valid_keys.each do |key|
|
||||||
|
next unless parsed[key].is_a?(Hash)
|
||||||
|
|
||||||
|
parsed[key].each do |_claim_name, claim_spec|
|
||||||
|
# Claim spec can be null (requested), true (essential), or a hash with specific keys
|
||||||
|
next if claim_spec.nil? || claim_spec == true || claim_spec == false
|
||||||
|
next if claim_spec.is_a?(Hash) && claim_spec.keys.all? { |k| ["essential", "value", "values"].include?(k) }
|
||||||
|
|
||||||
|
# Invalid claim specification
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
parsed
|
||||||
|
rescue JSON::ParserError
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
# Validate that requested claims are covered by granted scopes
|
||||||
|
# Per OIDC Core §5.5: Claims can only be requested if the corresponding scope is granted
|
||||||
|
def validate_claims_against_scopes(parsed_claims, granted_scopes)
|
||||||
|
granted = Array(granted_scopes).map(&:to_s)
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
# Standard claim-to-scope mapping
|
||||||
|
claim_scope_mapping = {
|
||||||
|
"email" => "email",
|
||||||
|
"email_verified" => "email",
|
||||||
|
"preferred_username" => "profile",
|
||||||
|
"name" => "profile",
|
||||||
|
"updated_at" => "profile",
|
||||||
|
"groups" => "groups"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check both id_token and userinfo claims
|
||||||
|
["id_token", "userinfo"].each do |context|
|
||||||
|
next unless parsed_claims[context]&.is_a?(Hash)
|
||||||
|
|
||||||
|
parsed_claims[context].each do |claim_name, _claim_spec|
|
||||||
|
# Skip custom claims (not in standard mapping)
|
||||||
|
# Custom claims are allowed since they're configured in the IdP
|
||||||
|
next unless claim_scope_mapping.key?(claim_name)
|
||||||
|
|
||||||
|
required_scope = claim_scope_mapping[claim_name]
|
||||||
|
unless granted.include?(required_scope)
|
||||||
|
errors << "#{claim_name} requires #{required_scope} scope"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if errors.any?
|
||||||
|
{valid: false, errors: errors}
|
||||||
|
else
|
||||||
|
{valid: true}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if claims match existing consent
|
||||||
|
# For MVP: treat any claims request as requiring new consent if consent has no claims stored
|
||||||
|
def claims_match_consent?(parsed_claims, consent)
|
||||||
|
return true if parsed_claims.nil? || parsed_claims.empty?
|
||||||
|
|
||||||
|
# If consent has no claims stored, this is a new claims request
|
||||||
|
# Require fresh consent
|
||||||
|
return false if consent.parsed_claims_requests.empty?
|
||||||
|
|
||||||
|
# If both have claims, they must match exactly
|
||||||
|
consent.parsed_claims_requests == parsed_claims
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if a claim should be included in UserInfo response
|
||||||
|
# Returns true if no claims filtering or claim is explicitly requested
|
||||||
|
def should_include_claim_for_userinfo?(claim_name, userinfo_claims)
|
||||||
|
return true if userinfo_claims.empty?
|
||||||
|
userinfo_claims.key?(claim_name)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Filter custom claims for UserInfo endpoint
|
||||||
|
# Removes claims not explicitly requested
|
||||||
|
# Applies value/values filtering if specified
|
||||||
|
def filter_custom_claims_for_userinfo(claims, userinfo_claims)
|
||||||
|
# Get all claim names that are NOT standard OIDC claims
|
||||||
|
standard_claims = %w[sub email email_verified name preferred_username updated_at groups]
|
||||||
|
custom_claim_names = claims.keys.map(&:to_s) - standard_claims
|
||||||
|
|
||||||
|
filtered = claims.dup
|
||||||
|
|
||||||
|
custom_claim_names.each do |claim_name|
|
||||||
|
claim_sym = claim_name.to_sym
|
||||||
|
|
||||||
|
unless userinfo_claims.key?(claim_name) || userinfo_claims.key?(claim_sym)
|
||||||
|
filtered.delete(claim_sym)
|
||||||
|
next
|
||||||
|
end
|
||||||
|
|
||||||
|
# Apply value/values filtering if specified
|
||||||
|
claim_spec = userinfo_claims[claim_name] || userinfo_claims[claim_sym]
|
||||||
|
next unless claim_spec.is_a?(Hash)
|
||||||
|
|
||||||
|
current_value = filtered[claim_sym]
|
||||||
|
|
||||||
|
# Check value constraint
|
||||||
|
if claim_spec["value"].present?
|
||||||
|
filtered.delete(claim_sym) unless current_value == claim_spec["value"]
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check values constraint (array of allowed values)
|
||||||
|
if claim_spec["values"].is_a?(Array)
|
||||||
|
filtered.delete(claim_sym) unless claim_spec["values"].include?(current_value)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
filtered
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
45
app/lib/duration_parser.rb
Normal file
45
app/lib/duration_parser.rb
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
class DurationParser
|
||||||
|
UNITS = {
|
||||||
|
"s" => 1, # seconds
|
||||||
|
"m" => 60, # minutes
|
||||||
|
"h" => 3600, # hours
|
||||||
|
"d" => 86400, # days
|
||||||
|
"w" => 604800, # weeks
|
||||||
|
"M" => 2592000, # months (30 days)
|
||||||
|
"y" => 31536000 # years (365 days)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Parse a duration string into seconds
|
||||||
|
# Accepts formats: "1h", "30m", "1d", "1M" (month), "3600" (plain number)
|
||||||
|
# Returns integer seconds or nil if invalid
|
||||||
|
# Case-sensitive: 1s, 1m, 1h, 1d, 1w, 1M (month), 1y
|
||||||
|
def self.parse(input)
|
||||||
|
# Handle integers directly
|
||||||
|
return input if input.is_a?(Integer)
|
||||||
|
|
||||||
|
# Convert to string and strip whitespace
|
||||||
|
str = input.to_s.strip
|
||||||
|
|
||||||
|
# Return nil for blank input
|
||||||
|
return nil if str.blank?
|
||||||
|
|
||||||
|
# Try to parse as plain number (already in seconds)
|
||||||
|
if str.match?(/^\d+$/)
|
||||||
|
return str.to_i
|
||||||
|
end
|
||||||
|
|
||||||
|
# Try to parse with unit (e.g., "1h", "30m", "1M")
|
||||||
|
# Allow optional space between number and unit
|
||||||
|
# Case-sensitive to avoid confusion (1m = minute, 1M = month)
|
||||||
|
match = str.match(/^(\d+)\s*([smhdwMy])$/)
|
||||||
|
return nil unless match
|
||||||
|
|
||||||
|
number = match[1].to_i
|
||||||
|
unit = match[2]
|
||||||
|
|
||||||
|
multiplier = UNITS[unit]
|
||||||
|
return nil unless multiplier
|
||||||
|
|
||||||
|
number * multiplier
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -44,6 +44,12 @@ class OidcAuthorizationCode < ApplicationRecord
|
|||||||
code_challenge.present?
|
code_challenge.present?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Parse claims_requests JSON field
|
||||||
|
def parsed_claims_requests
|
||||||
|
return {} if claims_requests.blank?
|
||||||
|
claims_requests.is_a?(Hash) ? claims_requests : {}
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def generate_code
|
def generate_code
|
||||||
|
|||||||
@@ -50,6 +50,12 @@ class OidcUserConsent < ApplicationRecord
|
|||||||
find_by(sid: sid)
|
find_by(sid: sid)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Parse claims_requests JSON field
|
||||||
|
def parsed_claims_requests
|
||||||
|
return {} if claims_requests.blank?
|
||||||
|
claims_requests.is_a?(Hash) ? claims_requests : {}
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def set_granted_at
|
def set_granted_at
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ class OidcJwtService
|
|||||||
|
|
||||||
class << self
|
class << self
|
||||||
# Generate an ID token (JWT) for the user
|
# Generate an ID token (JWT) for the user
|
||||||
def generate_id_token(user, application, consent: nil, nonce: nil, access_token: nil, auth_time: nil, acr: nil, scopes: "openid")
|
def generate_id_token(user, application, consent: nil, nonce: nil, access_token: nil, auth_time: nil, acr: nil, scopes: "openid", claims_requests: {})
|
||||||
now = Time.current.to_i
|
now = Time.current.to_i
|
||||||
# Use application's configured ID token TTL (defaults to 1 hour)
|
# Use application's configured ID token TTL (defaults to 1 hour)
|
||||||
ttl = application.id_token_expiry_seconds
|
ttl = application.id_token_expiry_seconds
|
||||||
@@ -14,6 +14,9 @@ class OidcJwtService
|
|||||||
# Parse scopes (space-separated string)
|
# Parse scopes (space-separated string)
|
||||||
requested_scopes = scopes.to_s.split
|
requested_scopes = scopes.to_s.split
|
||||||
|
|
||||||
|
# Parse claims_requests parameter for id_token context
|
||||||
|
id_token_claims = claims_requests["id_token"] || {}
|
||||||
|
|
||||||
# Required claims (always included per OIDC Core spec)
|
# Required claims (always included per OIDC Core spec)
|
||||||
payload = {
|
payload = {
|
||||||
iss: issuer_url,
|
iss: issuer_url,
|
||||||
@@ -23,10 +26,28 @@ class OidcJwtService
|
|||||||
iat: now
|
iat: now
|
||||||
}
|
}
|
||||||
|
|
||||||
# NOTE: Email and profile claims are NOT included in the ID token for authorization code flow
|
# Email claims (only if 'email' scope requested AND either no claims filter OR email requested)
|
||||||
# Per OIDC Core spec §5.4, these claims should only be returned via the UserInfo endpoint
|
if requested_scopes.include?("email")
|
||||||
# For implicit flow (response_type=id_token), claims would be included here, but we only
|
if should_include_claim?("email", id_token_claims)
|
||||||
# support authorization code flow, so these claims are omitted from the ID token.
|
payload[:email] = user.email_address
|
||||||
|
end
|
||||||
|
if should_include_claim?("email_verified", id_token_claims)
|
||||||
|
payload[:email_verified] = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Profile claims (only if 'profile' scope requested)
|
||||||
|
if requested_scopes.include?("profile")
|
||||||
|
if should_include_claim?("preferred_username", id_token_claims)
|
||||||
|
payload[:preferred_username] = user.username.presence || user.email_address
|
||||||
|
end
|
||||||
|
if should_include_claim?("name", id_token_claims)
|
||||||
|
payload[:name] = user.name.presence || user.email_address
|
||||||
|
end
|
||||||
|
if should_include_claim?("updated_at", id_token_claims)
|
||||||
|
payload[:updated_at] = user.updated_at.to_i
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# Add nonce if provided (OIDC requires this for implicit flow)
|
# Add nonce if provided (OIDC requires this for implicit flow)
|
||||||
payload[:nonce] = nonce if nonce.present?
|
payload[:nonce] = nonce if nonce.present?
|
||||||
@@ -49,10 +70,12 @@ class OidcJwtService
|
|||||||
payload[:at_hash] = at_hash
|
payload[:at_hash] = at_hash
|
||||||
end
|
end
|
||||||
|
|
||||||
# Groups claims (only if 'groups' scope requested)
|
# Groups claims (only if 'groups' scope requested AND requested in claims parameter)
|
||||||
if requested_scopes.include?("groups") && user.groups.any?
|
if requested_scopes.include?("groups") && user.groups.any?
|
||||||
|
if should_include_claim?("groups", id_token_claims)
|
||||||
payload[:groups] = user.groups.pluck(:name)
|
payload[:groups] = user.groups.pluck(:name)
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# Merge custom claims from groups (arrays are combined, not overwritten)
|
# Merge custom claims from groups (arrays are combined, not overwritten)
|
||||||
# Note: Custom claims from groups are always merged (not scope-dependent)
|
# Note: Custom claims from groups are always merged (not scope-dependent)
|
||||||
@@ -66,6 +89,12 @@ class OidcJwtService
|
|||||||
# Merge app-specific custom claims (highest priority, arrays are combined)
|
# Merge app-specific custom claims (highest priority, arrays are combined)
|
||||||
payload = deep_merge_claims(payload, application.custom_claims_for_user(user))
|
payload = deep_merge_claims(payload, application.custom_claims_for_user(user))
|
||||||
|
|
||||||
|
# Filter custom claims based on claims parameter
|
||||||
|
# If claims parameter is present, only include requested custom claims
|
||||||
|
if id_token_claims.any?
|
||||||
|
payload = filter_custom_claims(payload, id_token_claims)
|
||||||
|
end
|
||||||
|
|
||||||
JWT.encode(payload, private_key, "RS256", {kid: key_id, typ: "JWT"})
|
JWT.encode(payload, private_key, "RS256", {kid: key_id, typ: "JWT"})
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -178,5 +207,69 @@ class OidcJwtService
|
|||||||
def key_id
|
def key_id
|
||||||
@key_id ||= Digest::SHA256.hexdigest(public_key.to_pem)[0..15]
|
@key_id ||= Digest::SHA256.hexdigest(public_key.to_pem)[0..15]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Check if a claim should be included based on claims parameter
|
||||||
|
# Returns true if:
|
||||||
|
# - No claims parameter specified (include all scope-based claims)
|
||||||
|
# - Claim is explicitly requested (even with null spec or essential: true)
|
||||||
|
def should_include_claim?(claim_name, id_token_claims)
|
||||||
|
# No claims parameter = include all scope-based claims
|
||||||
|
return true if id_token_claims.empty?
|
||||||
|
|
||||||
|
# Check if claim is requested
|
||||||
|
return false unless id_token_claims.key?(claim_name)
|
||||||
|
|
||||||
|
# Claim specification can be:
|
||||||
|
# - null (requested)
|
||||||
|
# - true (essential, requested)
|
||||||
|
# - false (not requested)
|
||||||
|
# - Hash with essential/value/values
|
||||||
|
|
||||||
|
claim_spec = id_token_claims[claim_name]
|
||||||
|
return true if claim_spec.nil? || claim_spec == true
|
||||||
|
return false if claim_spec == false
|
||||||
|
|
||||||
|
# If it's a hash, the claim is requested (filtering happens later)
|
||||||
|
true if claim_spec.is_a?(Hash)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Filter custom claims based on claims parameter
|
||||||
|
# Removes claims not explicitly requested
|
||||||
|
# Applies value/values filtering if specified
|
||||||
|
def filter_custom_claims(payload, id_token_claims)
|
||||||
|
# Get all claim names that are NOT standard OIDC claims
|
||||||
|
standard_claims = %w[iss sub aud exp iat nbf jti nonce azp at_hash auth_time acr email email_verified name preferred_username updated_at groups]
|
||||||
|
custom_claim_names = payload.keys.map(&:to_s) - standard_claims
|
||||||
|
|
||||||
|
filtered = payload.dup
|
||||||
|
|
||||||
|
custom_claim_names.each do |claim_name|
|
||||||
|
claim_sym = claim_name.to_sym
|
||||||
|
|
||||||
|
# If claim is not requested, remove it
|
||||||
|
unless id_token_claims.key?(claim_name) || id_token_claims.key?(claim_sym)
|
||||||
|
filtered.delete(claim_sym)
|
||||||
|
next
|
||||||
|
end
|
||||||
|
|
||||||
|
# Apply value/values filtering if specified
|
||||||
|
claim_spec = id_token_claims[claim_name] || id_token_claims[claim_sym]
|
||||||
|
next unless claim_spec.is_a?(Hash)
|
||||||
|
|
||||||
|
current_value = filtered[claim_sym]
|
||||||
|
|
||||||
|
# Check value constraint
|
||||||
|
if claim_spec["value"].present?
|
||||||
|
filtered.delete(claim_sym) unless current_value == claim_spec["value"]
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check values constraint (array of allowed values)
|
||||||
|
if claim_spec["values"].is_a?(Array)
|
||||||
|
filtered.delete(claim_sym) unless claim_spec["values"].include?(current_value)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
filtered
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -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">
|
||||||
|
<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>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><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>
|
</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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
class AddSkipConsentToApplications < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
add_column :applications, :skip_consent, :boolean, default: false, null: false
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -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
|
||||||
@@ -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
5
db/schema.rb
generated
@@ -10,7 +10,7 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[8.1].define(version: 2025_12_31_060112) do
|
ActiveRecord::Schema[8.1].define(version: 2026_01_05_000809) do
|
||||||
create_table "active_storage_attachments", force: :cascade do |t|
|
create_table "active_storage_attachments", force: :cascade do |t|
|
||||||
t.bigint "blob_id", null: false
|
t.bigint "blob_id", null: false
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -56,7 +56,8 @@ This checklist ensures Clinch meets security, quality, and documentation standar
|
|||||||
- [x] Authorization code flow with PKCE support
|
- [x] Authorization code flow with PKCE support
|
||||||
- [x] Refresh token rotation
|
- [x] Refresh token rotation
|
||||||
- [x] Token family tracking (detects replay attacks)
|
- [x] Token family tracking (detects replay attacks)
|
||||||
- [x] All tokens HMAC-SHA256 hashed in database
|
- [x] All tokens and authorization codes HMAC-SHA256 hashed in database
|
||||||
|
- [x] TOTP secrets AES-256-GCM encrypted at rest (Rails credentials)
|
||||||
- [x] Configurable token expiry (access, refresh, ID)
|
- [x] Configurable token expiry (access, refresh, ID)
|
||||||
- [x] One-time use authorization codes
|
- [x] One-time use authorization codes
|
||||||
- [x] Pairwise subject identifiers (privacy)
|
- [x] Pairwise subject identifiers (privacy)
|
||||||
@@ -130,8 +131,7 @@ This checklist ensures Clinch meets security, quality, and documentation standar
|
|||||||
|
|
||||||
## Code Quality
|
## Code Quality
|
||||||
|
|
||||||
- [x] **RuboCop** - Code style and linting
|
- [x] **StandardRB** - Code style and linting
|
||||||
- Configuration: Rails Omakase
|
|
||||||
- CI: Runs on every PR and push to main
|
- CI: Runs on every PR and push to main
|
||||||
|
|
||||||
- [x] **Documentation** - Comprehensive README
|
- [x] **Documentation** - Comprehensive README
|
||||||
@@ -158,7 +158,7 @@ This checklist ensures Clinch meets security, quality, and documentation standar
|
|||||||
|
|
||||||
### Performance
|
### Performance
|
||||||
- [ ] Review N+1 queries
|
- [ ] Review N+1 queries
|
||||||
- [ ] Add database indexes where needed
|
- [x] Add database indexes where needed
|
||||||
- [ ] Test with realistic data volumes
|
- [ ] Test with realistic data volumes
|
||||||
- [ ] Review token cleanup job performance
|
- [ ] Review token cleanup job performance
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
394
test/controllers/oidc_claims_security_test.rb
Normal file
394
test/controllers/oidc_claims_security_test.rb
Normal file
@@ -0,0 +1,394 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class OidcClaimsSecurityTest < ActionDispatch::IntegrationTest
|
||||||
|
setup do
|
||||||
|
@user = User.create!(email_address: "claims_security_test@example.com", password: "password123")
|
||||||
|
@application = Application.create!(
|
||||||
|
name: "Claims Security Test App",
|
||||||
|
slug: "claims-security-test-app",
|
||||||
|
app_type: "oidc",
|
||||||
|
redirect_uris: ["http://localhost:4000/callback"].to_json,
|
||||||
|
active: true,
|
||||||
|
require_pkce: false
|
||||||
|
)
|
||||||
|
|
||||||
|
# Store the plain text client secret for testing
|
||||||
|
@application.generate_new_client_secret!
|
||||||
|
@plain_client_secret = @application.client_secret
|
||||||
|
@application.save!
|
||||||
|
end
|
||||||
|
|
||||||
|
def teardown
|
||||||
|
# Delete in correct order to avoid foreign key constraints
|
||||||
|
OidcRefreshToken.where(application: @application).delete_all
|
||||||
|
OidcAccessToken.where(application: @application).delete_all
|
||||||
|
OidcAuthorizationCode.where(application: @application).delete_all
|
||||||
|
OidcUserConsent.where(application: @application).delete_all
|
||||||
|
@user.destroy
|
||||||
|
@application.destroy
|
||||||
|
end
|
||||||
|
|
||||||
|
# ====================
|
||||||
|
# CLAIMS PARAMETER ESCALATION ATTACKS
|
||||||
|
# ====================
|
||||||
|
|
||||||
|
test "rejects claims parameter during authorization code exchange" do
|
||||||
|
# Create consent with minimal scopes (no profile, email, or admin access)
|
||||||
|
OidcUserConsent.create!(
|
||||||
|
user: @user,
|
||||||
|
application: @application,
|
||||||
|
scopes_granted: "openid",
|
||||||
|
granted_at: Time.current,
|
||||||
|
sid: "test-sid-123"
|
||||||
|
)
|
||||||
|
|
||||||
|
auth_code = OidcAuthorizationCode.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
redirect_uri: "http://localhost:4000/callback",
|
||||||
|
scope: "openid",
|
||||||
|
expires_at: 10.minutes.from_now
|
||||||
|
)
|
||||||
|
|
||||||
|
# ATTEMPT: Inject claims parameter during token exchange (ATTACK!)
|
||||||
|
# The client is trying to request 'admin' claim that they never got consent for
|
||||||
|
post "/oauth/token", params: {
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: auth_code.plaintext_code,
|
||||||
|
redirect_uri: "http://localhost:4000/callback",
|
||||||
|
claims: '{"id_token":{"admin":{"essential":true}}}' # ← ATTACK!
|
||||||
|
}, headers: {
|
||||||
|
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
|
||||||
|
}
|
||||||
|
|
||||||
|
# SHOULD: Reject the claims parameter - it's only allowed in authorization requests
|
||||||
|
assert_response :bad_request
|
||||||
|
error = JSON.parse(response.body)
|
||||||
|
assert_equal "invalid_request", error["error"], "Should reject claims parameter at token endpoint"
|
||||||
|
assert_match(/claims.*not allowed|unsupported parameter/i, error["error_description"], "Error should mention claims parameter not allowed")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects claims parameter during authorization code exchange with profile escalation" do
|
||||||
|
# Create consent with ONLY openid scope (no profile scope)
|
||||||
|
OidcUserConsent.create!(
|
||||||
|
user: @user,
|
||||||
|
application: @application,
|
||||||
|
scopes_granted: "openid",
|
||||||
|
granted_at: Time.current,
|
||||||
|
sid: "test-sid-123"
|
||||||
|
)
|
||||||
|
|
||||||
|
auth_code = OidcAuthorizationCode.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
redirect_uri: "http://localhost:4000/callback",
|
||||||
|
scope: "openid",
|
||||||
|
expires_at: 10.minutes.from_now
|
||||||
|
)
|
||||||
|
|
||||||
|
# ATTEMPT: Try to get profile claims via claims parameter without profile scope
|
||||||
|
post "/oauth/token", params: {
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: auth_code.plaintext_code,
|
||||||
|
redirect_uri: "http://localhost:4000/callback",
|
||||||
|
claims: '{"id_token":{"name":null,"email":{"essential":true}}}'
|
||||||
|
}, headers: {
|
||||||
|
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
|
||||||
|
}
|
||||||
|
|
||||||
|
# SHOULD: Reject the claims parameter
|
||||||
|
assert_response :bad_request
|
||||||
|
error = JSON.parse(response.body)
|
||||||
|
assert_equal "invalid_request", error["error"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects claims parameter during refresh token grant" do
|
||||||
|
access_token = OidcAccessToken.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
scope: "openid"
|
||||||
|
)
|
||||||
|
|
||||||
|
refresh_token = OidcRefreshToken.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
oidc_access_token: access_token,
|
||||||
|
scope: "openid"
|
||||||
|
)
|
||||||
|
|
||||||
|
plaintext_refresh_token = refresh_token.token
|
||||||
|
|
||||||
|
# ATTEMPT: Inject claims parameter during refresh (ATTACK!)
|
||||||
|
# Trying to escalate to admin claims during refresh
|
||||||
|
post "/oauth/token", params: {
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
refresh_token: plaintext_refresh_token,
|
||||||
|
claims: '{"id_token":{"admin":true,"role":{"essential":true}}}' # ← ATTACK!
|
||||||
|
}, headers: {
|
||||||
|
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
|
||||||
|
}
|
||||||
|
|
||||||
|
# SHOULD: Reject the claims parameter
|
||||||
|
assert_response :bad_request
|
||||||
|
error = JSON.parse(response.body)
|
||||||
|
assert_equal "invalid_request", error["error"], "Should reject claims parameter at refresh token endpoint"
|
||||||
|
assert_match(/claims.*not allowed|unsupported parameter/i, error["error_description"])
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects claims parameter during refresh token grant with custom claims escalation" do
|
||||||
|
# Setup: User has a custom claim at user level
|
||||||
|
@user.update!(custom_claims: {"role" => "user"})
|
||||||
|
|
||||||
|
access_token = OidcAccessToken.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
scope: "openid"
|
||||||
|
)
|
||||||
|
|
||||||
|
refresh_token = OidcRefreshToken.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
oidc_access_token: access_token,
|
||||||
|
scope: "openid"
|
||||||
|
)
|
||||||
|
|
||||||
|
plaintext_refresh_token = refresh_token.token
|
||||||
|
|
||||||
|
# ATTEMPT: Try to escalate role to admin via claims parameter
|
||||||
|
post "/oauth/token", params: {
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
refresh_token: plaintext_refresh_token,
|
||||||
|
claims: '{"id_token":{"role":{"value":"admin"}}}' # ← ATTACK! Trying to override role value
|
||||||
|
}, headers: {
|
||||||
|
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
|
||||||
|
}
|
||||||
|
|
||||||
|
# SHOULD: Reject the claims parameter
|
||||||
|
assert_response :bad_request
|
||||||
|
error = JSON.parse(response.body)
|
||||||
|
assert_equal "invalid_request", error["error"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "allows token exchange without claims parameter" do
|
||||||
|
# Create consent
|
||||||
|
OidcUserConsent.create!(
|
||||||
|
user: @user,
|
||||||
|
application: @application,
|
||||||
|
scopes_granted: "openid profile",
|
||||||
|
granted_at: Time.current,
|
||||||
|
sid: "test-sid-123"
|
||||||
|
)
|
||||||
|
|
||||||
|
auth_code = OidcAuthorizationCode.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
redirect_uri: "http://localhost:4000/callback",
|
||||||
|
scope: "openid profile",
|
||||||
|
expires_at: 10.minutes.from_now
|
||||||
|
)
|
||||||
|
|
||||||
|
# Normal token exchange WITHOUT claims parameter should work fine
|
||||||
|
post "/oauth/token", params: {
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: auth_code.plaintext_code,
|
||||||
|
redirect_uri: "http://localhost:4000/callback"
|
||||||
|
}, headers: {
|
||||||
|
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :success
|
||||||
|
response_body = JSON.parse(response.body)
|
||||||
|
assert response_body.key?("access_token")
|
||||||
|
assert response_body.key?("id_token")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "allows refresh without claims parameter" do
|
||||||
|
# Create consent for this application
|
||||||
|
OidcUserConsent.create!(
|
||||||
|
user: @user,
|
||||||
|
application: @application,
|
||||||
|
scopes_granted: "openid profile",
|
||||||
|
granted_at: Time.current,
|
||||||
|
sid: "test-sid-refresh-456"
|
||||||
|
)
|
||||||
|
|
||||||
|
access_token = OidcAccessToken.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
scope: "openid profile"
|
||||||
|
)
|
||||||
|
|
||||||
|
refresh_token = OidcRefreshToken.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
oidc_access_token: access_token,
|
||||||
|
scope: "openid profile"
|
||||||
|
)
|
||||||
|
|
||||||
|
plaintext_refresh_token = refresh_token.token
|
||||||
|
|
||||||
|
# Normal refresh WITHOUT claims parameter should work fine
|
||||||
|
post "/oauth/token", params: {
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
refresh_token: plaintext_refresh_token
|
||||||
|
}, headers: {
|
||||||
|
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :success
|
||||||
|
response_body = JSON.parse(response.body)
|
||||||
|
assert response_body.key?("access_token")
|
||||||
|
assert response_body.key?("id_token")
|
||||||
|
end
|
||||||
|
|
||||||
|
# ====================
|
||||||
|
# CLAIMS PARAMETER IS AUTHORIZATION-ONLY
|
||||||
|
# ====================
|
||||||
|
|
||||||
|
test "claims parameter is only valid in authorization request per OIDC spec" do
|
||||||
|
# Per OIDC Core spec section 18.2.1, claims parameter usage location is "Authorization Request"
|
||||||
|
# This test verifies that claims parameter cannot be used at token endpoint
|
||||||
|
|
||||||
|
OidcUserConsent.create!(
|
||||||
|
user: @user,
|
||||||
|
application: @application,
|
||||||
|
scopes_granted: "openid",
|
||||||
|
granted_at: Time.current,
|
||||||
|
sid: "test-sid-123"
|
||||||
|
)
|
||||||
|
|
||||||
|
auth_code = OidcAuthorizationCode.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
redirect_uri: "http://localhost:4000/callback",
|
||||||
|
scope: "openid",
|
||||||
|
expires_at: 10.minutes.from_now
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test various attempts to inject claims parameter
|
||||||
|
malicious_claims = [
|
||||||
|
'{"id_token":{"admin":true}}',
|
||||||
|
'{"id_token":{"email":{"essential":true}}}',
|
||||||
|
'{"userinfo":{"groups":{"values":["admin"]}}}',
|
||||||
|
'{"id_token":{"custom_claim":"custom_value"}}',
|
||||||
|
"invalid-json"
|
||||||
|
]
|
||||||
|
|
||||||
|
malicious_claims.each do |claims_value|
|
||||||
|
post "/oauth/token", params: {
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: auth_code.plaintext_code,
|
||||||
|
redirect_uri: "http://localhost:4000/callback",
|
||||||
|
claims: claims_value
|
||||||
|
}, headers: {
|
||||||
|
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
|
||||||
|
}
|
||||||
|
|
||||||
|
# All should be rejected
|
||||||
|
assert_response :bad_request, "Claims parameter '#{claims_value}' should be rejected"
|
||||||
|
error = JSON.parse(response.body)
|
||||||
|
assert_equal "invalid_request", error["error"]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# ====================
|
||||||
|
# VERIFY CONSENT-BASED ACCESS IS ENFORCED
|
||||||
|
# ====================
|
||||||
|
|
||||||
|
test "token endpoint respects scopes granted during authorization" do
|
||||||
|
# Create consent with ONLY openid scope (no email, profile, etc.)
|
||||||
|
OidcUserConsent.create!(
|
||||||
|
user: @user,
|
||||||
|
application: @application,
|
||||||
|
scopes_granted: "openid",
|
||||||
|
granted_at: Time.current,
|
||||||
|
sid: "test-sid-123"
|
||||||
|
)
|
||||||
|
|
||||||
|
auth_code = OidcAuthorizationCode.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
redirect_uri: "http://localhost:4000/callback",
|
||||||
|
scope: "openid",
|
||||||
|
expires_at: 10.minutes.from_now
|
||||||
|
)
|
||||||
|
|
||||||
|
# Exchange code for tokens
|
||||||
|
post "/oauth/token", params: {
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: auth_code.plaintext_code,
|
||||||
|
redirect_uri: "http://localhost:4000/callback"
|
||||||
|
}, headers: {
|
||||||
|
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :success
|
||||||
|
response_body = JSON.parse(response.body)
|
||||||
|
id_token = response_body["id_token"]
|
||||||
|
|
||||||
|
# Decode ID token to check claims
|
||||||
|
decoded = JWT.decode(id_token, nil, false).first
|
||||||
|
|
||||||
|
# Should only have required claims, not email/profile
|
||||||
|
assert_includes decoded.keys, "iss"
|
||||||
|
assert_includes decoded.keys, "sub"
|
||||||
|
assert_includes decoded.keys, "aud"
|
||||||
|
assert_includes decoded.keys, "exp"
|
||||||
|
assert_includes decoded.keys, "iat"
|
||||||
|
|
||||||
|
# Should NOT have claims that weren't consented to
|
||||||
|
refute_includes decoded.keys, "email", "Should not include email without email scope"
|
||||||
|
refute_includes decoded.keys, "email_verified", "Should not include email_verified without email scope"
|
||||||
|
refute_includes decoded.keys, "name", "Should not include name without profile scope"
|
||||||
|
refute_includes decoded.keys, "preferred_username", "Should not include preferred_username without profile scope"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "refresh token preserves original scopes granted during authorization" do
|
||||||
|
# Create consent with specific scopes
|
||||||
|
OidcUserConsent.create!(
|
||||||
|
user: @user,
|
||||||
|
application: @application,
|
||||||
|
scopes_granted: "openid email",
|
||||||
|
granted_at: Time.current,
|
||||||
|
sid: "test-sid-refresh-123"
|
||||||
|
)
|
||||||
|
|
||||||
|
access_token = OidcAccessToken.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
scope: "openid email"
|
||||||
|
)
|
||||||
|
|
||||||
|
refresh_token = OidcRefreshToken.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
oidc_access_token: access_token,
|
||||||
|
scope: "openid email"
|
||||||
|
)
|
||||||
|
|
||||||
|
plaintext_refresh_token = refresh_token.token
|
||||||
|
|
||||||
|
# Refresh the token
|
||||||
|
post "/oauth/token", params: {
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
refresh_token: plaintext_refresh_token
|
||||||
|
}, headers: {
|
||||||
|
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :success
|
||||||
|
response_body = JSON.parse(response.body)
|
||||||
|
id_token = response_body["id_token"]
|
||||||
|
|
||||||
|
# Decode ID token to verify scopes are preserved
|
||||||
|
decoded = JWT.decode(id_token, nil, false).first
|
||||||
|
|
||||||
|
# Should have email claims (from original consent)
|
||||||
|
assert_includes decoded.keys, "email", "Should preserve email scope from original consent"
|
||||||
|
assert_includes decoded.keys, "email_verified", "Should preserve email_verified scope from original consent"
|
||||||
|
|
||||||
|
# Should NOT have profile claims (not in original consent)
|
||||||
|
refute_includes decoded.keys, "name", "Should not add profile claims that weren't consented to"
|
||||||
|
refute_includes decoded.keys, "preferred_username", "Should not add preferred_username that wasn't consented to"
|
||||||
|
end
|
||||||
|
end
|
||||||
236
test/controllers/oidc_prompt_login_test.rb
Normal file
236
test/controllers/oidc_prompt_login_test.rb
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class OidcPromptLoginTest < ActionDispatch::IntegrationTest
|
||||||
|
setup do
|
||||||
|
@user = users(:alice)
|
||||||
|
@application = applications(:kavita_app)
|
||||||
|
@client_secret = SecureRandom.urlsafe_base64(48)
|
||||||
|
@application.client_secret = @client_secret
|
||||||
|
@application.save!
|
||||||
|
|
||||||
|
# Pre-authorize the application so we skip consent screen
|
||||||
|
consent = OidcUserConsent.find_or_initialize_by(
|
||||||
|
user: @user,
|
||||||
|
application: @application
|
||||||
|
)
|
||||||
|
consent.scopes_granted ||= "openid profile email"
|
||||||
|
consent.save!
|
||||||
|
end
|
||||||
|
|
||||||
|
teardown do
|
||||||
|
# Clean up
|
||||||
|
OidcAccessToken.where(user: @user, application: @application).destroy_all
|
||||||
|
OidcAuthorizationCode.where(user: @user, application: @application).destroy_all
|
||||||
|
end
|
||||||
|
|
||||||
|
test "max_age requires re-authentication when session is too old" do
|
||||||
|
# Sign in to create a session
|
||||||
|
post "/signin", params: {
|
||||||
|
email_address: @user.email_address,
|
||||||
|
password: "password"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :redirect
|
||||||
|
follow_redirect!
|
||||||
|
assert_response :success
|
||||||
|
|
||||||
|
# Get first auth_time
|
||||||
|
get "/oauth/authorize", params: {
|
||||||
|
client_id: @application.client_id,
|
||||||
|
redirect_uri: @application.parsed_redirect_uris.first,
|
||||||
|
response_type: "code",
|
||||||
|
scope: "openid",
|
||||||
|
state: "first-state",
|
||||||
|
nonce: "first-nonce"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :redirect
|
||||||
|
first_redirect_url = response.location
|
||||||
|
first_code = CGI.parse(URI(first_redirect_url).query)["code"].first
|
||||||
|
|
||||||
|
# Exchange for tokens and extract auth_time
|
||||||
|
post "/oauth/token", params: {
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: first_code,
|
||||||
|
redirect_uri: @application.parsed_redirect_uris.first,
|
||||||
|
client_id: @application.client_id,
|
||||||
|
client_secret: @client_secret
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :success
|
||||||
|
first_tokens = JSON.parse(response.body)
|
||||||
|
first_id_token = OidcJwtService.decode_id_token(first_tokens["id_token"])
|
||||||
|
first_auth_time = first_id_token[0]["auth_time"]
|
||||||
|
|
||||||
|
# Wait a bit (simulate time passing - in real scenario this would be actual seconds)
|
||||||
|
# Then request with max_age=0 (means session must be brand new)
|
||||||
|
get "/oauth/authorize", params: {
|
||||||
|
client_id: @application.client_id,
|
||||||
|
redirect_uri: @application.parsed_redirect_uris.first,
|
||||||
|
response_type: "code",
|
||||||
|
scope: "openid",
|
||||||
|
state: "second-state",
|
||||||
|
nonce: "second-nonce",
|
||||||
|
max_age: "0" # Requires session to be 0 seconds old (i.e., brand new)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Should redirect to sign in because session is too old
|
||||||
|
assert_response :redirect
|
||||||
|
assert_redirected_to(/signin/)
|
||||||
|
|
||||||
|
# Sign in again
|
||||||
|
post "/signin", params: {
|
||||||
|
email_address: @user.email_address,
|
||||||
|
password: "password"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :redirect
|
||||||
|
follow_redirect!
|
||||||
|
|
||||||
|
# Should receive authorization code
|
||||||
|
assert_response :redirect
|
||||||
|
second_redirect_url = response.location
|
||||||
|
second_code = CGI.parse(URI(second_redirect_url).query)["code"].first
|
||||||
|
|
||||||
|
assert second_code.present?, "Should receive authorization code after re-authentication"
|
||||||
|
|
||||||
|
# Exchange second authorization code for tokens
|
||||||
|
post "/oauth/token", params: {
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: second_code,
|
||||||
|
redirect_uri: @application.parsed_redirect_uris.first,
|
||||||
|
client_id: @application.client_id,
|
||||||
|
client_secret: @client_secret
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :success
|
||||||
|
second_tokens = JSON.parse(response.body)
|
||||||
|
second_id_token = OidcJwtService.decode_id_token(second_tokens["id_token"])
|
||||||
|
second_auth_time = second_id_token[0]["auth_time"]
|
||||||
|
|
||||||
|
# The second auth_time should be >= the first (re-authentication occurred)
|
||||||
|
# Note: May be equal if both occur in the same second (test timing edge case)
|
||||||
|
assert second_auth_time >= first_auth_time,
|
||||||
|
"max_age=0 should result in a re-authentication. " \
|
||||||
|
"First: #{first_auth_time}, Second: #{second_auth_time}"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "prompt=none returns login_required error when not authenticated" do
|
||||||
|
# Don't sign in - user is not authenticated
|
||||||
|
|
||||||
|
# Request authorization with prompt=none
|
||||||
|
get "/oauth/authorize", params: {
|
||||||
|
client_id: @application.client_id,
|
||||||
|
redirect_uri: @application.parsed_redirect_uris.first,
|
||||||
|
response_type: "code",
|
||||||
|
scope: "openid",
|
||||||
|
state: "test-state",
|
||||||
|
prompt: "none"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Should redirect with error=login_required (NOT to sign-in page)
|
||||||
|
assert_response :redirect
|
||||||
|
redirect_url = response.location
|
||||||
|
|
||||||
|
# Parse the redirect URL
|
||||||
|
uri = URI.parse(redirect_url)
|
||||||
|
query_params = uri.query ? CGI.parse(uri.query) : {}
|
||||||
|
|
||||||
|
assert_equal "login_required", query_params["error"]&.first,
|
||||||
|
"Should return login_required error for prompt=none when not authenticated"
|
||||||
|
assert_equal "test-state", query_params["state"]&.first,
|
||||||
|
"Should return state parameter"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "prompt=login forces re-authentication with new auth_time" do
|
||||||
|
# First authentication
|
||||||
|
post "/signin", params: {
|
||||||
|
email_address: @user.email_address,
|
||||||
|
password: "password"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :redirect
|
||||||
|
follow_redirect!
|
||||||
|
assert_response :success
|
||||||
|
|
||||||
|
# Get first authorization code
|
||||||
|
get "/oauth/authorize", params: {
|
||||||
|
client_id: @application.client_id,
|
||||||
|
redirect_uri: @application.parsed_redirect_uris.first,
|
||||||
|
response_type: "code",
|
||||||
|
scope: "openid",
|
||||||
|
state: "first-state",
|
||||||
|
nonce: "first-nonce"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :redirect
|
||||||
|
first_redirect_url = response.location
|
||||||
|
first_code = CGI.parse(URI(first_redirect_url).query)["code"].first
|
||||||
|
|
||||||
|
# Exchange for tokens and extract auth_time from ID token
|
||||||
|
post "/oauth/token", params: {
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: first_code,
|
||||||
|
redirect_uri: @application.parsed_redirect_uris.first,
|
||||||
|
client_id: @application.client_id,
|
||||||
|
client_secret: @client_secret
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :success
|
||||||
|
first_tokens = JSON.parse(response.body)
|
||||||
|
first_id_token = OidcJwtService.decode_id_token(first_tokens["id_token"])
|
||||||
|
first_auth_time = first_id_token[0]["auth_time"]
|
||||||
|
|
||||||
|
# Now request authorization again with prompt=login
|
||||||
|
get "/oauth/authorize", params: {
|
||||||
|
client_id: @application.client_id,
|
||||||
|
redirect_uri: @application.parsed_redirect_uris.first,
|
||||||
|
response_type: "code",
|
||||||
|
scope: "openid",
|
||||||
|
state: "second-state",
|
||||||
|
nonce: "second-nonce",
|
||||||
|
prompt: "login"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Should redirect to sign in
|
||||||
|
assert_response :redirect
|
||||||
|
assert_redirected_to(/signin/)
|
||||||
|
|
||||||
|
# Sign in again (simulating user re-authentication)
|
||||||
|
post "/signin", params: {
|
||||||
|
email_address: @user.email_address,
|
||||||
|
password: "password"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :redirect
|
||||||
|
# Follow redirect to after_authentication_url (which is /oauth/authorize without prompt=login)
|
||||||
|
follow_redirect!
|
||||||
|
|
||||||
|
# Should receive authorization code redirect
|
||||||
|
assert_response :redirect
|
||||||
|
second_redirect_url = response.location
|
||||||
|
second_code = CGI.parse(URI(second_redirect_url).query)["code"].first
|
||||||
|
|
||||||
|
assert second_code.present?, "Should receive authorization code after re-authentication"
|
||||||
|
|
||||||
|
# Exchange second authorization code for tokens
|
||||||
|
post "/oauth/token", params: {
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: second_code,
|
||||||
|
redirect_uri: @application.parsed_redirect_uris.first,
|
||||||
|
client_id: @application.client_id,
|
||||||
|
client_secret: @client_secret
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :success
|
||||||
|
second_tokens = JSON.parse(response.body)
|
||||||
|
second_id_token = OidcJwtService.decode_id_token(second_tokens["id_token"])
|
||||||
|
second_auth_time = second_id_token[0]["auth_time"]
|
||||||
|
|
||||||
|
# The second auth_time should be >= the first (re-authentication occurred)
|
||||||
|
# Note: May be equal if both occur in the same second (test timing edge case)
|
||||||
|
assert second_auth_time >= first_auth_time,
|
||||||
|
"prompt=login should result in a later auth_time. " \
|
||||||
|
"First: #{first_auth_time}, Second: #{second_auth_time}"
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -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|
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
136
test/lib/duration_parser_test.rb
Normal file
136
test/lib/duration_parser_test.rb
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class DurationParserTest < ActiveSupport::TestCase
|
||||||
|
# Valid formats
|
||||||
|
test "parses seconds" do
|
||||||
|
assert_equal 1, DurationParser.parse("1s")
|
||||||
|
assert_equal 30, DurationParser.parse("30s")
|
||||||
|
assert_equal 3600, DurationParser.parse("3600s")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "parses minutes" do
|
||||||
|
assert_equal 60, DurationParser.parse("1m")
|
||||||
|
assert_equal 300, DurationParser.parse("5m")
|
||||||
|
assert_equal 1800, DurationParser.parse("30m")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "parses hours" do
|
||||||
|
assert_equal 3600, DurationParser.parse("1h")
|
||||||
|
assert_equal 7200, DurationParser.parse("2h")
|
||||||
|
assert_equal 86400, DurationParser.parse("24h")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "parses days" do
|
||||||
|
assert_equal 86400, DurationParser.parse("1d")
|
||||||
|
assert_equal 172800, DurationParser.parse("2d")
|
||||||
|
assert_equal 2592000, DurationParser.parse("30d")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "parses weeks" do
|
||||||
|
assert_equal 604800, DurationParser.parse("1w")
|
||||||
|
assert_equal 1209600, DurationParser.parse("2w")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "parses months (30 days)" do
|
||||||
|
assert_equal 2592000, DurationParser.parse("1M")
|
||||||
|
assert_equal 5184000, DurationParser.parse("2M")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "parses years (365 days)" do
|
||||||
|
assert_equal 31536000, DurationParser.parse("1y")
|
||||||
|
assert_equal 63072000, DurationParser.parse("2y")
|
||||||
|
end
|
||||||
|
|
||||||
|
# Plain numbers
|
||||||
|
test "parses plain integer as seconds" do
|
||||||
|
assert_equal 3600, DurationParser.parse(3600)
|
||||||
|
assert_equal 300, DurationParser.parse(300)
|
||||||
|
assert_equal 0, DurationParser.parse(0)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "parses plain numeric string as seconds" do
|
||||||
|
assert_equal 3600, DurationParser.parse("3600")
|
||||||
|
assert_equal 300, DurationParser.parse("300")
|
||||||
|
assert_equal 0, DurationParser.parse("0")
|
||||||
|
end
|
||||||
|
|
||||||
|
# Whitespace handling
|
||||||
|
test "handles leading and trailing whitespace" do
|
||||||
|
assert_equal 3600, DurationParser.parse(" 1h ")
|
||||||
|
assert_equal 300, DurationParser.parse(" 5m ")
|
||||||
|
assert_equal 86400, DurationParser.parse("\t1d\n")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "handles space between number and unit" do
|
||||||
|
assert_equal 3600, DurationParser.parse("1 h")
|
||||||
|
assert_equal 300, DurationParser.parse("5 m")
|
||||||
|
assert_equal 86400, DurationParser.parse("1 d")
|
||||||
|
end
|
||||||
|
|
||||||
|
# Case sensitivity - only lowercase units work (except M for months)
|
||||||
|
test "lowercase units work" do
|
||||||
|
assert_equal 1, DurationParser.parse("1s")
|
||||||
|
assert_equal 60, DurationParser.parse("1m") # minute (lowercase)
|
||||||
|
assert_equal 3600, DurationParser.parse("1h")
|
||||||
|
assert_equal 86400, DurationParser.parse("1d")
|
||||||
|
assert_equal 604800, DurationParser.parse("1w")
|
||||||
|
assert_equal 31536000, DurationParser.parse("1y")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "uppercase M for months works" do
|
||||||
|
assert_equal 2592000, DurationParser.parse("1M") # month (uppercase)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns nil for wrong case" do
|
||||||
|
assert_nil DurationParser.parse("1S") # Should be 1s
|
||||||
|
assert_nil DurationParser.parse("1H") # Should be 1h
|
||||||
|
assert_nil DurationParser.parse("1D") # Should be 1d
|
||||||
|
assert_nil DurationParser.parse("1W") # Should be 1w
|
||||||
|
assert_nil DurationParser.parse("1Y") # Should be 1y
|
||||||
|
end
|
||||||
|
|
||||||
|
# Edge cases
|
||||||
|
test "handles zero duration" do
|
||||||
|
assert_equal 0, DurationParser.parse("0s")
|
||||||
|
assert_equal 0, DurationParser.parse("0m")
|
||||||
|
assert_equal 0, DurationParser.parse("0h")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "handles large numbers" do
|
||||||
|
assert_equal 86400000, DurationParser.parse("1000d")
|
||||||
|
assert_equal 360000, DurationParser.parse("100h")
|
||||||
|
end
|
||||||
|
|
||||||
|
# Invalid formats - should return nil (not raise)
|
||||||
|
test "returns nil for invalid format" do
|
||||||
|
assert_nil DurationParser.parse("invalid")
|
||||||
|
assert_nil DurationParser.parse("1x")
|
||||||
|
assert_nil DurationParser.parse("abc")
|
||||||
|
assert_nil DurationParser.parse("1.5h") # No decimals
|
||||||
|
assert_nil DurationParser.parse("-1h") # No negatives
|
||||||
|
assert_nil DurationParser.parse("h1") # Wrong order
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns nil for blank input" do
|
||||||
|
assert_nil DurationParser.parse("")
|
||||||
|
assert_nil DurationParser.parse(nil)
|
||||||
|
assert_nil DurationParser.parse(" ")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns nil for multiple units" do
|
||||||
|
assert_nil DurationParser.parse("1h30m") # Keep it simple, don't support this
|
||||||
|
assert_nil DurationParser.parse("1d2h")
|
||||||
|
end
|
||||||
|
|
||||||
|
# String coercion
|
||||||
|
test "handles string input" do
|
||||||
|
assert_equal 3600, DurationParser.parse("1h")
|
||||||
|
assert_equal 3600, DurationParser.parse(:"1h") # Symbol
|
||||||
|
end
|
||||||
|
|
||||||
|
# Boundary validation (not parser's job, but good to know)
|
||||||
|
test "parses values outside typical TTL ranges without error" do
|
||||||
|
assert_equal 1, DurationParser.parse("1s") # Below min access_token_ttl
|
||||||
|
assert_equal 315360000, DurationParser.parse("10y") # Above max refresh_token_ttl
|
||||||
|
end
|
||||||
|
end
|
||||||
109
test/models/application_duration_parser_test.rb
Normal file
109
test/models/application_duration_parser_test.rb
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class ApplicationDurationParserTest < ActiveSupport::TestCase
|
||||||
|
test "access_token_ttl accepts human-friendly durations" do
|
||||||
|
app = Application.new(access_token_ttl: "1h")
|
||||||
|
assert_equal 3600, app.access_token_ttl
|
||||||
|
|
||||||
|
app.access_token_ttl = "30m"
|
||||||
|
assert_equal 1800, app.access_token_ttl
|
||||||
|
|
||||||
|
app.access_token_ttl = "5m"
|
||||||
|
assert_equal 300, app.access_token_ttl
|
||||||
|
end
|
||||||
|
|
||||||
|
test "refresh_token_ttl accepts human-friendly durations" do
|
||||||
|
app = Application.new(refresh_token_ttl: "30d")
|
||||||
|
assert_equal 2592000, app.refresh_token_ttl
|
||||||
|
|
||||||
|
app.refresh_token_ttl = "1M"
|
||||||
|
assert_equal 2592000, app.refresh_token_ttl
|
||||||
|
|
||||||
|
app.refresh_token_ttl = "7d"
|
||||||
|
assert_equal 604800, app.refresh_token_ttl
|
||||||
|
end
|
||||||
|
|
||||||
|
test "id_token_ttl accepts human-friendly durations" do
|
||||||
|
app = Application.new(id_token_ttl: "1h")
|
||||||
|
assert_equal 3600, app.id_token_ttl
|
||||||
|
|
||||||
|
app.id_token_ttl = "2h"
|
||||||
|
assert_equal 7200, app.id_token_ttl
|
||||||
|
end
|
||||||
|
|
||||||
|
test "TTL fields still accept plain numbers" do
|
||||||
|
app = Application.new(
|
||||||
|
access_token_ttl: 3600,
|
||||||
|
refresh_token_ttl: 2592000,
|
||||||
|
id_token_ttl: 3600
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_equal 3600, app.access_token_ttl
|
||||||
|
assert_equal 2592000, app.refresh_token_ttl
|
||||||
|
assert_equal 3600, app.id_token_ttl
|
||||||
|
end
|
||||||
|
|
||||||
|
test "TTL fields accept plain number strings" do
|
||||||
|
app = Application.new(
|
||||||
|
access_token_ttl: "3600",
|
||||||
|
refresh_token_ttl: "2592000",
|
||||||
|
id_token_ttl: "3600"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_equal 3600, app.access_token_ttl
|
||||||
|
assert_equal 2592000, app.refresh_token_ttl
|
||||||
|
assert_equal 3600, app.id_token_ttl
|
||||||
|
end
|
||||||
|
|
||||||
|
test "invalid TTL values are set to nil" do
|
||||||
|
app = Application.new(
|
||||||
|
access_token_ttl: "invalid",
|
||||||
|
refresh_token_ttl: "bad",
|
||||||
|
id_token_ttl: "nope"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_nil app.access_token_ttl
|
||||||
|
assert_nil app.refresh_token_ttl
|
||||||
|
assert_nil app.id_token_ttl
|
||||||
|
end
|
||||||
|
|
||||||
|
test "validation still works with parsed values" do
|
||||||
|
app = Application.new(
|
||||||
|
name: "Test",
|
||||||
|
slug: "test",
|
||||||
|
app_type: "oidc",
|
||||||
|
redirect_uris: "https://example.com/callback"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Too short (below 5 minutes)
|
||||||
|
app.access_token_ttl = "1m"
|
||||||
|
assert_not app.valid?
|
||||||
|
assert_includes app.errors[:access_token_ttl], "must be greater than or equal to 300"
|
||||||
|
|
||||||
|
# Too long (above 24 hours for access token)
|
||||||
|
app.access_token_ttl = "2d"
|
||||||
|
assert_not app.valid?
|
||||||
|
assert_includes app.errors[:access_token_ttl], "must be less than or equal to 86400"
|
||||||
|
|
||||||
|
# Just right
|
||||||
|
app.access_token_ttl = "1h"
|
||||||
|
app.valid? # Revalidate
|
||||||
|
assert app.errors[:access_token_ttl].blank?
|
||||||
|
end
|
||||||
|
|
||||||
|
test "can create OIDC app with human-friendly TTL values" do
|
||||||
|
app = Application.create!(
|
||||||
|
name: "Test App",
|
||||||
|
slug: "test-app",
|
||||||
|
app_type: "oidc",
|
||||||
|
redirect_uris: "https://example.com/callback",
|
||||||
|
access_token_ttl: "1h",
|
||||||
|
refresh_token_ttl: "30d",
|
||||||
|
id_token_ttl: "2h"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_equal 3600, app.access_token_ttl
|
||||||
|
assert_equal 2592000, app.refresh_token_ttl
|
||||||
|
assert_equal 7200, app.id_token_ttl
|
||||||
|
end
|
||||||
|
end
|
||||||
Reference in New Issue
Block a user