18 Commits

Author SHA1 Message Date
Dan Milne
67d86e5835 Add Icons for apps 2025-11-25 19:11:22 +11:00
Dan Milne
d6029556d3 Add OIDC fixes, add prefered_username, add application-user claims 2025-11-25 16:29:40 +11:00
Dan Milne
7796c38c08 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
2025-11-23 11:16:06 +11:00
Dan Milne
e882a4d6d1 More complete oidc
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
2025-11-18 20:03:03 +11:00
Dan Milne
ab0085e9c9 More complete oidc 2025-11-18 20:02:45 +11:00
Dan Milne
1ee3302319 Improvements derived from rodauth-oauth 2025-11-12 22:17:55 +11:00
Dan Milne
67f28faaca Improve some front end views. More descriptive error condition reporting. Updates to CLINCH_HOST for better WEBAUTHN 2025-11-12 16:24:05 +11:00
Dan Milne
33ad956508 Add test 2025-11-12 15:50:04 +11:00
Dan Milne
11ec753c68 Bump up the forward auth token ttl, fix leaking of error data
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
2025-11-09 12:27:53 +11:00
Dan Milne
4df2eee4d9 Bug fix for domain names with empty string instead of null. Form errors and some security fixes
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
2025-11-09 12:22:41 +11:00
Dan Milne
d9f11abbbf Fixes for OIDC and HTML 2025-11-09 12:04:26 +11:00
Dan Milne
c92e69fa4a Add PCKE 2025-11-09 11:54:45 +11:00
Dan Milne
038801f34b Add pkce
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
2025-11-09 10:21:29 +11:00
Dan Milne
8e0b2c28eb CSP fixes 2025-11-08 20:01:07 +11:00
Dan Milne
f02665f690 Consolidate all the error messages - add some stimulus controller. 2025-11-07 16:58:28 +11:00
Dan Milne
631b2b53bb Fix CSP reporting endpoitn. Fix the SER for CSP
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
2025-11-04 23:22:15 +11:00
Dan Milne
6049429a41 Fix mobile view menu popout. Add an option SENTRY_DSN support, which uses rails event reporting
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
2025-11-04 23:16:28 +11:00
Dan Milne
2b15aa2c40 Add sentry, set csp reporting API 2025-11-04 22:58:32 +11:00
94 changed files with 4543 additions and 493 deletions

View File

@@ -68,3 +68,27 @@ CLINCH_ALLOW_LOCALHOST=true
# Optional: Set custom port
# PORT=9000
# Sentry Configuration (Optional)
# Enable error tracking and performance monitoring
# Leave SENTRY_DSN empty to disable Sentry completely
#
# Production: Get your DSN from https://sentry.io/settings/projects/
# SENTRY_DSN=https://your-dsn@sentry.io/project-id
#
# Optional: Override Sentry environment (defaults to Rails.env)
# SENTRY_ENVIRONMENT=production
#
# Optional: Override Sentry release (defaults to Git commit hash)
# SENTRY_RELEASE=v1.0.0
#
# Optional: Performance monitoring sample rate (0.0 to 1.0, default 0.2)
# Higher values provide more data but cost more
# SENTRY_TRACES_SAMPLE_RATE=0.2
#
# Optional: Continuous profiling sample rate (0.0 to 1.0, default 0.0)
# Very resource intensive, only enable for performance investigations
# SENTRY_PROFILES_SAMPLE_RATE=0.0
#
# Development: Enable Sentry in development for testing
# SENTRY_ENABLED_IN_DEVELOPMENT=true

View File

@@ -37,6 +37,10 @@ gem "webauthn", "~> 3.0"
# Public Suffix List for domain parsing
gem "public_suffix", "~> 6.0"
# Error tracking and performance monitoring (optional, configured via SENTRY_DSN)
gem "sentry-ruby", "~> 5.18"
gem "sentry-rails", "~> 5.18"
# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
gem "tzinfo-data", platforms: %i[ windows jruby ]

View File

@@ -333,6 +333,12 @@ GEM
rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 4.0)
websocket (~> 1.0)
sentry-rails (5.28.0)
railties (>= 5.0)
sentry-ruby (~> 5.28.0)
sentry-ruby (5.28.0)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
solid_cable (3.0.12)
actioncable (>= 7.2)
activejob (>= 7.2)
@@ -443,6 +449,8 @@ DEPENDENCIES
rqrcode (~> 3.1)
rubocop-rails-omakase
selenium-webdriver
sentry-rails (~> 5.18)
sentry-ruby (~> 5.18)
solid_cable
solid_cache
sqlite3 (>= 2.1)

View File

@@ -7,13 +7,14 @@
Clinch gives you one place to manage users and lets any web app authenticate against it without maintaining its own user table.
I've completed all planned features:
I've completed all planned features:
* Create Admin user on first login
* TOTP ( QR Code ) 2FA, with backup codes ( encrypted at rest )
* Passkey generation and login, with detection of Passkey during login
* Forward Auth configured and working
* OIDC provider with auto discovery working
* OIDC provider with auto discovery, refresh tokens, and token revocation
* Configurable token expiry per application (access, refresh, ID tokens)
* Invite users by email, assign to groups
* Self managed password reset by email
* Use Groups to assign Applications ( Family group can access Kavita, Developers can access Gitea )
@@ -75,22 +76,29 @@ Clinch sits in a sweet spot between two excellent open-source identity solutions
- **User statuses** - Active, disabled, or pending invitation
### Authentication Methods
- **WebAuthn/Passkeys** - Modern passwordless authentication using FIDO2 standards
- **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
- **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
#### OpenID Connect (OIDC)
Standard OAuth2/OIDC provider with endpoints:
- `/.well-known/openid-configuration` - Discovery endpoint
- `/authorize` - Authorization endpoint
- `/token` - Token endpoint
- `/authorize` - Authorization endpoint with PKCE support
- `/token` - Token endpoint (authorization_code and refresh_token grants)
- `/userinfo` - User info endpoint
- `/revoke` - Token revocation endpoint (RFC 7009)
Client apps (Audiobookshelf, Kavita, Grafana, etc.) redirect to Clinch for login and receive ID tokens and access tokens.
Features:
- **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
- **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.
#### Trusted-Header SSO (ForwardAuth)
Works with reverse proxies (Caddy, Traefik, Nginx):
@@ -114,10 +122,54 @@ Send emails for:
- **Session revocation** - Users and admins can revoke individual sessions
### Access Control
- **Group-based allowlists** - Restrict applications to specific user groups
- **Per-application access** - Each app defines which groups can access it
- **Automatic enforcement** - Access checks during OIDC authorization and ForwardAuth
- **Custom claims** - Add arbitrary claims to OIDC tokens via groups and users (perfect for app-specific roles)
#### Group-Based Application Access
Clinch uses groups to control which users can access which applications:
- **Create groups** - Organize users into logical groups (readers, editors, family, developers, etc.)
- **Assign groups to applications** - Each app defines which groups are allowed to access it
- Example: Kavita app allows the "readers" group → only users in the "readers" group can sign in
- If no groups are assigned to an app → all active users can access it
- **Automatic enforcement** - Access checks happen automatically:
- During OIDC authorization flow (before consent)
- During ForwardAuth verification (before proxying requests)
- Users not in allowed groups receive a "You do not have permission" error
#### Group Claims in Tokens
- **OIDC tokens include group membership** - ID tokens contain a `groups` claim with all user's groups
- **Custom claims** - Add arbitrary key-value pairs to tokens via groups and users
- Group claims apply to all members (e.g., `{"role": "viewer"}`)
- User claims override group claims for fine-grained control
- Perfect for app-specific authorization (e.g., admin vs. read-only roles)
#### Custom Claims Merging
Custom claims from groups and users are merged into OIDC ID tokens with the following precedence:
1. **Default OIDC claims** - Standard claims (`iss`, `sub`, `aud`, `exp`, `email`, etc.)
2. **Standard Clinch claims** - `groups` array (list of user's group names)
3. **Group custom claims** - Merged in order; later groups override earlier ones
4. **User custom claims** - Override all group claims
5. **Application-specific claims** - Highest priority; override all other claims
**Example:**
- Group "readers" has `{"role": "viewer", "max_items": 10}`
- Group "premium" has `{"role": "subscriber", "max_items": 100}`
- User (in both groups) has `{"max_items": 500}`
- **Result:** `{"role": "subscriber", "max_items": 500}` (user overrides max_items, premium overrides role)
#### Application-Specific Claims
Configure different claims for different applications on a per-user basis:
- **Per-app customization** - Each application can have unique claims for each user
- **Highest precedence** - App-specific claims override group and user global claims
- **Use case** - Different roles in different apps (e.g., admin in Kavita, user in Audiobookshelf)
- **Admin UI** - Configure via Admin → Users → Edit User → App-Specific Claim Overrides
**Example:**
- User Alice, global claims: `{"theme": "dark"}`
- Kavita app-specific: `{"kavita_groups": ["admin"]}`
- Audiobookshelf app-specific: `{"abs_groups": ["user"]}`
- **Result:** Kavita receives `{"theme": "dark", "kavita_groups": ["admin"]}`, Audiobookshelf receives `{"theme": "dark", "abs_groups": ["user"]}`
---
@@ -156,25 +208,29 @@ Send emails for:
- Redirect URIs (for OIDC apps)
- Domain pattern (for ForwardAuth apps, supports wildcards like *.example.com)
- Headers config (for ForwardAuth apps, JSON configuration for custom header names)
- Token TTL configuration (access_token_ttl, refresh_token_ttl, id_token_ttl)
- Metadata (flexible JSON storage)
- Active flag
- Many-to-many with Groups (allowlist)
**OIDC Tokens**
- Authorization codes (10-minute expiry, one-time use)
- Access tokens (1-hour expiry, revocable)
- Authorization codes (10-minute expiry, one-time use, PKCE support)
- Access tokens (opaque, BCrypt-hashed, configurable expiry 5min-24hr, revocable)
- Refresh tokens (opaque, BCrypt-hashed, configurable expiry 1-90 days, single-use with rotation)
- ID tokens (JWT, signed with RS256, configurable expiry 5min-24hr)
---
## Authentication Flows
### OIDC Authorization Flow
1. Client redirects user to `/authorize` with client_id, redirect_uri, scope
1. Client redirects user to `/authorize` with client_id, redirect_uri, scope (optional PKCE)
2. User authenticates with Clinch (username/password + optional TOTP)
3. Access control check: Is user in an allowed group for this app?
4. If allowed, generate authorization code and redirect to client
5. Client exchanges code for access token at `/token`
6. Client uses access token to fetch user info from `/userinfo`
5. Client exchanges code at `/token` for ID token, access token, and refresh token
6. Client uses access token to fetch fresh user info from `/userinfo`
7. When access token expires, client uses refresh token to get new tokens (no re-authentication)
### ForwardAuth Flow
1. User requests protected resource at `https://app.example.com/dashboard`
@@ -258,6 +314,10 @@ SMTP_ENABLE_STARTTLS=true
# Application
CLINCH_HOST=https://auth.example.com
CLINCH_FROM_EMAIL=noreply@example.com
# OIDC (optional - generates temporary key in development)
# Generate with: openssl genpkey -algorithm RSA -out private_key.pem -pkeyopt rsa_keygen_bits:2048
OIDC_PRIVATE_KEY=<contents-of-private-key.pem>
```
### First Run

1
VERSION Normal file
View File

@@ -0,0 +1 @@
2025.03

View File

@@ -99,8 +99,12 @@ module Admin
def application_params
params.require(:application).permit(
:name, :slug, :app_type, :active, :redirect_uris, :description, :metadata,
:domain_pattern, :landing_url, headers_config: {}
)
:domain_pattern, :landing_url, :access_token_ttl, :refresh_token_ttl, :id_token_ttl,
headers_config: {}
).tap do |whitelisted|
# Remove client_secret from params if present (shouldn't be updated via form)
whitelisted.delete(:client_secret)
end
end
end
end

View File

@@ -18,7 +18,25 @@ module Admin
end
def create
@group = Group.new(group_params)
create_params = group_params
# Parse custom_claims JSON if provided
if create_params[:custom_claims].present?
begin
create_params[:custom_claims] = JSON.parse(create_params[:custom_claims])
rescue JSON::ParserError
@group = Group.new
@group.errors.add(:custom_claims, "must be valid JSON")
@available_users = User.order(:email_address)
render :new, status: :unprocessable_entity
return
end
else
# If empty or blank, set to empty hash (NOT NULL constraint)
create_params[:custom_claims] = {}
end
@group = Group.new(create_params)
if @group.save
# Handle user assignments
@@ -39,7 +57,24 @@ module Admin
end
def update
if @group.update(group_params)
update_params = group_params
# Parse custom_claims JSON if provided
if update_params[:custom_claims].present?
begin
update_params[:custom_claims] = JSON.parse(update_params[:custom_claims])
rescue JSON::ParserError
@group.errors.add(:custom_claims, "must be valid JSON")
@available_users = User.order(:email_address)
render :edit, status: :unprocessable_entity
return
end
else
# If empty or blank, set to empty hash (NOT NULL constraint)
update_params[:custom_claims] = {}
end
if @group.update(update_params)
# Handle user assignments
if params[:group][:user_ids].present?
user_ids = params[:group][:user_ids].reject(&:blank?)
@@ -67,7 +102,7 @@ module Admin
end
def group_params
params.require(:group).permit(:name, :description, custom_claims: {})
params.require(:group).permit(:name, :description, :custom_claims)
end
end
end

View File

@@ -1,6 +1,6 @@
module Admin
class UsersController < BaseController
before_action :set_user, only: [:show, :edit, :update, :destroy, :resend_invitation]
before_action :set_user, only: [:show, :edit, :update, :destroy, :resend_invitation, :update_application_claims, :delete_application_claims]
def index
@users = User.order(created_at: :desc)
@@ -27,23 +27,34 @@ module Admin
end
def edit
@applications = Application.active.order(:name)
end
def update
# Prevent changing params for the current user's email and admin status
# to avoid locking themselves out
update_params = user_params.dup
if @user == Current.session.user
update_params.delete(:admin)
end
update_params = user_params
# Only update password if provided
update_params.delete(:password) if update_params[:password].blank?
# Parse custom_claims JSON if provided
if update_params[:custom_claims].present?
begin
update_params[:custom_claims] = JSON.parse(update_params[:custom_claims])
rescue JSON::ParserError
@user.errors.add(:custom_claims, "must be valid JSON")
@applications = Application.active.order(:name)
render :edit, status: :unprocessable_entity
return
end
else
# If empty or blank, set to empty hash (NOT NULL constraint)
update_params[:custom_claims] = {}
end
if @user.update(update_params)
redirect_to admin_users_path, notice: "User updated successfully."
else
@applications = Application.active.order(:name)
render :edit, status: :unprocessable_entity
end
end
@@ -69,6 +80,41 @@ module Admin
redirect_to admin_users_path, notice: "User deleted successfully."
end
# POST /admin/users/:id/update_application_claims
def update_application_claims
application = Application.find(params[:application_id])
claims_json = params[:custom_claims].presence || "{}"
begin
claims = JSON.parse(claims_json)
rescue JSON::ParserError
redirect_to edit_admin_user_path(@user), alert: "Invalid JSON format for claims."
return
end
app_claim = @user.application_user_claims.find_or_initialize_by(application: application)
app_claim.custom_claims = claims
if app_claim.save
redirect_to edit_admin_user_path(@user), notice: "App-specific claims updated for #{application.name}."
else
error_message = app_claim.errors.full_messages.join(", ")
redirect_to edit_admin_user_path(@user), alert: "Failed to update claims: #{error_message}"
end
end
# DELETE /admin/users/:id/delete_application_claims
def delete_application_claims
application = Application.find(params[:application_id])
app_claim = @user.application_user_claims.find_by(application: application)
if app_claim&.destroy
redirect_to edit_admin_user_path(@user), notice: "App-specific claims removed for #{application.name}."
else
redirect_to edit_admin_user_path(@user), alert: "No claims found to remove."
end
end
private
def set_user
@@ -76,7 +122,15 @@ module Admin
end
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, :username, :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

View File

@@ -8,19 +8,45 @@ module Api
def violation_report
# Parse CSP violation report
report_data = JSON.parse(request.body.read)
csp_report = report_data['csp-report']
# Validate that we have a proper CSP report
unless csp_report.is_a?(Hash) && csp_report.present?
Rails.logger.warn "Received empty or invalid CSP violation report"
head :bad_request
return
end
# Log the violation for security monitoring
Rails.logger.warn "CSP Violation Report:"
Rails.logger.warn " Blocked URI: #{report_data.dig('csp-report', 'blocked-uri')}"
Rails.logger.warn " Document URI: #{report_data.dig('csp-report', 'document-uri')}"
Rails.logger.warn " Referrer: #{report_data.dig('csp-report', 'referrer')}"
Rails.logger.warn " Violated Directive: #{report_data.dig('csp-report', 'violated-directive')}"
Rails.logger.warn " Original Policy: #{report_data.dig('csp-report', 'original-policy')}"
Rails.logger.warn " Blocked URI: #{csp_report['blocked-uri']}"
Rails.logger.warn " Document URI: #{csp_report['document-uri']}"
Rails.logger.warn " Referrer: #{csp_report['referrer']}"
Rails.logger.warn " Violated Directive: #{csp_report['violated-directive']}"
Rails.logger.warn " Original Policy: #{csp_report['original-policy']}"
Rails.logger.warn " User Agent: #{request.user_agent}"
Rails.logger.warn " IP Address: #{request.remote_ip}"
# In production, you might want to send this to a security monitoring service
# For now, we'll just log it and return a success response
# Emit structured event for CSP violation
# This allows multiple subscribers to process the event (Sentry, local logging, etc.)
Rails.event.notify("csp.violation", {
blocked_uri: csp_report['blocked-uri'],
document_uri: csp_report['document-uri'],
referrer: csp_report['referrer'],
violated_directive: csp_report['violated-directive'],
original_policy: csp_report['original-policy'],
disposition: csp_report['disposition'],
effective_directive: csp_report['effective-directive'],
source_file: csp_report['source-file'],
line_number: csp_report['line-number'],
column_number: csp_report['column-number'],
status_code: csp_report['status-code'],
user_agent: request.user_agent,
ip_address: request.remote_ip,
current_user_id: Current.user&.id,
timestamp: Time.current,
session_id: Current.session&.id
})
head :no_content
rescue JSON::ParserError => e

View File

@@ -221,7 +221,9 @@ module Api
# Try CLINCH_HOST environment variable first
if ENV['CLINCH_HOST'].present?
"https://#{ENV['CLINCH_HOST']}"
host = ENV['CLINCH_HOST']
# Ensure URL has https:// protocol
host.match?(/^https?:\/\//) ? host : "https://#{host}"
else
# Fallback to the request host
request_host = request.host || request.headers['X-Forwarded-Host']

View File

@@ -5,4 +5,7 @@ class ApplicationController < ActionController::Base
# Changes to the importmap will invalidate the etag for HTML responses
stale_when_importmap_changes
# CSRF protection
protect_from_forgery with: :exception
end

View File

@@ -120,11 +120,11 @@ module Authentication
# Generate a secure random token
token = SecureRandom.urlsafe_base64(32)
# Store it with an expiry of 30 seconds
# Store it with an expiry of 60 seconds
Rails.cache.write(
"forward_auth_token:#{token}",
session_obj.id,
expires_in: 30.seconds
expires_in: 60.seconds
)
# Set the token as a query parameter on the redirect URL
@@ -134,13 +134,16 @@ module Authentication
original_url = controller_session[:return_to_after_authenticating]
uri = URI.parse(original_url)
# Add token as query parameter
query_params = URI.decode_www_form(uri.query || "").to_h
query_params['fa_token'] = token
uri.query = URI.encode_www_form(query_params)
# Skip adding fa_token for OAuth URLs (OAuth flow should not have forward auth tokens)
unless uri.path&.start_with?("/oauth/")
# Add token as query parameter
query_params = URI.decode_www_form(uri.query || "").to_h
query_params['fa_token'] = token
uri.query = URI.encode_www_form(query_params)
# Update the session with the tokenized URL
controller_session[:return_to_after_authenticating] = uri.to_s
# Update the session with the tokenized URL
controller_session[:return_to_after_authenticating] = uri.to_s
end
end
end
end

View File

@@ -1,7 +1,7 @@
class OidcController < ApplicationController
# Discovery and JWKS endpoints are public
allow_unauthenticated_access only: [:discovery, :jwks, :token, :userinfo, :logout]
skip_before_action :verify_authenticity_token, only: [:token, :logout]
allow_unauthenticated_access only: [:discovery, :jwks, :token, :revoke, :userinfo, :logout]
skip_before_action :verify_authenticity_token, only: [:token, :revoke, :logout]
# GET /.well-known/openid-configuration
def discovery
@@ -11,15 +11,19 @@ class OidcController < ApplicationController
issuer: base_url,
authorization_endpoint: "#{base_url}/oauth/authorize",
token_endpoint: "#{base_url}/oauth/token",
revocation_endpoint: "#{base_url}/oauth/revoke",
userinfo_endpoint: "#{base_url}/oauth/userinfo",
jwks_uri: "#{base_url}/.well-known/jwks.json",
end_session_endpoint: "#{base_url}/logout",
response_types_supported: ["code"],
response_modes_supported: ["query"],
grant_types_supported: ["authorization_code", "refresh_token"],
subject_types_supported: ["public"],
id_token_signing_alg_values_supported: ["RS256"],
scopes_supported: ["openid", "profile", "email", "groups"],
token_endpoint_auth_methods_supported: ["client_secret_post", "client_secret_basic"],
claims_supported: ["sub", "email", "email_verified", "name", "preferred_username", "groups", "admin"]
claims_supported: ["sub", "email", "email_verified", "name", "preferred_username", "groups", "admin"],
code_challenge_methods_supported: ["plain", "S256"]
}
render json: config
@@ -32,30 +36,71 @@ class OidcController < ApplicationController
# GET /oauth/authorize
def authorize
# Get parameters
# Get parameters (ignore forward auth tokens and other unknown params)
client_id = params[:client_id]
redirect_uri = params[:redirect_uri]
state = params[:state]
nonce = params[:nonce]
scope = params[:scope] || "openid"
response_type = params[:response_type]
code_challenge = params[:code_challenge]
code_challenge_method = params[:code_challenge_method] || "plain"
# Validate required parameters
unless client_id.present? && redirect_uri.present? && response_type == "code"
render plain: "Invalid request: missing required parameters", status: :bad_request
error_details = []
error_details << "client_id is required" unless client_id.present?
error_details << "redirect_uri is required" unless redirect_uri.present?
error_details << "response_type must be 'code'" unless response_type == "code"
render plain: "Invalid request: #{error_details.join(', ')}", status: :bad_request
return
end
# Validate PKCE parameters if present
if code_challenge.present?
unless %w[plain S256].include?(code_challenge_method)
render plain: "Invalid code_challenge_method: must be 'plain' or 'S256'", status: :bad_request
return
end
# Validate code challenge format (base64url-encoded, 43-128 characters)
unless code_challenge.match?(/\A[A-Za-z0-9\-_]{43,128}\z/)
render plain: "Invalid code_challenge format: must be 43-128 characters of base64url encoding", status: :bad_request
return
end
end
# Find the application
@application = Application.find_by(client_id: client_id, app_type: "oidc")
unless @application
render plain: "Invalid client_id", status: :bad_request
# Log all OIDC applications for debugging
all_oidc_apps = Application.where(app_type: "oidc")
Rails.logger.error "OAuth: Invalid request - application not found for client_id: #{client_id}"
Rails.logger.error "OAuth: Available OIDC applications: #{all_oidc_apps.pluck(:id, :client_id, :name)}"
error_msg = if Rails.env.development?
"Invalid request: Application not found for client_id '#{client_id}'. Available OIDC applications: #{all_oidc_apps.pluck(:name, :client_id).map { |name, id| "#{name} (#{id})" }.join(', ')}"
else
"Invalid request: Application not found"
end
render plain: error_msg, status: :bad_request
return
end
# Validate redirect URI
unless @application.parsed_redirect_uris.include?(redirect_uri)
render plain: "Invalid redirect_uri", status: :bad_request
Rails.logger.error "OAuth: Invalid request - redirect URI mismatch. Expected: #{@application.parsed_redirect_uris}, Got: #{redirect_uri}"
# For development, show detailed error
error_msg = if Rails.env.development?
"Invalid request: Redirect URI mismatch. Application is configured for: #{@application.parsed_redirect_uris.join(', ')}, but received: #{redirect_uri}"
else
"Invalid request: Redirect URI not registered for this application"
end
render plain: error_msg, status: :bad_request
return
end
@@ -67,7 +112,9 @@ class OidcController < ApplicationController
redirect_uri: redirect_uri,
state: state,
nonce: nonce,
scope: scope
scope: scope,
code_challenge: code_challenge,
code_challenge_method: code_challenge_method
}
redirect_to signin_path, alert: "Please sign in to continue"
return
@@ -96,6 +143,8 @@ class OidcController < ApplicationController
redirect_uri: redirect_uri,
scope: scope,
nonce: nonce,
code_challenge: code_challenge,
code_challenge_method: code_challenge_method,
expires_at: 10.minutes.from_now
)
@@ -112,12 +161,34 @@ class OidcController < ApplicationController
redirect_uri: redirect_uri,
state: state,
nonce: nonce,
scope: scope
scope: scope,
code_challenge: code_challenge,
code_challenge_method: code_challenge_method
}
# Render consent page
# Render consent page with dynamic CSP for OAuth redirect
@redirect_uri = redirect_uri
@scopes = requested_scopes
# Add the redirect URI to CSP form-action for this specific request
# This allows the OAuth redirect to work while maintaining security
# CSP must allow the OAuth client's redirect_uri as a form submission target
if redirect_uri.present?
begin
redirect_host = URI.parse(redirect_uri).host
csp = request.content_security_policy
if csp && redirect_host
# Only modify if form_action is available and mutable
if csp.respond_to?(:form_action) && csp.form_action.respond_to?(:<<)
csp.form_action << "https://#{redirect_host}"
end
end
rescue => e
# Log CSP modification errors but don't fail the request
Rails.logger.warn "OAuth: Could not modify CSP for redirect_uri #{redirect_uri}: #{e.message}"
end
end
render :consent
end
@@ -165,6 +236,8 @@ class OidcController < ApplicationController
redirect_uri: oauth_params['redirect_uri'],
scope: oauth_params['scope'],
nonce: oauth_params['nonce'],
code_challenge: oauth_params['code_challenge'],
code_challenge_method: oauth_params['code_challenge_method'],
expires_at: 10.minutes.from_now
)
@@ -182,10 +255,17 @@ class OidcController < ApplicationController
def token
grant_type = params[:grant_type]
unless grant_type == "authorization_code"
case grant_type
when "authorization_code"
handle_authorization_code_grant
when "refresh_token"
handle_refresh_token_grant
else
render json: { error: "unsupported_grant_type" }, status: :bad_request
return
end
end
def handle_authorization_code_grant
# Get client credentials from Authorization header or params
client_id, client_secret = extract_client_credentials
@@ -205,11 +285,11 @@ class OidcController < ApplicationController
# Get the authorization code
code = params[:code]
redirect_uri = params[:redirect_uri]
code_verifier = params[:code_verifier]
auth_code = OidcAuthorizationCode.find_by(
application: application,
code: code,
used: false
code: code
)
unless auth_code
@@ -217,45 +297,198 @@ class OidcController < ApplicationController
return
end
# Check if code is expired
if auth_code.expires_at < Time.current
render json: { error: "invalid_grant", error_description: "Authorization code expired" }, status: :bad_request
# Use a transaction with pessimistic locking to prevent code reuse
begin
OidcAuthorizationCode.transaction do
# Lock the record to prevent concurrent access
auth_code.lock!
# Check if code has already been used (CRITICAL: check AFTER locking)
if auth_code.used?
# Per OAuth 2.0 spec, if an auth code is reused, revoke all tokens issued from it
Rails.logger.warn "OAuth Security: Authorization code reuse detected for code #{auth_code.id}"
# Revoke all access tokens issued from this authorization code
OidcAccessToken.where(
application: application,
user: auth_code.user,
created_at: auth_code.created_at..Time.current
).update_all(expires_at: Time.current)
render json: {
error: "invalid_grant",
error_description: "Authorization code has already been used"
}, status: :bad_request
return
end
# Check if code is expired
if auth_code.expires_at < Time.current
render json: { error: "invalid_grant", error_description: "Authorization code expired" }, status: :bad_request
return
end
# Validate redirect URI matches
unless auth_code.redirect_uri == redirect_uri
render json: { error: "invalid_grant", error_description: "Redirect URI mismatch" }, status: :bad_request
return
end
# Validate PKCE if code challenge is present
pkce_result = validate_pkce(auth_code, code_verifier)
unless pkce_result[:valid]
render json: {
error: pkce_result[:error],
error_description: pkce_result[:error_description]
}, status: pkce_result[:status]
return
end
# Mark code as used BEFORE generating tokens (prevents reuse)
auth_code.update!(used: true)
# Get the user
user = auth_code.user
# Generate access token record (opaque token with BCrypt hashing)
access_token_record = OidcAccessToken.create!(
application: application,
user: user,
scope: auth_code.scope
)
# Generate refresh token (opaque, with hashing)
refresh_token_record = OidcRefreshToken.create!(
application: application,
user: user,
oidc_access_token: access_token_record,
scope: auth_code.scope
)
# Find user consent for this application
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
render json: {
access_token: access_token_record.plaintext_token, # Opaque token
token_type: "Bearer",
expires_in: application.access_token_ttl || 3600,
id_token: id_token, # JWT
refresh_token: refresh_token_record.token, # Opaque token
scope: auth_code.scope
}
end
rescue ActiveRecord::RecordNotFound
render json: { error: "invalid_grant" }, status: :bad_request
end
end
def handle_refresh_token_grant
# Get client credentials from Authorization header or params
client_id, client_secret = extract_client_credentials
unless client_id && client_secret
render json: { error: "invalid_client" }, status: :unauthorized
return
end
# Validate redirect URI matches
unless auth_code.redirect_uri == redirect_uri
render json: { error: "invalid_grant", error_description: "Redirect URI mismatch" }, status: :bad_request
# Find and validate the application
application = Application.find_by(client_id: client_id)
unless application && application.authenticate_client_secret(client_secret)
render json: { error: "invalid_client" }, status: :unauthorized
return
end
# Mark code as used
auth_code.update!(used: true)
# Get the refresh token
refresh_token = params[:refresh_token]
unless refresh_token.present?
render json: { error: "invalid_request", error_description: "refresh_token is required" }, status: :bad_request
return
end
# Find the refresh token record
# Note: This is inefficient with BCrypt hashing, but necessary for security
# In production, consider adding a token prefix for faster lookup
refresh_token_record = OidcRefreshToken.where(application: application).find do |rt|
rt.token_matches?(refresh_token)
end
unless refresh_token_record
render json: { error: "invalid_grant", error_description: "Invalid refresh token" }, status: :bad_request
return
end
# Check if refresh token is expired
if refresh_token_record.expired?
render json: { error: "invalid_grant", error_description: "Refresh token expired" }, status: :bad_request
return
end
# Check if refresh token is revoked
if refresh_token_record.revoked?
# If a revoked refresh token is used, it's a security issue
# Revoke all tokens in the family (token rotation attack detection)
Rails.logger.warn "OAuth Security: Revoked refresh token reuse detected for token family #{refresh_token_record.token_family_id}"
refresh_token_record.revoke_family!
render json: { error: "invalid_grant", error_description: "Refresh token has been revoked" }, status: :bad_request
return
end
# Get the user
user = auth_code.user
user = refresh_token_record.user
# Generate access token
access_token = SecureRandom.urlsafe_base64(32)
OidcAccessToken.create!(
# Revoke the old refresh token (token rotation)
refresh_token_record.revoke!
# Generate new access token record (opaque token with BCrypt hashing)
new_access_token = OidcAccessToken.create!(
application: application,
user: user,
token: access_token,
scope: auth_code.scope,
expires_at: 1.hour.from_now
scope: refresh_token_record.scope
)
# Generate ID token
id_token = OidcJwtService.generate_id_token(user, application, nonce: auth_code.nonce)
# Generate new refresh token (token rotation)
new_refresh_token = OidcRefreshToken.create!(
application: application,
user: user,
oidc_access_token: new_access_token,
scope: refresh_token_record.scope,
token_family_id: refresh_token_record.token_family_id # Keep same family for rotation tracking
)
# Return tokens
# Find user consent for this 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
render json: {
access_token: access_token,
access_token: new_access_token.plaintext_token, # Opaque token
token_type: "Bearer",
expires_in: 3600,
id_token: id_token,
scope: auth_code.scope
expires_in: application.access_token_ttl || 3600,
id_token: id_token, # JWT
refresh_token: new_refresh_token.token, # Opaque token
scope: refresh_token_record.scope
}
rescue ActiveRecord::RecordNotFound
render json: { error: "invalid_grant" }, status: :bad_request
end
# GET /oauth/userinfo
@@ -267,27 +500,29 @@ class OidcController < ApplicationController
return
end
access_token = auth_header.sub("Bearer ", "")
token = auth_header.sub("Bearer ", "")
# Find the access token
token_record = OidcAccessToken.find_by(token: access_token)
unless token_record
# Find and validate access token (opaque token with BCrypt hashing)
access_token = OidcAccessToken.find_by_token(token)
unless access_token&.active?
head :unauthorized
return
end
# Check if token is expired
if token_record.expires_at < Time.current
# Get the user (with fresh data from database)
user = access_token.user
unless user
head :unauthorized
return
end
# Get the user
user = token_record.user
# 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
claims = {
sub: user.id.to_s,
sub: subject,
email: user.email_address,
email_verified: true,
preferred_username: user.email_address,
@@ -299,9 +534,6 @@ class OidcController < ApplicationController
claims[:groups] = user.groups.pluck(:name)
end
# Add admin claim if user is admin
claims[:admin] = true if user.admin?
# Merge custom claims from groups
user.groups.each do |group|
claims.merge!(group.parsed_custom_claims)
@@ -310,9 +542,80 @@ class OidcController < ApplicationController
# Merge custom claims from user (overrides group claims)
claims.merge!(user.parsed_custom_claims)
# Merge app-specific custom claims (highest priority)
application = access_token.application
claims.merge!(application.custom_claims_for_user(user))
render json: claims
end
# POST /oauth/revoke
# RFC 7009 - Token Revocation
def revoke
# Get client credentials
client_id, client_secret = extract_client_credentials
unless client_id && client_secret
# RFC 7009 says we should return 200 OK even for invalid client
# But log the attempt for security monitoring
Rails.logger.warn "OAuth: Token revocation attempted with invalid client credentials"
head :ok
return
end
# Find and validate the application
application = Application.find_by(client_id: client_id)
unless application && application.authenticate_client_secret(client_secret)
Rails.logger.warn "OAuth: Token revocation attempted for invalid application: #{client_id}"
head :ok
return
end
# Get the token to revoke
token = params[:token]
token_type_hint = params[:token_type_hint] # Optional hint: "access_token" or "refresh_token"
unless token.present?
# RFC 7009: Missing token parameter is an error
render json: { error: "invalid_request", error_description: "token parameter is required" }, status: :bad_request
return
end
# Try to find and revoke the token
# Check token type hint first for efficiency, otherwise try both
revoked = false
if token_type_hint == "refresh_token" || token_type_hint.nil?
# Try to find as refresh token
refresh_token_record = OidcRefreshToken.where(application: application).find do |rt|
rt.token_matches?(token)
end
if refresh_token_record
refresh_token_record.revoke!
Rails.logger.info "OAuth: Refresh token revoked for application #{application.name}"
revoked = true
end
end
if !revoked && (token_type_hint == "access_token" || token_type_hint.nil?)
# Try to find as access token
access_token_record = OidcAccessToken.where(application: application).find do |at|
at.token_matches?(token)
end
if access_token_record
access_token_record.revoke!
Rails.logger.info "OAuth: Access token revoked for application #{application.name}"
revoked = true
end
end
# RFC 7009: Always return 200 OK, even if token was not found
# This prevents token scanning attacks
head :ok
end
# GET /logout
def logout
# OpenID Connect RP-Initiated Logout
@@ -329,11 +632,19 @@ class OidcController < ApplicationController
reset_session
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?
redirect_uri = post_logout_redirect_uri
redirect_uri += "?state=#{state}" if state.present?
redirect_to redirect_uri, allow_other_host: true
validated_uri = validate_logout_redirect_uri(post_logout_redirect_uri)
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
# Default redirect to home page
redirect_to root_path
@@ -342,6 +653,58 @@ class OidcController < ApplicationController
private
def validate_pkce(auth_code, code_verifier)
# Skip PKCE validation if no code challenge was stored (legacy clients)
return { valid: true } unless auth_code.code_challenge.present?
# PKCE is required but no verifier provided
unless code_verifier.present?
return {
valid: false,
error: "invalid_request",
error_description: "code_verifier is required when code_challenge was provided",
status: :bad_request
}
end
# Validate code verifier format (base64url-encoded, 43-128 characters)
unless code_verifier.match?(/\A[A-Za-z0-9\-_]{43,128}\z/)
return {
valid: false,
error: "invalid_request",
error_description: "Invalid code_verifier format. Must be 43-128 characters of base64url encoding",
status: :bad_request
}
end
# Recreate code challenge based on method
expected_challenge = case auth_code.code_challenge_method
when "plain"
code_verifier
when "S256"
Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false)
else
return {
valid: false,
error: "server_error",
error_description: "Unsupported code challenge method",
status: :internal_server_error
}
end
# Validate the code challenge
unless auth_code.code_challenge == expected_challenge
return {
valid: false,
error: "invalid_grant",
error_description: "Invalid code verifier",
status: :bad_request
}
end
{ valid: true }
end
def extract_client_credentials
# Try Authorization header first (Basic auth)
if request.headers["Authorization"]&.start_with?("Basic ")
@@ -353,4 +716,54 @@ class OidcController < ApplicationController
[params[:client_id], params[:client_secret]]
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

View File

@@ -11,7 +11,7 @@ class PasswordsController < ApplicationController
PasswordsMailer.reset(user).deliver_later
end
redirect_to new_session_path, notice: "Password reset instructions sent (if user with that email address exists)."
redirect_to signin_path, notice: "Password reset instructions sent (if user with that email address exists)."
end
def edit
@@ -20,7 +20,7 @@ class PasswordsController < ApplicationController
def update
if @user.update(params.permit(:password, :password_confirmation))
@user.sessions.destroy_all
redirect_to new_session_path, notice: "Password has been reset."
redirect_to signin_path, notice: "Password has been reset."
else
redirect_to edit_password_path(params[:token]), alert: "Passwords did not match."
end
@@ -29,6 +29,7 @@ class PasswordsController < ApplicationController
private
def set_user_by_token
@user = User.find_by_token_for(:password_reset, params[:token])
redirect_to new_password_path, alert: "Password reset link is invalid or has expired." if @user.nil?
rescue ActiveSupport::MessageVerifier::InvalidSignature
redirect_to new_password_path, alert: "Password reset link is invalid or has expired."
end

View File

@@ -6,7 +6,18 @@ class SessionsController < ApplicationController
def new
# 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
def create
@@ -33,8 +44,22 @@ class SessionsController < ApplicationController
return
end
# Check if TOTP is required
if user.totp_enabled?
# Check if TOTP is required or 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
session[:pending_totp_user_id] = user.id
# Preserve the redirect URL through TOTP verification (after validation)
@@ -275,12 +300,12 @@ class SessionsController < ApplicationController
redirect_domain = uri.host.downcase
return nil unless redirect_domain.present?
# Check against our ForwardAuthRules
matching_rule = ForwardAuthRule.active.find do |rule|
rule.matches_domain?(redirect_domain)
# Check against our forward auth applications
matching_app = Application.forward_auth.active.find do |app|
app.matches_domain?(redirect_domain)
end
matching_rule ? url : nil
matching_app ? url : nil
rescue URI::InvalidURIError
nil

View File

@@ -5,6 +5,9 @@ class TotpController < ApplicationController
# GET /totp/new - Show QR code to set up TOTP
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
@totp_secret = ROTP::Base32.random
@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
session[:temp_backup_codes] = plain_codes
# Redirect to backup codes page with success message
redirect_to backup_codes_totp_path, notice: "Two-factor authentication has been enabled successfully! Save these backup codes now."
# Check if this was a required setup from login
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
redirect_to new_totp_path, alert: "Invalid verification code. Please try again."
end
@@ -43,6 +54,12 @@ class TotpController < ApplicationController
if session[:temp_backup_codes].present?
@backup_codes = session[:temp_backup_codes]
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
# This will be shown after password verification for existing users
# 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!"
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)
def destroy
unless @user.authenticate(params[:password])
@@ -88,6 +117,12 @@ class TotpController < ApplicationController
return
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!
redirect_to profile_path, notice: "Two-factor authentication has been disabled."
end
@@ -99,7 +134,8 @@ class TotpController < ApplicationController
end
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."
end
end

View File

@@ -19,4 +19,14 @@ module ApplicationHelper
:smtp
end
end
def border_class_for(type)
case type.to_s
when 'notice' then 'border-green-200'
when 'alert', 'error' then 'border-red-200'
when 'warning' then 'border-yellow-200'
when 'info' then 'border-blue-200'
else 'border-gray-200'
end
end
end

View File

@@ -0,0 +1,69 @@
module ClaimsHelper
include ClaimsMerger
# Preview final merged claims for a user accessing an application
def preview_user_claims(user, application)
claims = {
# Standard OIDC claims
email: user.email_address,
email_verified: true,
preferred_username: user.username.presence || user.email_address,
name: user.name.presence || user.email_address
}
# Add groups
if user.groups.any?
claims[:groups] = user.groups.pluck(:name)
end
# Merge group custom claims (arrays are combined, not overwritten)
user.groups.each do |group|
claims = deep_merge_claims(claims, group.parsed_custom_claims)
end
# Merge user custom claims (arrays are combined, other values override)
claims = deep_merge_claims(claims, user.parsed_custom_claims)
# Merge app-specific claims (arrays are combined)
claims = deep_merge_claims(claims, application.custom_claims_for_user(user))
claims
end
# Get claim sources breakdown for display
def claim_sources(user, application)
sources = []
# Group claims
user.groups.each do |group|
if group.parsed_custom_claims.any?
sources << {
type: :group,
name: group.name,
claims: group.parsed_custom_claims
}
end
end
# User claims
if user.parsed_custom_claims.any?
sources << {
type: :user,
name: "User Override",
claims: user.parsed_custom_claims
}
end
# App-specific claims
app_claims = application.custom_claims_for_user(user)
if app_claims.any?
sources << {
type: :application,
name: "App-Specific (#{application.name})",
claims: app_claims
}
end
sources
end
end

View File

@@ -0,0 +1,96 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["input", "dropzone", "preview", "previewImage", "filename", "filesize"]
connect() {
// Prevent default drag behaviors on the whole document
["dragenter", "dragover", "dragleave", "drop"].forEach(eventName => {
document.body.addEventListener(eventName, this.preventDefaults, false)
})
}
disconnect() {
["dragenter", "dragover", "dragleave", "drop"].forEach(eventName => {
document.body.removeEventListener(eventName, this.preventDefaults, false)
})
}
preventDefaults(e) {
e.preventDefault()
e.stopPropagation()
}
dragover(e) {
e.preventDefault()
e.stopPropagation()
this.dropzoneTarget.classList.add("border-blue-500", "bg-blue-50")
}
dragleave(e) {
e.preventDefault()
e.stopPropagation()
this.dropzoneTarget.classList.remove("border-blue-500", "bg-blue-50")
}
drop(e) {
e.preventDefault()
e.stopPropagation()
this.dropzoneTarget.classList.remove("border-blue-500", "bg-blue-50")
const files = e.dataTransfer.files
if (files.length > 0) {
// Set the file to the input element
this.inputTarget.files = files
this.handleFiles()
}
}
handleFiles() {
const file = this.inputTarget.files[0]
if (!file) return
// Validate file type
const validTypes = ["image/png", "image/jpg", "image/jpeg", "image/gif", "image/svg+xml"]
if (!validTypes.includes(file.type)) {
alert("Please upload a PNG, JPG, GIF, or SVG image")
this.clear()
return
}
// Validate file size (2MB)
if (file.size > 2 * 1024 * 1024) {
alert("File size must be less than 2MB")
this.clear()
return
}
// Show preview
this.filenameTarget.textContent = file.name
this.filesizeTarget.textContent = this.formatFileSize(file.size)
// Create preview image
const reader = new FileReader()
reader.onload = (e) => {
this.previewImageTarget.src = e.target.result
this.previewTarget.classList.remove("hidden")
}
reader.readAsDataURL(file)
}
clear(e) {
if (e) {
e.preventDefault()
}
this.inputTarget.value = ""
this.previewTarget.classList.add("hidden")
}
formatFileSize(bytes) {
if (bytes === 0) return "0 Bytes"
const k = 1024
const sizes = ["Bytes", "KB", "MB"]
const i = Math.floor(Math.log(bytes) / Math.log(k))
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + " " + sizes[i]
}
}

View File

@@ -0,0 +1,85 @@
import { Controller } from "@hotwired/stimulus"
/**
* Manages flash message display, auto-dismissal, and user interactions
* Supports different flash types with appropriate styling and behavior
*/
export default class extends Controller {
static values = {
autoDismiss: String, // "false" or delay in milliseconds
type: String
}
connect() {
// Auto-dismiss if enabled
if (this.autoDismissValue && this.autoDismissValue !== "false") {
this.scheduleAutoDismiss()
}
// Smooth entrance animation
this.element.classList.add('transition-all', 'duration-300', 'ease-out')
this.element.style.opacity = '0'
this.element.style.transform = 'translateY(-10px)'
// Animate in
requestAnimationFrame(() => {
this.element.style.opacity = '1'
this.element.style.transform = 'translateY(0)'
})
}
/**
* Dismisses the flash message with smooth animation
*/
dismiss() {
// Add dismiss animation
this.element.classList.add('transition-all', 'duration-300', 'ease-in')
this.element.style.opacity = '0'
this.element.style.transform = 'translateY(-10px)'
// Remove from DOM after animation
setTimeout(() => {
this.element.remove()
}, 300)
}
/**
* Schedules auto-dismissal based on the configured delay
*/
scheduleAutoDismiss() {
const delay = parseInt(this.autoDismissValue)
if (delay > 0) {
setTimeout(() => {
this.dismiss()
}, delay)
}
}
/**
* Pause auto-dismissal on hover (for user reading)
*/
mouseEnter() {
if (this.autoDismissTimer) {
clearTimeout(this.autoDismissTimer)
this.autoDismissTimer = null
}
}
/**
* Resume auto-dismissal when hover ends
*/
mouseLeave() {
if (this.autoDismissValue && this.autoDismissValue !== "false") {
this.scheduleAutoDismiss()
}
}
/**
* Handle keyboard interactions
*/
keydown(event) {
if (event.key === 'Escape' || event.key === 'Enter') {
this.dismiss()
}
}
}

View File

@@ -0,0 +1,89 @@
import { Controller } from "@hotwired/stimulus"
/**
* Manages form error display and dismissal
* Provides consistent error handling across all forms
*/
export default class extends Controller {
static targets = ["container"]
/**
* Dismisses the error container with a smooth fade-out animation
*/
dismiss() {
if (!this.hasContainerTarget) return
// Add transition classes
this.containerTarget.classList.add('transition-all', 'duration-300', 'opacity-0', 'transform', 'scale-95')
// Remove from DOM after animation completes
setTimeout(() => {
this.containerTarget.remove()
}, 300)
}
/**
* Shows server-side validation errors after form submission
* Auto-focuses the first error field for better accessibility
*/
connect() {
// Auto-focus first error field if errors exist
this.focusFirstErrorField()
// Scroll to errors if needed
this.scrollToErrors()
}
/**
* Focuses the first field with validation errors
*/
focusFirstErrorField() {
if (!this.hasContainerTarget) return
// Find first form field with errors (look for error classes or aria-invalid)
const form = this.element.closest('form')
if (!form) return
const errorField = form.querySelector('[aria-invalid="true"], .border-red-500, .ring-red-500')
if (errorField) {
setTimeout(() => {
errorField.focus()
errorField.scrollIntoView({ behavior: 'smooth', block: 'center' })
}, 100)
}
}
/**
* Scrolls error container into view if it's not visible
*/
scrollToErrors() {
if (!this.hasContainerTarget) return
const rect = this.containerTarget.getBoundingClientRect()
const isInViewport = rect.top >= 0 && rect.left >= 0 &&
rect.bottom <= window.innerHeight &&
rect.right <= window.innerWidth
if (!isInViewport) {
setTimeout(() => {
this.containerTarget.scrollIntoView({
behavior: 'smooth',
block: 'start',
inline: 'nearest'
})
}, 100)
}
}
/**
* Auto-dismisses success messages after a delay
* Can be called from other controllers
*/
autoDismiss(delay = 5000) {
if (!this.hasContainerTarget) return
setTimeout(() => {
this.dismiss()
}, delay)
}
}

View File

@@ -1,7 +0,0 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
connect() {
this.element.textContent = "Hello World!"
}
}

View File

@@ -0,0 +1,81 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["textarea", "status"]
static classes = ["valid", "invalid", "validStatus", "invalidStatus"]
connect() {
this.validate()
}
validate() {
const value = this.textareaTarget.value.trim()
if (!value) {
this.clearStatus()
return true
}
try {
JSON.parse(value)
this.showValid()
return true
} catch (error) {
this.showInvalid(error.message)
return false
}
}
format() {
const value = this.textareaTarget.value.trim()
if (!value) return
try {
const parsed = JSON.parse(value)
const formatted = JSON.stringify(parsed, null, 2)
this.textareaTarget.value = formatted
this.showValid()
} catch (error) {
this.showInvalid(error.message)
}
}
clearStatus() {
this.textareaTarget.classList.remove(...this.invalidClasses)
this.textareaTarget.classList.remove(...this.validClasses)
if (this.hasStatusTarget) {
this.statusTarget.textContent = ""
this.statusTarget.classList.remove(...this.validStatusClasses, ...this.invalidStatusClasses)
}
}
showValid() {
this.textareaTarget.classList.remove(...this.invalidClasses)
this.textareaTarget.classList.add(...this.validClasses)
if (this.hasStatusTarget) {
this.statusTarget.textContent = "✓ Valid JSON"
this.statusTarget.classList.remove(...this.invalidStatusClasses)
this.statusTarget.classList.add(...this.validStatusClasses)
}
}
showInvalid(errorMessage) {
this.textareaTarget.classList.remove(...this.validClasses)
this.textareaTarget.classList.add(...this.invalidClasses)
if (this.hasStatusTarget) {
this.statusTarget.textContent = `✗ Invalid JSON: ${errorMessage}`
this.statusTarget.classList.remove(...this.validStatusClasses)
this.statusTarget.classList.add(...this.invalidStatusClasses)
}
}
insertSample(event) {
event.preventDefault()
const sample = event.params.json || event.target.dataset.jsonSample
if (sample) {
this.textareaTarget.value = sample
this.format()
}
}
}

View File

@@ -1,21 +1,48 @@
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static targets = ["sidebarOverlay", "button"];
static targets = ["sidebarOverlay"];
connect() {
// Initialize mobile sidebar functionality
// Add escape key listener to close sidebar
this.boundHandleEscape = this.handleEscape.bind(this);
document.addEventListener('keydown', this.boundHandleEscape);
}
disconnect() {
// Clean up event listeners
document.removeEventListener('keydown', this.boundHandleEscape);
}
openSidebar() {
if (this.hasSidebarOverlayTarget) {
this.sidebarOverlayTarget.classList.remove('hidden');
// Prevent body scroll when sidebar is open
document.body.style.overflow = 'hidden';
}
}
closeSidebar() {
if (this.hasSidebarOverlayTarget) {
this.sidebarOverlayTarget.classList.add('hidden');
// Restore body scroll
document.body.style.overflow = '';
}
}
// Close sidebar when clicking on the overlay background
closeOnBackgroundClick(event) {
// Check if the click is on the overlay background (the semi-transparent layer)
if (event.target === this.sidebarOverlayTarget || event.target.classList.contains('bg-gray-900/80')) {
this.closeSidebar();
}
}
// Handle escape key to close sidebar
handleEscape(event) {
if (event.key === 'Escape' && this.hasSidebarOverlayTarget && !this.sidebarOverlayTarget.classList.contains('hidden')) {
this.closeSidebar();
}
}
}

View File

@@ -1,51 +0,0 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["userSelect", "assignLink", "editForm"]
connect() {
console.log("Role management controller connected")
}
assignRole(event) {
event.preventDefault()
const link = event.currentTarget
const roleId = link.dataset.roleId
const select = document.getElementById(`assign-user-${roleId}`)
if (!select.value) {
alert("Please select a user")
return
}
// Update the href with the selected user ID
const originalHref = link.href
const newHref = originalHref.replace("PLACEHOLDER", select.value)
// Navigate to the updated URL
window.location.href = newHref
}
toggleEdit(event) {
event.preventDefault()
const roleId = event.currentTarget.dataset.roleId
const editForm = document.getElementById(`edit-role-${roleId}`)
if (editForm) {
editForm.classList.toggle("hidden")
}
}
hideEdit(event) {
event.preventDefault()
const roleId = event.currentTarget.dataset.roleId
const editForm = document.getElementById(`edit-role-${roleId}`)
if (editForm) {
editForm.classList.add("hidden")
}
}
}

View File

@@ -0,0 +1,29 @@
class OidcTokenCleanupJob < ApplicationJob
queue_as :default
def perform
# Delete expired access tokens (keep revoked ones for audit trail)
expired_access_tokens = OidcAccessToken.where("expires_at < ?", 7.days.ago)
deleted_count = expired_access_tokens.delete_all
Rails.logger.info "OIDC Token Cleanup: Deleted #{deleted_count} expired access tokens"
# Delete expired refresh tokens (keep revoked ones for audit trail)
expired_refresh_tokens = OidcRefreshToken.where("expires_at < ?", 7.days.ago)
deleted_count = expired_refresh_tokens.delete_all
Rails.logger.info "OIDC Token Cleanup: Deleted #{deleted_count} expired refresh tokens"
# Delete old revoked tokens (after 30 days for audit trail)
old_revoked_access_tokens = OidcAccessToken.where("revoked_at < ?", 30.days.ago)
deleted_count = old_revoked_access_tokens.delete_all
Rails.logger.info "OIDC Token Cleanup: Deleted #{deleted_count} old revoked access tokens"
old_revoked_refresh_tokens = OidcRefreshToken.where("revoked_at < ?", 30.days.ago)
deleted_count = old_revoked_refresh_tokens.delete_all
Rails.logger.info "OIDC Token Cleanup: Deleted #{deleted_count} old revoked refresh tokens"
# Delete old used authorization codes (after 7 days)
old_auth_codes = OidcAuthorizationCode.where("created_at < ?", 7.days.ago)
deleted_count = old_auth_codes.delete_all
Rails.logger.info "OIDC Token Cleanup: Deleted #{deleted_count} old authorization codes"
end
end

View File

@@ -1,4 +1,4 @@
class ApplicationMailer < ActionMailer::Base
default from: ENV.fetch('CLINCH_EMAIL_FROM', 'clinch@example.com')
default from: ENV.fetch('CLINCH_FROM_EMAIL', 'clinch@example.com')
layout "mailer"
end

View File

@@ -3,8 +3,10 @@ class Application < ApplicationRecord
has_many :application_groups, dependent: :destroy
has_many :allowed_groups, through: :application_groups, source: :group
has_many :application_user_claims, dependent: :destroy
has_many :oidc_authorization_codes, dependent: :destroy
has_many :oidc_access_tokens, dependent: :destroy
has_many :oidc_refresh_tokens, dependent: :destroy
has_many :oidc_user_consents, dependent: :destroy
validates :name, presence: true
@@ -13,12 +15,20 @@ class Application < ApplicationRecord
validates :app_type, presence: true,
inclusion: { in: %w[oidc forward_auth] }
validates :client_id, uniqueness: { allow_nil: true }
validates :client_secret, presence: true, if: -> { oidc? && new_record? }
validates :client_secret, presence: true, on: :create, if: -> { oidc? }
validates :domain_pattern, presence: true, uniqueness: { case_sensitive: false }, if: :forward_auth?
validates :landing_url, format: { with: URI::regexp(%w[http https]), allow_nil: true, message: "must be a valid URL" }
# Token TTL validations (for OIDC apps)
validates :access_token_ttl, numericality: { greater_than_or_equal_to: 300, less_than_or_equal_to: 86400 }, if: :oidc? # 5 min - 24 hours
validates :refresh_token_ttl, numericality: { greater_than_or_equal_to: 86400, less_than_or_equal_to: 7776000 }, if: :oidc? # 1 day - 90 days
validates :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 :domain_pattern, with: ->(pattern) { pattern&.strip&.downcase }
normalizes :domain_pattern, with: ->(pattern) {
normalized = pattern&.strip&.downcase
normalized.blank? ? nil : normalized
}
before_validation :generate_client_credentials, on: :create, if: :oidc?
@@ -151,8 +161,50 @@ class Application < ApplicationRecord
secret
end
# Token TTL helper methods (for OIDC)
def access_token_expiry
(access_token_ttl || 3600).seconds.from_now
end
def refresh_token_expiry
(refresh_token_ttl || 2592000).seconds.from_now
end
def id_token_expiry_seconds
id_token_ttl || 3600
end
# Human-readable TTL for display
def access_token_ttl_human
duration_to_human(access_token_ttl || 3600)
end
def refresh_token_ttl_human
duration_to_human(refresh_token_ttl || 2592000)
end
def id_token_ttl_human
duration_to_human(id_token_ttl || 3600)
end
# Get app-specific custom claims for a user
def custom_claims_for_user(user)
app_claim = application_user_claims.find_by(user: user)
app_claim&.parsed_custom_claims || {}
end
private
def duration_to_human(seconds)
if seconds < 3600
"#{seconds / 60} minutes"
elsif seconds < 86400
"#{seconds / 3600} hours"
else
"#{seconds / 86400} days"
end
end
def generate_client_credentials
self.client_id ||= SecureRandom.urlsafe_base64(32)
# Generate and hash the client secret

View File

@@ -0,0 +1,31 @@
class ApplicationUserClaim < ApplicationRecord
belongs_to :application
belongs_to :user
# Reserved OIDC claim names that should not be overridden
RESERVED_CLAIMS = %w[
iss sub aud exp iat nbf jti nonce azp
email email_verified preferred_username name
groups
].freeze
validates :user_id, uniqueness: { scope: :application_id }
validate :no_reserved_claim_names
# Parse custom_claims JSON field
def parsed_custom_claims
return {} if custom_claims.blank?
custom_claims.is_a?(Hash) ? custom_claims : {}
end
private
def no_reserved_claim_names
return if custom_claims.blank?
reserved_used = parsed_custom_claims.keys.map(&:to_s) & RESERVED_CLAIMS
if reserved_used.any?
errors.add(:custom_claims, "cannot override reserved OIDC claims: #{reserved_used.join(', ')}")
end
end
end

View File

@@ -4,11 +4,31 @@ class Group < ApplicationRecord
has_many :application_groups, dependent: :destroy
has_many :applications, through: :application_groups
# Reserved OIDC claim names that should not be overridden
RESERVED_CLAIMS = %w[
iss sub aud exp iat nbf jti nonce azp
email email_verified preferred_username name
groups
].freeze
validates :name, presence: true, uniqueness: { case_sensitive: false }
normalizes :name, with: ->(name) { name.strip.downcase }
validate :no_reserved_claim_names
# Parse custom_claims JSON field
def parsed_custom_claims
custom_claims || {}
return {} if custom_claims.blank?
custom_claims.is_a?(Hash) ? custom_claims : {}
end
private
def no_reserved_claim_names
return if custom_claims.blank?
reserved_used = parsed_custom_claims.keys.map(&:to_s) & RESERVED_CLAIMS
if reserved_used.any?
errors.add(:custom_claims, "cannot override reserved OIDC claims: #{reserved_used.join(', ')}")
end
end
end

View File

@@ -1,34 +1,83 @@
class OidcAccessToken < ApplicationRecord
belongs_to :application
belongs_to :user
has_many :oidc_refresh_tokens, dependent: :destroy
before_validation :generate_token, on: :create
before_validation :set_expiry, on: :create
validates :token, presence: true, uniqueness: true
validates :token, uniqueness: true, presence: true
scope :valid, -> { where("expires_at > ?", Time.current) }
scope :valid, -> { where("expires_at > ?", Time.current).where(revoked_at: nil) }
scope :expired, -> { where("expires_at <= ?", Time.current) }
scope :revoked, -> { where.not(revoked_at: nil) }
scope :active, -> { valid }
attr_accessor :plaintext_token # Store plaintext temporarily for returning to client
def expired?
expires_at <= Time.current
end
def revoked?
revoked_at.present?
end
def active?
!expired?
!expired? && !revoked?
end
def revoke!
update!(expires_at: Time.current)
update!(revoked_at: Time.current)
# Also revoke associated refresh tokens
oidc_refresh_tokens.each(&:revoke!)
end
# Check if a plaintext token matches the hashed token
def token_matches?(plaintext_token)
return false if plaintext_token.blank?
# Use BCrypt to compare if token_digest exists
if token_digest.present?
BCrypt::Password.new(token_digest) == plaintext_token
# Fall back to direct comparison for backward compatibility
elsif token.present?
token == plaintext_token
else
false
end
end
# Find by token (validates and checks if revoked)
def self.find_by_token(plaintext_token)
return nil if plaintext_token.blank?
# Find all non-revoked, non-expired tokens
valid.find_each do |access_token|
# Use BCrypt to compare (if token_digest exists) or direct comparison
if access_token.token_digest.present?
return access_token if BCrypt::Password.new(access_token.token_digest) == plaintext_token
elsif access_token.token == plaintext_token
return access_token
end
end
nil
end
private
def generate_token
self.token ||= SecureRandom.urlsafe_base64(48)
return if token.present?
# Generate opaque access token
plaintext = SecureRandom.urlsafe_base64(48)
self.plaintext_token = plaintext # Store temporarily for returning to client
self.token_digest = BCrypt::Password.create(plaintext)
# Keep token column for backward compatibility during migration
self.token = plaintext
end
def set_expiry
self.expires_at ||= 1.hour.from_now
self.expires_at ||= application.access_token_expiry
end
end

View File

@@ -7,6 +7,8 @@ class OidcAuthorizationCode < ApplicationRecord
validates :code, presence: true, uniqueness: true
validates :redirect_uri, presence: true
validates :code_challenge_method, inclusion: { in: %w[plain S256], allow_nil: true }
validate :validate_code_challenge_format, if: -> { code_challenge.present? }
scope :valid, -> { where(used: false).where("expires_at > ?", Time.current) }
scope :expired, -> { where("expires_at <= ?", Time.current) }
@@ -23,6 +25,10 @@ class OidcAuthorizationCode < ApplicationRecord
update!(used: true)
end
def uses_pkce?
code_challenge.present?
end
private
def generate_code
@@ -32,4 +38,11 @@ class OidcAuthorizationCode < ApplicationRecord
def set_expiry
self.expires_at ||= 10.minutes.from_now
end
def validate_code_challenge_format
# PKCE code challenge should be base64url-encoded, 43-128 characters
unless code_challenge.match?(/\A[A-Za-z0-9\-_]{43,128}\z/)
errors.add(:code_challenge, "must be 43-128 characters of base64url encoding")
end
end
end

View File

@@ -0,0 +1,87 @@
class OidcRefreshToken < ApplicationRecord
belongs_to :application
belongs_to :user
belongs_to :oidc_access_token
has_many :oidc_access_tokens, foreign_key: :oidc_access_token_id, dependent: :nullify
before_validation :generate_token, on: :create
before_validation :set_expiry, on: :create
before_validation :set_token_family_id, on: :create
validates :token_digest, presence: true, uniqueness: true
scope :valid, -> { where("expires_at > ?", Time.current).where(revoked_at: nil) }
scope :expired, -> { where("expires_at <= ?", Time.current) }
scope :revoked, -> { where.not(revoked_at: nil) }
scope :active, -> { valid }
# For token rotation detection (prevents reuse attacks)
scope :in_family, ->(family_id) { where(token_family_id: family_id) }
attr_accessor :token # Store plaintext token temporarily for returning to client
def expired?
expires_at <= Time.current
end
def revoked?
revoked_at.present?
end
def active?
!expired? && !revoked?
end
def revoke!
update!(revoked_at: Time.current)
end
# Revoke all refresh tokens in the same family (token rotation security)
def revoke_family!
return unless token_family_id.present?
OidcRefreshToken.in_family(token_family_id).update_all(revoked_at: Time.current)
end
# Verify a plaintext token against the stored digest
def self.find_by_token(plaintext_token)
return nil if plaintext_token.blank?
# Try to find tokens that could match (we can't search by hash directly)
# This is less efficient but necessary with BCrypt
# In production, you might want to add a token prefix or other optimization
all.find do |refresh_token|
refresh_token.token_matches?(plaintext_token)
end
end
def token_matches?(plaintext_token)
return false if plaintext_token.blank? || token_digest.blank?
BCrypt::Password.new(token_digest) == plaintext_token
rescue BCrypt::Errors::InvalidHash
false
end
private
def generate_token
# Generate a secure random token
plaintext = SecureRandom.urlsafe_base64(48)
self.token = plaintext # Store temporarily for returning to client
# Hash it with BCrypt for storage
self.token_digest = BCrypt::Password.create(plaintext)
end
def set_expiry
# Use application's configured refresh token TTL
self.expires_at ||= application.refresh_token_expiry
end
def set_token_family_id
# Use a random ID to group tokens in the same rotation chain
# This helps detect token reuse attacks
self.token_family_id ||= SecureRandom.random_number(2**31)
end
end

View File

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

View File

@@ -3,6 +3,7 @@ class User < ApplicationRecord
has_many :sessions, dependent: :destroy
has_many :user_groups, dependent: :destroy
has_many :groups, through: :user_groups
has_many :application_user_claims, dependent: :destroy
has_many :oidc_user_consents, dependent: :destroy
has_many :webauthn_credentials, dependent: :destroy
@@ -20,10 +21,22 @@ class User < ApplicationRecord
end
normalizes :email_address, with: ->(e) { e.strip.downcase }
normalizes :username, with: ->(u) { u.strip.downcase if u.present? }
# Reserved OIDC claim names that should not be overridden
RESERVED_CLAIMS = %w[
iss sub aud exp iat nbf jti nonce azp
email email_verified preferred_username name
groups
].freeze
validates :email_address, presence: true, uniqueness: { case_sensitive: false },
format: { with: URI::MailTo::EMAIL_REGEXP }
validates :username, uniqueness: { case_sensitive: false }, allow_nil: true,
format: { with: /\A[a-zA-Z0-9_-]+\z/, message: "can only contain letters, numbers, underscores, and hyphens" },
length: { minimum: 2, maximum: 30 }
validates :password, length: { minimum: 8 }, allow_nil: true
validate :no_reserved_claim_names
# Enum - automatically creates scopes (User.active, User.disabled, etc.)
enum :status, { active: 0, disabled: 1, pending_invitation: 2 }
@@ -44,7 +57,9 @@ class User < ApplicationRecord
end
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
def totp_provisioning_uri(issuer: "Clinch")
@@ -180,11 +195,39 @@ class User < ApplicationRecord
# Parse custom_claims JSON field
def parsed_custom_claims
custom_claims || {}
return {} if custom_claims.blank?
custom_claims.is_a?(Hash) ? custom_claims : {}
end
# Get fully merged claims for a specific application
def merged_claims_for_application(application)
merged = {}
# Start with group claims (in order)
groups.each do |group|
merged.merge!(group.parsed_custom_claims)
end
# Merge user global claims
merged.merge!(parsed_custom_claims)
# Merge app-specific claims (highest priority)
merged.merge!(application.custom_claims_for_user(self))
merged
end
private
def no_reserved_claim_names
return if custom_claims.blank?
reserved_used = parsed_custom_claims.keys.map(&:to_s) & RESERVED_CLAIMS
if reserved_used.any?
errors.add(:custom_claims, "cannot override reserved OIDC claims: #{reserved_used.join(', ')}")
end
end
def generate_backup_codes
# Generate plain codes for user to see/save
plain_codes = Array.new(10) { SecureRandom.alphanumeric(8).upcase }

View File

@@ -0,0 +1,35 @@
module ClaimsMerger
extend ActiveSupport::Concern
# Deep merge claims, combining arrays instead of overwriting them
# This ensures that array values (like roles) are combined across group/user/app claims
#
# Example:
# base = { "roles" => ["user"], "level" => 1 }
# incoming = { "roles" => ["admin"], "department" => "IT" }
# deep_merge_claims(base, incoming)
# # => { "roles" => ["user", "admin"], "level" => 1, "department" => "IT" }
def deep_merge_claims(base, incoming)
result = base.dup
incoming.each do |key, value|
if result.key?(key)
# If both values are arrays, combine them (union to avoid duplicates)
if result[key].is_a?(Array) && value.is_a?(Array)
result[key] = (result[key] + value).uniq
# If both values are hashes, recursively merge them
elsif result[key].is_a?(Hash) && value.is_a?(Hash)
result[key] = deep_merge_claims(result[key], value)
else
# Otherwise, incoming value wins (override)
result[key] = value
end
else
# New key, just add it
result[key] = value
end
end
result
end
end

View File

@@ -1,18 +1,25 @@
class OidcJwtService
extend ClaimsMerger
class << self
# 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
# Use application's configured ID token TTL (defaults to 1 hour)
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 = {
iss: issuer_url,
sub: user.id.to_s,
sub: subject,
aud: application.client_id,
exp: now + 3600, # 1 hour
exp: now + ttl,
iat: now,
email: user.email_address,
email_verified: true,
preferred_username: user.email_address,
preferred_username: user.username.presence || user.email_address,
name: user.name.presence || user.email_address
}
@@ -24,16 +31,16 @@ class OidcJwtService
payload[:groups] = user.groups.pluck(:name)
end
# Add admin claim if user is admin
payload[:admin] = true if user.admin?
# Merge custom claims from groups
# Merge custom claims from groups (arrays are combined, not overwritten)
user.groups.each do |group|
payload.merge!(group.parsed_custom_claims)
payload = deep_merge_claims(payload, group.parsed_custom_claims)
end
# Merge custom claims from user (overrides group claims)
payload.merge!(user.parsed_custom_claims)
# Merge custom claims from user (arrays are combined, other values override)
payload = deep_merge_claims(payload, user.parsed_custom_claims)
# Merge app-specific custom claims (highest priority, arrays are combined)
payload = deep_merge_claims(payload, application.custom_claims_for_user(user))
JWT.encode(payload, private_key, "RS256", { kid: key_id, typ: "JWT" })
end
@@ -63,7 +70,14 @@ class OidcJwtService
def issuer_url
# In production, this should come from ENV or config
# For now, we'll use a placeholder that can be overridden
"https://#{ENV.fetch("CLINCH_HOST", "localhost:3000")}"
host = ENV.fetch("CLINCH_HOST", "localhost:3000")
# Ensure URL has protocol - use https:// in production, http:// in development
if host.match?(/^https?:\/\//)
host
else
protocol = Rails.env.production? ? "https" : "http"
"#{protocol}://#{host}"
end
end
private
@@ -71,17 +85,37 @@ class OidcJwtService
# Get or generate RSA private key
def private_key
@private_key ||= begin
key_source = nil
# Try ENV variable first (best for Docker/Kamal)
if ENV["OIDC_PRIVATE_KEY"].present?
OpenSSL::PKey::RSA.new(ENV["OIDC_PRIVATE_KEY"])
key_source = ENV["OIDC_PRIVATE_KEY"]
# Then try Rails credentials
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
# Generate a new key for development
# In production, you MUST set OIDC_PRIVATE_KEY env var or add to credentials
# In production, we should never generate a key on the fly
# 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: 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)
end
end

View File

@@ -1,22 +1,5 @@
<%= form_with(model: [:admin, application], class: "space-y-6", data: { controller: "application-form" }) do |form| %>
<% if application.errors.any? %>
<div class="rounded-md bg-red-50 p-4">
<div class="flex">
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800">
<%= pluralize(application.errors.count, "error") %> prohibited this application from being saved:
</h3>
<div class="mt-2 text-sm text-red-700">
<ul class="list-disc pl-5 space-y-1">
<% application.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
</div>
</div>
</div>
<% end %>
<%= form_with(model: [:admin, application], class: "space-y-6", data: { controller: "application-form form-errors" }) do |form| %>
<%= render "shared/form_errors", form: form %>
<div>
<%= form.label :name, class: "block text-sm font-medium text-gray-700" %>
@@ -61,6 +44,53 @@
<%= 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" %>
<p class="mt-1 text-sm text-gray-500">One URI per line. These are the allowed callback URLs for your application.</p>
</div>
<div class="border-t border-gray-200 pt-4 mt-4">
<h4 class="text-sm font-semibold text-gray-900 mb-3">Token Expiration Settings</h4>
<p class="text-sm text-gray-500 mb-4">Configure how long tokens remain valid. Shorter times are more secure but require more frequent refreshes.</p>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<%= form.label :access_token_ttl, "Access Token TTL (seconds)", class: "block text-sm font-medium text-gray-700" %>
<%= form.number_field :access_token_ttl, value: application.access_token_ttl || 3600, min: 300, max: 86400, step: 60, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
<p class="mt-1 text-xs text-gray-500">
Range: 5 min - 24 hours
<br>Default: 1 hour (3600s)
<br>Current: <span class="font-medium"><%= application.access_token_ttl_human || "1 hour" %></span>
</p>
</div>
<div>
<%= form.label :refresh_token_ttl, "Refresh Token TTL (seconds)", class: "block text-sm font-medium text-gray-700" %>
<%= form.number_field :refresh_token_ttl, value: application.refresh_token_ttl || 2592000, min: 86400, max: 7776000, step: 86400, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
<p class="mt-1 text-xs text-gray-500">
Range: 1 day - 90 days
<br>Default: 30 days (2592000s)
<br>Current: <span class="font-medium"><%= application.refresh_token_ttl_human || "30 days" %></span>
</p>
</div>
<div>
<%= form.label :id_token_ttl, "ID Token TTL (seconds)", class: "block text-sm font-medium text-gray-700" %>
<%= form.number_field :id_token_ttl, value: application.id_token_ttl || 3600, min: 300, max: 86400, step: 60, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
<p class="mt-1 text-xs text-gray-500">
Range: 5 min - 24 hours
<br>Default: 1 hour (3600s)
<br>Current: <span class="font-medium"><%= application.id_token_ttl_human || "1 hour" %></span>
</p>
</div>
</div>
<details class="mt-3">
<summary class="cursor-pointer text-sm text-blue-600 hover:text-blue-800">Understanding Token Types</summary>
<div class="mt-2 ml-4 space-y-2 text-sm text-gray-600">
<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>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>
</details>
</div>
</div>
<!-- Forward Auth-specific fields -->
@@ -73,12 +103,25 @@
<p class="mt-1 text-sm text-gray-500">Domain pattern to match. Use * for wildcard subdomains (e.g., *.example.com matches app.example.com, api.example.com, etc.)</p>
</div>
<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">
<%= form.label :headers_config, "Custom Headers Configuration (JSON)", class: "block text-sm font-medium text-gray-700" %>
<%= form.text_area :headers_config, rows: 10, 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: '{"user": "Remote-User", "groups": "Remote-Groups"}' %>
<%= form.text_area :headers_config, value: (application.headers_config.present? && application.headers_config.any? ? JSON.pretty_generate(application.headers_config) : ""), rows: 10,
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: '{"user": "Remote-User", "groups": "Remote-Groups"}',
data: {
action: "input->json-validator#validate blur->json-validator#format",
json_validator_target: "textarea"
} %>
<div class="mt-2 text-sm text-gray-600 space-y-1">
<p class="font-medium">Optional: Customize header names sent to your application.</p>
<div class="flex items-center justify-between">
<p class="font-medium">Optional: Customize header names sent to your application.</p>
<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#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>
</div>
</div>
<p><strong>Default headers:</strong> X-Remote-User, X-Remote-Email, X-Remote-Name, X-Remote-Groups, X-Remote-Admin</p>
<div data-json-validator-target="status" class="text-xs font-medium"></div>
<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>
<div class="mt-2 ml-4 space-y-1 text-xs">

View File

@@ -37,6 +37,8 @@
<% case application.app_type %>
<% when "oidc" %>
<span class="inline-flex items-center rounded-full bg-purple-100 px-2 py-1 text-xs font-medium text-purple-700">OIDC</span>
<% when "forward_auth" %>
<span class="inline-flex items-center rounded-full bg-blue-100 px-2 py-1 text-xs font-medium text-blue-700">Forward Auth</span>
<% when "saml" %>
<span class="inline-flex items-center rounded-full bg-orange-100 px-2 py-1 text-xs font-medium text-orange-700">SAML</span>
<% end %>

View File

@@ -1,22 +1,5 @@
<%= form_with(model: [:admin, group], class: "space-y-6") do |form| %>
<% if group.errors.any? %>
<div class="rounded-md bg-red-50 p-4">
<div class="flex">
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800">
<%= pluralize(group.errors.count, "error") %> prohibited this group from being saved:
</h3>
<div class="mt-2 text-sm text-red-700">
<ul class="list-disc pl-5 space-y-1">
<% group.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
</div>
</div>
</div>
<% end %>
<%= form_with(model: [:admin, group], class: "space-y-6", data: { controller: "form-errors" }) do |form| %>
<%= render "shared/form_errors", form: form %>
<div>
<%= form.label :name, class: "block text-sm font-medium text-gray-700" %>
@@ -49,10 +32,25 @@
<p class="mt-1 text-sm text-gray-500">Select which users should be members of this group.</p>
</div>
<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">
<%= form.label :custom_claims, "Custom Claims (JSON)", class: "block text-sm font-medium text-gray-700" %>
<%= form.text_area :custom_claims, value: (group.custom_claims.present? ? JSON.pretty_generate(group.custom_claims) : ""), rows: 8, 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: '{"roles": ["admin", "editor"]}' %>
<p class="mt-1 text-sm text-gray-500">Optional: Custom claims to add to OIDC tokens for all members. These will be merged with user-level claims.</p>
<%= form.text_area :custom_claims, value: (group.custom_claims.present? ? JSON.pretty_generate(group.custom_claims) : ""), rows: 8,
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: '{"roles": ["admin", "editor"]}',
data: {
action: "input->json-validator#validate blur->json-validator#format",
json_validator_target: "textarea"
} %>
<div class="mt-2 text-sm text-gray-600 space-y-1">
<div class="flex items-center justify-between">
<p>Optional: Custom claims to add to OIDC tokens for all members. These will be merged with user-level claims.</p>
<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#insertSample" data-json-sample='{"roles": ["admin", "editor"], "permissions": ["read", "write"], "team": "backend"}' class="text-xs bg-blue-100 hover:bg-blue-200 text-blue-700 px-2 py-1 rounded">Insert Example</button>
</div>
</div>
<div data-json-validator-target="status" class="text-xs font-medium"></div>
</div>
</div>
<div class="flex gap-3">

View File

@@ -39,9 +39,11 @@
<%= pluralize(group.applications.count, "app") %>
</td>
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-0">
<%= link_to "View", admin_group_path(group), class: "text-blue-600 hover:text-blue-900 mr-4" %>
<%= link_to "Edit", edit_admin_group_path(group), class: "text-blue-600 hover:text-blue-900 mr-4" %>
<%= button_to "Delete", admin_group_path(group), method: :delete, data: { turbo_confirm: "Are you sure you want to delete this group?" }, class: "text-red-600 hover:text-red-900" %>
<div class="flex justify-end space-x-3">
<%= link_to "View", admin_group_path(group), class: "text-blue-600 hover:text-blue-900 whitespace-nowrap" %>
<%= link_to "Edit", edit_admin_group_path(group), class: "text-blue-600 hover:text-blue-900 whitespace-nowrap" %>
<%= button_to "Delete", admin_group_path(group), method: :delete, data: { turbo_confirm: "Are you sure you want to delete this group?" }, class: "text-red-600 hover:text-red-900 whitespace-nowrap" %>
</div>
</td>
</tr>
<% end %>

View File

@@ -0,0 +1,185 @@
<% oidc_apps = applications.select(&:oidc?) %>
<% forward_auth_apps = applications.select(&:forward_auth?) %>
<!-- OIDC Apps: Custom Claims -->
<% if oidc_apps.any? %>
<div class="mt-12 border-t pt-8">
<h2 class="text-xl font-semibold text-gray-900 mb-4">OIDC App-Specific Claims</h2>
<p class="text-sm text-gray-600 mb-6">
Configure custom claims that apply only to specific OIDC applications. These override both group and user global claims and are included in ID tokens.
</p>
<div class="space-y-6">
<% oidc_apps.each do |app| %>
<% app_claim = user.application_user_claims.find_by(application: app) %>
<details class="border rounded-lg" <%= "open" if app_claim&.custom_claims&.any? %>>
<summary class="cursor-pointer bg-gray-50 px-4 py-3 hover:bg-gray-100 rounded-t-lg flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="font-medium text-gray-900"><%= app.name %></span>
<span class="text-xs px-2 py-1 rounded-full bg-blue-100 text-blue-700">
OIDC
</span>
<% if app_claim&.custom_claims&.any? %>
<span class="text-xs px-2 py-1 rounded-full bg-amber-100 text-amber-700">
<%= app_claim.custom_claims.keys.count %> claim(s)
</span>
<% end %>
</div>
<svg class="h-5 w-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</summary>
<div class="p-4 space-y-4">
<%= form_with url: update_application_claims_admin_user_path(user), method: :post, class: "space-y-4", data: { controller: "json-validator" } do |form| %>
<%= hidden_field_tag :application_id, app.id %>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Custom Claims (JSON)</label>
<%= text_area_tag :custom_claims,
(app_claim&.custom_claims.present? ? JSON.pretty_generate(app_claim.custom_claims) : ""),
rows: 8,
class: "w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono",
placeholder: '{"kavita_groups": ["admin"], "library_access": "all"}',
data: {
action: "input->json-validator#validate blur->json-validator#format",
json_validator_target: "textarea"
} %>
<div class="mt-2 space-y-1">
<p class="text-xs text-gray-600">
Example for <%= app.name %>: Add claims that this app specifically needs to read.
</p>
<p class="text-xs text-amber-600">
<strong>Note:</strong> Do not use reserved claim names (<code class="bg-amber-50 px-1 rounded">groups</code>, <code class="bg-amber-50 px-1 rounded">email</code>, <code class="bg-amber-50 px-1 rounded">name</code>, etc.). Use app-specific names like <code class="bg-amber-50 px-1 rounded">kavita_groups</code> instead.
</p>
<div data-json-validator-target="status" class="text-xs font-medium"></div>
</div>
</div>
<div class="flex gap-3">
<%= button_tag type: :submit, class: "rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500" do %>
<%= app_claim ? "Update" : "Add" %> Claims
<% end %>
<% if app_claim %>
<%= button_to "Remove Override",
delete_application_claims_admin_user_path(user, application_id: app.id),
method: :delete,
data: { turbo_confirm: "Remove app-specific claims for #{app.name}?" },
class: "rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50" %>
<% end %>
</div>
<% end %>
<!-- Preview merged claims -->
<div class="mt-4 border-t pt-4">
<h4 class="text-sm font-medium text-gray-700 mb-2">Preview: Final ID Token Claims for <%= app.name %></h4>
<div class="bg-gray-50 rounded-lg p-3">
<pre class="text-xs font-mono text-gray-800 overflow-x-auto"><%= JSON.pretty_generate(preview_user_claims(user, app)) %></pre>
</div>
<details class="mt-2">
<summary class="cursor-pointer text-xs text-gray-600 hover:text-gray-900">Show claim sources</summary>
<div class="mt-2 space-y-1">
<% claim_sources(user, app).each do |source| %>
<div class="flex gap-2 items-start text-xs">
<span class="px-2 py-1 rounded <%= source[:type] == :group ? 'bg-blue-100 text-blue-700' : (source[:type] == :user ? 'bg-green-100 text-green-700' : 'bg-amber-100 text-amber-700') %>">
<%= source[:name] %>
</span>
<code class="text-gray-700"><%= source[:claims].to_json %></code>
</div>
<% end %>
</div>
</details>
</div>
</div>
</details>
<% end %>
</div>
</div>
<% end %>
<!-- ForwardAuth Apps: Headers Preview -->
<% if forward_auth_apps.any? %>
<div class="mt-12 border-t pt-8">
<h2 class="text-xl font-semibold text-gray-900 mb-4">ForwardAuth Headers Preview</h2>
<p class="text-sm text-gray-600 mb-6">
ForwardAuth applications receive HTTP headers (not OIDC tokens). Headers are based on user's email, name, groups, and admin status.
</p>
<div class="space-y-6">
<% forward_auth_apps.each do |app| %>
<details class="border rounded-lg">
<summary class="cursor-pointer bg-gray-50 px-4 py-3 hover:bg-gray-100 rounded-t-lg flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="font-medium text-gray-900"><%= app.name %></span>
<span class="text-xs px-2 py-1 rounded-full bg-green-100 text-green-700">
FORWARD AUTH
</span>
<span class="text-xs text-gray-500">
<%= app.domain_pattern %>
</span>
</div>
<svg class="h-5 w-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</summary>
<div class="p-4 space-y-4">
<div class="bg-blue-50 border border-blue-200 rounded-lg p-3">
<div class="flex items-start">
<svg class="h-5 w-5 text-blue-400 mr-2 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
<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" />
</svg>
</div>
</div>
<div>
<h4 class="text-sm font-medium text-gray-700 mb-2">Headers Sent to <%= app.name %></h4>
<div class="bg-gray-50 rounded-lg p-3 border">
<% headers = app.headers_for_user(user) %>
<% if headers.any? %>
<dl class="space-y-2 text-xs font-mono">
<% headers.each do |header_name, value| %>
<div class="flex">
<dt class="text-blue-600 font-semibold w-48"><%= header_name %>:</dt>
<dd class="text-gray-800 flex-1"><%= value %></dd>
</div>
<% end %>
</dl>
<% else %>
<p class="text-xs text-gray-500 italic">All headers disabled for this application.</p>
<% end %>
</div>
<p class="mt-2 text-xs text-gray-500">
These headers are configured in the application settings and sent by your reverse proxy (Caddy/Traefik) to the upstream application.
</p>
</div>
<% if user.groups.any? %>
<div>
<h4 class="text-sm font-medium text-gray-700 mb-2">User's Groups</h4>
<div class="flex flex-wrap gap-2">
<% user.groups.each do |group| %>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
<%= group.name %>
</span>
<% end %>
</div>
</div>
<% end %>
</div>
</details>
<% end %>
</div>
</div>
<% end %>
<% if oidc_apps.empty? && forward_auth_apps.empty? %>
<div class="mt-12 border-t pt-8">
<div class="text-center py-12 bg-gray-50 rounded-lg">
<p class="text-gray-500">No active applications found.</p>
<p class="text-sm text-gray-400 mt-1">Create applications in the Admin panel first.</p>
</div>
</div>
<% end %>

View File

@@ -1,32 +1,21 @@
<%= form_with(model: [:admin, user], class: "space-y-6") do |form| %>
<% if user.errors.any? %>
<div class="rounded-md bg-red-50 p-4">
<div class="flex">
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800">
<%= pluralize(user.errors.count, "error") %> prohibited this user from being saved:
</h3>
<div class="mt-2 text-sm text-red-700">
<ul class="list-disc pl-5 space-y-1">
<% user.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
</div>
</div>
</div>
<% end %>
<%= form_with(model: [:admin, user], class: "space-y-6", data: { controller: "form-errors" }) do |form| %>
<%= render "shared/form_errors", form: form %>
<div>
<%= form.label :email_address, class: "block text-sm font-medium text-gray-700" %>
<%= form.email_field :email_address, required: true, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "user@example.com" %>
</div>
<div>
<%= form.label :username, "Username (Optional)", class: "block text-sm font-medium text-gray-700" %>
<%= form.text_field :username, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "jsmith" %>
<p class="mt-1 text-sm text-gray-500">Optional: Short username/handle for login. Can only contain letters, numbers, underscores, and hyphens.</p>
</div>
<div>
<%= form.label :name, "Display Name (Optional)", class: "block text-sm font-medium text-gray-700" %>
<%= form.text_field :name, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "John Smith" %>
<p class="mt-1 text-sm text-gray-500">Optional: Name shown in applications. Defaults to email address if not set.</p>
<p class="mt-1 text-sm text-gray-500">Optional: Full name shown in applications. Defaults to email address if not set.</p>
</div>
<div>
@@ -53,9 +42,43 @@
</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">
<%= 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, 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: '{"department": "engineering", "level": "senior"}' %>
<p class="mt-1 text-sm text-gray-500">Optional: User-specific custom claims to add to OIDC tokens. These override group-level claims.</p>
<%= form.text_area :custom_claims, value: (user.custom_claims.present? ? JSON.pretty_generate(user.custom_claims) : ""), rows: 8,
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: '{"department": "engineering", "level": "senior"}',
data: {
action: "input->json-validator#validate blur->json-validator#format",
json_validator_target: "textarea"
} %>
<div class="mt-2 text-sm text-gray-600 space-y-1">
<div class="flex items-center justify-between">
<p>Optional: User-specific custom claims to add to OIDC tokens. These override group-level claims.</p>
<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#insertSample" data-json-sample='{"department": "engineering", "level": "senior", "location": "remote"}' class="text-xs bg-blue-100 hover:bg-blue-200 text-blue-700 px-2 py-1 rounded">Insert Example</button>
</div>
</div>
<div data-json-validator-target="status" class="text-xs font-medium"></div>
</div>
</div>
<div class="flex gap-3">

View File

@@ -1,5 +1,12 @@
<div class="max-w-2xl">
<div class="max-w-4xl">
<h1 class="text-2xl font-semibold text-gray-900 mb-6">Edit User</h1>
<p class="text-sm text-gray-600 mb-6">Editing: <%= @user.email_address %></p>
<%= render "form", user: @user %>
<div class="max-w-2xl">
<%= render "form", user: @user %>
</div>
<% if @user.persisted? %>
<%= render "application_claims", user: @user, applications: @applications %>
<% end %>
</div>

View File

@@ -85,15 +85,20 @@
<% end %>
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
<% if user.totp_enabled? %>
<svg class="h-5 w-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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>
<% else %>
<svg class="h-5 w-5 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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>
<% end %>
<div class="flex items-center gap-2">
<% if user.totp_enabled? %>
<svg class="h-5 w-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" title="2FA 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>
<% else %>
<svg class="h-5 w-5 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24" title="2FA Not Enabled">
<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>
<% 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 class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
<%= user.groups.count %>

View File

@@ -25,27 +25,29 @@
<body>
<% if authenticated? %>
<%= render "shared/sidebar" %>
<div class="lg:pl-64" data-controller="mobile-sidebar">
<!-- Mobile menu button -->
<div class="sticky top-0 z-40 flex h-16 shrink-0 items-center gap-x-4 border-b border-gray-200 bg-white px-4 shadow-sm sm:gap-x-6 sm:px-6 lg:hidden">
<button type="button"
class="-m-2.5 p-2.5 text-gray-700"
id="mobile-menu-button"
data-action="click->mobile-sidebar#openSidebar">
<span class="sr-only">Open sidebar</span>
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
</svg>
</button>
</div>
<main class="py-10">
<div class="px-4 sm:px-6 lg:px-8">
<%= render "shared/flash" %>
<%= yield %>
<div data-controller="mobile-sidebar">
<%= render "shared/sidebar" %>
<div class="lg:pl-64">
<!-- Mobile menu button -->
<div class="sticky top-0 z-40 flex h-16 shrink-0 items-center gap-x-4 border-b border-gray-200 bg-white px-4 shadow-sm sm:gap-x-6 sm:px-6 lg:hidden">
<button type="button"
class="-m-2.5 p-2.5 text-gray-700"
id="mobile-menu-button"
data-action="click->mobile-sidebar#openSidebar">
<span class="sr-only">Open sidebar</span>
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
</svg>
</button>
</div>
</main>
<main class="py-10">
<div class="px-4 sm:px-6 lg:px-8">
<%= render "shared/flash" %>
<%= yield %>
</div>
</main>
</div>
</div>
<% else %>
<!-- Public layout (signup/signin) -->

View File

@@ -57,7 +57,7 @@
</div>
</div>
<%= form_with url: oauth_consent_path, method: :post, class: "space-y-3", data: { turbo: false } do |form| %>
<%= form_with url: "/oauth/authorize/consent", method: :post, class: "space-y-3", data: { turbo: false }, local: true do |form| %>
<%= form.submit "Authorize",
class: "w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %>

View File

@@ -5,7 +5,7 @@
<h1 class="font-bold text-4xl">Forgot your password?</h1>
<%= form_with url: passwords_path, class: "contents" do |form| %>
<%= form_with url: passwords_path, class: "contents", data: { controller: "form-errors" } do |form| %>
<div class="my-5">
<%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email_address], class: "block shadow-sm rounded-md border border-gray-400 focus:outline-solid focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
</div>

View File

@@ -98,23 +98,52 @@
<p class="text-sm font-medium text-green-800">
Two-factor authentication is enabled
</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 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>
<% if @user.totp_required? %>
<div class="mt-4 rounded-md bg-blue-50 p-4">
<div class="flex">
<svg class="h-5 w-5 text-blue-400 mr-2 flex-shrink-0" viewBox="0 0 20 20" fill="currentColor">
<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" />
</svg>
<p class="text-sm text-blue-800">
Your administrator requires two-factor authentication. You cannot disable it.
</p>
</div>
</div>
<div class="mt-4 flex gap-3">
<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>
<% 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 %>
<%= 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

View File

@@ -3,7 +3,7 @@
<h1 class="font-bold text-4xl">Sign in to Clinch</h1>
</div>
<%= form_with url: signin_path, class: "contents" do |form| %>
<%= form_with url: signin_path, class: "contents", data: { controller: "form-errors" } do |form| %>
<%= hidden_field_tag :rd, params[:rd] if params[:rd].present? %>
<div class="my-5">
<%= form.label :email_address, "Email Address", class: "block font-medium text-sm text-gray-700" %>

View File

@@ -1,29 +1,73 @@
<% if flash[:alert] %>
<div class="mb-4 rounded-lg bg-red-50 p-4" role="alert">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
</svg>
</div>
<div class="ml-3">
<p class="text-sm font-medium text-red-800"><%= flash[:alert] %></p>
</div>
</div>
</div>
<% end %>
<%# Enhanced Flash Messages with Support for Multiple Types and Auto-Dismiss %>
<% flash.each do |type, message| %>
<% next if message.blank? %>
<% if flash[:notice] %>
<div class="mb-4 rounded-lg bg-green-50 p-4" role="alert">
<%
# Map flash types to styling
case type.to_s
when 'notice'
bg_class = 'bg-green-50'
text_class = 'text-green-800'
icon_class = 'text-green-400'
icon_path = 'M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z'
auto_dismiss = true
when 'alert', 'error'
bg_class = 'bg-red-50'
text_class = 'text-red-800'
icon_class = 'text-red-400'
icon_path = 'M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z'
auto_dismiss = false
when 'warning'
bg_class = 'bg-yellow-50'
text_class = 'text-yellow-800'
icon_class = 'text-yellow-400'
icon_path = 'M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z'
auto_dismiss = false
when 'info'
bg_class = 'bg-blue-50'
text_class = 'text-blue-800'
icon_class = 'text-blue-400'
icon_path = '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'
auto_dismiss = true
else
# Default styling for unknown types
bg_class = 'bg-gray-50'
text_class = 'text-gray-800'
icon_class = 'text-gray-400'
icon_path = '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'
auto_dismiss = false
end
%>
<div class="mb-4 rounded-lg <%= bg_class %> p-4 border border-opacity-20 <%= border_class_for(type) %>"
role="alert"
data-controller="flash"
data-flash-auto-dismiss-value="<%= auto_dismiss ? '5000' : 'false' %>"
data-flash-type-value="<%= type %>">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-green-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
<div class="shrink-0">
<svg class="h-5 w-5 <%= icon_class %>" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="<%= icon_path %>" clip-rule="evenodd"/>
</svg>
</div>
<div class="ml-3">
<p class="text-sm font-medium text-green-800"><%= flash[:notice] %></p>
<div class="ml-3 flex-1">
<p class="text-sm font-medium <%= text_class %>"><%= message %></p>
</div>
<% if auto_dismiss || type.to_s != 'alert' %>
<div class="ml-auto pl-3">
<div class="-mx-1.5 -my-1.5">
<button type="button"
data-action="click->flash#dismiss"
class="inline-flex rounded-md <%= bg_class %> p-1.5 <%= icon_class %> hover:bg-opacity-70 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-<%= bg_class.gsub('bg-', '') %>"
aria-label="Dismiss">
<span class="sr-only">Dismiss</span>
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"/>
</svg>
</button>
</div>
</div>
<% end %>
</div>
</div>
<% end %>

View File

@@ -1,23 +1,36 @@
<% if form.object.errors.any? %>
<div class="rounded-md bg-red-50 p-4">
<%# Usage: render "shared/form_errors", object: @user %>
<%# Usage: render "shared/form_errors", form: form %>
<% form_object = form.respond_to?(:object) ? form.object : (object || form) %>
<% if form_object&.errors&.any? %>
<div class="rounded-md bg-red-50 p-4 mb-6 border border-red-200" role="alert" aria-labelledby="form-errors-title" data-form-errors-target="container">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z" clip-rule="evenodd" />
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
</svg>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800">
There were <%= pluralize(form.object.errors.count, "error") %> with your submission:
<div class="ml-3 flex-1">
<h3 id="form-errors-title" class="text-sm font-medium text-red-800">
<%= pluralize(form_object.errors.count, "error") %> prohibited this <%= form_object.class.name.downcase.gsub(/^admin::/, '') %> from being saved:
</h3>
<div class="mt-2 text-sm text-red-700">
<ul class="list-disc space-y-1 pl-5">
<% form.object.errors.full_messages.each do |message| %>
<div class="mt-2">
<ul class="list-disc space-y-1 pl-5 text-sm text-red-700">
<% form_object.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
</div>
<div class="ml-auto pl-3">
<div class="-mx-1.5 -my-1.5">
<button type="button" data-action="click->form-errors#dismiss" class="inline-flex rounded-md bg-red-50 p-1.5 text-red-500 hover:bg-red-100 focus:outline-none focus:ring-2 focus:ring-red-600 focus:ring-offset-2 focus:ring-offset-red-50" aria-label="Dismiss">
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" />
</svg>
</button>
</div>
</div>
</div>
</div>
<% end %>

View File

@@ -90,7 +90,7 @@
<!-- Sign Out -->
<li>
<%= link_to signout_path, data: { turbo_method: :delete }, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-red-600 hover:text-red-700 hover:bg-red-50" do %>
<%= link_to signout_path, data: { turbo_method: :delete, action: "click->mobile-sidebar#closeSidebar" }, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-red-600 hover:text-red-700 hover:bg-red-50" do %>
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" />
</svg>
@@ -105,7 +105,10 @@
</div>
<!-- Mobile sidebar overlay -->
<div class="relative z-50 lg:hidden hidden" data-mobile-sidebar-target="sidebarOverlay" id="mobile-sidebar-overlay">
<div class="relative z-50 lg:hidden hidden"
data-mobile-sidebar-target="sidebarOverlay"
id="mobile-sidebar-overlay"
data-action="click->mobile-sidebar#closeOnBackgroundClick">
<div class="fixed inset-0 bg-gray-900/80"></div>
<div class="fixed inset-0 flex">
<div class="relative mr-16 flex w-full max-w-xs flex-1">
@@ -141,7 +144,7 @@
<!-- Same nav items as desktop -->
<ul role="list" class="-mx-2 space-y-1">
<li>
<%= link_to root_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-gray-700 hover:text-blue-600 hover:bg-gray-50" do %>
<%= link_to root_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path == '/' ? 'bg-gray-50 text-blue-600' : 'text-gray-700 hover:text-blue-600 hover:bg-gray-50' }", data: { action: "click->mobile-sidebar#closeSidebar" } do %>
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" />
</svg>
@@ -150,7 +153,7 @@
</li>
<% if user.admin? %>
<li>
<%= link_to admin_users_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-gray-700 hover:text-blue-600 hover:bg-gray-50" do %>
<%= link_to admin_users_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path.start_with?('/admin/users') ? 'bg-gray-50 text-blue-600' : 'text-gray-700 hover:text-blue-600 hover:bg-gray-50' }", data: { action: "click->mobile-sidebar#closeSidebar" } do %>
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
</svg>
@@ -158,7 +161,7 @@
<% end %>
</li>
<li>
<%= link_to admin_applications_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-gray-700 hover:text-blue-600 hover:bg-gray-50" do %>
<%= link_to admin_applications_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path.start_with?('/admin/applications') ? 'bg-gray-50 text-blue-600' : 'text-gray-700 hover:text-blue-600 hover:bg-gray-50' }", data: { action: "click->mobile-sidebar#closeSidebar" } do %>
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z" />
</svg>
@@ -166,7 +169,7 @@
<% end %>
</li>
<li>
<%= link_to admin_groups_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-gray-700 hover:text-blue-600 hover:bg-gray-50" do %>
<%= link_to admin_groups_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path.start_with?('/admin/groups') ? 'bg-gray-50 text-blue-600' : 'text-gray-700 hover:text-blue-600 hover:bg-gray-50' }", data: { action: "click->mobile-sidebar#closeSidebar" } do %>
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z" />
</svg>
@@ -175,7 +178,7 @@
</li>
<% end %>
<li>
<%= link_to profile_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-gray-700 hover:text-blue-600 hover:bg-gray-50" do %>
<%= link_to profile_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path == '/profile' ? 'bg-gray-50 text-blue-600' : 'text-gray-700 hover:text-blue-600 hover:bg-gray-50' }", data: { action: "click->mobile-sidebar#closeSidebar" } do %>
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M17.982 18.725A7.488 7.488 0 0012 15.75a7.488 7.488 0 00-5.982 2.975m11.963 0a9 9 0 10-11.963 0m11.963 0A8.966 8.966 0 0112 21a8.966 8.966 0 01-5.982-2.275M15 9.75a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
@@ -183,7 +186,7 @@
<% end %>
</li>
<li>
<%= link_to active_sessions_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-gray-700 hover:text-blue-600 hover:bg-gray-50" do %>
<%= link_to active_sessions_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path == '/active_sessions' ? 'bg-gray-50 text-blue-600' : 'text-gray-700 hover:text-blue-600 hover:bg-gray-50' }", data: { action: "click->mobile-sidebar#closeSidebar" } do %>
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 18v-5.25m0 0a6.01 6.01 0 001.5-.189m-1.5.189a6.01 6.01 0 01-1.5-.189m3.75 7.478a12.06 12.06 0 01-4.5 0m3.75 2.383a14.406 14.406 0 01-3 0M14.25 18v-.192c0-.983.658-1.823 1.508-2.316a7.5 7.5 0 10-7.517 0c.85.493 1.509 1.333 1.509 2.316V18" />
</svg>
@@ -191,7 +194,7 @@
<% end %>
</li>
<li>
<%= link_to signout_path, data: { turbo_method: :delete }, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-red-600 hover:text-red-700 hover:bg-red-50" do %>
<%= link_to signout_path, data: { turbo_method: :delete, action: "click->mobile-sidebar#closeSidebar" }, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 text-red-600 hover:text-red-700 hover:bg-red-50" do %>
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75" />
</svg>

View File

@@ -45,8 +45,13 @@
</div>
<div class="mt-8">
<%= 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" %>
<% if @auto_signin_pending %>
<%= 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>

View File

@@ -4,17 +4,8 @@
<p class="mt-2 text-gray-600">Create your admin account to get started</p>
</div>
<%= form_with model: @user, url: signup_path, class: "contents" do |form| %>
<% if @user.errors.any? %>
<div class="bg-red-50 text-red-500 px-3 py-2 font-medium rounded-lg mt-3">
<h2><%= pluralize(@user.errors.count, "error") %> prohibited this account from being saved:</h2>
<ul class="list-disc list-inside">
<% @user.errors.each do |error| %>
<li><%= error.full_message %></li>
<% end %>
</ul>
</div>
<% end %>
<%= form_with model: @user, url: signup_path, class: "contents", data: { controller: "form-errors" } do |form| %>
<%= render "shared/form_errors", form: form %>
<div class="my-5">
<%= form.label :email_address, class: "block font-medium text-sm text-gray-700" %>

View File

@@ -83,4 +83,14 @@ Rails.application.configure do
# Apply autocorrection by RuboCop to files generated by `bin/rails generate`.
# config.generators.apply_rubocop_autocorrect_after_generate!
# Sentry configuration for development
# Only enabled if SENTRY_DSN environment variable is set and explicitly enabled
if ENV["SENTRY_DSN"].present? && ENV["SENTRY_ENABLED_IN_DEVELOPMENT"] == "true"
config.sentry.enabled = true
# High sample rates for development debugging
config.sentry.traces_sample_rate = ENV.fetch("SENTRY_TRACES_SAMPLE_RATE", 0.5).to_f
config.sentry.profiles_sample_rate = ENV.fetch("SENTRY_PROFILES_SAMPLE_RATE", 0.2).to_f
end
end

View File

@@ -80,14 +80,28 @@ Rails.application.configure do
# Only use :id for inspections in production.
config.active_record.attributes_for_inspect = [ :id ]
# Helper method to extract domain from CLINCH_HOST (removes protocol if present)
def self.extract_domain(host)
return host if host.blank?
# Remove protocol (http:// or https://) if present
host.gsub(/^https?:\/\//, '')
end
# Helper method to ensure URL has https:// protocol
def self.ensure_https(url)
return url if url.blank?
# Add https:// if no protocol is present
url.match?(/^https?:\/\//) ? url : "https://#{url}"
end
# Enable DNS rebinding protection and other `Host` header attacks.
# Configure allowed hosts based on deployment scenario
allowed_hosts = [
ENV.fetch('CLINCH_HOST', 'auth.example.com'), # External domain (auth service itself)
extract_domain(ENV.fetch('CLINCH_HOST', 'auth.example.com')), # External domain (auth service itself)
]
# Use PublicSuffix to extract registrable domain and allow all subdomains
host_domain = ENV.fetch('CLINCH_HOST', 'auth.example.com')
host_domain = extract_domain(ENV.fetch('CLINCH_HOST', 'auth.example.com'))
if host_domain.present?
begin
# Use PublicSuffix to properly extract the domain
@@ -133,4 +147,18 @@ Rails.application.configure do
# Skip DNS rebinding protection for the default health check endpoint.
config.host_authorization = { exclude: ->(request) { request.path == "/up" } }
# Sentry configuration for production
# Only enabled if SENTRY_DSN environment variable is set
if ENV["SENTRY_DSN"].present?
config.sentry.enabled = true
# Performance monitoring: sample 20% of transactions for traces
# Adjust based on your traffic volume and Sentry plan limits
config.sentry.traces_sample_rate = ENV.fetch("SENTRY_TRACES_SAMPLE_RATE", 0.2).to_f
# Continuous profiling: disabled by default in production due to cost
# Enable temporarily for performance investigations if needed
config.sentry.profiles_sample_rate = ENV.fetch("SENTRY_PROFILES_SAMPLE_RATE", 0.0).to_f
end
end

View File

@@ -50,4 +50,8 @@ Rails.application.configure do
# Raise error when a before_action's only/except options reference missing actions.
config.action_controller.raise_on_missing_callback_actions = true
# Disable Sentry in test environment to avoid interference with tests
# Sentry can be explicitly enabled for integration testing if needed
ENV["SENTRY_ENABLED_IN_DEVELOPMENT"] = "false"
end

View File

@@ -0,0 +1,14 @@
# Configure ActiveStorage content type resolution
Rails.application.config.after_initialize do
# Ensure SVG files are served with the correct content type
ActiveStorage::Blob.class_eval do
def content_type_for_serving
# Override content type for SVG files
if filename.extension == "svg" && content_type == "application/octet-stream"
"image/svg+xml"
else
content_type
end
end
end
end

View File

@@ -39,6 +39,7 @@ Rails.application.configure do
policy.base_uri :self
# Form actions: Allow self for all form submissions
# Note: OAuth redirects will be handled dynamically in the consent page
policy.form_action :self
# Manifest sources: Allow self for PWA manifest
@@ -53,8 +54,12 @@ Rails.application.configure do
# Additional security headers for WebAuthn
# Required for WebAuthn to work properly
policy.require_trusted_types_for :none
# CSP reporting using report_uri (supported method)
policy.report_uri "/api/csp-violation-report"
end
# Start with CSP in report-only mode for testing
# Set to false after verifying everything works in production
config.content_security_policy_report_only = Rails.env.development?

View File

@@ -0,0 +1,128 @@
# Local file logger for CSP violations
# Provides local logging even when Sentry is not configured
Rails.application.config.after_initialize do
# Create a dedicated logger for CSP violations
csp_log_path = Rails.root.join("log", "csp_violations.log")
# Configure log rotation
csp_logger = Logger.new(
csp_log_path,
'daily', # Rotate daily
30 # Keep 30 old log files
)
csp_logger.level = Logger::INFO
# Format: [TIMESTAMP] LEVEL MESSAGE
csp_logger.formatter = proc do |severity, datetime, progname, msg|
"[#{datetime.strftime('%Y-%m-%d %H:%M:%S')}] #{severity} #{msg}\n"
end
module CspViolationLocalLogger
def self.emit(event)
csp_data = event[:payload] || {}
# Skip logging if there's no meaningful violation data
return if csp_data.empty? ||
(csp_data[:violated_directive].nil? &&
csp_data[:blocked_uri].nil? &&
csp_data[:document_uri].nil?)
# Build a structured log message
violated_directive = csp_data[:violated_directive] || "unknown"
blocked_uri = csp_data[:blocked_uri] || "unknown"
document_uri = csp_data[:document_uri] || "unknown"
# Create a comprehensive log entry
log_message = "CSP VIOLATION DETECTED\n"
log_message += " Directive: #{violated_directive}\n"
log_message += " Blocked URI: #{blocked_uri}\n"
log_message += " Document URI: #{document_uri}\n"
log_message += " User Agent: #{csp_data[:user_agent]}\n"
log_message += " IP Address: #{csp_data[:ip_address]}\n"
log_message += " Timestamp: #{csp_data[:timestamp]}\n"
if csp_data[:current_user_id].present?
log_message += " Authenticated User ID: #{csp_data[:current_user_id]}\n"
log_message += " Session ID: #{csp_data[:session_id]}\n"
else
log_message += " User: Anonymous\n"
end
# Add additional details if available
if csp_data[:source_file].present?
log_message += " Source File: #{csp_data[:source_file]}"
log_message += ":#{csp_data[:line_number]}" if csp_data[:line_number].present?
log_message += ":#{csp_data[:column_number]}" if csp_data[:column_number].present?
log_message += "\n"
end
if csp_data[:referrer].present?
log_message += " Referrer: #{csp_data[:referrer]}\n"
end
# Determine severity for log level
level = determine_log_level(csp_data[:violated_directive])
self.csp_logger.log(level, log_message)
# Also log to main Rails logger for visibility
Rails.logger.info "CSP violation logged to csp_violations.log: #{violated_directive} - #{blocked_uri}"
rescue => e
# Ensure logger errors don't break the CSP reporting flow
Rails.logger.error "Failed to log CSP violation to file: #{e.message}"
Rails.logger.error e.backtrace.join("\n") if Rails.env.development?
end
def self.csp_logger
@csp_logger ||= begin
csp_log_path = Rails.root.join("log", "csp_violations.log")
logger = Logger.new(
csp_log_path,
'daily', # Rotate daily
30 # Keep 30 old log files
)
logger.level = Logger::INFO
logger.formatter = proc do |severity, datetime, progname, msg|
"[#{datetime.strftime('%Y-%m-%d %H:%M:%S')}] #{severity} #{msg}\n"
end
logger
end
end
private
def self.determine_log_level(violated_directive)
return Logger::INFO unless violated_directive.present?
case violated_directive.to_sym
when :script_src, :script_src_elem, :script_src_attr, :frame_src, :child_src
Logger::WARN # Higher priority violations
when :connect_src, :default_src, :style_src, :style_src_elem, :style_src_attr
Logger::INFO # Medium priority violations
else
Logger::DEBUG # Lower priority violations
end
end
end
# Register the local logger subscriber
Rails.event.subscribe(CspViolationLocalLogger)
Rails.logger.info "CSP violation local logger registered - logging to: #{csp_log_path}"
# Ensure the log file is created and writable
begin
# Create log file if it doesn't exist
FileUtils.touch(csp_log_path) unless File.exist?(csp_log_path)
# Test write to ensure permissions are correct
csp_logger.info "CSP Logger initialized at #{Time.current}"
rescue => e
Rails.logger.error "Failed to initialize CSP local logger: #{e.message}"
Rails.logger.error "CSP violations will only be sent to Sentry (if configured)"
end
end

View File

@@ -0,0 +1,140 @@
# Sentry configuration for error tracking and performance monitoring
# Only initializes if SENTRY_DSN environment variable is set
return unless ENV["SENTRY_DSN"].present?
Rails.application.configure do
config.sentry.dsn = ENV["SENTRY_DSN"]
# Set environment (defaults to Rails.env)
config.sentry.environment = ENV["SENTRY_ENVIRONMENT"] || Rails.env
# Set release version from Git or environment variable
config.sentry.release = ENV["SENTRY_RELEASE"] || `git rev-parse HEAD 2>/dev/null`.strip.presence || nil
# Sample rate for performance monitoring (0.0 to 1.0)
config.sentry.traces_sample_rate = ENV.fetch("SENTRY_TRACES_SAMPLE_RATE", 0.1).to_f
# Enable profiling in development/staging, disable in production unless explicitly enabled
config.sentry.profiles_sample_rate = if Rails.env.production?
ENV.fetch("SENTRY_PROFILES_SAMPLE_RATE", 0.0).to_f
else
ENV.fetch("SENTRY_PROFILES_SAMPLE_RATE", 0.5).to_f
end
# Include additional context
config.sentry.before_send = lambda do |event, hint|
# Filter out sensitive information
if event.context[:extra]
event.context[:extra].reject! { |key, value|
key.to_s.match?(/password|secret|token|key/i) || value.to_s.match?(/password|secret/i)
}
end
# Filter sensitive parameters
if event.context[:request]
event.context[:request].reject! { |key, value|
key.to_s.match?(/password|secret|token|key|authorization/i)
}
end
event
end
# Include breadcrumbs for debugging
config.sentry.breadcrumbs_logger = [:active_support_logger, :http_logger]
# Send session data for user context
config.sentry.user_context = lambda do
if Current.user.present?
{
id: Current.user.id,
email: Current.user.email_address,
admin: Current.user.admin?
}
end
end
# Ignore common non-critical exceptions
config.sentry.excluded_exceptions += [
"ActionController::RoutingError",
"ActionController::InvalidAuthenticityToken",
"ActionController::UnknownFormat",
"ActionDispatch::Http::Parameters::ParseError",
"Rack::QueryParser::InvalidParameterError",
"Rack::Timeout::RequestTimeoutException",
"ActiveRecord::RecordNotFound"
]
# Add CSP-specific tags for security events
config.sentry.tags = lambda do
{
# Add application context
app_name: "clinch",
app_environment: Rails.env,
# Add CSP policy status
csp_enabled: defined?(Rails.application.config.content_security_policy) &&
Rails.application.config.content_security_policy.present?
}
end
# Enhance before_send to handle CSP events properly
config.sentry.before_send = lambda do |event, hint|
# Filter out sensitive information
if event.context[:extra]
event.context[:extra].reject! { |key, value|
key.to_s.match?(/password|secret|token|key/i) || value.to_s.match?(/password|secret/i)
}
end
# Filter sensitive parameters
if event.context[:request]
event.context[:request].reject! { |key, value|
key.to_s.match?(/password|secret|token|key|authorization/i)
}
end
# Special handling for CSP violations
if event.tags&.dig(:csp_violation)
# Ensure CSP violations have proper security context
event.context[:server] = event.context[:server] || {}
event.context[:server][:name] = "clinch-auth-service"
event.context[:server][:environment] = Rails.env
# Add additional security context
event.context[:extra] ||= {}
event.context[:extra][:security_context] = {
csp_reporting: true,
user_authenticated: event.context[:user].present?,
request_origin: event.context[:request]&.dig(:headers, "Origin"),
request_referer: event.context[:request]&.dig(:headers, "Referer")
}
end
event
end
# Add CSP-specific breadcrumbs for security events
config.sentry.before_breadcrumb = lambda do |breadcrumb, hint|
# Filter out sensitive breadcrumb data
if breadcrumb[:data]
breadcrumb[:data].reject! { |key, value|
key.to_s.match?(/password|secret|token|key|authorization/i) ||
value.to_s.match?(/password|secret/i)
}
end
# Mark CSP-related events
if breadcrumb[:message]&.include?("CSP Violation") ||
breadcrumb[:category]&.include?("csp")
breadcrumb[:data] ||= {}
breadcrumb[:data][:security_event] = true
breadcrumb[:data][:csp_violation] = true
end
breadcrumb
end
# Only send errors in production unless explicitly enabled
config.sentry.enabled = Rails.env.production? || ENV["SENTRY_ENABLED_IN_DEVELOPMENT"] == "true"
end

View File

@@ -0,0 +1,120 @@
# Sentry subscriber for CSP violations via Structured Event Reporting
# This subscriber only sends events to Sentry if Sentry is properly initialized
Rails.application.config.after_initialize do
# Only register the subscriber if Sentry is available and configured
if defined?(Sentry) && Sentry.initialized?
module CspViolationSentrySubscriber
def self.emit(event)
# Extract relevant CSP violation data
csp_data = event[:payload] || {}
# Build a descriptive message for Sentry
violated_directive = csp_data[:violated_directive]
blocked_uri = csp_data[:blocked_uri]
document_uri = csp_data[:document_uri]
message = "CSP Violation: #{violated_directive}"
message += " - Blocked: #{blocked_uri}" if blocked_uri.present?
message += " - On: #{document_uri}" if document_uri.present?
# Extract domain from blocked_uri for better classification
blocked_domain = extract_domain(blocked_uri) if blocked_uri.present?
# Determine severity based on violation type
level = determine_severity(violated_directive, blocked_uri)
# Send to Sentry with rich context
Sentry.capture_message(
message,
level: level,
tags: {
csp_violation: true,
violated_directive: violated_directive,
blocked_domain: blocked_domain,
document_domain: extract_domain(document_uri),
user_authenticated: csp_data[:current_user_id].present?
},
extra: {
# Full CSP report data
csp_violation_details: csp_data,
# Additional context for security analysis
request_context: {
user_agent: csp_data[:user_agent],
ip_address: csp_data[:ip_address],
session_id: csp_data[:session_id],
timestamp: csp_data[:timestamp]
}
},
user: csp_data[:current_user_id] ? { id: csp_data[:current_user_id] } : nil
)
# Log to Rails logger for redundancy
Rails.logger.info "CSP violation sent to Sentry: #{message}"
rescue => e
# Ensure subscriber errors don't break the CSP reporting flow
Rails.logger.error "Failed to send CSP violation to Sentry: #{e.message}"
Rails.logger.error e.backtrace.join("\n") if Rails.env.development?
end
private
# Extract domain from URI for better analysis
def self.extract_domain(uri)
return nil if uri.blank?
begin
parsed = URI.parse(uri)
parsed.host
rescue URI::InvalidURIError
# Handle cases where URI might be malformed or just a path
if uri.start_with?('/')
nil # It's a relative path, no domain
else
uri.split('/').first # Best effort extraction
end
end
end
# Determine severity level based on violation type
def self.determine_severity(violated_directive, blocked_uri)
return :warning unless violated_directive.present?
case violated_directive.to_sym
when :script_src, :script_src_elem, :script_src_attr
# Script violations are highest priority (XSS risk)
:error
when :style_src, :style_src_elem, :style_src_attr
# Style violations are moderate risk
:warning
when :img_src
# Image violations are typically lower priority
:info
when :connect_src
# Network violations are important
:warning
when :font_src, :media_src
# Font/media violations are lower priority
:info
when :frame_src, :child_src
# Frame violations can be security critical
:error
when :default_src
# Default src violations are important
:warning
else
# Unknown or custom directives
:warning
end
end
end
# Register the subscriber for CSP violation events
Rails.event.subscribe(CspViolationSentrySubscriber)
Rails.logger.info "CSP violation Sentry subscriber registered"
else
Rails.logger.info "Sentry not initialized - CSP violations will only be logged locally"
end
end

View File

@@ -1,14 +1,31 @@
# WebAuthn configuration for Clinch Identity Provider
WebAuthn.configure do |config|
# Relying Party name (displayed in authenticator prompts)
# For development, use http://localhost to match passkey in Passwords app
# CLINCH_HOST should include protocol (https://) for WebAuthn
origin_host = ENV.fetch("CLINCH_HOST", "http://localhost")
config.allowed_origins = [origin_host]
# Relying Party ID (must match origin domain)
# Extract domain from origin for RP ID
origin_uri = URI.parse(origin_host)
config.rp_id = ENV.fetch("CLINCH_RP_ID", "localhost")
# Relying Party ID (must match origin domain without protocol)
# Extract domain from origin for RP ID if CLINCH_RP_ID not set
if ENV["CLINCH_RP_ID"].present?
config.rp_id = ENV["CLINCH_RP_ID"]
else
# Extract registrable domain from CLINCH_HOST using PublicSuffix
origin_uri = URI.parse(origin_host)
if origin_uri.host
begin
# Use PublicSuffix to get the registrable domain (e.g., "aapamilne.com" from "auth.aapamilne.com")
domain = PublicSuffix.parse(origin_uri.host)
config.rp_id = domain.domain || origin_uri.host
rescue PublicSuffix::DomainInvalid => e
Rails.logger.warn "WebAuthn: Failed to parse domain '#{origin_uri.host}': #{e.message}, using host as fallback"
config.rp_id = origin_uri.host
end
else
Rails.logger.error "WebAuthn: Could not extract host from CLINCH_HOST '#{origin_host}'"
config.rp_id = "localhost"
end
end
# For development, we also allow localhost with common ports and without port
if Rails.env.development?

17
config/recurring.yml Normal file
View File

@@ -0,0 +1,17 @@
# Solid Queue Recurring Jobs Configuration
# This file defines scheduled/cron-like jobs that run periodically
production:
oidc_token_cleanup:
class: OidcTokenCleanupJob
schedule: "0 3 * * *" # Run daily at 3:00 AM
queue: default
development:
oidc_token_cleanup:
class: OidcTokenCleanupJob
schedule: "0 3 * * *" # Run daily at 3:00 AM
queue: default
test:
# No recurring jobs in test environment

View File

@@ -29,6 +29,7 @@ Rails.application.routes.draw do
get "/oauth/authorize", to: "oidc#authorize"
post "/oauth/authorize/consent", to: "oidc#consent", as: :oauth_consent
post "/oauth/token", to: "oidc#token"
post "/oauth/revoke", to: "oidc#revoke"
get "/oauth/userinfo", to: "oidc#userinfo"
get "/logout", to: "oidc#logout"
@@ -66,6 +67,7 @@ Rails.application.routes.draw do
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
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
get '/webauthn/new', to: 'webauthn#new', as: :new_webauthn
@@ -80,6 +82,8 @@ Rails.application.routes.draw do
resources :users do
member do
post :resend_invitation
post :update_application_claims
delete :delete_application_claims
end
end
resources :applications do

View File

@@ -0,0 +1,9 @@
class AddPkceSupportToOidcAuthorizationCodes < ActiveRecord::Migration[8.1]
def change
add_column :oidc_authorization_codes, :code_challenge, :string
add_column :oidc_authorization_codes, :code_challenge_method, :string
# Add index for code_challenge to improve query performance
add_index :oidc_authorization_codes, :code_challenge
end
end

View File

@@ -0,0 +1,17 @@
class FixEmptyDomainPatterns < ActiveRecord::Migration[8.1]
def up
# Convert empty string domain_patterns to NULL
# This fixes a unique constraint issue where multiple OIDC apps
# had empty string domain_patterns, causing uniqueness violations
execute <<-SQL
UPDATE applications
SET domain_pattern = NULL
WHERE domain_pattern = ''
SQL
end
def down
# No need to reverse this - empty strings and NULL are functionally equivalent
# for OIDC applications where domain_pattern is not used
end
end

View File

@@ -0,0 +1,22 @@
class CreateOidcRefreshTokens < ActiveRecord::Migration[8.1]
def change
create_table :oidc_refresh_tokens do |t|
t.string :token_digest, null: false # BCrypt hashed token
t.references :application, null: false, foreign_key: true
t.references :user, null: false, foreign_key: true
t.references :oidc_access_token, null: false, foreign_key: true
t.string :scope
t.datetime :expires_at, null: false
t.datetime :revoked_at
t.integer :token_family_id # For token rotation detection
t.timestamps
end
add_index :oidc_refresh_tokens, :token_digest, unique: true
add_index :oidc_refresh_tokens, :expires_at
add_index :oidc_refresh_tokens, :revoked_at
add_index :oidc_refresh_tokens, :token_family_id
add_index :oidc_refresh_tokens, [ :application_id, :user_id ]
end
end

View File

@@ -0,0 +1,9 @@
class AddTokenDigestToOidcAccessTokens < ActiveRecord::Migration[8.1]
def change
add_column :oidc_access_tokens, :token_digest, :string
add_column :oidc_access_tokens, :revoked_at, :datetime
add_index :oidc_access_tokens, :token_digest, unique: true
add_index :oidc_access_tokens, :revoked_at
end
end

View File

@@ -0,0 +1,7 @@
class AddTokenExpiryToApplications < ActiveRecord::Migration[8.1]
def change
add_column :applications, :access_token_ttl, :integer, default: 3600 # 1 hour in seconds
add_column :applications, :refresh_token_ttl, :integer, default: 2592000 # 30 days in seconds
add_column :applications, :id_token_ttl, :integer, default: 3600 # 1 hour in seconds
end
end

View File

@@ -0,0 +1,5 @@
class MakeOidcAccessTokenTokenNullable < ActiveRecord::Migration[8.1]
def change
change_column_null :oidc_access_tokens, :token, true
end
end

View File

@@ -0,0 +1,15 @@
class AddSidToOidcUserConsent < ActiveRecord::Migration[8.1]
def change
add_column :oidc_user_consents, :sid, :string
add_index :oidc_user_consents, :sid
# Generate UUIDs for existing consent records
reversible do |dir|
dir.up do
OidcUserConsent.where(sid: nil).find_each do |consent|
consent.update_column(:sid, SecureRandom.uuid)
end
end
end
end
end

View File

@@ -0,0 +1,13 @@
class CreateApplicationUserClaims < ActiveRecord::Migration[8.1]
def change
create_table :application_user_claims do |t|
t.references :application, null: false, foreign_key: { on_delete: :cascade }
t.references :user, null: false, foreign_key: { on_delete: :cascade }
t.json :custom_claims, default: {}, null: false
t.timestamps
end
add_index :application_user_claims, [:application_id, :user_id], unique: true, name: 'index_app_user_claims_unique'
end
end

View File

@@ -0,0 +1,6 @@
class AddUsernameToUsers < ActiveRecord::Migration[8.1]
def change
add_column :users, :username, :string
add_index :users, :username, unique: true
end
end

View File

@@ -0,0 +1,57 @@
# This migration comes from active_storage (originally 20170806125915)
class CreateActiveStorageTables < ActiveRecord::Migration[7.0]
def change
# Use Active Record's configured type for primary and foreign keys
primary_key_type, foreign_key_type = primary_and_foreign_key_types
create_table :active_storage_blobs, id: primary_key_type do |t|
t.string :key, null: false
t.string :filename, null: false
t.string :content_type
t.text :metadata
t.string :service_name, null: false
t.bigint :byte_size, null: false
t.string :checksum
if connection.supports_datetime_with_precision?
t.datetime :created_at, precision: 6, null: false
else
t.datetime :created_at, null: false
end
t.index [ :key ], unique: true
end
create_table :active_storage_attachments, id: primary_key_type do |t|
t.string :name, null: false
t.references :record, null: false, polymorphic: true, index: false, type: foreign_key_type
t.references :blob, null: false, type: foreign_key_type
if connection.supports_datetime_with_precision?
t.datetime :created_at, precision: 6, null: false
else
t.datetime :created_at, null: false
end
t.index [ :record_type, :record_id, :name, :blob_id ], name: :index_active_storage_attachments_uniqueness, unique: true
t.foreign_key :active_storage_blobs, column: :blob_id
end
create_table :active_storage_variant_records, id: primary_key_type do |t|
t.belongs_to :blob, null: false, index: false, type: foreign_key_type
t.string :variation_digest, null: false
t.index [ :blob_id, :variation_digest ], name: :index_active_storage_variant_records_uniqueness, unique: true
t.foreign_key :active_storage_blobs, column: :blob_id
end
end
private
def primary_and_foreign_key_types
config = Rails.configuration.generators
setting = config.options[config.orm][:primary_key_type]
primary_key_type = setting || :primary_key
foreign_key_type = setting || :bigint
[ primary_key_type, foreign_key_type ]
end
end

55
db/schema.rb generated
View File

@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.1].define(version: 2025_11_04_064114) do
ActiveRecord::Schema[8.1].define(version: 2025_11_25_012446) do
create_table "application_groups", force: :cascade do |t|
t.integer "application_id", null: false
t.datetime "created_at", null: false
@@ -21,7 +21,19 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_04_064114) do
t.index ["group_id"], name: "index_application_groups_on_group_id"
end
create_table "application_user_claims", force: :cascade do |t|
t.integer "application_id", null: false
t.datetime "created_at", null: false
t.json "custom_claims", default: {}, null: false
t.datetime "updated_at", null: false
t.integer "user_id", null: false
t.index ["application_id", "user_id"], name: "index_app_user_claims_unique", unique: true
t.index ["application_id"], name: "index_application_user_claims_on_application_id"
t.index ["user_id"], name: "index_application_user_claims_on_user_id"
end
create_table "applications", force: :cascade do |t|
t.integer "access_token_ttl", default: 3600
t.boolean "active", default: true, null: false
t.string "app_type", null: false
t.string "client_id"
@@ -30,10 +42,12 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_04_064114) do
t.text "description"
t.string "domain_pattern"
t.json "headers_config", default: {}, null: false
t.integer "id_token_ttl", default: 3600
t.string "landing_url"
t.text "metadata"
t.string "name", null: false
t.text "redirect_uris"
t.integer "refresh_token_ttl", default: 2592000
t.string "slug", null: false
t.datetime "updated_at", null: false
t.index ["active"], name: "index_applications_on_active"
@@ -55,20 +69,26 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_04_064114) do
t.integer "application_id", null: false
t.datetime "created_at", null: false
t.datetime "expires_at", null: false
t.datetime "revoked_at"
t.string "scope"
t.string "token", null: false
t.string "token"
t.string "token_digest"
t.datetime "updated_at", null: false
t.integer "user_id", null: false
t.index ["application_id", "user_id"], name: "index_oidc_access_tokens_on_application_id_and_user_id"
t.index ["application_id"], name: "index_oidc_access_tokens_on_application_id"
t.index ["expires_at"], name: "index_oidc_access_tokens_on_expires_at"
t.index ["revoked_at"], name: "index_oidc_access_tokens_on_revoked_at"
t.index ["token"], name: "index_oidc_access_tokens_on_token", unique: true
t.index ["token_digest"], name: "index_oidc_access_tokens_on_token_digest", unique: true
t.index ["user_id"], name: "index_oidc_access_tokens_on_user_id"
end
create_table "oidc_authorization_codes", force: :cascade do |t|
t.integer "application_id", null: false
t.string "code", null: false
t.string "code_challenge"
t.string "code_challenge_method"
t.datetime "created_at", null: false
t.datetime "expires_at", null: false
t.string "nonce"
@@ -80,19 +100,43 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_04_064114) do
t.index ["application_id", "user_id"], name: "index_oidc_authorization_codes_on_application_id_and_user_id"
t.index ["application_id"], name: "index_oidc_authorization_codes_on_application_id"
t.index ["code"], name: "index_oidc_authorization_codes_on_code", unique: true
t.index ["code_challenge"], name: "index_oidc_authorization_codes_on_code_challenge"
t.index ["expires_at"], name: "index_oidc_authorization_codes_on_expires_at"
t.index ["user_id"], name: "index_oidc_authorization_codes_on_user_id"
end
create_table "oidc_refresh_tokens", force: :cascade do |t|
t.integer "application_id", null: false
t.datetime "created_at", null: false
t.datetime "expires_at", null: false
t.integer "oidc_access_token_id", null: false
t.datetime "revoked_at"
t.string "scope"
t.string "token_digest", null: false
t.integer "token_family_id"
t.datetime "updated_at", null: false
t.integer "user_id", null: false
t.index ["application_id", "user_id"], name: "index_oidc_refresh_tokens_on_application_id_and_user_id"
t.index ["application_id"], name: "index_oidc_refresh_tokens_on_application_id"
t.index ["expires_at"], name: "index_oidc_refresh_tokens_on_expires_at"
t.index ["oidc_access_token_id"], name: "index_oidc_refresh_tokens_on_oidc_access_token_id"
t.index ["revoked_at"], name: "index_oidc_refresh_tokens_on_revoked_at"
t.index ["token_digest"], name: "index_oidc_refresh_tokens_on_token_digest", unique: true
t.index ["token_family_id"], name: "index_oidc_refresh_tokens_on_token_family_id"
t.index ["user_id"], name: "index_oidc_refresh_tokens_on_user_id"
end
create_table "oidc_user_consents", force: :cascade do |t|
t.integer "application_id", null: false
t.datetime "created_at", null: false
t.datetime "granted_at", null: false
t.text "scopes_granted", null: false
t.string "sid"
t.datetime "updated_at", null: false
t.integer "user_id", null: false
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 ["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"], name: "index_oidc_user_consents_on_user_id"
end
@@ -136,10 +180,12 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_04_064114) do
t.boolean "totp_required", default: false, null: false
t.string "totp_secret"
t.datetime "updated_at", null: false
t.string "username"
t.string "webauthn_id"
t.boolean "webauthn_required", default: false, null: false
t.index ["email_address"], name: "index_users_on_email_address", unique: true
t.index ["status"], name: "index_users_on_status"
t.index ["username"], name: "index_users_on_username", unique: true
t.index ["webauthn_id"], name: "index_users_on_webauthn_id", unique: true
end
@@ -167,10 +213,15 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_04_064114) do
add_foreign_key "application_groups", "applications"
add_foreign_key "application_groups", "groups"
add_foreign_key "application_user_claims", "applications", on_delete: :cascade
add_foreign_key "application_user_claims", "users", on_delete: :cascade
add_foreign_key "oidc_access_tokens", "applications"
add_foreign_key "oidc_access_tokens", "users"
add_foreign_key "oidc_authorization_codes", "applications"
add_foreign_key "oidc_authorization_codes", "users"
add_foreign_key "oidc_refresh_tokens", "applications"
add_foreign_key "oidc_refresh_tokens", "oidc_access_tokens"
add_foreign_key "oidc_refresh_tokens", "users"
add_foreign_key "oidc_user_consents", "applications"
add_foreign_key "oidc_user_consents", "users"
add_foreign_key "sessions", "users"

View File

@@ -44,10 +44,7 @@ Then set it securely:
# Generate key
bin/generate_oidc_key > oidc_private_key.pem
# Option A: Using kamal env push (Kamal 2.0+)
kamal env push OIDC_PRIVATE_KEY="$(cat oidc_private_key.pem)"
# Option B: Add to .kamal/secrets
# Add to .kamal/secrets
echo "OIDC_PRIVATE_KEY=$(cat oidc_private_key.pem)" >> .kamal/secrets
```
@@ -60,57 +57,6 @@ bin/rails runner "puts OidcJwtService.send(:private_key).present? ? 'Key loaded'
---
## Option 2: Rails Credentials (Simpler but less flexible)
### 1. Generate the key
```bash
openssl genrsa -out oidc_private_key.pem 2048
```
### 2. Add to Rails credentials
```bash
EDITOR="nano" bin/rails credentials:edit
```
Add this section:
```yaml
oidc_private_key: |
-----BEGIN RSA PRIVATE KEY-----
MIIEpAIBAAKCAQEAyZ0qaICMiLVWSFs+ef9Xok3fzy0p6k/7D5TQzmxf7C2vQG7s
2Odmi8iAHLoaUBaFj70qTbaconWyMr8s+ah+qZwrwolTLUe23VrceVXvInU57hBL
...
-----END RSA PRIVATE KEY-----
```
**Important:** Use the `|` pipe character for multi-line, and indent the key content with 2 spaces.
### 3. Save and verify
```bash
# Verify credentials file
cat config/credentials.yml.enc # Should show encrypted data
# Test in console
bin/rails runner "puts OidcJwtService.send(:private_key).present? ? 'Key loaded' : 'Key missing'"
```
### 4. For deployment
The `config/credentials.yml.enc` file is committed to git. You need to:
1. **Set RAILS_MASTER_KEY** env variable in production
2. Get the key from `config/master.key` (don't commit this!)
```bash
# In Kamal
kamal env push RAILS_MASTER_KEY="$(cat config/master.key)"
```
---
## Comparison
| Feature | ENV Variable | Rails Credentials |
@@ -145,31 +91,7 @@ kamal env push RAILS_MASTER_KEY="$(cat config/master.key)"
## Key Rotation (Advanced)
If you need to rotate keys (security incident, etc.):
### 1. Generate new key
```bash
openssl genrsa -out oidc_private_key_new.pem 2048
```
### 2. Add NEW key alongside old (dual-key setup)
This requires code changes to support multiple keys in JWKS. For now, rotation means:
**Warning:** Rotating the key will **invalidate all existing OIDC sessions**. Users will need to log in again.
### 3. Update OIDC_PRIVATE_KEY
```bash
kamal env push OIDC_PRIVATE_KEY="$(cat oidc_private_key_new.pem)"
```
### 4. Restart application
```bash
kamal deploy
```
Todo
---

View File

@@ -0,0 +1,441 @@
require "test_helper"
class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
def setup
@user = User.create!(email_address: "security_test@example.com", password: "password123")
@application = Application.create!(
name: "Security Test App",
slug: "security-test-app",
app_type: "oidc",
redirect_uris: ["http://localhost:4000/callback"].to_json,
active: true
)
# Store the plain text client secret for testing
@client_secret = @application.client_secret_digest
@application.generate_new_client_secret!
@plain_client_secret = @application.client_secret
@application.save!
end
def teardown
OidcAuthorizationCode.where(application: @application).delete_all
# Use delete_all to avoid triggering callbacks that might have issues with the schema
OidcAccessToken.where(application: @application).delete_all
@user.destroy
@application.destroy
end
# ====================
# CRITICAL SECURITY TESTS
# ====================
test "prevents authorization code reuse - sequential attempts" do
# Create a valid authorization code
auth_code = OidcAuthorizationCode.create!(
application: @application,
user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
expires_at: 10.minutes.from_now
)
token_params = {
grant_type: "authorization_code",
code: auth_code.code,
redirect_uri: "http://localhost:4000/callback"
}
# First request should succeed
post "/oauth/token", params: token_params, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
}
assert_response :success
first_response = JSON.parse(@response.body)
assert first_response.key?("access_token")
assert first_response.key?("id_token")
# Second request with same code should fail
post "/oauth/token", params: token_params, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
}
assert_response :bad_request
error = JSON.parse(@response.body)
assert_equal "invalid_grant", error["error"]
assert_match(/already been used/, error["error_description"])
end
test "revokes existing tokens when authorization code is reused" do
# Create a valid authorization code
auth_code = OidcAuthorizationCode.create!(
application: @application,
user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
expires_at: 10.minutes.from_now
)
token_params = {
grant_type: "authorization_code",
code: auth_code.code,
redirect_uri: "http://localhost:4000/callback"
}
# First request - get access token
post "/oauth/token", params: token_params, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
}
assert_response :success
first_response = JSON.parse(@response.body)
first_access_token = first_response["access_token"]
# Verify the token works
get "/oauth/userinfo", headers: {
"Authorization" => "Bearer #{first_access_token}"
}
assert_response :success
# Second request with same code - should fail AND revoke first token
post "/oauth/token", params: token_params, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
}
assert_response :bad_request
# Verify the first token is now revoked (expired)
get "/oauth/userinfo", headers: {
"Authorization" => "Bearer #{first_access_token}"
}
assert_response :unauthorized, "First access token should be revoked after code reuse"
end
test "rejects already used authorization code" do
# Create and mark code as used
auth_code = OidcAuthorizationCode.create!(
application: @application,
user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
used: true,
expires_at: 10.minutes.from_now
)
token_params = {
grant_type: "authorization_code",
code: auth_code.code,
redirect_uri: "http://localhost:4000/callback"
}
post "/oauth/token", params: token_params, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
}
assert_response :bad_request
error = JSON.parse(@response.body)
assert_equal "invalid_grant", error["error"]
assert_match(/already been used/, error["error_description"])
end
test "rejects expired authorization code" do
# Create expired code
auth_code = OidcAuthorizationCode.create!(
application: @application,
user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
expires_at: 5.minutes.ago
)
token_params = {
grant_type: "authorization_code",
code: auth_code.code,
redirect_uri: "http://localhost:4000/callback"
}
post "/oauth/token", params: token_params, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
}
assert_response :bad_request
error = JSON.parse(@response.body)
assert_equal "invalid_grant", error["error"]
assert_match(/expired/, error["error_description"])
end
test "rejects authorization code with mismatched redirect_uri" do
auth_code = OidcAuthorizationCode.create!(
application: @application,
user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
expires_at: 10.minutes.from_now
)
token_params = {
grant_type: "authorization_code",
code: auth_code.code,
redirect_uri: "http://evil.com/callback" # Wrong redirect URI
}
post "/oauth/token", params: token_params, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
}
assert_response :bad_request
error = JSON.parse(@response.body)
assert_equal "invalid_grant", error["error"]
assert_match(/Redirect URI mismatch/, error["error_description"])
end
test "rejects non-existent authorization code" do
token_params = {
grant_type: "authorization_code",
code: "nonexistent_code_12345",
redirect_uri: "http://localhost:4000/callback"
}
post "/oauth/token", params: token_params, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
}
assert_response :bad_request
error = JSON.parse(@response.body)
assert_equal "invalid_grant", error["error"]
end
test "rejects authorization code for different application" do
# Create another application
other_app = Application.create!(
name: "Other App",
slug: "other-app",
app_type: "oidc",
redirect_uris: ["http://localhost:5000/callback"].to_json,
active: true
)
other_secret = other_app.client_secret
# Create auth code for first application
auth_code = OidcAuthorizationCode.create!(
application: @application,
user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
expires_at: 10.minutes.from_now
)
# Try to use it with different application credentials
token_params = {
grant_type: "authorization_code",
code: auth_code.code,
redirect_uri: "http://localhost:4000/callback"
}
post "/oauth/token", params: token_params, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{other_app.client_id}:#{other_secret}")
}
assert_response :bad_request
error = JSON.parse(@response.body)
assert_equal "invalid_grant", error["error"]
other_app.destroy
end
# ====================
# CLIENT AUTHENTICATION TESTS
# ====================
test "rejects invalid client_id in Basic auth" do
auth_code = OidcAuthorizationCode.create!(
application: @application,
user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
expires_at: 10.minutes.from_now
)
token_params = {
grant_type: "authorization_code",
code: auth_code.code,
redirect_uri: "http://localhost:4000/callback"
}
post "/oauth/token", params: token_params, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("invalid_client_id:#{@plain_client_secret}")
}
assert_response :unauthorized
error = JSON.parse(@response.body)
assert_equal "invalid_client", error["error"]
end
test "rejects invalid client_secret in Basic auth" do
auth_code = OidcAuthorizationCode.create!(
application: @application,
user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
expires_at: 10.minutes.from_now
)
token_params = {
grant_type: "authorization_code",
code: auth_code.code,
redirect_uri: "http://localhost:4000/callback"
}
post "/oauth/token", params: token_params, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:wrong_secret")
}
assert_response :unauthorized
error = JSON.parse(@response.body)
assert_equal "invalid_client", error["error"]
end
test "accepts client credentials in POST body" do
auth_code = OidcAuthorizationCode.create!(
application: @application,
user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
expires_at: 10.minutes.from_now
)
token_params = {
grant_type: "authorization_code",
code: auth_code.code,
redirect_uri: "http://localhost:4000/callback",
client_id: @application.client_id,
client_secret: @plain_client_secret
}
post "/oauth/token", params: token_params
assert_response :success
response_body = JSON.parse(@response.body)
assert response_body.key?("access_token")
assert response_body.key?("id_token")
end
test "rejects request with no client authentication" do
auth_code = OidcAuthorizationCode.create!(
application: @application,
user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
expires_at: 10.minutes.from_now
)
token_params = {
grant_type: "authorization_code",
code: auth_code.code,
redirect_uri: "http://localhost:4000/callback"
}
post "/oauth/token", params: token_params
assert_response :unauthorized
error = JSON.parse(@response.body)
assert_equal "invalid_client", error["error"]
end
# ====================
# GRANT TYPE VALIDATION
# ====================
test "rejects unsupported grant_type" do
post "/oauth/token", params: {
grant_type: "password",
username: "user",
password: "pass"
}, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
}
assert_response :bad_request
error = JSON.parse(@response.body)
assert_equal "unsupported_grant_type", error["error"]
end
test "rejects missing grant_type" do
post "/oauth/token", params: {
code: "some_code",
redirect_uri: "http://localhost:4000/callback"
}, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
}
assert_response :bad_request
error = JSON.parse(@response.body)
assert_equal "unsupported_grant_type", error["error"]
end
# ====================
# TIMING ATTACK PROTECTION
# ====================
test "client authentication uses constant-time comparison" do
auth_code = OidcAuthorizationCode.create!(
application: @application,
user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
expires_at: 10.minutes.from_now
)
token_params = {
grant_type: "authorization_code",
code: auth_code.code,
redirect_uri: "http://localhost:4000/callback"
}
# Test with completely wrong secret
times_wrong = []
5.times do
start_time = Time.now.to_f
post "/oauth/token", params: token_params, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:wrong_secret_xxx")
}
times_wrong << (Time.now.to_f - start_time)
assert_response :unauthorized
end
# Test with almost correct secret (differs by one character)
correct_secret = @plain_client_secret
almost_correct = correct_secret[0..-2] + "X"
times_almost = []
5.times do
start_time = Time.now.to_f
post "/oauth/token", params: token_params, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{almost_correct}")
}
times_almost << (Time.now.to_f - start_time)
assert_response :unauthorized
end
# The timing difference should be minimal (within 50ms) if using constant-time comparison
avg_wrong = times_wrong.sum / times_wrong.size
avg_almost = times_almost.sum / times_almost.size
timing_difference = (avg_wrong - avg_almost).abs
# This is a best-effort check - in practice, constant-time comparison is handled by bcrypt
assert timing_difference < 0.05,
"Timing difference #{timing_difference}s suggests potential timing attack vulnerability"
end
end

View File

@@ -0,0 +1,299 @@
require "test_helper"
class OidcPkceControllerTest < ActionDispatch::IntegrationTest
def setup
@user = User.create!(email_address: "pkce_test@example.com", password: "password123")
@application = Application.create!(
name: "PKCE Test App",
slug: "pkce-test-app",
app_type: "oidc",
redirect_uris: ["http://localhost:4000/callback"].to_json,
active: true
)
# Sign in the user using the test helper
sign_in_as(@user)
end
def teardown
Current.session&.destroy
OidcAuthorizationCode.where(application: @application).destroy_all
OidcAccessToken.where(application: @application).destroy_all
@user.destroy
@application.destroy
end
test "discovery endpoint includes PKCE support" do
get "/.well-known/openid-configuration"
assert_response :success
config = JSON.parse(@response.body)
assert config.key?("code_challenge_methods_supported")
assert_includes config["code_challenge_methods_supported"], "S256"
assert_includes config["code_challenge_methods_supported"], "plain"
end
test "authorization endpoint accepts PKCE parameters (S256)" do
code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
code_challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
auth_params = {
response_type: "code",
client_id: @application.client_id,
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
state: "test_state",
nonce: "test_nonce",
code_challenge: code_challenge,
code_challenge_method: "S256"
}
get "/oauth/authorize", params: auth_params
# Should show consent page (user is already authenticated)
assert_response :success
assert_match /consent/, @response.body.downcase
end
test "authorization endpoint accepts PKCE parameters (plain)" do
code_challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
auth_params = {
response_type: "code",
client_id: @application.client_id,
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
state: "test_state",
nonce: "test_nonce",
code_challenge: code_challenge,
code_challenge_method: "plain"
}
get "/oauth/authorize", params: auth_params
# Should show consent page (user is already authenticated)
assert_response :success
assert_match /consent/, @response.body.downcase
end
test "authorization endpoint rejects invalid code_challenge_method" do
auth_params = {
response_type: "code",
client_id: @application.client_id,
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
code_challenge_method: "invalid_method"
}
get "/oauth/authorize", params: auth_params
assert_response :bad_request
assert_match(/Invalid code_challenge_method/, @response.body)
end
test "authorization endpoint rejects invalid code_challenge format" do
# Contains + character which is not base64url
auth_params = {
response_type: "code",
client_id: @application.client_id,
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
code_challenge: "invalid+challenge",
code_challenge_method: "S256"
}
get "/oauth/authorize", params: auth_params
assert_response :bad_request
assert_match(/Invalid code_challenge format/, @response.body)
end
test "token endpoint requires code_verifier when PKCE was used (S256)" do
# Create authorization code with PKCE S256
auth_code = OidcAuthorizationCode.create!(
application: @application,
user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
code_challenge_method: "S256",
expires_at: 10.minutes.from_now
)
token_params = {
grant_type: "authorization_code",
code: auth_code.code,
redirect_uri: "http://localhost:4000/callback"
}
post "/oauth/token", params: token_params, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@application.client_secret}")
}
assert_response :bad_request
error = JSON.parse(@response.body)
assert_equal "invalid_request", error["error"]
assert_match(/code_verifier is required/, error["error_description"])
end
test "token endpoint requires code_verifier when PKCE was used (plain)" do
# Create authorization code with PKCE plain
auth_code = OidcAuthorizationCode.create!(
application: @application,
user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
code_challenge_method: "plain",
expires_at: 10.minutes.from_now
)
token_params = {
grant_type: "authorization_code",
code: auth_code.code,
redirect_uri: "http://localhost:4000/callback"
}
post "/oauth/token", params: token_params, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@application.client_secret}")
}
assert_response :bad_request
error = JSON.parse(@response.body)
assert_equal "invalid_request", error["error"]
assert_match(/code_verifier is required/, error["error_description"])
end
test "token endpoint rejects invalid code_verifier (S256)" do
# Create authorization code with PKCE S256
auth_code = OidcAuthorizationCode.create!(
application: @application,
user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
code_challenge_method: "S256",
expires_at: 10.minutes.from_now
)
token_params = {
grant_type: "authorization_code",
code: auth_code.code,
redirect_uri: "http://localhost:4000/callback",
# Use a properly formatted but wrong verifier (43+ chars, base64url)
code_verifier: "wrongverifier_with_enough_characters_base64url"
}
post "/oauth/token", params: token_params, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@application.client_secret}")
}
assert_response :bad_request
error = JSON.parse(@response.body)
assert_equal "invalid_grant", error["error"]
assert_match(/Invalid code verifier/, error["error_description"])
end
test "token endpoint accepts valid code_verifier (S256)" do
# Generate valid PKCE pair
code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
code_challenge = Digest::SHA256.base64digest(code_verifier)
.tr("+/", "-_")
.tr("=", "")
# Create authorization code with PKCE S256
auth_code = OidcAuthorizationCode.create!(
application: @application,
user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
code_challenge: code_challenge,
code_challenge_method: "S256",
expires_at: 10.minutes.from_now
)
token_params = {
grant_type: "authorization_code",
code: auth_code.code,
redirect_uri: "http://localhost:4000/callback",
code_verifier: code_verifier
}
post "/oauth/token", params: token_params, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@application.client_secret}")
}
assert_response :success
tokens = JSON.parse(@response.body)
assert tokens.key?("access_token")
assert tokens.key?("id_token")
assert_equal "Bearer", tokens["token_type"]
end
test "token endpoint accepts valid code_verifier (plain)" do
code_verifier = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
# Create authorization code with PKCE plain
auth_code = OidcAuthorizationCode.create!(
application: @application,
user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
code_challenge: code_verifier, # Same as verifier for plain method
code_challenge_method: "plain",
expires_at: 10.minutes.from_now
)
token_params = {
grant_type: "authorization_code",
code: auth_code.code,
redirect_uri: "http://localhost:4000/callback",
code_verifier: code_verifier
}
post "/oauth/token", params: token_params, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@application.client_secret}")
}
assert_response :success
tokens = JSON.parse(@response.body)
assert tokens.key?("access_token")
assert tokens.key?("id_token")
assert_equal "Bearer", tokens["token_type"]
end
test "token endpoint works without PKCE (backward compatibility)" do
# Create authorization code without PKCE
auth_code = OidcAuthorizationCode.create!(
application: @application,
user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
expires_at: 10.minutes.from_now
)
token_params = {
grant_type: "authorization_code",
code: auth_code.code,
redirect_uri: "http://localhost:4000/callback"
}
post "/oauth/token", params: token_params, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@application.client_secret}")
}
assert_response :success
tokens = JSON.parse(@response.body)
assert tokens.key?("access_token")
assert tokens.key?("id_token")
assert_equal "Bearer", tokens["token_type"]
end
end

View File

@@ -0,0 +1,235 @@
require "test_helper"
class OidcRefreshTokenControllerTest < ActionDispatch::IntegrationTest
setup do
@user = users(:alice)
@application = applications(:kavita_app)
# Store a known client secret for testing
@client_secret = SecureRandom.urlsafe_base64(48)
@application.client_secret = @client_secret
@application.save!
end
test "token endpoint returns refresh_token with authorization_code grant" do
# Create an authorization code
auth_code = OidcAuthorizationCode.create!(
application: @application,
user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: @application.parsed_redirect_uris.first,
scope: "openid profile email",
expires_at: 10.minutes.from_now
)
# Exchange authorization code for tokens
post "/oauth/token", params: {
grant_type: "authorization_code",
code: auth_code.code,
redirect_uri: @application.parsed_redirect_uris.first,
client_id: @application.client_id,
client_secret: @client_secret
}
assert_response :success
json = JSON.parse(response.body)
assert json["access_token"].present?
assert json["id_token"].present?
assert json["refresh_token"].present?
assert_equal "Bearer", json["token_type"]
assert_equal 3600, json["expires_in"]
end
test "refresh_token grant exchanges refresh token for new tokens" do
# Create access and refresh tokens
access_token = OidcAccessToken.create!(
application: @application,
user: @user,
scope: "openid profile email"
)
refresh_token = OidcRefreshToken.create!(
application: @application,
user: @user,
oidc_access_token: access_token,
scope: "openid profile email"
)
# Store the plaintext refresh token (available only during creation)
plaintext_refresh_token = refresh_token.token
# Use refresh token to get new tokens
post "/oauth/token", params: {
grant_type: "refresh_token",
refresh_token: plaintext_refresh_token,
client_id: @application.client_id,
client_secret: @client_secret
}
assert_response :success
json = JSON.parse(response.body)
assert json["access_token"].present?
assert json["id_token"].present?
assert json["refresh_token"].present?
assert_equal "Bearer", json["token_type"]
# Old refresh token should be revoked
assert refresh_token.reload.revoked?
end
test "refresh_token grant fails with expired refresh token" do
access_token = OidcAccessToken.create!(
application: @application,
user: @user,
scope: "openid profile email"
)
refresh_token = OidcRefreshToken.create!(
application: @application,
user: @user,
oidc_access_token: access_token,
scope: "openid profile email",
expires_at: 1.hour.ago # Expired
)
plaintext_refresh_token = refresh_token.token
post "/oauth/token", params: {
grant_type: "refresh_token",
refresh_token: plaintext_refresh_token,
client_id: @application.client_id,
client_secret: @client_secret
}
assert_response :bad_request
json = JSON.parse(response.body)
assert_equal "invalid_grant", json["error"]
end
test "refresh_token grant fails with revoked refresh token" do
access_token = OidcAccessToken.create!(
application: @application,
user: @user,
scope: "openid profile email"
)
refresh_token = OidcRefreshToken.create!(
application: @application,
user: @user,
oidc_access_token: access_token,
scope: "openid profile email"
)
plaintext_refresh_token = refresh_token.token
refresh_token.revoke!
post "/oauth/token", params: {
grant_type: "refresh_token",
refresh_token: plaintext_refresh_token,
client_id: @application.client_id,
client_secret: @client_secret
}
assert_response :bad_request
json = JSON.parse(response.body)
assert_equal "invalid_grant", json["error"]
end
test "token revocation endpoint revokes access tokens" do
access_token = OidcAccessToken.create!(
application: @application,
user: @user,
scope: "openid profile email"
)
plaintext_access_token = access_token.plaintext_token
post "/oauth/revoke", params: {
token: plaintext_access_token,
token_type_hint: "access_token",
client_id: @application.client_id,
client_secret: @client_secret
}
assert_response :success
assert access_token.reload.revoked?
end
test "token revocation endpoint revokes refresh tokens" do
access_token = OidcAccessToken.create!(
application: @application,
user: @user,
scope: "openid profile email"
)
refresh_token = OidcRefreshToken.create!(
application: @application,
user: @user,
oidc_access_token: access_token,
scope: "openid profile email"
)
plaintext_refresh_token = refresh_token.token
post "/oauth/revoke", params: {
token: plaintext_refresh_token,
token_type_hint: "refresh_token",
client_id: @application.client_id,
client_secret: @client_secret
}
assert_response :success
assert refresh_token.reload.revoked?
end
test "token rotation: new refresh token has same family id" do
access_token = OidcAccessToken.create!(
application: @application,
user: @user,
scope: "openid profile email"
)
old_refresh_token = OidcRefreshToken.create!(
application: @application,
user: @user,
oidc_access_token: access_token,
scope: "openid profile email"
)
family_id = old_refresh_token.token_family_id
plaintext_refresh_token = old_refresh_token.token
post "/oauth/token", params: {
grant_type: "refresh_token",
refresh_token: plaintext_refresh_token,
client_id: @application.client_id,
client_secret: @client_secret
}
assert_response :success
# Find the new refresh token
new_refresh_token = OidcRefreshToken.active.where(user: @user, application: @application).last
assert_equal family_id, new_refresh_token.token_family_id
end
test "userinfo endpoint works with hashed access token" do
access_token = OidcAccessToken.create!(
application: @application,
user: @user,
scope: "openid profile email"
)
plaintext_token = access_token.plaintext_token
get "/oauth/userinfo", headers: {
"Authorization" => "Bearer #{plaintext_token}"
}
assert_response :success
json = JSON.parse(response.body)
assert_equal @user.id.to_s, json["sub"]
assert_equal @user.email_address, json["email"]
end
end

View File

@@ -11,7 +11,7 @@ class PasswordsControllerTest < ActionDispatch::IntegrationTest
test "create" do
post passwords_path, params: { email_address: @user.email_address }
assert_enqueued_email_with PasswordsMailer, :reset, args: [ @user ]
assert_redirected_to new_session_path
assert_redirected_to signin_path
follow_redirect!
assert_notice "reset instructions sent"
@@ -20,14 +20,14 @@ class PasswordsControllerTest < ActionDispatch::IntegrationTest
test "create for an unknown user redirects but sends no mail" do
post passwords_path, params: { email_address: "missing-user@example.com" }
assert_enqueued_emails 0
assert_redirected_to new_session_path
assert_redirected_to signin_path
follow_redirect!
assert_notice "reset instructions sent"
end
test "edit" do
get edit_password_path(@user.password_reset_token)
get edit_password_path(@user.generate_token_for(:password_reset))
assert_response :success
end
@@ -41,8 +41,8 @@ class PasswordsControllerTest < ActionDispatch::IntegrationTest
test "update" do
assert_changes -> { @user.reload.password_digest } do
put password_path(@user.password_reset_token), params: { password: "new", password_confirmation: "new" }
assert_redirected_to new_session_path
put password_path(@user.generate_token_for(:password_reset)), params: { password: "newpassword", password_confirmation: "newpassword" }
assert_redirected_to signin_path
end
follow_redirect!

View File

@@ -18,7 +18,7 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest
test "create with invalid credentials" do
post session_path, params: { email_address: @user.email_address, password: "wrong" }
assert_redirected_to new_session_path
assert_redirected_to signin_path
assert_nil cookies[:session_id]
end
@@ -27,7 +27,7 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest
delete session_path
assert_redirected_to new_session_path
assert_redirected_to signin_path
assert_empty cookies[:session_id]
end
end

View File

@@ -0,0 +1,11 @@
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
kavita_alice_claims:
application: kavita_app
user: alice
custom_claims: { "kavita_groups": ["admin"], "library_access": "all" }
abs_alice_claims:
application: audiobookshelf_app
user: alice
custom_claims: { "abs_groups": ["user"], "abs_permissions": { "canDownload": true, "canUpload": false } }

View File

@@ -24,3 +24,14 @@ another_app:
https://app.example.com/auth/callback
metadata: "{}"
active: true
audiobookshelf_app:
name: Audiobookshelf
slug: audiobookshelf
app_type: oidc
client_id: <%= SecureRandom.urlsafe_base64(32) %>
client_secret_digest: <%= BCrypt::Password.create(SecureRandom.urlsafe_base64(48)) %>
redirect_uris: |
https://abs.example.com/auth/openid/callback
metadata: "{}"
active: true

View File

@@ -1,5 +1,13 @@
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
one:
name: Group One
description: First test group
two:
name: Group Two
description: Second test group
admin_group:
name: Administrators
description: System administrators with full access

View File

@@ -1,5 +1,17 @@
<% password_digest = BCrypt::Password.create("password") %>
one:
email_address: one@example.com
password_digest: <%= password_digest %>
admin: false
status: 0 # active
two:
email_address: two@example.com
password_digest: <%= password_digest %>
admin: true
status: 0 # active
alice:
email_address: alice@example.com
password_digest: <%= password_digest %>

View File

@@ -58,8 +58,8 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
# Domain and Rule Integration Tests
test "different domain patterns with same session" do
# Create test rules
wildcard_rule = ForwardAuthRule.create!(domain_pattern: "*.example.com", active: true)
exact_rule = ForwardAuthRule.create!(domain_pattern: "api.example.com", active: true)
wildcard_rule = Application.create!(domain_pattern: "*.example.com", active: true)
exact_rule = Application.create!(domain_pattern: "api.example.com", active: true)
# Sign in
post "/signin", params: { email_address: @user.email_address, password: "password" }
@@ -82,7 +82,7 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
test "group-based access control integration" do
# Create restricted rule
restricted_rule = ForwardAuthRule.create!(domain_pattern: "restricted.example.com", active: true)
restricted_rule = Application.create!(domain_pattern: "restricted.example.com", active: true)
restricted_rule.allowed_groups << @group
# Sign in user without group
@@ -104,17 +104,19 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
# Header Configuration Integration Tests
test "different header configurations with same user" do
# Create rules with different header configs
default_rule = ForwardAuthRule.create!(domain_pattern: "default.example.com", active: true)
custom_rule = ForwardAuthRule.create!(
# Create applications with different configs
default_rule = Application.create!(name: "Default App", slug: "default-app", app_type: "forward_auth", domain_pattern: "default.example.com", active: true)
custom_rule = Application.create!(
name: "Custom App", slug: "custom-app", app_type: "forward_auth",
domain_pattern: "custom.example.com",
active: true,
headers_config: { user: "X-WEBAUTH-USER", groups: "X-WEBAUTH-ROLES" }
metadata: { headers: { user: "X-WEBAUTH-USER", groups: "X-WEBAUTH-ROLES" } }.to_json
)
no_headers_rule = ForwardAuthRule.create!(
no_headers_rule = Application.create!(
name: "No Headers App", slug: "no-headers-app", app_type: "forward_auth",
domain_pattern: "noheaders.example.com",
active: true,
headers_config: { user: "", email: "", name: "", groups: "", admin: "" }
metadata: { headers: { user: "", email: "", name: "", groups: "", admin: "" } }.to_json
)
# Add user to groups
@@ -191,7 +193,7 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
admin_user = users(:two)
# Create restricted rule
admin_rule = ForwardAuthRule.create!(
admin_rule = Application.create!(
domain_pattern: "admin.example.com",
active: true,
headers_config: { user: "X-Admin-User", admin: "X-Admin-Flag" }

View File

@@ -25,8 +25,8 @@ class InvitationsMailerTest < ActionMailer::TestCase
assert_equal "You're invited to join Clinch", email.subject
assert_equal [@user.email_address], email.to
assert_equal [], email.cc
assert_equal [], email.bcc
assert_equal [], email.cc || []
assert_equal [], email.bcc || []
# From address is configured in ApplicationMailer
assert_not_nil email.from
assert email.from.is_a?(Array)

View File

@@ -0,0 +1,7 @@
require "test_helper"
class OidcTokenCleanupJobTest < ActiveJob::TestCase
# test "the truth" do
# assert true
# end
end

View File

@@ -25,8 +25,8 @@ class PasswordsMailerTest < ActionMailer::TestCase
assert_equal "Reset your password", email.subject
assert_equal [@user.email_address], email.to
assert_equal [], email.cc
assert_equal [], email.bcc
assert_equal [], email.cc || []
assert_equal [], email.bcc || []
# From address is configured in ApplicationMailer
assert_not_nil email.from
assert email.from.is_a?(Array)

View File

@@ -0,0 +1,78 @@
require "test_helper"
class ApplicationUserClaimTest < ActiveSupport::TestCase
def setup
@user = users(:bob)
@application = applications(:another_app)
end
test "should create valid application user claim" do
claim = ApplicationUserClaim.new(
user: @user,
application: @application,
custom_claims: { "role": "admin" }
)
assert claim.valid?
assert claim.save
end
test "should enforce uniqueness of user per application" do
ApplicationUserClaim.create!(
user: @user,
application: @application,
custom_claims: { "role": "admin" }
)
duplicate = ApplicationUserClaim.new(
user: @user,
application: @application,
custom_claims: { "role": "user" }
)
assert_not duplicate.valid?
assert_includes duplicate.errors[:user_id], "has already been taken"
end
test "parsed_custom_claims returns hash" do
claim = ApplicationUserClaim.new(
user: @user,
application: @application,
custom_claims: { "role": "admin", "level": 5 }
)
parsed = claim.parsed_custom_claims
assert_equal "admin", parsed["role"]
assert_equal 5, parsed["level"]
end
test "parsed_custom_claims returns empty hash when nil" do
claim = ApplicationUserClaim.new(
user: @user,
application: @application,
custom_claims: nil
)
assert_equal({}, claim.parsed_custom_claims)
end
test "should not allow reserved OIDC claim names" do
claim = ApplicationUserClaim.new(
user: @user,
application: @application,
custom_claims: { "groups": ["admin"], "role": "user" }
)
assert_not claim.valid?
assert_includes claim.errors[:custom_claims], "cannot override reserved OIDC claims: groups"
end
test "should allow non-reserved claim names" do
claim = ApplicationUserClaim.new(
user: @user,
application: @application,
custom_claims: { "kavita_groups": ["admin"], "role": "user" }
)
assert claim.valid?
end
end

View File

@@ -0,0 +1,177 @@
require "test_helper"
class PkceAuthorizationCodeTest < ActiveSupport::TestCase
def setup
@user = User.create!(email_address: "pkce_test@example.com", password: "password123")
@application = Application.create!(
name: "PKCE Test App",
slug: "pkce-test-app",
app_type: "oidc",
redirect_uris: ["http://localhost:4000/callback"].to_json,
active: true
)
end
def teardown
# Clean up any authorization codes first to avoid foreign key constraints
OidcAuthorizationCode.where(application: @application).destroy_all
@user.destroy
@application.destroy
end
test "authorization code can store PKCE challenge with S256 method" do
code_challenge = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
code_challenge_method = "S256"
auth_code = OidcAuthorizationCode.create!(
application: @application,
user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
code_challenge: code_challenge,
code_challenge_method: code_challenge_method,
expires_at: 10.minutes.from_now
)
assert_equal code_challenge, auth_code.code_challenge
assert_equal code_challenge_method, auth_code.code_challenge_method
assert auth_code.uses_pkce?
end
test "authorization code can store PKCE challenge with plain method" do
code_challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
code_challenge_method = "plain"
auth_code = OidcAuthorizationCode.create!(
application: @application,
user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
code_challenge: code_challenge,
code_challenge_method: code_challenge_method,
expires_at: 10.minutes.from_now
)
assert_equal code_challenge, auth_code.code_challenge
assert_equal code_challenge_method, auth_code.code_challenge_method
assert auth_code.uses_pkce?
end
test "authorization code works without PKCE (backward compatibility)" do
auth_code = OidcAuthorizationCode.create!(
application: @application,
user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
expires_at: 10.minutes.from_now
)
assert_nil auth_code.code_challenge
assert_nil auth_code.code_challenge_method
assert_not auth_code.uses_pkce?
end
test "code_challenge_method validation accepts valid methods" do
auth_code = OidcAuthorizationCode.new(
application: @application,
user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
code_challenge: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk",
code_challenge_method: "S256",
expires_at: 10.minutes.from_now
)
assert auth_code.valid?
end
test "code_challenge_method validation rejects invalid methods" do
auth_code = OidcAuthorizationCode.new(
application: @application,
user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
code_challenge: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk",
code_challenge_method: "invalid_method",
expires_at: 10.minutes.from_now
)
assert_not auth_code.valid?
assert_includes auth_code.errors[:code_challenge_method], "is not included in the list"
end
test "code_challenge format validation accepts valid base64url" do
# Valid base64url encoded string (43 characters, valid characters)
valid_challenge = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
auth_code = OidcAuthorizationCode.new(
application: @application,
user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
code_challenge: valid_challenge,
code_challenge_method: "S256",
expires_at: 10.minutes.from_now
)
assert auth_code.valid?
end
test "code_challenge format validation rejects invalid format" do
# Invalid: contains + character (not base64url)
invalid_challenge = "dBjftJeZ4CVP+mB92K27uhbUJU1p1r/wW1gFWFOEjXk"
auth_code = OidcAuthorizationCode.new(
application: @application,
user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
code_challenge: invalid_challenge,
code_challenge_method: "S256",
expires_at: 10.minutes.from_now
)
assert_not auth_code.valid?
assert_includes auth_code.errors[:code_challenge], "must be 43-128 characters of base64url encoding"
end
test "code_challenge format validation rejects wrong length" do
# Invalid: too short (42 characters)
short_challenge = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjX"
auth_code = OidcAuthorizationCode.new(
application: @application,
user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
code_challenge: short_challenge,
code_challenge_method: "S256",
expires_at: 10.minutes.from_now
)
assert_not auth_code.valid?
assert_includes auth_code.errors[:code_challenge], "must be 43-128 characters of base64url encoding"
end
test "code_challenge validation is skipped when no challenge present" do
auth_code = OidcAuthorizationCode.new(
application: @application,
user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
expires_at: 10.minutes.from_now
)
# Should be valid even without code_challenge
assert auth_code.valid?
end
end

View File

@@ -14,7 +14,8 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
assert token.length > 100, "Token should be substantial"
assert token.include?('.')
decoded = JWT.decode(token, nil, true)
# Decode without verification for testing the payload
decoded = JWT.decode(token, nil, false).first
assert_equal @application.client_id, decoded['aud'], "Should have correct audience"
assert_equal @user.id.to_s, decoded['sub'], "Should have correct subject"
assert_equal @user.email_address, decoded['email'], "Should have correct email"
@@ -22,16 +23,16 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
assert_equal @user.email_address, decoded['preferred_username'], "Should have preferred username"
assert_equal @user.email_address, decoded['name'], "Should have name"
assert_equal "https://localhost:3000", decoded['iss'], "Should have correct issuer"
assert_equal Time.now.to_i + 3600, decoded['exp'], "Should have correct expiration"
assert_in_delta Time.current.to_i + 3600, decoded['exp'], 5, "Should have correct expiration"
end
test "should handle nonce in id token" do
nonce = "test-nonce-12345"
token = @service.generate_id_token(@user, @application, nonce: nonce)
decoded = JWT.decode(token, nil, true)
decoded = JWT.decode(token, nil, false).first
assert_equal nonce, decoded['nonce'], "Should preserve nonce in token"
assert_equal Time.now.to_i + 3600, decoded['exp'], "Should have correct expiration with nonce"
assert_in_delta Time.current.to_i + 3600, decoded['exp'], 5, "Should have correct expiration with nonce"
end
test "should include groups in token when user has groups" do
@@ -39,17 +40,17 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
token = @service.generate_id_token(@user, @application)
decoded = JWT.decode(token, nil, true)
decoded = JWT.decode(token, nil, false).first
assert_includes decoded['groups'], "admin", "Should include user's groups"
end
test "should include admin claim for admin users" do
test "admin claim should not be included in token" do
@user.update!(admin: true)
token = @service.generate_id_token(@user, @application)
decoded = JWT.decode(token, nil, true)
assert_equal true, decoded['admin'], "Admin users should have admin claim"
decoded = JWT.decode(token, nil, false).first
refute decoded.key?('admin'), "Admin claim should not be included in ID tokens (use groups instead)"
end
test "should handle role-based claims when enabled" do
@@ -63,7 +64,7 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
token = @service.generate_id_token(@user, @application)
decoded = JWT.decode(token, nil, true)
decoded = JWT.decode(token, nil, false).first
assert_includes decoded['roles'], "editor", "Should include user's role"
end
@@ -96,7 +97,7 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
token = @service.generate_id_token(@user, @application)
decoded = JWT.decode(token, nil, true)
decoded = JWT.decode(token, nil, false).first
assert_equal "Content Editor", decoded['role_display_name'], "Should include role display name"
assert_includes decoded['role_permissions'], "read", "Should include read permission"
assert_includes decoded['role_permissions'], "write", "Should include write permission"
@@ -107,15 +108,99 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
test "should handle missing roles gracefully" do
token = @service.generate_id_token(@user, @application)
decoded = JWT.decode(token, nil, true)
decoded = JWT.decode(token, nil, false).first
refute_includes decoded, 'roles', "Should not have roles when not configured"
end
test "should use RSA private key from environment" do
ENV.stub(:fetch, "OIDC_PRIVATE_KEY") { "test-private-key" }
test "should load RSA private key from environment with escaped newlines" do
# 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
assert_equal "test-private-key", private_key.to_s, "Should use private key from environment"
# 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_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
test "should generate RSA private key when missing" do
@@ -176,7 +261,7 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
test "should handle access token generation" do
token = @service.generate_id_token(@user, @application)
decoded = JWT.decode(token, nil, true)
decoded = JWT.decode(token, nil, false).first
refute_includes decoded.keys, 'email_verified'
assert_equal @user.id.to_s, decoded['sub'], "Should decode subject correctly"
assert_equal @application.client_id, decoded['aud'], "Should decode audience correctly"
@@ -207,4 +292,215 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
end
assert_match /no key found/, error.message, "Should warn about missing private key"
end
test "should include app-specific custom claims in token" do
# Use bob and another_app to avoid fixture conflicts
user = users(:bob)
app = applications(:another_app)
# Create app-specific claim
ApplicationUserClaim.create!(
user: user,
application: app,
custom_claims: { "app_groups": ["admin"], "library_access": "all" }
)
token = @service.generate_id_token(user, app)
decoded = JWT.decode(token, nil, false).first
assert_equal ["admin"], decoded["app_groups"]
assert_equal "all", decoded["library_access"]
end
test "app-specific claims should override user and group claims" do
# Use bob and another_app to avoid fixture conflicts
user = users(:bob)
app = applications(:another_app)
# Add user to group with claims
group = groups(:admin_group)
group.update!(custom_claims: { "role": "viewer", "max_items": 10 })
user.groups << group
# Add user custom claims
user.update!(custom_claims: { "role": "editor", "theme": "dark" })
# Add app-specific claims (should override both)
ApplicationUserClaim.create!(
user: user,
application: app,
custom_claims: { "role": "admin", "app_specific": true }
)
token = @service.generate_id_token(user, app)
decoded = JWT.decode(token, nil, false).first
# App-specific claim should win
assert_equal "admin", decoded["role"]
# App-specific claim should be present
assert_equal true, decoded["app_specific"]
# User claim not overridden should still be present
assert_equal "dark", decoded["theme"]
# Group claim not overridden should still be present
assert_equal 10, decoded["max_items"]
end
test "should deep merge array claims from group and user" do
user = users(:bob)
app = applications(:another_app)
# Group has roles: ["user"]
group = groups(:admin_group)
group.update!(custom_claims: { "roles" => ["user"], "permissions" => ["read"] })
user.groups << group
# User adds roles: ["admin"]
user.update!(custom_claims: { "roles" => ["admin"], "permissions" => ["write"] })
token = @service.generate_id_token(user, app)
decoded = JWT.decode(token, nil, false).first
# Roles should be combined (not overwritten)
assert_equal 2, decoded["roles"].length
assert_includes decoded["roles"], "user"
assert_includes decoded["roles"], "admin"
# Permissions should also be combined
assert_equal 2, decoded["permissions"].length
assert_includes decoded["permissions"], "read"
assert_includes decoded["permissions"], "write"
end
test "should deep merge array claims from multiple groups" do
user = users(:bob)
app = applications(:another_app)
# First group has roles: ["user"]
group1 = groups(:admin_group)
group1.update!(custom_claims: { "roles" => ["user"] })
user.groups << group1
# Second group has roles: ["moderator"]
group2 = Group.create!(name: "moderators", description: "Moderators group")
group2.update!(custom_claims: { "roles" => ["moderator"] })
user.groups << group2
# User adds roles: ["admin"]
user.update!(custom_claims: { "roles" => ["admin"] })
token = @service.generate_id_token(user, app)
decoded = JWT.decode(token, nil, false).first
# All roles should be combined
assert_equal 3, decoded["roles"].length
assert_includes decoded["roles"], "user"
assert_includes decoded["roles"], "moderator"
assert_includes decoded["roles"], "admin"
end
test "should remove duplicate values when merging arrays" do
user = users(:bob)
app = applications(:another_app)
# Group has roles: ["user", "reader"]
group = groups(:admin_group)
group.update!(custom_claims: { "roles" => ["user", "reader"] })
user.groups << group
# User also has "user" role (duplicate)
user.update!(custom_claims: { "roles" => ["user", "admin"] })
token = @service.generate_id_token(user, app)
decoded = JWT.decode(token, nil, false).first
# "user" should only appear once
assert_equal 3, decoded["roles"].length
assert_includes decoded["roles"], "user"
assert_includes decoded["roles"], "reader"
assert_includes decoded["roles"], "admin"
end
test "should override non-array values while merging arrays" do
user = users(:bob)
app = applications(:another_app)
# Group has roles array and max_items scalar
group = groups(:admin_group)
group.update!(custom_claims: { "roles" => ["user"], "max_items" => 10, "theme" => "light" })
user.groups << group
# User overrides max_items and theme, adds to roles
user.update!(custom_claims: { "roles" => ["admin"], "max_items" => 100, "theme" => "dark" })
token = @service.generate_id_token(user, app)
decoded = JWT.decode(token, nil, false).first
# Arrays should be combined
assert_equal 2, decoded["roles"].length
assert_includes decoded["roles"], "user"
assert_includes decoded["roles"], "admin"
# Scalar values should be overridden (user wins)
assert_equal 100, decoded["max_items"]
assert_equal "dark", decoded["theme"]
end
test "should deep merge nested hashes in claims" do
user = users(:bob)
app = applications(:another_app)
# Group has nested config
group = groups(:admin_group)
group.update!(custom_claims: {
"config" => {
"theme" => "light",
"notifications" => { "email" => true }
}
})
user.groups << group
# User adds to nested config
user.update!(custom_claims: {
"config" => {
"language" => "en",
"notifications" => { "sms" => true }
}
})
token = @service.generate_id_token(user, app)
decoded = JWT.decode(token, nil, false).first
# Nested hashes should be deep merged
assert_equal "light", decoded["config"]["theme"]
assert_equal "en", decoded["config"]["language"]
assert_equal true, decoded["config"]["notifications"]["email"]
assert_equal true, decoded["config"]["notifications"]["sms"]
end
test "app-specific claims should combine arrays with group and user claims" do
user = users(:bob)
app = applications(:another_app)
# Group has roles: ["user"]
group = groups(:admin_group)
group.update!(custom_claims: { "roles" => ["user"] })
user.groups << group
# User has roles: ["moderator"]
user.update!(custom_claims: { "roles" => ["moderator"] })
# App-specific has roles: ["app_admin"]
ApplicationUserClaim.create!(
user: user,
application: app,
custom_claims: { "roles" => ["app_admin"] }
)
token = @service.generate_id_token(user, app)
decoded = JWT.decode(token, nil, false).first
# All three sources should be combined
assert_equal 3, decoded["roles"].length
assert_includes decoded["roles"], "user"
assert_includes decoded["roles"], "moderator"
assert_includes decoded["roles"], "app_admin"
end
end