Compare commits
22 Commits
bf104a9983
...
feature/en
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e882a4d6d1 | ||
|
|
ab0085e9c9 | ||
|
|
1ee3302319 | ||
|
|
67f28faaca | ||
|
|
33ad956508 | ||
|
|
11ec753c68 | ||
|
|
4df2eee4d9 | ||
|
|
d9f11abbbf | ||
|
|
c92e69fa4a | ||
|
|
038801f34b | ||
|
|
8e0b2c28eb | ||
|
|
f02665f690 | ||
|
|
631b2b53bb | ||
|
|
6049429a41 | ||
|
|
2b15aa2c40 | ||
|
|
4f5974dd37 | ||
|
|
5de53f1841 | ||
|
|
73b2ae2f02 | ||
|
|
4c5ac344bd | ||
|
|
044b9239d6 | ||
|
|
e9b1995e89 | ||
|
|
fb14ce032f |
24
.env.example
24
.env.example
@@ -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
|
||||
|
||||
6
Gemfile
6
Gemfile
@@ -1,7 +1,7 @@
|
||||
source "https://rubygems.org"
|
||||
|
||||
# Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main"
|
||||
gem "rails", "~> 8.1.0"
|
||||
gem "rails", "~> 8.1.1"
|
||||
# The modern asset pipeline for Rails [https://github.com/rails/propshaft]
|
||||
gem "propshaft"
|
||||
# Use sqlite3 as the database for Active Record
|
||||
@@ -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 ]
|
||||
|
||||
|
||||
136
Gemfile.lock
136
Gemfile.lock
@@ -3,29 +3,29 @@ GEM
|
||||
specs:
|
||||
action_text-trix (2.1.15)
|
||||
railties
|
||||
actioncable (8.1.0)
|
||||
actionpack (= 8.1.0)
|
||||
activesupport (= 8.1.0)
|
||||
actioncable (8.1.1)
|
||||
actionpack (= 8.1.1)
|
||||
activesupport (= 8.1.1)
|
||||
nio4r (~> 2.0)
|
||||
websocket-driver (>= 0.6.1)
|
||||
zeitwerk (~> 2.6)
|
||||
actionmailbox (8.1.0)
|
||||
actionpack (= 8.1.0)
|
||||
activejob (= 8.1.0)
|
||||
activerecord (= 8.1.0)
|
||||
activestorage (= 8.1.0)
|
||||
activesupport (= 8.1.0)
|
||||
actionmailbox (8.1.1)
|
||||
actionpack (= 8.1.1)
|
||||
activejob (= 8.1.1)
|
||||
activerecord (= 8.1.1)
|
||||
activestorage (= 8.1.1)
|
||||
activesupport (= 8.1.1)
|
||||
mail (>= 2.8.0)
|
||||
actionmailer (8.1.0)
|
||||
actionpack (= 8.1.0)
|
||||
actionview (= 8.1.0)
|
||||
activejob (= 8.1.0)
|
||||
activesupport (= 8.1.0)
|
||||
actionmailer (8.1.1)
|
||||
actionpack (= 8.1.1)
|
||||
actionview (= 8.1.1)
|
||||
activejob (= 8.1.1)
|
||||
activesupport (= 8.1.1)
|
||||
mail (>= 2.8.0)
|
||||
rails-dom-testing (~> 2.2)
|
||||
actionpack (8.1.0)
|
||||
actionview (= 8.1.0)
|
||||
activesupport (= 8.1.0)
|
||||
actionpack (8.1.1)
|
||||
actionview (= 8.1.1)
|
||||
activesupport (= 8.1.1)
|
||||
nokogiri (>= 1.8.5)
|
||||
rack (>= 2.2.4)
|
||||
rack-session (>= 1.0.1)
|
||||
@@ -33,36 +33,36 @@ GEM
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
useragent (~> 0.16)
|
||||
actiontext (8.1.0)
|
||||
actiontext (8.1.1)
|
||||
action_text-trix (~> 2.1.15)
|
||||
actionpack (= 8.1.0)
|
||||
activerecord (= 8.1.0)
|
||||
activestorage (= 8.1.0)
|
||||
activesupport (= 8.1.0)
|
||||
actionpack (= 8.1.1)
|
||||
activerecord (= 8.1.1)
|
||||
activestorage (= 8.1.1)
|
||||
activesupport (= 8.1.1)
|
||||
globalid (>= 0.6.0)
|
||||
nokogiri (>= 1.8.5)
|
||||
actionview (8.1.0)
|
||||
activesupport (= 8.1.0)
|
||||
actionview (8.1.1)
|
||||
activesupport (= 8.1.1)
|
||||
builder (~> 3.1)
|
||||
erubi (~> 1.11)
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
activejob (8.1.0)
|
||||
activesupport (= 8.1.0)
|
||||
activejob (8.1.1)
|
||||
activesupport (= 8.1.1)
|
||||
globalid (>= 0.3.6)
|
||||
activemodel (8.1.0)
|
||||
activesupport (= 8.1.0)
|
||||
activerecord (8.1.0)
|
||||
activemodel (= 8.1.0)
|
||||
activesupport (= 8.1.0)
|
||||
activemodel (8.1.1)
|
||||
activesupport (= 8.1.1)
|
||||
activerecord (8.1.1)
|
||||
activemodel (= 8.1.1)
|
||||
activesupport (= 8.1.1)
|
||||
timeout (>= 0.4.0)
|
||||
activestorage (8.1.0)
|
||||
actionpack (= 8.1.0)
|
||||
activejob (= 8.1.0)
|
||||
activerecord (= 8.1.0)
|
||||
activesupport (= 8.1.0)
|
||||
activestorage (8.1.1)
|
||||
actionpack (= 8.1.1)
|
||||
activejob (= 8.1.1)
|
||||
activerecord (= 8.1.1)
|
||||
activesupport (= 8.1.1)
|
||||
marcel (~> 1.0)
|
||||
activesupport (8.1.0)
|
||||
activesupport (8.1.1)
|
||||
base64
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0, >= 1.3.1)
|
||||
@@ -112,14 +112,14 @@ GEM
|
||||
cbor (~> 0.5.9)
|
||||
openssl-signature_algorithm (~> 1.0)
|
||||
crass (1.0.6)
|
||||
date (3.4.1)
|
||||
date (3.5.0)
|
||||
debug (1.11.0)
|
||||
irb (~> 1.10)
|
||||
reline (>= 0.3.8)
|
||||
dotenv (3.1.8)
|
||||
drb (2.2.3)
|
||||
ed25519 (1.4.0)
|
||||
erb (5.1.1)
|
||||
erb (5.1.3)
|
||||
erubi (1.13.1)
|
||||
ffi (1.17.2-aarch64-linux-gnu)
|
||||
ffi (1.17.2-aarch64-linux-musl)
|
||||
@@ -140,14 +140,14 @@ GEM
|
||||
activesupport (>= 6.0.0)
|
||||
railties (>= 6.0.0)
|
||||
io-console (0.8.1)
|
||||
irb (1.15.2)
|
||||
irb (1.15.3)
|
||||
pp (>= 0.6.0)
|
||||
rdoc (>= 4.0.0)
|
||||
reline (>= 0.4.2)
|
||||
jbuilder (2.14.1)
|
||||
actionview (>= 7.0.0)
|
||||
activesupport (>= 7.0.0)
|
||||
json (2.15.1)
|
||||
json (2.15.2)
|
||||
jwt (3.1.2)
|
||||
base64
|
||||
kamal (2.8.1)
|
||||
@@ -200,7 +200,7 @@ GEM
|
||||
net-smtp (0.5.1)
|
||||
net-protocol
|
||||
net-ssh (7.3.0)
|
||||
nio4r (2.7.4)
|
||||
nio4r (2.7.5)
|
||||
nokogiri (1.18.10-aarch64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.10-aarch64-linux-musl)
|
||||
@@ -238,7 +238,7 @@ GEM
|
||||
puma (7.1.0)
|
||||
nio4r (~> 2.0)
|
||||
racc (1.8.1)
|
||||
rack (3.2.3)
|
||||
rack (3.2.4)
|
||||
rack-session (2.1.1)
|
||||
base64 (>= 0.1.0)
|
||||
rack (>= 3.0.0)
|
||||
@@ -246,20 +246,20 @@ GEM
|
||||
rack (>= 1.3)
|
||||
rackup (2.2.1)
|
||||
rack (>= 3)
|
||||
rails (8.1.0)
|
||||
actioncable (= 8.1.0)
|
||||
actionmailbox (= 8.1.0)
|
||||
actionmailer (= 8.1.0)
|
||||
actionpack (= 8.1.0)
|
||||
actiontext (= 8.1.0)
|
||||
actionview (= 8.1.0)
|
||||
activejob (= 8.1.0)
|
||||
activemodel (= 8.1.0)
|
||||
activerecord (= 8.1.0)
|
||||
activestorage (= 8.1.0)
|
||||
activesupport (= 8.1.0)
|
||||
rails (8.1.1)
|
||||
actioncable (= 8.1.1)
|
||||
actionmailbox (= 8.1.1)
|
||||
actionmailer (= 8.1.1)
|
||||
actionpack (= 8.1.1)
|
||||
actiontext (= 8.1.1)
|
||||
actionview (= 8.1.1)
|
||||
activejob (= 8.1.1)
|
||||
activemodel (= 8.1.1)
|
||||
activerecord (= 8.1.1)
|
||||
activestorage (= 8.1.1)
|
||||
activesupport (= 8.1.1)
|
||||
bundler (>= 1.15.0)
|
||||
railties (= 8.1.0)
|
||||
railties (= 8.1.1)
|
||||
rails-dom-testing (2.3.0)
|
||||
activesupport (>= 5.0.0)
|
||||
minitest
|
||||
@@ -267,9 +267,9 @@ GEM
|
||||
rails-html-sanitizer (1.6.2)
|
||||
loofah (~> 2.21)
|
||||
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
|
||||
railties (8.1.0)
|
||||
actionpack (= 8.1.0)
|
||||
activesupport (= 8.1.0)
|
||||
railties (8.1.1)
|
||||
actionpack (= 8.1.1)
|
||||
activesupport (= 8.1.1)
|
||||
irb (~> 1.13)
|
||||
rackup (>= 1.0.0)
|
||||
rake (>= 12.2)
|
||||
@@ -277,8 +277,8 @@ GEM
|
||||
tsort (>= 0.2)
|
||||
zeitwerk (~> 2.6)
|
||||
rainbow (3.1.1)
|
||||
rake (13.3.0)
|
||||
rdoc (6.15.0)
|
||||
rake (13.3.1)
|
||||
rdoc (6.15.1)
|
||||
erb
|
||||
psych (>= 4.0.0)
|
||||
tsort
|
||||
@@ -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)
|
||||
@@ -373,7 +379,7 @@ GEM
|
||||
thruster (0.1.16-aarch64-linux)
|
||||
thruster (0.1.16-arm64-darwin)
|
||||
thruster (0.1.16-x86_64-linux)
|
||||
timeout (0.4.3)
|
||||
timeout (0.4.4)
|
||||
tpm-key_attestation (0.14.1)
|
||||
bindata (~> 2.4)
|
||||
openssl (> 2.0)
|
||||
@@ -387,7 +393,7 @@ GEM
|
||||
unicode-display_width (3.2.0)
|
||||
unicode-emoji (~> 4.1)
|
||||
unicode-emoji (4.1.0)
|
||||
uri (1.0.4)
|
||||
uri (1.1.0)
|
||||
useragent (0.16.11)
|
||||
web-console (4.2.1)
|
||||
actionview (>= 6.0.0)
|
||||
@@ -438,11 +444,13 @@ DEPENDENCIES
|
||||
propshaft
|
||||
public_suffix (~> 6.0)
|
||||
puma (>= 5.0)
|
||||
rails (~> 8.1.0)
|
||||
rails (~> 8.1.1)
|
||||
rotp (~> 6.3)
|
||||
rqrcode (~> 3.1)
|
||||
rubocop-rails-omakase
|
||||
selenium-webdriver
|
||||
sentry-rails (~> 5.18)
|
||||
sentry-ruby (~> 5.18)
|
||||
solid_cable
|
||||
solid_cache
|
||||
sqlite3 (>= 2.1)
|
||||
|
||||
49
README.md
49
README.md
@@ -3,10 +3,27 @@
|
||||
> [!NOTE]
|
||||
> This software is experiemental. If you'd like to try it out, find bugs, security flaws and improvements, please do.
|
||||
|
||||
**A lightweight, self-hosted identity & SSO portal**
|
||||
**A lightweight, self-hosted identity & SSO / IpD portal**
|
||||
|
||||
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:
|
||||
|
||||
* 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, 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 )
|
||||
* Configurable Group and User custom claims for OIDC token
|
||||
* Display all Applications available to the user on their Dashboard
|
||||
* Display all logged in sessions and OIDC logged in sessions
|
||||
|
||||
What remains now is ensure test coverage,
|
||||
|
||||
## Why Clinch?
|
||||
|
||||
Do you host your own web apps? MeTube, Kavita, Audiobookshelf, Gitea? Rather than managing all those separate user accounts, set everyone up on Clinch and let it do the authentication and user management.
|
||||
@@ -70,11 +87,17 @@ Clinch sits in a sweet spot between two excellent open-source identity solutions
|
||||
#### 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
|
||||
|
||||
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):
|
||||
@@ -140,25 +163,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`
|
||||
@@ -242,6 +269,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -137,7 +137,7 @@ module Api
|
||||
|
||||
# Get the redirect URL from query params or construct default
|
||||
redirect_url = validate_redirect_url(params[:rd])
|
||||
base_url = redirect_url || "https://clinch.aapamilne.com"
|
||||
base_url = determine_base_url(redirect_url)
|
||||
|
||||
# Set the original URL that user was trying to access
|
||||
# This will be used after authentication
|
||||
@@ -214,5 +214,27 @@ module Api
|
||||
app.matches_domain?(domain.downcase)
|
||||
end
|
||||
end
|
||||
|
||||
def determine_base_url(redirect_url)
|
||||
# If we have a valid redirect URL, use it
|
||||
return redirect_url if redirect_url.present?
|
||||
|
||||
# Try CLINCH_HOST environment variable first
|
||||
if ENV['CLINCH_HOST'].present?
|
||||
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']
|
||||
if request_host.present?
|
||||
Rails.logger.warn "ForwardAuth: CLINCH_HOST not set, using request host: #{request_host}"
|
||||
"https://#{request_host}"
|
||||
else
|
||||
# No host information available - raise exception to force proper configuration
|
||||
raise StandardError, "ForwardAuth: CLINCH_HOST environment variable not set and no request host available. Please configure CLINCH_HOST properly."
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,6 +134,8 @@ module Authentication
|
||||
original_url = controller_session[:return_to_after_authenticating]
|
||||
uri = URI.parse(original_url)
|
||||
|
||||
# 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
|
||||
@@ -144,3 +146,4 @@ module Authentication
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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,6 +297,31 @@ class OidcController < ApplicationController
|
||||
return
|
||||
end
|
||||
|
||||
# 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
|
||||
@@ -229,34 +334,144 @@ class OidcController < ApplicationController
|
||||
return
|
||||
end
|
||||
|
||||
# Mark code as used
|
||||
# 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
|
||||
access_token = SecureRandom.urlsafe_base64(32)
|
||||
OidcAccessToken.create!(
|
||||
# Generate access token record (opaque token with BCrypt hashing)
|
||||
access_token_record = OidcAccessToken.create!(
|
||||
application: application,
|
||||
user: user,
|
||||
token: access_token,
|
||||
scope: auth_code.scope,
|
||||
expires_at: 1.hour.from_now
|
||||
scope: auth_code.scope
|
||||
)
|
||||
|
||||
# Generate ID token
|
||||
# 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
|
||||
)
|
||||
|
||||
# Generate ID token (JWT)
|
||||
id_token = OidcJwtService.generate_id_token(user, application, nonce: auth_code.nonce)
|
||||
|
||||
# Return tokens
|
||||
render json: {
|
||||
access_token: access_token,
|
||||
access_token: access_token_record.plaintext_token, # Opaque token
|
||||
token_type: "Bearer",
|
||||
expires_in: 3600,
|
||||
id_token: id_token,
|
||||
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
|
||||
|
||||
# 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
|
||||
|
||||
# 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 = refresh_token_record.user
|
||||
|
||||
# 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,
|
||||
scope: refresh_token_record.scope
|
||||
)
|
||||
|
||||
# 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
|
||||
)
|
||||
|
||||
# Generate new ID token (JWT, no nonce for refresh grants)
|
||||
id_token = OidcJwtService.generate_id_token(user, application)
|
||||
|
||||
# Return new tokens
|
||||
render json: {
|
||||
access_token: new_access_token.plaintext_token, # Opaque token
|
||||
token_type: "Bearer",
|
||||
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
|
||||
def userinfo
|
||||
@@ -267,24 +482,22 @@ 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
|
||||
|
||||
# Return user claims
|
||||
claims = {
|
||||
sub: user.id.to_s,
|
||||
@@ -313,6 +526,73 @@ class OidcController < ApplicationController
|
||||
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
|
||||
@@ -342,6 +622,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 ")
|
||||
|
||||
@@ -24,9 +24,12 @@ class TotpController < ApplicationController
|
||||
if totp.verify(code, drift_behind: 30, drift_ahead: 30)
|
||||
# Save the secret and generate backup codes
|
||||
@user.totp_secret = totp_secret
|
||||
@user.backup_codes = generate_backup_codes
|
||||
plain_codes = @user.send(:generate_backup_codes) # Use private method from User model
|
||||
@user.save!
|
||||
|
||||
# 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."
|
||||
else
|
||||
@@ -36,8 +39,15 @@ class TotpController < ApplicationController
|
||||
|
||||
# GET /totp/backup_codes - Show backup codes (requires password)
|
||||
def backup_codes
|
||||
# This will be shown after password verification
|
||||
@backup_codes = @user.parsed_backup_codes
|
||||
# Check if we have temporary codes from TOTP setup
|
||||
if session[:temp_backup_codes].present?
|
||||
@backup_codes = session[:temp_backup_codes]
|
||||
session.delete(:temp_backup_codes) # Clear after use
|
||||
else
|
||||
# This will be shown after password verification for existing users
|
||||
# Since we can't display BCrypt hashes, redirect to regenerate
|
||||
redirect_to regenerate_backup_codes_totp_path
|
||||
end
|
||||
end
|
||||
|
||||
# POST /totp/verify_password - Verify password before showing backup codes
|
||||
@@ -49,6 +59,28 @@ class TotpController < ApplicationController
|
||||
end
|
||||
end
|
||||
|
||||
# GET /totp/regenerate_backup_codes - Regenerate backup codes (requires password)
|
||||
def regenerate_backup_codes
|
||||
# This will be shown after password verification
|
||||
end
|
||||
|
||||
# POST /totp/regenerate_backup_codes - Actually regenerate backup codes
|
||||
def create_new_backup_codes
|
||||
unless @user.authenticate(params[:password])
|
||||
redirect_to regenerate_backup_codes_totp_path, alert: "Incorrect password."
|
||||
return
|
||||
end
|
||||
|
||||
# Generate new backup codes and store BCrypt hashes
|
||||
plain_codes = @user.send(:generate_backup_codes)
|
||||
@user.save!
|
||||
|
||||
# Store plain codes temporarily in session for display
|
||||
session[:temp_backup_codes] = plain_codes
|
||||
|
||||
redirect_to backup_codes_totp_path, notice: "New backup codes have been generated. Save them now!"
|
||||
end
|
||||
|
||||
# DELETE /totp - Disable TOTP (requires password)
|
||||
def destroy
|
||||
unless @user.authenticate(params[:password])
|
||||
@@ -77,8 +109,4 @@ class TotpController < ApplicationController
|
||||
redirect_to profile_path, alert: "Two-factor authentication is not enabled."
|
||||
end
|
||||
end
|
||||
|
||||
def generate_backup_codes
|
||||
Array.new(10) { SecureRandom.alphanumeric(8).upcase }.to_json
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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
|
||||
|
||||
24
app/javascript/controllers/application_form_controller.js
Normal file
24
app/javascript/controllers/application_form_controller.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["appTypeSelect", "oidcFields", "forwardAuthFields"]
|
||||
|
||||
connect() {
|
||||
this.updateFieldVisibility()
|
||||
}
|
||||
|
||||
updateFieldVisibility() {
|
||||
const appType = this.appTypeSelectTarget.value
|
||||
|
||||
if (appType === 'oidc') {
|
||||
this.oidcFieldsTarget.classList.remove('hidden')
|
||||
this.forwardAuthFieldsTarget.classList.add('hidden')
|
||||
} else if (appType === 'forward_auth') {
|
||||
this.oidcFieldsTarget.classList.add('hidden')
|
||||
this.forwardAuthFieldsTarget.classList.remove('hidden')
|
||||
} else {
|
||||
this.oidcFieldsTarget.classList.add('hidden')
|
||||
this.forwardAuthFieldsTarget.classList.add('hidden')
|
||||
}
|
||||
}
|
||||
}
|
||||
28
app/javascript/controllers/backup_codes_controller.js
Normal file
28
app/javascript/controllers/backup_codes_controller.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static values = {
|
||||
codes: Array
|
||||
}
|
||||
|
||||
download() {
|
||||
const content = "Clinch Backup Codes\n" +
|
||||
"===================\n\n" +
|
||||
this.codesValue.join("\n") +
|
||||
"\n\nSave these codes in a secure location."
|
||||
|
||||
const blob = new Blob([content], { type: 'text/plain' })
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = 'clinch-backup-codes.txt'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
window.URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
print() {
|
||||
window.print()
|
||||
}
|
||||
}
|
||||
85
app/javascript/controllers/flash_controller.js
Normal file
85
app/javascript/controllers/flash_controller.js
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
89
app/javascript/controllers/form_errors_controller.js
Normal file
89
app/javascript/controllers/form_errors_controller.js
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
connect() {
|
||||
this.element.textContent = "Hello World!"
|
||||
}
|
||||
}
|
||||
81
app/javascript/controllers/json_validator_controller.js
Normal file
81
app/javascript/controllers/json_validator_controller.js
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
48
app/javascript/controllers/mobile_sidebar_controller.js
Normal file
48
app/javascript/controllers/mobile_sidebar_controller.js
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
export default class extends Controller {
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,11 @@ export default class extends Controller {
|
||||
}
|
||||
|
||||
hide() {
|
||||
if (this.hasDialogTarget) {
|
||||
// Find the currently visible modal to hide it
|
||||
const visibleModal = document.querySelector('[data-controller="modal"] .fixed.inset-0:not(.hidden)');
|
||||
if (visibleModal) {
|
||||
visibleModal.classList.add("hidden");
|
||||
} else if (this.hasDialogTarget) {
|
||||
this.dialogTarget.classList.add("hidden");
|
||||
} else {
|
||||
this.element.classList.add("hidden");
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
29
app/jobs/oidc_token_cleanup_job.rb
Normal file
29
app/jobs/oidc_token_cleanup_job.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -5,6 +5,7 @@ class Application < ApplicationRecord
|
||||
has_many :allowed_groups, through: :application_groups, source: :group
|
||||
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 +14,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?
|
||||
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 +160,44 @@ 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
|
||||
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
87
app/models/oidc_refresh_token.rb
Normal file
87
app/models/oidc_refresh_token.rb
Normal 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
|
||||
@@ -66,19 +66,53 @@ class User < ApplicationRecord
|
||||
def verify_backup_code(code)
|
||||
return false unless backup_codes.present?
|
||||
|
||||
codes = JSON.parse(backup_codes)
|
||||
if codes.include?(code)
|
||||
codes.delete(code)
|
||||
update(backup_codes: codes.to_json)
|
||||
# Rate limiting: prevent brute force attacks on backup codes
|
||||
if rate_limit_backup_code_verification?
|
||||
Rails.logger.warn "Rate limit exceeded for backup code verification - User ID: #{id}"
|
||||
return false
|
||||
end
|
||||
|
||||
# backup_codes is now an Array (JSON column), no need to parse
|
||||
# Find the matching hash by comparing with BCrypt
|
||||
matching_hash = backup_codes.find do |hashed_code|
|
||||
BCrypt::Password.new(hashed_code) == code
|
||||
end
|
||||
|
||||
if matching_hash
|
||||
# Remove the used hash from the array (single-use property)
|
||||
backup_codes.delete(matching_hash)
|
||||
save! # Save the updated array
|
||||
|
||||
# Log successful backup code usage for security monitoring
|
||||
Rails.logger.info "Backup code used successfully - User ID: #{id}, IP: #{Current.session&.client_ip}"
|
||||
true
|
||||
else
|
||||
# Increment failed attempt counter and log for security monitoring
|
||||
increment_backup_code_failed_attempts
|
||||
Rails.logger.warn "Failed backup code attempt - User ID: #{id}, IP: #{Current.session&.client_ip}"
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
def parsed_backup_codes
|
||||
return [] unless backup_codes.present?
|
||||
JSON.parse(backup_codes)
|
||||
# Rate limiting for backup code verification to prevent brute force attacks
|
||||
def rate_limit_backup_code_verification?
|
||||
# Use Rails.cache to track failed attempts
|
||||
cache_key = "backup_code_failed_attempts_#{id}"
|
||||
attempts = Rails.cache.read(cache_key) || 0
|
||||
|
||||
if attempts >= 5 # Allow max 5 failed attempts per hour
|
||||
true
|
||||
else
|
||||
# Don't increment here - increment only on failed attempts
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
# Increment failed attempt counter
|
||||
def increment_backup_code_failed_attempts
|
||||
cache_key = "backup_code_failed_attempts_#{id}"
|
||||
attempts = Rails.cache.read(cache_key) || 0
|
||||
Rails.cache.write(cache_key, attempts + 1, expires_in: 1.hour)
|
||||
end
|
||||
|
||||
# WebAuthn methods
|
||||
@@ -152,6 +186,16 @@ class User < ApplicationRecord
|
||||
private
|
||||
|
||||
def generate_backup_codes
|
||||
Array.new(10) { SecureRandom.alphanumeric(8).upcase }.to_json
|
||||
# Generate plain codes for user to see/save
|
||||
plain_codes = Array.new(10) { SecureRandom.alphanumeric(8).upcase }
|
||||
|
||||
# Store BCrypt hashes of the codes
|
||||
hashed_codes = plain_codes.map { |code| BCrypt::Password.create(code) }
|
||||
|
||||
# Return plain codes for display (will be shown to user once)
|
||||
# Store only hashes in the database (as Array for JSON column)
|
||||
self.backup_codes = hashed_codes
|
||||
|
||||
plain_codes
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,12 +3,14 @@ class OidcJwtService
|
||||
# Generate an ID token (JWT) for the user
|
||||
def generate_id_token(user, application, nonce: nil)
|
||||
now = Time.current.to_i
|
||||
# Use application's configured ID token TTL (defaults to 1 hour)
|
||||
ttl = application.id_token_expiry_seconds
|
||||
|
||||
payload = {
|
||||
iss: issuer_url,
|
||||
sub: user.id.to_s,
|
||||
aud: application.client_id,
|
||||
exp: now + 3600, # 1 hour
|
||||
exp: now + ttl,
|
||||
iat: now,
|
||||
email: user.email_address,
|
||||
email_verified: true,
|
||||
@@ -63,7 +65,9 @@ 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 https:// protocol
|
||||
host.match?(/^https?:\/\//) ? host : "https://#{host}"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -1,22 +1,5 @@
|
||||
<%= form_with(model: [:admin, application], class: "space-y-6") 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" %>
|
||||
@@ -42,14 +25,18 @@
|
||||
|
||||
<div>
|
||||
<%= form.label :app_type, "Application Type", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.select :app_type, [["OpenID Connect (OIDC)", "oidc"], ["Forward Auth (Reverse Proxy)", "forward_auth"]], {}, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", disabled: application.persisted? %>
|
||||
<%= form.select :app_type, [["OpenID Connect (OIDC)", "oidc"], ["Forward Auth (Reverse Proxy)", "forward_auth"]], {}, {
|
||||
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm",
|
||||
disabled: application.persisted?,
|
||||
data: { action: "change->application-form#updateFieldVisibility", application_form_target: "appTypeSelect" }
|
||||
} %>
|
||||
<% if application.persisted? %>
|
||||
<p class="mt-1 text-sm text-gray-500">Application type cannot be changed after creation.</p>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<!-- OIDC-specific fields -->
|
||||
<div id="oidc-fields" class="space-y-6 border-t border-gray-200 pt-6" style="<%= 'display: none;' unless application.oidc? || !application.persisted? %>">
|
||||
<div id="oidc-fields" class="space-y-6 border-t border-gray-200 pt-6 <%= 'hidden' unless application.oidc? || !application.persisted? %>" data-application-form-target="oidcFields">
|
||||
<h3 class="text-base font-semibold text-gray-900">OIDC Configuration</h3>
|
||||
|
||||
<div>
|
||||
@@ -57,10 +44,57 @@
|
||||
<%= 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 -->
|
||||
<div id="forward-auth-fields" class="space-y-6 border-t border-gray-200 pt-6" style="<%= 'display: none;' unless application.forward_auth? %>">
|
||||
<div id="forward-auth-fields" class="space-y-6 border-t border-gray-200 pt-6 <%= 'hidden' unless application.forward_auth? %>" data-application-form-target="forwardAuthFields">
|
||||
<h3 class="text-base font-semibold text-gray-900">Forward Auth Configuration</h3>
|
||||
|
||||
<div>
|
||||
@@ -69,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">
|
||||
<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">
|
||||
@@ -120,30 +167,3 @@
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<script>
|
||||
// Show/hide type-specific fields based on app type selection
|
||||
const appTypeSelect = document.querySelector('#application_app_type');
|
||||
const oidcFields = document.querySelector('#oidc-fields');
|
||||
const forwardAuthFields = document.querySelector('#forward-auth-fields');
|
||||
|
||||
function updateFieldVisibility() {
|
||||
if (!appTypeSelect) return;
|
||||
|
||||
const appType = appTypeSelect.value;
|
||||
|
||||
if (oidcFields) {
|
||||
oidcFields.style.display = appType === 'oidc' ? 'block' : 'none';
|
||||
}
|
||||
|
||||
if (forwardAuthFields) {
|
||||
forwardAuthFields.style.display = appType === 'forward_auth' ? 'block' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
if (appTypeSelect) {
|
||||
appTypeSelect.addEventListener('change', updateFieldVisibility);
|
||||
}
|
||||
|
||||
// Initialize visibility on page load
|
||||
updateFieldVisibility();
|
||||
</script>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -1,22 +1,5 @@
|
||||
<%= 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" %>
|
||||
@@ -52,10 +35,25 @@
|
||||
<% end %>
|
||||
</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: (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">
|
||||
|
||||
@@ -25,11 +25,15 @@
|
||||
|
||||
<body>
|
||||
<% if authenticated? %>
|
||||
<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">
|
||||
<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" />
|
||||
@@ -44,6 +48,7 @@
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<!-- Public layout (signup/signin) -->
|
||||
<main class="container mx-auto mt-28 px-5">
|
||||
@@ -52,23 +57,5 @@
|
||||
</main>
|
||||
<% end %>
|
||||
|
||||
<script>
|
||||
// Mobile sidebar toggle
|
||||
const mobileMenuButton = document.getElementById('mobile-menu-button');
|
||||
const mobileMenuClose = document.getElementById('mobile-menu-close');
|
||||
const mobileSidebarOverlay = document.getElementById('mobile-sidebar-overlay');
|
||||
|
||||
if (mobileMenuButton) {
|
||||
mobileMenuButton.addEventListener('click', () => {
|
||||
mobileSidebarOverlay?.classList.remove('hidden');
|
||||
});
|
||||
}
|
||||
|
||||
if (mobileMenuClose) {
|
||||
mobileMenuClose.addEventListener('click', () => {
|
||||
mobileSidebarOverlay?.classList.add('hidden');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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" %>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<div class="space-y-8">
|
||||
<div class="space-y-8" data-controller="modal">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900">Account Security</h1>
|
||||
<p class="mt-2 text-sm text-gray-600">Manage your account settings, active sessions, and connected applications.</p>
|
||||
@@ -126,7 +126,6 @@
|
||||
|
||||
<!-- Disable 2FA Modal -->
|
||||
<div id="disable-2fa-modal"
|
||||
data-controller="modal"
|
||||
data-action="click->modal#closeOnBackdrop keyup@window->modal#closeOnEscape"
|
||||
class="hidden fixed inset-0 bg-gray-500 bg-opacity-75 flex items-center justify-center z-50">
|
||||
<div class="bg-white rounded-lg px-4 pt-5 pb-4 shadow-xl max-w-md w-full">
|
||||
@@ -164,18 +163,27 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- View Backup Codes Modal -->
|
||||
<!-- Regenerate Backup Codes Modal -->
|
||||
<div id="view-backup-codes-modal"
|
||||
data-controller="modal"
|
||||
data-action="click->modal#closeOnBackdrop keyup@window->modal#closeOnEscape"
|
||||
class="hidden fixed inset-0 bg-gray-500 bg-opacity-75 flex items-center justify-center z-50">
|
||||
<div class="bg-white rounded-lg px-4 pt-5 pb-4 shadow-xl max-w-md w-full">
|
||||
<div>
|
||||
<h3 class="text-lg font-medium leading-6 text-gray-900">View Backup Codes</h3>
|
||||
<h3 class="text-lg font-medium leading-6 text-gray-900">Generate New Backup Codes</h3>
|
||||
<div class="mt-2">
|
||||
<p class="text-sm text-gray-500">Enter your password to view your backup codes.</p>
|
||||
<p class="text-sm text-gray-500">Due to security improvements, you need to generate new backup codes. Your old codes have been invalidated.</p>
|
||||
</div>
|
||||
<%= form_with url: verify_password_totp_path, method: :post, class: "mt-4" do |form| %>
|
||||
<div class="mt-3 p-3 bg-yellow-50 rounded-md">
|
||||
<div class="flex">
|
||||
<svg class="h-5 w-5 text-yellow-400 mr-2 flex-shrink-0" viewBox="0 0 20 20" fill="currentColor">
|
||||
<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>
|
||||
<p class="text-sm text-yellow-800">
|
||||
<strong>Important:</strong> Save the new codes immediately after generation. You won't be able to see them again without regenerating.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<%= form_with url: create_new_backup_codes_totp_path, method: :post, class: "mt-4" do |form| %>
|
||||
<div>
|
||||
<%= password_field_tag :password, nil,
|
||||
placeholder: "Enter your password",
|
||||
@@ -184,7 +192,7 @@
|
||||
class: "block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
||||
</div>
|
||||
<div class="mt-4 flex gap-3">
|
||||
<%= form.submit "View Codes",
|
||||
<%= form.submit "Generate New Codes",
|
||||
class: "inline-flex justify-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" %>
|
||||
<button type="button"
|
||||
data-action="click->modal#hide"
|
||||
|
||||
@@ -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" %>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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 %>
|
||||
@@ -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,12 +105,18 @@
|
||||
</div>
|
||||
|
||||
<!-- Mobile sidebar overlay -->
|
||||
<div class="relative z-50 lg:hidden hidden" 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">
|
||||
<div class="absolute left-full top-0 flex w-16 justify-center pt-5">
|
||||
<button type="button" class="-m-2.5 p-2.5" id="mobile-menu-close">
|
||||
<button type="button"
|
||||
class="-m-2.5 p-2.5"
|
||||
id="mobile-menu-close"
|
||||
data-action="click->mobile-sidebar#closeSidebar">
|
||||
<span class="sr-only">Close sidebar</span>
|
||||
<svg class="h-6 w-6 text-white" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
@@ -138,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>
|
||||
@@ -147,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>
|
||||
@@ -155,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>
|
||||
@@ -163,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>
|
||||
@@ -172,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>
|
||||
@@ -180,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>
|
||||
@@ -188,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>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<div class="max-w-2xl mx-auto" data-controller="backup-codes" data-backup-codes-codes-value="<%= @backup_codes.to_json %>">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900">Backup Codes</h1>
|
||||
<p class="mt-2 text-sm text-gray-600">
|
||||
@@ -29,14 +29,14 @@
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex gap-3">
|
||||
<button onclick="downloadBackupCodes()" class="inline-flex items-center rounded-md border border-gray-300 bg-white py-2 px-4 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">
|
||||
<button data-action="click->backup-codes#download" class="inline-flex items-center rounded-md border border-gray-300 bg-white py-2 px-4 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">
|
||||
<svg class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
Download Codes
|
||||
</button>
|
||||
|
||||
<button onclick="printBackupCodes()" class="inline-flex items-center rounded-md border border-gray-300 bg-white py-2 px-4 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">
|
||||
<button data-action="click->backup-codes#print" class="inline-flex items-center rounded-md border border-gray-300 bg-white py-2 px-4 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">
|
||||
<svg class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z" />
|
||||
</svg>
|
||||
@@ -52,27 +52,3 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const backupCodes = <%= raw @backup_codes.to_json %>;
|
||||
|
||||
function downloadBackupCodes() {
|
||||
const content = "Clinch Backup Codes\n" +
|
||||
"===================\n\n" +
|
||||
backupCodes.join("\n") +
|
||||
"\n\nSave these codes in a secure location.";
|
||||
|
||||
const blob = new Blob([content], { type: 'text/plain' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'clinch-backup-codes.txt';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function printBackupCodes() {
|
||||
window.print();
|
||||
}
|
||||
</script>
|
||||
|
||||
45
app/views/totp/regenerate_backup_codes.html.erb
Normal file
45
app/views/totp/regenerate_backup_codes.html.erb
Normal file
@@ -0,0 +1,45 @@
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900">Regenerate Backup Codes</h1>
|
||||
<p class="mt-2 text-sm text-gray-600">
|
||||
This will invalidate all existing backup codes and generate new ones.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white shadow sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<div class="rounded-md bg-yellow-50 p-4 mb-6">
|
||||
<div class="flex">
|
||||
<svg class="h-5 w-5 text-yellow-400 mr-3 flex-shrink-0" viewBox="0 0 20 20" fill="currentColor">
|
||||
<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>
|
||||
<div class="text-sm text-yellow-800">
|
||||
<p class="font-medium">Important Security Notice</p>
|
||||
<p class="mt-1">All your current backup codes will become invalid after this action. Make sure you're ready to save the new codes.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= form_with(url: create_new_backup_codes_totp_path, method: :post, class: "space-y-6") do |form| %>
|
||||
<div>
|
||||
<%= form.label :password, "Enter your password to confirm", class: "block text-sm font-medium text-gray-700" %>
|
||||
<div class="mt-1">
|
||||
<%= form.password_field :password, required: true,
|
||||
class: "block w-full appearance-none rounded-md border border-gray-300 px-3 py-2 placeholder-gray-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 sm:text-sm" %>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500">
|
||||
This is required to verify your identity before regenerating backup codes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<%= form.submit "Generate New Backup Codes",
|
||||
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" %>
|
||||
|
||||
<%= link_to "Cancel", profile_path,
|
||||
class: "inline-flex justify-center rounded-md border border-gray-300 bg-white py-2 px-4 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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" %>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6,72 +6,64 @@
|
||||
|
||||
Rails.application.configure do
|
||||
config.content_security_policy do |policy|
|
||||
# Default policy: only allow resources from same origin and HTTPS
|
||||
policy.default_src :self, :https
|
||||
# Default to self for everything, plus blob: for file downloads
|
||||
policy.default_src :self, "blob:"
|
||||
|
||||
# Scripts: strict security with nonce support for dynamic content
|
||||
policy.script_src :self, :https, :strict_dynamic
|
||||
# Scripts: Allow self, importmaps, unsafe-inline for Turbo/StimulusJS, and blob: for downloads
|
||||
# Note: unsafe_inline is needed for Stimulus controllers and Turbo navigation
|
||||
policy.script_src :self, :unsafe_inline, :unsafe_eval, "blob:"
|
||||
|
||||
# Styles: allow inline styles for CSS frameworks, but require HTTPS
|
||||
policy.style_src :self, :https, :unsafe_inline
|
||||
# Styles: Allow self and unsafe_inline for TailwindCSS dynamic classes
|
||||
# and Stimulus controller style manipulations
|
||||
policy.style_src :self, :unsafe_inline
|
||||
|
||||
# Images: allow data URIs for inline images and HTTPS sources
|
||||
policy.img_src :self, :https, :data
|
||||
# Images: Allow self, data URLs, and https for external images
|
||||
policy.img_src :self, :data, :https
|
||||
|
||||
# Fonts: allow self-hosted and HTTPS fonts, plus data URIs
|
||||
policy.font_src :self, :https, :data
|
||||
# Fonts: Allow self and data URLs
|
||||
policy.font_src :self, :data
|
||||
|
||||
# Media: allow self and HTTPS media sources
|
||||
policy.media_src :self, :https
|
||||
# Connect: Allow self for API calls, WebAuthn, and ActionCable if needed
|
||||
# WebAuthn endpoints are on the same domain, so self is sufficient
|
||||
policy.connect_src :self, "wss:"
|
||||
|
||||
# Objects: block potentially dangerous plugins
|
||||
# Media: Allow self
|
||||
policy.media_src :self
|
||||
|
||||
# Object and embed sources: Disallow for security (no Flash/etc)
|
||||
policy.object_src :none
|
||||
|
||||
# Base URI: restrict base tag to same origin
|
||||
policy.base_uri :self
|
||||
|
||||
# Form actions: only allow forms to submit to same origin
|
||||
policy.form_action :self
|
||||
|
||||
# Frame ancestors: prevent clickjacking by disallowing framing
|
||||
policy.frame_src :none
|
||||
policy.frame_ancestors :none
|
||||
|
||||
# Frame sources: block iframes unless explicitly needed
|
||||
policy.frame_src :none
|
||||
# Base URI: Restricted to self
|
||||
policy.base_uri :self
|
||||
|
||||
# Connect sources: control where XHR/Fetch can connect
|
||||
policy.connect_src :self, :https
|
||||
# Form actions: Allow self for all form submissions
|
||||
# Note: OAuth redirects will be handled dynamically in the consent page
|
||||
policy.form_action :self
|
||||
|
||||
# Manifest: only allow same-origin manifest files
|
||||
# Manifest sources: Allow self for PWA manifest
|
||||
policy.manifest_src :self
|
||||
|
||||
# Worker sources: control web worker origins
|
||||
policy.worker_src :self, :https
|
||||
# Worker sources: Allow self for potential Web Workers
|
||||
policy.worker_src :self
|
||||
|
||||
# Report URI: send violation reports to our monitoring endpoint
|
||||
if Rails.env.production?
|
||||
# Child sources: Allow self for any future iframes
|
||||
policy.child_src :self
|
||||
|
||||
# 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
|
||||
end
|
||||
|
||||
# Generate session nonces for permitted inline scripts and styles
|
||||
config.content_security_policy_nonce_generator = ->(request) {
|
||||
# Use a secure random nonce instead of session ID for better security
|
||||
SecureRandom.base64(16)
|
||||
}
|
||||
|
||||
# Apply nonces to script and style directives
|
||||
config.content_security_policy_nonce_directives = %w(script-src style-src)
|
||||
|
||||
# Automatically add `nonce` attributes to script/style tags
|
||||
config.content_security_policy_nonce_auto = true
|
||||
|
||||
# Enforce CSP in production, but use report-only in development for debugging
|
||||
if Rails.env.production?
|
||||
# Enforce the policy in production
|
||||
config.content_security_policy_report_only = false
|
||||
else
|
||||
# Report violations only in development (helps with debugging)
|
||||
config.content_security_policy_report_only = true
|
||||
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?
|
||||
|
||||
# Report CSP violations (optional - uncomment to enable)
|
||||
# config.content_security_policy_report_uri = "/csp-violations"
|
||||
end
|
||||
128
config/initializers/csp_local_logger.rb
Normal file
128
config/initializers/csp_local_logger.rb
Normal 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
|
||||
140
config/initializers/sentry.rb
Normal file
140
config/initializers/sentry.rb
Normal 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
|
||||
120
config/initializers/sentry_subscriber.rb
Normal file
120
config/initializers/sentry_subscriber.rb
Normal 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
|
||||
@@ -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
|
||||
# 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)
|
||||
config.rp_id = ENV.fetch("CLINCH_RP_ID", "localhost")
|
||||
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?
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -64,6 +65,8 @@ Rails.application.routes.draw do
|
||||
delete '/totp', to: 'totp#destroy'
|
||||
get '/totp/backup_codes', to: 'totp#backup_codes', as: :backup_codes_totp
|
||||
post '/totp/verify_password', to: 'totp#verify_password', as: :verify_password_totp
|
||||
get '/totp/regenerate_backup_codes', to: 'totp#regenerate_backup_codes', as: :regenerate_backup_codes_totp
|
||||
post '/totp/regenerate_backup_codes', to: 'totp#create_new_backup_codes', as: :create_new_backup_codes_totp
|
||||
|
||||
# WebAuthn (Passkeys) routes
|
||||
get '/webauthn/new', to: 'webauthn#new', as: :new_webauthn
|
||||
|
||||
13
db/migrate/20251104061455_clear_existing_backup_codes.rb
Normal file
13
db/migrate/20251104061455_clear_existing_backup_codes.rb
Normal file
@@ -0,0 +1,13 @@
|
||||
class ClearExistingBackupCodes < ActiveRecord::Migration[8.1]
|
||||
def up
|
||||
# Clear all existing backup codes to force regeneration with BCrypt hashing
|
||||
# This is a security migration to move from plain text to hashed storage
|
||||
User.where.not(backup_codes: nil).update_all(backup_codes: nil)
|
||||
end
|
||||
|
||||
def down
|
||||
# This migration cannot be safely reversed
|
||||
# as the original plain text codes cannot be recovered
|
||||
raise ActiveRecord::IrreversibleMigration
|
||||
end
|
||||
end
|
||||
12
db/migrate/20251104064114_change_backup_codes_to_json.rb
Normal file
12
db/migrate/20251104064114_change_backup_codes_to_json.rb
Normal file
@@ -0,0 +1,12 @@
|
||||
class ChangeBackupCodesToJson < ActiveRecord::Migration[8.1]
|
||||
def up
|
||||
# Change the column type from text to json
|
||||
# This will automatically handle JSON serialization/deserialization
|
||||
change_column :users, :backup_codes, :json
|
||||
end
|
||||
|
||||
def down
|
||||
# Revert back to text if needed
|
||||
change_column :users, :backup_codes, :text
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
17
db/migrate/20251109011443_fix_empty_domain_patterns.rb
Normal file
17
db/migrate/20251109011443_fix_empty_domain_patterns.rb
Normal 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
|
||||
22
db/migrate/20251112114852_create_oidc_refresh_tokens.rb
Normal file
22
db/migrate/20251112114852_create_oidc_refresh_tokens.rb
Normal 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
class MakeOidcAccessTokenTokenNullable < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
change_column_null :oidc_access_tokens, :token, true
|
||||
end
|
||||
end
|
||||
40
db/schema.rb
generated
40
db/schema.rb
generated
@@ -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_054909) do
|
||||
ActiveRecord::Schema[8.1].define(version: 2025_11_12_120314) do
|
||||
create_table "application_groups", force: :cascade do |t|
|
||||
t.integer "application_id", null: false
|
||||
t.datetime "created_at", null: false
|
||||
@@ -22,6 +22,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_04_054909) do
|
||||
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 +31,12 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_04_054909) 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 +58,26 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_04_054909) 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,10 +89,32 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_04_054909) 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
|
||||
@@ -124,7 +155,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_04_054909) do
|
||||
|
||||
create_table "users", force: :cascade do |t|
|
||||
t.boolean "admin", default: false, null: false
|
||||
t.text "backup_codes"
|
||||
t.json "backup_codes"
|
||||
t.datetime "created_at", null: false
|
||||
t.json "custom_claims", default: {}, null: false
|
||||
t.string "email_address", null: false
|
||||
@@ -171,6 +202,9 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_04_054909) do
|
||||
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"
|
||||
|
||||
136
docs/oidc-key-setup.md
Normal file
136
docs/oidc-key-setup.md
Normal file
@@ -0,0 +1,136 @@
|
||||
# OIDC Private Key Setup
|
||||
|
||||
Your OIDC provider needs an RSA private key to sign ID tokens. **This key must persist across deployments** or all existing tokens will become invalid.
|
||||
|
||||
## Option 1: Environment Variable (Recommended for Docker/Kamal)
|
||||
|
||||
### 1. Generate the key
|
||||
|
||||
```bash
|
||||
# Generate a 2048-bit RSA key
|
||||
openssl genrsa -out oidc_private_key.pem 2048
|
||||
|
||||
# View the key (you'll copy this)
|
||||
cat oidc_private_key.pem
|
||||
```
|
||||
|
||||
### 2. Store in your `.env` file
|
||||
|
||||
```bash
|
||||
# .env (for local development)
|
||||
OIDC_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----
|
||||
MIIEpAIBAAKCAQEAyZ0qaICMiLVWSFs+ef9Xok3fzy0p6k/7D5TQzmxf7C2vQG7s
|
||||
2Odmi8iAHLoaUBaFj70qTbaconWyMr8s+ah+qZwrwolTLUe23VrceVXvInU57hBL
|
||||
...
|
||||
-----END RSA PRIVATE KEY-----"
|
||||
```
|
||||
|
||||
**Important:** Keep the quotes and include the full key with `-----BEGIN` and `-----END` lines.
|
||||
|
||||
### 3. For Kamal deployment
|
||||
|
||||
Add to your Kamal secrets:
|
||||
|
||||
```yaml
|
||||
# config/deploy.yml
|
||||
env:
|
||||
secret:
|
||||
- OIDC_PRIVATE_KEY
|
||||
```
|
||||
|
||||
Then set it securely:
|
||||
|
||||
```bash
|
||||
# Generate key
|
||||
bin/generate_oidc_key > oidc_private_key.pem
|
||||
|
||||
# Add to .kamal/secrets
|
||||
echo "OIDC_PRIVATE_KEY=$(cat oidc_private_key.pem)" >> .kamal/secrets
|
||||
```
|
||||
|
||||
### 4. Verify it's loaded
|
||||
|
||||
```bash
|
||||
# In Rails console
|
||||
bin/rails runner "puts OidcJwtService.send(:private_key).present? ? 'Key loaded' : 'Key missing'"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Comparison
|
||||
|
||||
| Feature | ENV Variable | Rails Credentials |
|
||||
|---------|-------------|-------------------|
|
||||
| **Best for** | Docker, Kamal, 12-factor | Simple deployments |
|
||||
| **Key rotation** | Easy (just update ENV) | Medium (re-encrypt) |
|
||||
| **Per-environment** | Yes (dev/staging/prod can differ) | No (same key everywhere) |
|
||||
| **Secrets manager** | Compatible (AWS Secrets, etc.) | Needs RAILS_MASTER_KEY |
|
||||
| **Setup complexity** | Low | Medium |
|
||||
|
||||
**Recommendation:** Use ENV variable (`OIDC_PRIVATE_KEY`) for production with Kamal.
|
||||
|
||||
---
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
### DO:
|
||||
- ✅ Generate the key once and keep it forever
|
||||
- ✅ Store in secret manager (AWS Secrets Manager, 1Password, etc.)
|
||||
- ✅ Use strong key (2048-bit RSA minimum)
|
||||
- ✅ Backup the key securely
|
||||
- ✅ Restrict access (only ops team)
|
||||
|
||||
### DON'T:
|
||||
- ❌ Commit the key to git (except encrypted credentials)
|
||||
- ❌ Share the key in Slack/email
|
||||
- ❌ Regenerate the key (invalidates all tokens)
|
||||
- ❌ Store in `.env` if it's committed to git
|
||||
- ❌ Use the same key for multiple environments
|
||||
|
||||
---
|
||||
|
||||
## Key Rotation (Advanced)
|
||||
|
||||
Todo
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "No private key found" warning
|
||||
|
||||
Check your setup:
|
||||
|
||||
```bash
|
||||
# Is ENV set?
|
||||
echo $OIDC_PRIVATE_KEY
|
||||
|
||||
# Can Rails load it?
|
||||
bin/rails runner "puts ENV['OIDC_PRIVATE_KEY'].present? ? 'ENV set' : 'ENV missing'"
|
||||
|
||||
# Does it load correctly?
|
||||
bin/rails runner "puts OidcJwtService.send(:private_key).to_s[0..50]"
|
||||
```
|
||||
|
||||
### "invalid RSA key" error
|
||||
|
||||
- Make sure you include `-----BEGIN RSA PRIVATE KEY-----` header
|
||||
- Ensure newlines are preserved (use quotes in ENV)
|
||||
- Check for extra spaces or characters
|
||||
|
||||
### Different JWKS key ID on each restart
|
||||
|
||||
This means the key is being regenerated. You need to set `OIDC_PRIVATE_KEY` or add to credentials.
|
||||
|
||||
### All tokens invalid after deployment
|
||||
|
||||
The key changed. You either:
|
||||
- Regenerated the key (don't do this!)
|
||||
- Forgot to set ENV variable in production
|
||||
- The key wasn't loaded correctly
|
||||
|
||||
Check logs for warnings and verify key is loaded:
|
||||
|
||||
```bash
|
||||
kamal app logs --grep "OIDC"
|
||||
```
|
||||
440
test/controllers/oidc_authorization_code_security_test.rb
Normal file
440
test/controllers/oidc_authorization_code_security_test.rb
Normal file
@@ -0,0 +1,440 @@
|
||||
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).destroy_all
|
||||
OidcAccessToken.where(application: @application).destroy_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
|
||||
299
test/controllers/oidc_pkce_controller_test.rb
Normal file
299
test/controllers/oidc_pkce_controller_test.rb
Normal 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
|
||||
235
test/controllers/oidc_refresh_token_controller_test.rb
Normal file
235
test/controllers/oidc_refresh_token_controller_test.rb
Normal 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
|
||||
7
test/jobs/oidc_token_cleanup_job_test.rb
Normal file
7
test/jobs/oidc_token_cleanup_job_test.rb
Normal file
@@ -0,0 +1,7 @@
|
||||
require "test_helper"
|
||||
|
||||
class OidcTokenCleanupJobTest < ActiveJob::TestCase
|
||||
# test "the truth" do
|
||||
# assert true
|
||||
# end
|
||||
end
|
||||
177
test/models/pkce_authorization_code_test.rb
Normal file
177
test/models/pkce_authorization_code_test.rb
Normal 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
|
||||
@@ -230,4 +230,132 @@ class UserTest < ActiveSupport::TestCase
|
||||
assert_not user.valid?
|
||||
assert_includes user.errors[:password], "is too short (minimum is 8 characters)"
|
||||
end
|
||||
|
||||
# Backup codes tests
|
||||
test "generate_backup_codes returns 10 plain codes and stores BCrypt hashes" do
|
||||
user = User.create!(
|
||||
email_address: "test@example.com",
|
||||
password: "password123"
|
||||
)
|
||||
|
||||
# Generate backup codes
|
||||
plain_codes = user.send(:generate_backup_codes)
|
||||
|
||||
# Should return 10 plain codes
|
||||
assert_equal 10, plain_codes.length
|
||||
assert_kind_of Array, plain_codes
|
||||
|
||||
# All codes should be 8 characters, alphanumeric, uppercase
|
||||
plain_codes.each do |code|
|
||||
assert_equal 8, code.length
|
||||
assert_match(/\A[A-Z0-9]+\z/, code)
|
||||
end
|
||||
|
||||
# Save user to persist the backup codes
|
||||
user.save!
|
||||
|
||||
# Reload user from database to check stored values
|
||||
user.reload
|
||||
stored_hashes = user.backup_codes || []
|
||||
|
||||
# Should store 10 BCrypt hashes
|
||||
assert_equal 10, stored_hashes.length
|
||||
stored_hashes.each do |hash|
|
||||
assert hash.start_with?('$2a$'), "Should be BCrypt hash"
|
||||
end
|
||||
|
||||
# Verify each plain code matches its corresponding hash
|
||||
plain_codes.each_with_index do |code, index|
|
||||
assert BCrypt::Password.new(stored_hashes[index]) == code, "Plain code should match stored hash"
|
||||
end
|
||||
end
|
||||
|
||||
test "verify_backup_code works with BCrypt hashes" do
|
||||
user = User.create!(
|
||||
email_address: "test@example.com",
|
||||
password: "password123"
|
||||
)
|
||||
|
||||
# Generate backup codes using the new flow (simulate what happens in controller)
|
||||
plain_codes = user.send(:generate_backup_codes)
|
||||
user.save!
|
||||
user.reload
|
||||
|
||||
# Should successfully verify a valid backup code
|
||||
assert user.verify_backup_code(plain_codes.first), "Should verify first backup code"
|
||||
|
||||
# Code should be deleted after use (single-use property)
|
||||
user.reload
|
||||
assert user.verify_backup_code(plain_codes.first) == false, "Used code should not be verifiable again"
|
||||
|
||||
# Should still verify other unused codes
|
||||
assert user.verify_backup_code(plain_codes.second), "Should verify second backup code"
|
||||
end
|
||||
|
||||
test "verify_backup_code returns false for invalid codes" do
|
||||
user = User.create!(
|
||||
email_address: "test@example.com",
|
||||
password: "password123"
|
||||
)
|
||||
|
||||
# Generate backup codes
|
||||
plain_codes = user.send(:generate_backup_codes)
|
||||
user.save!
|
||||
user.reload
|
||||
|
||||
# Should fail for invalid codes
|
||||
assert_not user.verify_backup_code("INVALID123"), "Should fail for invalid code"
|
||||
assert_not user.verify_backup_code(""), "Should fail for empty code"
|
||||
assert_not user.verify_backup_code(plain_codes.first + "X"), "Should fail for modified valid code"
|
||||
end
|
||||
|
||||
test "verify_backup_code returns false when no backup codes exist" do
|
||||
user = User.create!(
|
||||
email_address: "test@example.com",
|
||||
password: "password123"
|
||||
)
|
||||
|
||||
# Should return false when user has no backup codes
|
||||
assert_not user.verify_backup_code("ANYCODE123"), "Should fail when no backup codes exist"
|
||||
end
|
||||
|
||||
test "verify_backup_code respects rate limiting" do
|
||||
# Temporarily use memory store for this test
|
||||
original_cache_store = Rails.cache
|
||||
Rails.cache = ActiveSupport::Cache::MemoryStore.new
|
||||
|
||||
user = User.create!(
|
||||
email_address: "test@example.com",
|
||||
password: "password123"
|
||||
)
|
||||
|
||||
# Generate backup codes
|
||||
plain_codes = user.send(:generate_backup_codes)
|
||||
user.save!
|
||||
user.reload
|
||||
|
||||
# Make 5 failed attempts to trigger rate limit
|
||||
5.times do |i|
|
||||
result = user.verify_backup_code("INVALID123")
|
||||
assert_not result, "Failed attempt #{i+1} should return false"
|
||||
end
|
||||
|
||||
# Check that the cache is tracking attempts
|
||||
attempts = Rails.cache.read("backup_code_failed_attempts_#{user.id}") || 0
|
||||
assert_equal 5, attempts, "Should have 5 failed attempts tracked"
|
||||
|
||||
# 6th attempt should be rate limited (both valid and invalid codes should fail)
|
||||
assert_not user.verify_backup_code(plain_codes.first), "Valid code should be rate limited after 5 failed attempts"
|
||||
assert_not user.verify_backup_code("INVALID123"), "Invalid code should also be rate limited"
|
||||
|
||||
# Valid code should still work if we clear the rate limit
|
||||
Rails.cache.delete("backup_code_failed_attempts_#{user.id}")
|
||||
assert user.verify_backup_code(plain_codes.first), "Should work after clearing rate limit"
|
||||
|
||||
# Restore original cache store
|
||||
Rails.cache = original_cache_store
|
||||
end
|
||||
|
||||
# Note: parsed_backup_codes method and legacy tests removed
|
||||
# All users now use BCrypt hashes stored in JSON column
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user