Add pairwise SID with a UUIDv4, a significatant upgrade over User.id.to_s. Complete allowing admin to enforce TOTP per user
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled

This commit is contained in:
Dan Milne
2025-11-23 11:16:06 +11:00
parent e882a4d6d1
commit 7796c38c08
15 changed files with 398 additions and 69 deletions

View File

@@ -76,11 +76,11 @@ Clinch sits in a sweet spot between two excellent open-source identity solutions
- **User statuses** - Active, disabled, or pending invitation - **User statuses** - Active, disabled, or pending invitation
### Authentication Methods ### Authentication Methods
- **WebAuthn/Passkeys** - Modern passwordless authentication using FIDO2 standards
- **Password authentication** - Secure bcrypt-based password storage - **Password authentication** - Secure bcrypt-based password storage
- **Magic login links** - Passwordless login via email (15-minute expiry)
- **TOTP 2FA** - Optional time-based one-time passwords with QR code setup - **TOTP 2FA** - Optional time-based one-time passwords with QR code setup
- **Backup codes** - 10 single-use recovery codes per user - **Backup codes** - 10 single-use recovery codes per user
- **Configurable 2FA enforcement** - Admins can require TOTP for specific users/groups - **Configurable 2FA enforcement** - Admins can require TOTP for specific users
### SSO Protocols ### SSO Protocols
@@ -96,6 +96,7 @@ Features:
- **Refresh tokens** - Long-lived tokens (30 days default) with automatic rotation and revocation - **Refresh tokens** - Long-lived tokens (30 days default) with automatic rotation and revocation
- **Configurable token expiry** - Set access token (5min-24hr), refresh token (1-90 days), and ID token TTL per application - **Configurable token expiry** - Set access token (5min-24hr), refresh token (1-90 days), and ID token TTL per application
- **Token security** - BCrypt-hashed tokens, automatic cleanup of expired tokens - **Token security** - BCrypt-hashed tokens, automatic cleanup of expired tokens
- **Pairwise subject identifiers** - Each user gets a unique, stable `sub` claim per application for enhanced privacy
Client apps (Audiobookshelf, Kavita, Grafana, etc.) redirect to Clinch for login and receive ID tokens, access tokens, and refresh tokens. Client apps (Audiobookshelf, Kavita, Grafana, etc.) redirect to Clinch for login and receive ID tokens, access tokens, and refresh tokens.

View File

@@ -30,13 +30,7 @@ module Admin
end end
def update def update
# Prevent changing params for the current user's email and admin status update_params = user_params
# to avoid locking themselves out
update_params = user_params.dup
if @user == Current.session.user
update_params.delete(:admin)
end
# Only update password if provided # Only update password if provided
update_params.delete(:password) if update_params[:password].blank? update_params.delete(:password) if update_params[:password].blank?
@@ -76,7 +70,15 @@ module Admin
end end
def user_params def user_params
params.require(:user).permit(:email_address, :name, :password, :admin, :status, custom_claims: {}) # Base attributes that all admins can modify
base_params = params.require(:user).permit(:email_address, :name, :password, :status, :totp_required, custom_claims: {})
# Only allow modifying admin status when editing other users (prevent self-demotion)
if params[:id] != Current.session.user.id.to_s
base_params[:admin] = params[:user][:admin] if params[:user][:admin].present?
end
base_params
end end
end end
end end

View File

@@ -365,8 +365,17 @@ class OidcController < ApplicationController
scope: auth_code.scope scope: auth_code.scope
) )
# Generate ID token (JWT) # Find user consent for this application
id_token = OidcJwtService.generate_id_token(user, application, nonce: auth_code.nonce) consent = OidcUserConsent.find_by(user: user, application: application)
unless consent
Rails.logger.error "OIDC Security: Token requested without consent record (user: #{user.id}, app: #{application.id})"
render json: { error: "invalid_grant", error_description: "Authorization consent not found" }, status: :bad_request
return
end
# Generate ID token (JWT) with pairwise SID
id_token = OidcJwtService.generate_id_token(user, application, consent: consent, nonce: auth_code.nonce)
# Return tokens # Return tokens
render json: { render json: {
@@ -457,8 +466,17 @@ class OidcController < ApplicationController
token_family_id: refresh_token_record.token_family_id # Keep same family for rotation tracking token_family_id: refresh_token_record.token_family_id # Keep same family for rotation tracking
) )
# Generate new ID token (JWT, no nonce for refresh grants) # Find user consent for this application
id_token = OidcJwtService.generate_id_token(user, application) consent = OidcUserConsent.find_by(user: user, application: application)
unless consent
Rails.logger.error "OIDC Security: Refresh token used without consent record (user: #{user.id}, app: #{application.id})"
render json: { error: "invalid_grant", error_description: "Authorization consent not found" }, status: :bad_request
return
end
# Generate new ID token (JWT with pairwise SID, no nonce for refresh grants)
id_token = OidcJwtService.generate_id_token(user, application, consent: consent)
# Return new tokens # Return new tokens
render json: { render json: {
@@ -498,9 +516,13 @@ class OidcController < ApplicationController
return return
end end
# Find user consent for this application to get pairwise SID
consent = OidcUserConsent.find_by(user: user, application: access_token.application)
subject = consent&.sid || user.id.to_s
# Return user claims # Return user claims
claims = { claims = {
sub: user.id.to_s, sub: subject,
email: user.email_address, email: user.email_address,
email_verified: true, email_verified: true,
preferred_username: user.email_address, preferred_username: user.email_address,
@@ -609,11 +631,19 @@ class OidcController < ApplicationController
reset_session reset_session
end end
# If post_logout_redirect_uri is provided, redirect there # If post_logout_redirect_uri is provided, validate and redirect
if post_logout_redirect_uri.present? if post_logout_redirect_uri.present?
redirect_uri = post_logout_redirect_uri validated_uri = validate_logout_redirect_uri(post_logout_redirect_uri)
redirect_uri += "?state=#{state}" if state.present?
redirect_to redirect_uri, allow_other_host: true if validated_uri
redirect_uri = validated_uri
redirect_uri += "?state=#{state}" if state.present?
redirect_to redirect_uri, allow_other_host: true
else
# Invalid redirect URI - log warning and go to default
Rails.logger.warn "OIDC Logout: Invalid post_logout_redirect_uri attempted: #{post_logout_redirect_uri}"
redirect_to root_path
end
else else
# Default redirect to home page # Default redirect to home page
redirect_to root_path redirect_to root_path
@@ -685,4 +715,54 @@ class OidcController < ApplicationController
[params[:client_id], params[:client_secret]] [params[:client_id], params[:client_secret]]
end end
end end
def validate_logout_redirect_uri(uri)
return nil unless uri.present?
begin
parsed_uri = URI.parse(uri)
# Only allow HTTP/HTTPS schemes (prevent javascript:, data:, etc.)
return nil unless parsed_uri.is_a?(URI::HTTP) || parsed_uri.is_a?(URI::HTTPS)
# Only allow HTTPS in production
return nil if Rails.env.production? && parsed_uri.scheme != 'https'
# Check if URI matches any registered OIDC application's redirect URIs
# According to OIDC spec, post_logout_redirect_uri should be pre-registered
Application.oidc.active.find_each do |app|
# Check if this URI matches any of the app's registered redirect URIs
if app.parsed_redirect_uris.any? { |registered_uri| logout_uri_matches?(uri, registered_uri) }
return uri
end
end
# No matching application found
nil
rescue URI::InvalidURIError
nil
end
end
# Check if logout URI matches a registered redirect URI
# More lenient than exact match - allows same host/path with different query params
def logout_uri_matches?(provided, registered)
# Exact match is always valid
return true if provided == registered
# Parse both URIs to compare components
begin
provided_parsed = URI.parse(provided)
registered_parsed = URI.parse(registered)
# Match if scheme, host, port, and path are the same
# (allows different query params which is common for logout redirects)
provided_parsed.scheme == registered_parsed.scheme &&
provided_parsed.host == registered_parsed.host &&
provided_parsed.port == registered_parsed.port &&
provided_parsed.path == registered_parsed.path
rescue URI::InvalidURIError
false
end
end
end end

View File

@@ -6,7 +6,18 @@ class SessionsController < ApplicationController
def new def new
# Redirect to signup if this is first run # Redirect to signup if this is first run
redirect_to signup_path if User.count.zero? if User.count.zero?
respond_to do |format|
format.html { redirect_to signup_path }
format.json { render json: { error: "No users exist. Please complete initial setup." }, status: :service_unavailable }
end
return
end
respond_to do |format|
format.html # render HTML login page
format.json { render json: { error: "Authentication required" }, status: :unauthorized }
end
end end
def create def create
@@ -33,8 +44,22 @@ class SessionsController < ApplicationController
return return
end end
# Check if TOTP is required # Check if TOTP is required or enabled
if user.totp_enabled? if user.totp_required? || user.totp_enabled?
# If TOTP is required but not yet set up, redirect to setup
if user.totp_required? && !user.totp_enabled?
# Store user ID in session for TOTP setup
session[:pending_totp_setup_user_id] = user.id
# Preserve the redirect URL through TOTP setup
if params[:rd].present?
validated_url = validate_redirect_url(params[:rd])
session[:totp_redirect_url] = validated_url if validated_url
end
redirect_to new_totp_path, alert: "Your administrator requires two-factor authentication. Please set it up now to continue."
return
end
# TOTP is enabled, proceed to verification
# Store user ID in session temporarily for TOTP verification # Store user ID in session temporarily for TOTP verification
session[:pending_totp_user_id] = user.id session[:pending_totp_user_id] = user.id
# Preserve the redirect URL through TOTP verification (after validation) # Preserve the redirect URL through TOTP verification (after validation)
@@ -275,12 +300,12 @@ class SessionsController < ApplicationController
redirect_domain = uri.host.downcase redirect_domain = uri.host.downcase
return nil unless redirect_domain.present? return nil unless redirect_domain.present?
# Check against our ForwardAuthRules # Check against our forward auth applications
matching_rule = ForwardAuthRule.active.find do |rule| matching_app = Application.forward_auth.active.find do |app|
rule.matches_domain?(redirect_domain) app.matches_domain?(redirect_domain)
end end
matching_rule ? url : nil matching_app ? url : nil
rescue URI::InvalidURIError rescue URI::InvalidURIError
nil nil

View File

@@ -5,6 +5,9 @@ class TotpController < ApplicationController
# GET /totp/new - Show QR code to set up TOTP # GET /totp/new - Show QR code to set up TOTP
def new def new
# Check if user is being forced to set up TOTP by admin
@totp_setup_required = session[:pending_totp_setup_user_id].present?
# Generate TOTP secret but don't save yet # Generate TOTP secret but don't save yet
@totp_secret = ROTP::Base32.random @totp_secret = ROTP::Base32.random
@provisioning_uri = ROTP::TOTP.new(@totp_secret, issuer: "Clinch").provisioning_uri(@user.email_address) @provisioning_uri = ROTP::TOTP.new(@totp_secret, issuer: "Clinch").provisioning_uri(@user.email_address)
@@ -30,8 +33,16 @@ class TotpController < ApplicationController
# Store plain codes temporarily in session for display after redirect # Store plain codes temporarily in session for display after redirect
session[:temp_backup_codes] = plain_codes session[:temp_backup_codes] = plain_codes
# Redirect to backup codes page with success message # Check if this was a required setup from login
redirect_to backup_codes_totp_path, notice: "Two-factor authentication has been enabled successfully! Save these backup codes now." if session[:pending_totp_setup_user_id].present?
session.delete(:pending_totp_setup_user_id)
# Mark that user should be auto-signed in after viewing backup codes
session[:auto_signin_after_forced_totp] = true
redirect_to backup_codes_totp_path, notice: "Two-factor authentication has been enabled successfully! Save these backup codes, then you'll be signed in."
else
# Regular setup from profile
redirect_to backup_codes_totp_path, notice: "Two-factor authentication has been enabled successfully! Save these backup codes now."
end
else else
redirect_to new_totp_path, alert: "Invalid verification code. Please try again." redirect_to new_totp_path, alert: "Invalid verification code. Please try again."
end end
@@ -43,6 +54,12 @@ class TotpController < ApplicationController
if session[:temp_backup_codes].present? if session[:temp_backup_codes].present?
@backup_codes = session[:temp_backup_codes] @backup_codes = session[:temp_backup_codes]
session.delete(:temp_backup_codes) # Clear after use session.delete(:temp_backup_codes) # Clear after use
# Check if this was a forced TOTP setup during login
@auto_signin_pending = session[:auto_signin_after_forced_totp].present?
if @auto_signin_pending
session.delete(:auto_signin_after_forced_totp)
end
else else
# This will be shown after password verification for existing users # This will be shown after password verification for existing users
# Since we can't display BCrypt hashes, redirect to regenerate # Since we can't display BCrypt hashes, redirect to regenerate
@@ -81,6 +98,18 @@ class TotpController < ApplicationController
redirect_to backup_codes_totp_path, notice: "New backup codes have been generated. Save them now!" redirect_to backup_codes_totp_path, notice: "New backup codes have been generated. Save them now!"
end end
# POST /totp/complete_setup - Complete forced TOTP setup and sign in
def complete_setup
# Sign in the user after they've saved their backup codes
# This is only used when admin requires TOTP and user just set it up during login
if session[:totp_redirect_url].present?
session[:return_to_after_authenticating] = session.delete(:totp_redirect_url)
end
start_new_session_for @user
redirect_to after_authentication_url, notice: "Two-factor authentication enabled. Signed in successfully.", allow_other_host: true
end
# DELETE /totp - Disable TOTP (requires password) # DELETE /totp - Disable TOTP (requires password)
def destroy def destroy
unless @user.authenticate(params[:password]) unless @user.authenticate(params[:password])
@@ -88,6 +117,12 @@ class TotpController < ApplicationController
return return
end end
# Prevent disabling if admin requires TOTP
if @user.totp_required?
redirect_to profile_path, alert: "Two-factor authentication is required by your administrator and cannot be disabled."
return
end
@user.disable_totp! @user.disable_totp!
redirect_to profile_path, notice: "Two-factor authentication has been disabled." redirect_to profile_path, notice: "Two-factor authentication has been disabled."
end end
@@ -99,7 +134,8 @@ class TotpController < ApplicationController
end end
def redirect_if_totp_enabled def redirect_if_totp_enabled
if @user.totp_enabled? # Allow setup if admin requires it, even if already enabled (for regeneration)
if @user.totp_enabled? && !session[:pending_totp_setup_user_id].present?
redirect_to profile_path, alert: "Two-factor authentication is already enabled." redirect_to profile_path, alert: "Two-factor authentication is already enabled."
end end
end end

View File

@@ -6,6 +6,7 @@ class OidcUserConsent < ApplicationRecord
validates :user_id, uniqueness: { scope: :application_id } validates :user_id, uniqueness: { scope: :application_id }
before_validation :set_granted_at, on: :create before_validation :set_granted_at, on: :create
before_validation :set_sid, on: :create
# Parse scopes_granted into an array # Parse scopes_granted into an array
def scopes def scopes
@@ -44,9 +45,18 @@ class OidcUserConsent < ApplicationRecord
end.join(', ') end.join(', ')
end end
# Find consent by SID
def self.find_by_sid(sid)
find_by(sid: sid)
end
private private
def set_granted_at def set_granted_at
self.granted_at ||= Time.current self.granted_at ||= Time.current
end end
def set_sid
self.sid ||= SecureRandom.uuid
end
end end

View File

@@ -44,7 +44,9 @@ class User < ApplicationRecord
end end
def disable_totp! def disable_totp!
update!(totp_secret: nil, totp_required: false, backup_codes: nil) # Note: This does NOT clear totp_required flag
# Admins control that flag via admin panel, users cannot remove admin-required 2FA
update!(totp_secret: nil, backup_codes: nil)
end end
def totp_provisioning_uri(issuer: "Clinch") def totp_provisioning_uri(issuer: "Clinch")

View File

@@ -1,14 +1,17 @@
class OidcJwtService 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, nonce: nil) def generate_id_token(user, application, consent: nil, nonce: nil)
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
# Use pairwise SID from consent if available, fallback to user ID
subject = consent&.sid || user.id.to_s
payload = { payload = {
iss: issuer_url, iss: issuer_url,
sub: user.id.to_s, sub: subject,
aud: application.client_id, aud: application.client_id,
exp: now + ttl, exp: now + ttl,
iat: now, iat: now,
@@ -66,8 +69,13 @@ class OidcJwtService
# In production, this should come from ENV or config # In production, this should come from ENV or config
# For now, we'll use a placeholder that can be overridden # For now, we'll use a placeholder that can be overridden
host = ENV.fetch("CLINCH_HOST", "localhost:3000") host = ENV.fetch("CLINCH_HOST", "localhost:3000")
# Ensure URL has https:// protocol # Ensure URL has protocol - use https:// in production, http:// in development
host.match?(/^https?:\/\//) ? host : "https://#{host}" if host.match?(/^https?:\/\//)
host
else
protocol = Rails.env.production? ? "https" : "http"
"#{protocol}://#{host}"
end
end end
private private
@@ -75,17 +83,37 @@ class OidcJwtService
# Get or generate RSA private key # Get or generate RSA private key
def private_key def private_key
@private_key ||= begin @private_key ||= begin
key_source = nil
# Try ENV variable first (best for Docker/Kamal) # Try ENV variable first (best for Docker/Kamal)
if ENV["OIDC_PRIVATE_KEY"].present? if ENV["OIDC_PRIVATE_KEY"].present?
OpenSSL::PKey::RSA.new(ENV["OIDC_PRIVATE_KEY"]) key_source = ENV["OIDC_PRIVATE_KEY"]
# Then try Rails credentials # Then try Rails credentials
elsif Rails.application.credentials.oidc_private_key.present? elsif Rails.application.credentials.oidc_private_key.present?
OpenSSL::PKey::RSA.new(Rails.application.credentials.oidc_private_key) key_source = Rails.application.credentials.oidc_private_key
end
if key_source.present?
begin
# Handle both actual newlines and escaped \n sequences
# Some .env loaders may escape newlines, so we need to convert them back
key_data = key_source.gsub("\\n", "\n")
OpenSSL::PKey::RSA.new(key_data)
rescue OpenSSL::PKey::RSAError => e
Rails.logger.error "OIDC: Failed to load private key: #{e.message}"
Rails.logger.error "OIDC: Key source length: #{key_source.length}, starts with: #{key_source[0..50]}"
raise "Invalid OIDC private key format. Please ensure the key is in PEM format with proper newlines."
end
else else
# Generate a new key for development # In production, we should never generate a key on the fly
# In production, you MUST set OIDC_PRIVATE_KEY env var or add to credentials # because it would be different across servers/deployments
if Rails.env.production?
raise "OIDC private key not configured. Set OIDC_PRIVATE_KEY environment variable or add to Rails credentials."
end
# Generate a new key for development/test only
Rails.logger.warn "OIDC: No private key found in ENV or credentials, generating new key (development only)" Rails.logger.warn "OIDC: No private key found in ENV or credentials, generating new key (development only)"
Rails.logger.warn "OIDC: Set OIDC_PRIVATE_KEY environment variable in production!" Rails.logger.warn "OIDC: Set OIDC_PRIVATE_KEY environment variable for consistency across restarts"
OpenSSL::PKey::RSA.new(2048) OpenSSL::PKey::RSA.new(2048)
end end
end end

View File

@@ -35,6 +35,25 @@
<% end %> <% end %>
</div> </div>
<div>
<div class="flex items-center">
<%= form.check_box :totp_required, class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" %>
<%= form.label :totp_required, "Require Two-Factor Authentication", class: "ml-2 block text-sm text-gray-900" %>
<% if user.totp_required? && !user.totp_enabled? %>
<span class="ml-2 text-xs text-amber-600">(User has not set up 2FA yet)</span>
<% end %>
</div>
<% if user.totp_required? && !user.totp_enabled? %>
<p class="mt-1 text-sm text-amber-600">
<svg class="inline h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd" />
</svg>
Warning: This user will be prompted to set up 2FA on their next login.
</p>
<% end %>
<p class="mt-1 text-sm text-gray-500">When enabled, this user must use two-factor authentication to sign in.</p>
</div>
<div data-controller="json-validator" data-json-validator-valid-class="border-green-500 focus:border-green-500 focus:ring-green-500" data-json-validator-invalid-class="border-red-500 focus:border-red-500 focus:ring-red-500" data-json-validator-valid-status-class="text-green-600" data-json-validator-invalid-status-class="text-red-600"> <div data-controller="json-validator" data-json-validator-valid-class="border-green-500 focus:border-green-500 focus:ring-green-500" data-json-validator-invalid-class="border-red-500 focus:border-red-500 focus:ring-red-500" data-json-validator-valid-status-class="text-green-600" data-json-validator-invalid-status-class="text-red-600">
<%= form.label :custom_claims, "Custom Claims (JSON)", class: "block text-sm font-medium text-gray-700" %> <%= form.label :custom_claims, "Custom Claims (JSON)", class: "block text-sm font-medium text-gray-700" %>
<%= form.text_area :custom_claims, value: (user.custom_claims.present? ? JSON.pretty_generate(user.custom_claims) : ""), rows: 8, <%= form.text_area :custom_claims, value: (user.custom_claims.present? ? JSON.pretty_generate(user.custom_claims) : ""), rows: 8,

View File

@@ -85,15 +85,20 @@
<% end %> <% end %>
</td> </td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500"> <td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
<% if user.totp_enabled? %> <div class="flex items-center gap-2">
<svg class="h-5 w-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <% if user.totp_enabled? %>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path> <svg class="h-5 w-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" title="2FA Enabled">
</svg> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
<% else %> </svg>
<svg class="h-5 w-5 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <% else %>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"></path> <svg class="h-5 w-5 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24" title="2FA Not Enabled">
</svg> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
<% end %> </svg>
<% end %>
<% if user.totp_required? %>
<span class="inline-flex items-center rounded-full bg-blue-100 px-2 py-1 text-xs font-medium text-blue-700" title="2FA Required by Admin">Required</span>
<% end %>
</div>
</td> </td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500"> <td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
<%= user.groups.count %> <%= user.groups.count %>

View File

@@ -98,23 +98,52 @@
<p class="text-sm font-medium text-green-800"> <p class="text-sm font-medium text-green-800">
Two-factor authentication is enabled Two-factor authentication is enabled
</p> </p>
<% if @user.totp_required? %>
<p class="mt-1 text-sm text-green-700">
<svg class="inline h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clip-rule="evenodd" />
</svg>
Required by administrator
</p>
<% end %>
</div> </div>
</div> </div>
</div> </div>
<div class="mt-4 flex gap-3"> <% if @user.totp_required? %>
<button type="button" <div class="mt-4 rounded-md bg-blue-50 p-4">
data-action="click->modal#show" <div class="flex">
data-modal-id="disable-2fa-modal" <svg class="h-5 w-5 text-blue-400 mr-2 flex-shrink-0" viewBox="0 0 20 20" fill="currentColor">
class="inline-flex items-center rounded-md border border-red-300 bg-white px-4 py-2 text-sm font-medium text-red-700 shadow-sm hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2"> <path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
Disable 2FA </svg>
</button> <p class="text-sm text-blue-800">
<button type="button" Your administrator requires two-factor authentication. You cannot disable it.
data-action="click->modal#show" </p>
data-modal-id="view-backup-codes-modal" </div>
class="inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"> </div>
View Backup Codes <div class="mt-4 flex gap-3">
</button> <button type="button"
</div> data-action="click->modal#show"
data-modal-id="view-backup-codes-modal"
class="inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
View Backup Codes
</button>
</div>
<% else %>
<div class="mt-4 flex gap-3">
<button type="button"
data-action="click->modal#show"
data-modal-id="disable-2fa-modal"
class="inline-flex items-center rounded-md border border-red-300 bg-white px-4 py-2 text-sm font-medium text-red-700 shadow-sm hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2">
Disable 2FA
</button>
<button type="button"
data-action="click->modal#show"
data-modal-id="view-backup-codes-modal"
class="inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
View Backup Codes
</button>
</div>
<% end %>
<% else %> <% else %>
<%= link_to new_totp_path, class: "inline-flex items-center rounded-md border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2" do %> <%= link_to new_totp_path, class: "inline-flex items-center rounded-md border border-transparent bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2" do %>
Enable 2FA Enable 2FA

View File

@@ -45,8 +45,13 @@
</div> </div>
<div class="mt-8"> <div class="mt-8">
<%= link_to "Done", profile_path, <% if @auto_signin_pending %>
class: "inline-flex justify-center rounded-md border border-transparent bg-blue-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2" %> <%= button_to "Continue to Sign In", complete_totp_setup_path, method: :post,
class: "inline-flex justify-center rounded-md border border-transparent bg-blue-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2" %>
<% else %>
<%= link_to "Done", profile_path,
class: "inline-flex justify-center rounded-md border border-transparent bg-blue-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2" %>
<% end %>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -67,6 +67,7 @@ Rails.application.routes.draw do
post '/totp/verify_password', to: 'totp#verify_password', as: :verify_password_totp post '/totp/verify_password', to: 'totp#verify_password', as: :verify_password_totp
get '/totp/regenerate_backup_codes', to: 'totp#regenerate_backup_codes', as: :regenerate_backup_codes_totp get '/totp/regenerate_backup_codes', to: 'totp#regenerate_backup_codes', as: :regenerate_backup_codes_totp
post '/totp/regenerate_backup_codes', to: 'totp#create_new_backup_codes', as: :create_new_backup_codes_totp post '/totp/regenerate_backup_codes', to: 'totp#create_new_backup_codes', as: :create_new_backup_codes_totp
post '/totp/complete_setup', to: 'totp#complete_setup', as: :complete_totp_setup
# WebAuthn (Passkeys) routes # WebAuthn (Passkeys) routes
get '/webauthn/new', to: 'webauthn#new', as: :new_webauthn get '/webauthn/new', to: 'webauthn#new', as: :new_webauthn

4
db/schema.rb generated
View File

@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.1].define(version: 2025_11_12_120314) do ActiveRecord::Schema[8.1].define(version: 2025_11_22_235519) do
create_table "application_groups", force: :cascade do |t| create_table "application_groups", force: :cascade do |t|
t.integer "application_id", null: false t.integer "application_id", null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
@@ -120,10 +120,12 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_12_120314) do
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
t.string "sid"
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.integer "user_id", null: false t.integer "user_id", null: false
t.index ["application_id"], name: "index_oidc_user_consents_on_application_id" t.index ["application_id"], name: "index_oidc_user_consents_on_application_id"
t.index ["granted_at"], name: "index_oidc_user_consents_on_granted_at" t.index ["granted_at"], name: "index_oidc_user_consents_on_granted_at"
t.index ["sid"], name: "index_oidc_user_consents_on_sid"
t.index ["user_id", "application_id"], name: "index_oidc_user_consents_on_user_id_and_application_id", unique: true t.index ["user_id", "application_id"], name: "index_oidc_user_consents_on_user_id_and_application_id", unique: true
t.index ["user_id"], name: "index_oidc_user_consents_on_user_id" t.index ["user_id"], name: "index_oidc_user_consents_on_user_id"
end end

View File

@@ -111,11 +111,95 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
refute_includes decoded, 'roles', "Should not have roles when not configured" refute_includes decoded, 'roles', "Should not have roles when not configured"
end end
test "should use RSA private key from environment" do test "should load RSA private key from environment with escaped newlines" do
ENV.stub(:fetch, "OIDC_PRIVATE_KEY") { "test-private-key" } # Simulate how direnv exports multi-line strings with \n escape sequences
key_with_escaped_newlines = "-----BEGIN PRIVATE KEY-----\\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDg3SfOR4UW6wV2\\nyKnE/pN5/tvUC7Fpol5/NjJQHm24F8+r6iipdLWJrJ3T2oEzaKw/RTGYPBQvjj6c\\nz3+tc7QkJLOESJCA0WqgawE1WdKSx5ug3sP0Y7woTPipt+afGaV58YvV/sqFD1ft\\nU+2w8olBHqWphUCd/LakfvqHbwrmF58IASk4IbGceqQ7f98d/8C8TrR6k3SKQAto\\n0OWo+xuyJg0RoSS8S220/qyIukXxtHS89NQj3dgJI06fGCSATCu8uVdsKwBDNw3F\\nBSQEX3xhk8E/JXXZfwRFR1K3zUIVQu8haQ3YA52b0jkzE2xI6TaHVbuGdifmGAmX\\nb5jsJ/eNAgMBAAECggEAAWJb3PwlOUANWTe630Pp1OegV5M1Tn2vi+oQPosPl1iX\\nFlbymrj80EfaRPWo84oKnq0t1/RnogrbDa3txgdpSVCsEWk9N2SyoJXy8+MZu6Er\\nQHka8qfBVfe4PbHyRj3FSeQKvZOEvvOgNJkYpIFeb5zkHa1ISyloEWvAxr0njJbQ\\n0F2jML4sUeduYulCWI9dSJdB+yp8BsmOPu8VzUFthW/GPPuw4a4ngzoGtPV6f/kp\\ncjPa2YT8L8z6zXE0IiDU8bc5abC++QBNLJrMy55tM+zfgGyShandITbcpuWptIqT\\n2yhMulifOMw0hdV0cYRqetkWkevz07nrwnh/1FGjYQKBgQD9C/Ls720tULS7SIdh\\nuDWnrtMG4sidSbxWJTOqPUNZ9a0vaHnx/FwlmvURyCojn5leLByY8ZNN08DxKBVq\\nwH6ZJe7KGOik5wMtFV1zrhyHNpa/H/RrLaYAZqCVlGYyOVqNa7mA7oOIeqtbv9x+\\nOaEz3BnoXHOJOwM10h20Nos6bQKBgQDjfQCSQXcrkV8hKf+F65N7Kcf7JMlZQAA3\\n9dvJxxek683bhYTLZhubY/tegfhxlZGkgP3eHKI1XyUYBCNBnztn3t1zD0ovcqRX\\no21m5TaJ0fGW4X3iyi1IWioMBPXffR8tXk5+LnWVZ26RgmaBG1rgOJEQ5bHYMtHj\\n+jo9JLV9oQKBgQDt1nNHm2qEcxzMAsmsYVWc+8bA7BsfKxTn6yN6WQaa4T0cGBi2\\nBzoc5l59jiN9RB8E0nU2k6ieN+9bOw+WPMNA8tRUA8F2bOMhVrl1ZyrNM9PQZBp5\\nOniSW+OHc+nyPtILpjq/Im9isdmp7NUzlrsbYT7AlVTKoTrNNWZR4gpOqQKBgQC3\\nIWwSUS00H4TrV7nh/zDsl0fr/0Mv2/vRENTsbJ+2HjXMIII0k3Bp+WTkQdDU70kd\\nmtHDul1CheOAn+QZ8auLBLhU5dwcsjdmbaOmj6MF88J+aexDY+psMlli76NXVIyC\\no0ahAZmaunciIE2QZYsUsbTmW2J93vtkgY3cpu6LwQKBgDigl7dCQl38Vt7FhxjJ\\naC6wmmM8YX6y5f5t3caVVBizVhx8xOXQla96zB0nW6ibTpaIKCSdORxMGAoajTZ9\\n8Ww2gOfZpZeojU2YHTV/KFd7wHGYE8QaBKqP6DuibLnP5farjuwPeGvbjZW6e9cy\\nntHkSPI0VmhqsUQEMgPnYuCg\\n-----END PRIVATE KEY-----"
private_key = @service.private_key # Clear any cached keys
assert_equal "test-private-key", private_key.to_s, "Should use private key from environment" OidcJwtService.instance_variable_set(:@private_key, nil)
# Stub ENV to return the test key
original_value = ENV["OIDC_PRIVATE_KEY"]
ENV["OIDC_PRIVATE_KEY"] = key_with_escaped_newlines
# The service should convert \n to actual newlines and load successfully
private_key = OidcJwtService.send(:private_key)
assert_not_nil private_key
assert_kind_of OpenSSL::PKey::RSA, private_key
assert_equal 2048, private_key.n.num_bits
ensure
# Restore original value and clear cached key
ENV["OIDC_PRIVATE_KEY"] = original_value
OidcJwtService.instance_variable_set(:@private_key, nil)
end
test "should handle key with actual newlines" do
# Generate a real test key
test_key = OpenSSL::PKey::RSA.new(2048)
key_pem = test_key.to_pem
# Clear any cached keys
OidcJwtService.instance_variable_set(:@private_key, nil)
# Stub ENV to return the test key
original_value = ENV["OIDC_PRIVATE_KEY"]
ENV["OIDC_PRIVATE_KEY"] = key_pem
private_key = OidcJwtService.send(:private_key)
assert_not_nil private_key
assert_kind_of OpenSSL::PKey::RSA, private_key
assert_equal 2048, private_key.n.num_bits
ensure
# Restore original value and clear cached key
ENV["OIDC_PRIVATE_KEY"] = original_value
OidcJwtService.instance_variable_set(:@private_key, nil)
end
test "should raise error for invalid key format" do
# Clear any cached keys
OidcJwtService.instance_variable_set(:@private_key, nil)
# Stub ENV to return invalid key
original_value = ENV["OIDC_PRIVATE_KEY"]
ENV["OIDC_PRIVATE_KEY"] = "invalid-key-data"
error = assert_raises RuntimeError do
OidcJwtService.send(:private_key)
end
assert_match /Invalid OIDC private key format/, error.message
ensure
# Restore original value and clear cached key
ENV["OIDC_PRIVATE_KEY"] = original_value
OidcJwtService.instance_variable_set(:@private_key, nil)
end
test "should raise error in production when no key configured" do
# Skip this test if we can't properly stub Rails.env
skip "Skipping production env test" unless Rails.env.development? || Rails.env.test?
# Clear any cached keys
OidcJwtService.instance_variable_set(:@private_key, nil)
# Temporarily remove the key
original_value = ENV["OIDC_PRIVATE_KEY"]
ENV.delete("OIDC_PRIVATE_KEY")
# Stub Rails.env to be production
Rails.env = ActiveSupport::StringInquirer.new("production")
error = assert_raises RuntimeError do
OidcJwtService.send(:private_key)
end
assert_match /OIDC private key not configured/, error.message
ensure
# Restore original environment and clear cached key
ENV["OIDC_PRIVATE_KEY"] = original_value if original_value
Rails.env = ActiveSupport::StringInquirer.new(ENV.fetch("RAILS_ENV", "test"))
OidcJwtService.instance_variable_set(:@private_key, nil)
end end
test "should generate RSA private key when missing" do test "should generate RSA private key when missing" do