Compare commits
5 Commits
feature/en
...
94785dbfe7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94785dbfe7 | ||
|
|
10bbbc8c40 | ||
|
|
02e46a7168 | ||
|
|
a2a954b4c3 | ||
|
|
0ce38e3202 |
60
.env.example
60
.env.example
@@ -16,43 +16,9 @@ SMTP_AUTHENTICATION=plain
|
||||
SMTP_ENABLE_STARTTLS=true
|
||||
|
||||
# Application Configuration
|
||||
CLINCH_HOST=http://localhost:3000
|
||||
CLINCH_HOST=http://localhost:9000
|
||||
CLINCH_FROM_EMAIL=noreply@example.com
|
||||
|
||||
# WebAuthn / Passkey Configuration
|
||||
# Required for passkeys to work in production (HTTPS required)
|
||||
#
|
||||
# CLINCH_RP_ID is the Relying Party Identifier - the domain that owns the passkeys
|
||||
# - If your site is auth.example.com, use either "auth.example.com" or "example.com"
|
||||
# - Using parent domain (e.g., "example.com") allows passkeys to work across all subdomains
|
||||
# - Using subdomain (e.g., "auth.example.com") restricts passkeys to that specific subdomain
|
||||
#
|
||||
# CLINCH_RP_NAME is shown to users when creating/using passkeys
|
||||
#
|
||||
# Examples:
|
||||
# For https://auth.example.com:
|
||||
# CLINCH_HOST=https://auth.example.com
|
||||
# CLINCH_RP_ID=example.com
|
||||
# CLINCH_RP_NAME="Example Company"
|
||||
#
|
||||
# For https://sso.mycompany.com:
|
||||
# CLINCH_HOST=https://sso.mycompany.com
|
||||
# CLINCH_RP_ID=mycompany.com
|
||||
# CLINCH_RP_NAME="My Company Identity"
|
||||
#
|
||||
CLINCH_RP_ID=localhost
|
||||
CLINCH_RP_NAME="Clinch Identity Provider"
|
||||
|
||||
# DNS Rebinding Protection Configuration
|
||||
# Set to service name (e.g., 'clinch') if running in same Docker compose as Caddy
|
||||
CLINCH_DOCKER_SERVICE_NAME=
|
||||
|
||||
# Allow internal IP access for cross-compose deployments (true/false)
|
||||
CLINCH_ALLOW_INTERNAL_IPS=true
|
||||
|
||||
# Allow localhost access for development (true/false)
|
||||
CLINCH_ALLOW_LOCALHOST=true
|
||||
|
||||
# OIDC Configuration
|
||||
# RSA private key for signing ID tokens (JWT)
|
||||
# Generate with: openssl genrsa 2048
|
||||
@@ -68,27 +34,3 @@ 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
|
||||
|
||||
@@ -32,7 +32,7 @@ FROM base AS build
|
||||
|
||||
# Install packages needed to build gems
|
||||
RUN apt-get update -qq && \
|
||||
apt-get install --no-install-recommends -y build-essential git libyaml-dev pkg-config libssl-dev && \
|
||||
apt-get install --no-install-recommends -y build-essential git libyaml-dev pkg-config && \
|
||||
rm -rf /var/lib/apt/lists /var/cache/apt/archives
|
||||
|
||||
# Install application gems
|
||||
|
||||
12
Gemfile
12
Gemfile
@@ -1,7 +1,7 @@
|
||||
source "https://rubygems.org"
|
||||
|
||||
# Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main"
|
||||
gem "rails", "~> 8.1.1"
|
||||
gem "rails", "~> 8.1.0"
|
||||
# The modern asset pipeline for Rails [https://github.com/rails/propshaft]
|
||||
gem "propshaft"
|
||||
# Use sqlite3 as the database for Active Record
|
||||
@@ -31,16 +31,6 @@ gem "rqrcode", "~> 3.1"
|
||||
# JWT for OIDC ID tokens
|
||||
gem "jwt", "~> 3.1"
|
||||
|
||||
# WebAuthn for passkey support
|
||||
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 ]
|
||||
|
||||
|
||||
161
Gemfile.lock
161
Gemfile.lock
@@ -3,29 +3,29 @@ GEM
|
||||
specs:
|
||||
action_text-trix (2.1.15)
|
||||
railties
|
||||
actioncable (8.1.1)
|
||||
actionpack (= 8.1.1)
|
||||
activesupport (= 8.1.1)
|
||||
actioncable (8.1.0)
|
||||
actionpack (= 8.1.0)
|
||||
activesupport (= 8.1.0)
|
||||
nio4r (~> 2.0)
|
||||
websocket-driver (>= 0.6.1)
|
||||
zeitwerk (~> 2.6)
|
||||
actionmailbox (8.1.1)
|
||||
actionpack (= 8.1.1)
|
||||
activejob (= 8.1.1)
|
||||
activerecord (= 8.1.1)
|
||||
activestorage (= 8.1.1)
|
||||
activesupport (= 8.1.1)
|
||||
actionmailbox (8.1.0)
|
||||
actionpack (= 8.1.0)
|
||||
activejob (= 8.1.0)
|
||||
activerecord (= 8.1.0)
|
||||
activestorage (= 8.1.0)
|
||||
activesupport (= 8.1.0)
|
||||
mail (>= 2.8.0)
|
||||
actionmailer (8.1.1)
|
||||
actionpack (= 8.1.1)
|
||||
actionview (= 8.1.1)
|
||||
activejob (= 8.1.1)
|
||||
activesupport (= 8.1.1)
|
||||
actionmailer (8.1.0)
|
||||
actionpack (= 8.1.0)
|
||||
actionview (= 8.1.0)
|
||||
activejob (= 8.1.0)
|
||||
activesupport (= 8.1.0)
|
||||
mail (>= 2.8.0)
|
||||
rails-dom-testing (~> 2.2)
|
||||
actionpack (8.1.1)
|
||||
actionview (= 8.1.1)
|
||||
activesupport (= 8.1.1)
|
||||
actionpack (8.1.0)
|
||||
actionview (= 8.1.0)
|
||||
activesupport (= 8.1.0)
|
||||
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.1)
|
||||
actiontext (8.1.0)
|
||||
action_text-trix (~> 2.1.15)
|
||||
actionpack (= 8.1.1)
|
||||
activerecord (= 8.1.1)
|
||||
activestorage (= 8.1.1)
|
||||
activesupport (= 8.1.1)
|
||||
actionpack (= 8.1.0)
|
||||
activerecord (= 8.1.0)
|
||||
activestorage (= 8.1.0)
|
||||
activesupport (= 8.1.0)
|
||||
globalid (>= 0.6.0)
|
||||
nokogiri (>= 1.8.5)
|
||||
actionview (8.1.1)
|
||||
activesupport (= 8.1.1)
|
||||
actionview (8.1.0)
|
||||
activesupport (= 8.1.0)
|
||||
builder (~> 3.1)
|
||||
erubi (~> 1.11)
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
activejob (8.1.1)
|
||||
activesupport (= 8.1.1)
|
||||
activejob (8.1.0)
|
||||
activesupport (= 8.1.0)
|
||||
globalid (>= 0.3.6)
|
||||
activemodel (8.1.1)
|
||||
activesupport (= 8.1.1)
|
||||
activerecord (8.1.1)
|
||||
activemodel (= 8.1.1)
|
||||
activesupport (= 8.1.1)
|
||||
activemodel (8.1.0)
|
||||
activesupport (= 8.1.0)
|
||||
activerecord (8.1.0)
|
||||
activemodel (= 8.1.0)
|
||||
activesupport (= 8.1.0)
|
||||
timeout (>= 0.4.0)
|
||||
activestorage (8.1.1)
|
||||
actionpack (= 8.1.1)
|
||||
activejob (= 8.1.1)
|
||||
activerecord (= 8.1.1)
|
||||
activesupport (= 8.1.1)
|
||||
activestorage (8.1.0)
|
||||
actionpack (= 8.1.0)
|
||||
activejob (= 8.1.0)
|
||||
activerecord (= 8.1.0)
|
||||
activesupport (= 8.1.0)
|
||||
marcel (~> 1.0)
|
||||
activesupport (8.1.1)
|
||||
activesupport (8.1.0)
|
||||
base64
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0, >= 1.3.1)
|
||||
@@ -77,13 +77,11 @@ GEM
|
||||
uri (>= 0.13.1)
|
||||
addressable (2.8.7)
|
||||
public_suffix (>= 2.0.2, < 7.0)
|
||||
android_key_attestation (0.3.0)
|
||||
ast (2.4.3)
|
||||
base64 (0.3.0)
|
||||
bcrypt (3.1.20)
|
||||
bcrypt_pbkdf (1.1.1)
|
||||
bigdecimal (3.3.1)
|
||||
bindata (2.5.1)
|
||||
bindex (0.8.1)
|
||||
bootsnap (1.18.6)
|
||||
msgpack (~> 1.2)
|
||||
@@ -102,24 +100,20 @@ GEM
|
||||
rack-test (>= 0.6.3)
|
||||
regexp_parser (>= 1.5, < 3.0)
|
||||
xpath (~> 3.2)
|
||||
cbor (0.5.10.1)
|
||||
childprocess (5.1.0)
|
||||
logger (~> 1.5)
|
||||
chunky_png (1.4.0)
|
||||
concurrent-ruby (1.3.5)
|
||||
connection_pool (2.5.4)
|
||||
cose (1.3.1)
|
||||
cbor (~> 0.5.9)
|
||||
openssl-signature_algorithm (~> 1.0)
|
||||
crass (1.0.6)
|
||||
date (3.5.0)
|
||||
date (3.4.1)
|
||||
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.3)
|
||||
erb (5.1.1)
|
||||
erubi (1.13.1)
|
||||
ffi (1.17.2-aarch64-linux-gnu)
|
||||
ffi (1.17.2-aarch64-linux-musl)
|
||||
@@ -140,14 +134,14 @@ GEM
|
||||
activesupport (>= 6.0.0)
|
||||
railties (>= 6.0.0)
|
||||
io-console (0.8.1)
|
||||
irb (1.15.3)
|
||||
irb (1.15.2)
|
||||
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.2)
|
||||
json (2.15.1)
|
||||
jwt (3.1.2)
|
||||
base64
|
||||
kamal (2.8.1)
|
||||
@@ -200,7 +194,7 @@ GEM
|
||||
net-smtp (0.5.1)
|
||||
net-protocol
|
||||
net-ssh (7.3.0)
|
||||
nio4r (2.7.5)
|
||||
nio4r (2.7.4)
|
||||
nokogiri (1.18.10-aarch64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.10-aarch64-linux-musl)
|
||||
@@ -215,9 +209,6 @@ GEM
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.10-x86_64-linux-musl)
|
||||
racc (~> 1.4)
|
||||
openssl (3.3.2)
|
||||
openssl-signature_algorithm (1.3.0)
|
||||
openssl (> 2.0)
|
||||
ostruct (0.6.3)
|
||||
parallel (1.27.0)
|
||||
parser (3.3.9.0)
|
||||
@@ -238,7 +229,7 @@ GEM
|
||||
puma (7.1.0)
|
||||
nio4r (~> 2.0)
|
||||
racc (1.8.1)
|
||||
rack (3.2.4)
|
||||
rack (3.2.3)
|
||||
rack-session (2.1.1)
|
||||
base64 (>= 0.1.0)
|
||||
rack (>= 3.0.0)
|
||||
@@ -246,20 +237,20 @@ GEM
|
||||
rack (>= 1.3)
|
||||
rackup (2.2.1)
|
||||
rack (>= 3)
|
||||
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)
|
||||
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)
|
||||
bundler (>= 1.15.0)
|
||||
railties (= 8.1.1)
|
||||
railties (= 8.1.0)
|
||||
rails-dom-testing (2.3.0)
|
||||
activesupport (>= 5.0.0)
|
||||
minitest
|
||||
@@ -267,9 +258,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.1)
|
||||
actionpack (= 8.1.1)
|
||||
activesupport (= 8.1.1)
|
||||
railties (8.1.0)
|
||||
actionpack (= 8.1.0)
|
||||
activesupport (= 8.1.0)
|
||||
irb (~> 1.13)
|
||||
rackup (>= 1.0.0)
|
||||
rake (>= 12.2)
|
||||
@@ -277,8 +268,8 @@ GEM
|
||||
tsort (>= 0.2)
|
||||
zeitwerk (~> 2.6)
|
||||
rainbow (3.1.1)
|
||||
rake (13.3.1)
|
||||
rdoc (6.15.1)
|
||||
rake (13.3.0)
|
||||
rdoc (6.15.0)
|
||||
erb
|
||||
psych (>= 4.0.0)
|
||||
tsort
|
||||
@@ -324,8 +315,6 @@ GEM
|
||||
ffi (~> 1.12)
|
||||
logger
|
||||
rubyzip (3.2.1)
|
||||
safety_net_attestation (0.5.0)
|
||||
jwt (>= 2.0, < 4.0)
|
||||
securerandom (0.4.1)
|
||||
selenium-webdriver (4.38.0)
|
||||
base64 (~> 0.2)
|
||||
@@ -333,12 +322,6 @@ 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)
|
||||
@@ -379,11 +362,7 @@ GEM
|
||||
thruster (0.1.16-aarch64-linux)
|
||||
thruster (0.1.16-arm64-darwin)
|
||||
thruster (0.1.16-x86_64-linux)
|
||||
timeout (0.4.4)
|
||||
tpm-key_attestation (0.14.1)
|
||||
bindata (~> 2.4)
|
||||
openssl (> 2.0)
|
||||
openssl-signature_algorithm (~> 1.0)
|
||||
timeout (0.4.3)
|
||||
tsort (0.2.0)
|
||||
turbo-rails (2.0.17)
|
||||
actionpack (>= 7.1.0)
|
||||
@@ -393,21 +372,13 @@ GEM
|
||||
unicode-display_width (3.2.0)
|
||||
unicode-emoji (~> 4.1)
|
||||
unicode-emoji (4.1.0)
|
||||
uri (1.1.0)
|
||||
uri (1.0.4)
|
||||
useragent (0.16.11)
|
||||
web-console (4.2.1)
|
||||
actionview (>= 6.0.0)
|
||||
activemodel (>= 6.0.0)
|
||||
bindex (>= 0.4.0)
|
||||
railties (>= 6.0.0)
|
||||
webauthn (3.4.3)
|
||||
android_key_attestation (~> 0.3.0)
|
||||
bindata (~> 2.4)
|
||||
cbor (~> 0.5.9)
|
||||
cose (~> 1.1)
|
||||
openssl (>= 2.2)
|
||||
safety_net_attestation (~> 0.5.0)
|
||||
tpm-key_attestation (~> 0.14.0)
|
||||
websocket (1.2.11)
|
||||
websocket-driver (0.8.0)
|
||||
base64
|
||||
@@ -442,15 +413,12 @@ DEPENDENCIES
|
||||
kamal
|
||||
letter_opener
|
||||
propshaft
|
||||
public_suffix (~> 6.0)
|
||||
puma (>= 5.0)
|
||||
rails (~> 8.1.1)
|
||||
rails (~> 8.1.0)
|
||||
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)
|
||||
@@ -460,7 +428,6 @@ DEPENDENCIES
|
||||
turbo-rails
|
||||
tzinfo-data
|
||||
web-console
|
||||
webauthn (~> 3.0)
|
||||
|
||||
BUNDLED WITH
|
||||
2.7.2
|
||||
|
||||
94
README.md
94
README.md
@@ -1,28 +1,10 @@
|
||||
# Clinch
|
||||
|
||||
> [!NOTE]
|
||||
> This software is experiemental. If you'd like to try it out, find bugs, security flaws and improvements, please do.
|
||||
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 / IpD portal**
|
||||
**A lightweight, self-hosted identity & SSO 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,
|
||||
Clinch gives you one place to manage users and lets any web app authenticate against it without maintaining its own user table.
|
||||
|
||||
## Why Clinch?
|
||||
|
||||
@@ -38,35 +20,6 @@ Clinch sits in a sweet spot between two excellent open-source identity solutions
|
||||
|
||||
---
|
||||
|
||||
## Screenshots
|
||||
|
||||
### User Dashboard
|
||||
[](docs/screenshots/0-dashboard.png)
|
||||
|
||||
### Sign In
|
||||
[](docs/screenshots/1-signin.png)
|
||||
|
||||
### Sign In with 2FA
|
||||
[](docs/screenshots/2-signin.png)
|
||||
|
||||
### Users Management
|
||||
[](docs/screenshots/3-users.png)
|
||||
|
||||
### Welcome Screen
|
||||
[](docs/screenshots/4-welcome.png)
|
||||
|
||||
### Welcome Setup
|
||||
[](docs/screenshots/5-welcome-2.png)
|
||||
|
||||
### Setup 2FA
|
||||
[](docs/screenshots/6-setup-2fa.png)
|
||||
|
||||
### Forward Auth Example 1
|
||||
[](docs/screenshots/7-forward-auth-1.png)
|
||||
|
||||
### Forward Auth Example 2
|
||||
[](docs/screenshots/8-forward-auth-2.png)
|
||||
|
||||
## Features
|
||||
|
||||
### User Management
|
||||
@@ -87,17 +40,11 @@ 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 with PKCE support
|
||||
- `/token` - Token endpoint (authorization_code and refresh_token grants)
|
||||
- `/authorize` - Authorization endpoint
|
||||
- `/token` - Token endpoint
|
||||
- `/userinfo` - User info endpoint
|
||||
- `/revoke` - Token revocation endpoint (RFC 7009)
|
||||
|
||||
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.
|
||||
Client apps (Audiobookshelf, Kavita, Grafana, etc.) redirect to Clinch for login and receive ID tokens and access tokens.
|
||||
|
||||
#### Trusted-Header SSO (ForwardAuth)
|
||||
Works with reverse proxies (Caddy, Traefik, Nginx):
|
||||
@@ -124,7 +71,6 @@ Send emails for:
|
||||
- **Group-based allowlists** - Restrict applications to specific user groups
|
||||
- **Per-application access** - Each app defines which groups can access it
|
||||
- **Automatic enforcement** - Access checks during OIDC authorization and ForwardAuth
|
||||
- **Custom claims** - Add arbitrary claims to OIDC tokens via groups and users (perfect for app-specific roles)
|
||||
|
||||
---
|
||||
|
||||
@@ -139,13 +85,11 @@ Send emails for:
|
||||
- TOTP secret and backup codes (encrypted)
|
||||
- TOTP enforcement flag
|
||||
- Status (active, disabled, pending_invitation)
|
||||
- Custom claims (JSON) - arbitrary key-value pairs added to OIDC tokens
|
||||
- Token generation for invitations, password resets, and magic logins
|
||||
|
||||
**Group**
|
||||
- Name (unique, normalized to lowercase)
|
||||
- Description
|
||||
- Custom claims (JSON) - shared claims for all members (merged with user claims)
|
||||
- Many-to-many with Users and Applications
|
||||
|
||||
**Session**
|
||||
@@ -158,34 +102,28 @@ Send emails for:
|
||||
|
||||
**Application**
|
||||
- Name and slug (URL-safe identifier)
|
||||
- Type (oidc or forward_auth)
|
||||
- Client ID and secret (for OIDC apps)
|
||||
- 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)
|
||||
- Type (oidc, trusted_header, saml)
|
||||
- Client ID and secret (for OIDC)
|
||||
- Redirect URIs (JSON array)
|
||||
- Metadata (flexible JSON storage)
|
||||
- Active flag
|
||||
- Many-to-many with Groups (allowlist)
|
||||
|
||||
**OIDC Tokens**
|
||||
- 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)
|
||||
- Authorization codes (10-minute expiry, one-time use)
|
||||
- Access tokens (1-hour expiry, revocable)
|
||||
|
||||
---
|
||||
|
||||
## Authentication Flows
|
||||
|
||||
### OIDC Authorization Flow
|
||||
1. Client redirects user to `/authorize` with client_id, redirect_uri, scope (optional PKCE)
|
||||
1. Client redirects user to `/authorize` with client_id, redirect_uri, scope
|
||||
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 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)
|
||||
5. Client exchanges code for access token at `/token`
|
||||
6. Client uses access token to fetch user info from `/userinfo`
|
||||
|
||||
### ForwardAuth Flow
|
||||
1. User requests protected resource at `https://app.example.com/dashboard`
|
||||
@@ -269,10 +207,6 @@ SMTP_ENABLE_STARTTLS=true
|
||||
# Application
|
||||
CLINCH_HOST=https://auth.example.com
|
||||
CLINCH_FROM_EMAIL=noreply@example.com
|
||||
|
||||
# OIDC (optional - generates temporary key in development)
|
||||
# Generate with: openssl genpkey -algorithm RSA -out private_key.pem -pkeyopt rsa_keygen_bits:2048
|
||||
OIDC_PRIVATE_KEY=<contents-of-private-key.pem>
|
||||
```
|
||||
|
||||
### First Run
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
class ActiveSessionsController < ApplicationController
|
||||
def show
|
||||
@user = Current.session.user
|
||||
@active_sessions = @user.sessions.active.order(last_activity_at: :desc)
|
||||
@connected_applications = @user.oidc_user_consents.includes(:application).order(granted_at: :desc)
|
||||
end
|
||||
|
||||
def revoke_consent
|
||||
@user = Current.session.user
|
||||
application = Application.find(params[:application_id])
|
||||
|
||||
# Check if user has consent for this application
|
||||
consent = @user.oidc_user_consents.find_by(application: application)
|
||||
unless consent
|
||||
redirect_to active_sessions_path, alert: "No consent found for this application."
|
||||
return
|
||||
end
|
||||
|
||||
# Revoke the consent
|
||||
consent.destroy
|
||||
redirect_to active_sessions_path, notice: "Successfully revoked access to #{application.name}."
|
||||
end
|
||||
|
||||
def revoke_all_consents
|
||||
@user = Current.session.user
|
||||
count = @user.oidc_user_consents.count
|
||||
|
||||
if count > 0
|
||||
@user.oidc_user_consents.destroy_all
|
||||
redirect_to active_sessions_path, notice: "Successfully revoked access to #{count} applications."
|
||||
else
|
||||
redirect_to active_sessions_path, alert: "No applications to revoke."
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,6 +1,6 @@
|
||||
module Admin
|
||||
class ApplicationsController < BaseController
|
||||
before_action :set_application, only: [:show, :edit, :update, :destroy, :regenerate_credentials]
|
||||
before_action :set_application, only: [:show, :edit, :update, :destroy, :regenerate_credentials, :roles, :create_role, :update_role, :assign_role, :remove_role]
|
||||
|
||||
def index
|
||||
@applications = Application.order(created_at: :desc)
|
||||
@@ -90,6 +90,53 @@ module Admin
|
||||
end
|
||||
end
|
||||
|
||||
def roles
|
||||
@application_roles = @application.application_roles.includes(:user_role_assignments)
|
||||
@available_users = User.active.order(:email_address)
|
||||
end
|
||||
|
||||
def create_role
|
||||
@role = @application.application_roles.build(role_params)
|
||||
|
||||
if @role.save
|
||||
redirect_to roles_admin_application_path(@application), notice: "Role created successfully."
|
||||
else
|
||||
@application_roles = @application.application_roles.includes(:user_role_assignments)
|
||||
@available_users = User.active.order(:email_address)
|
||||
render :roles, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def update_role
|
||||
@role = @application.application_roles.find(params[:role_id])
|
||||
|
||||
if @role.update(role_params)
|
||||
redirect_to roles_admin_application_path(@application), notice: "Role updated successfully."
|
||||
else
|
||||
@application_roles = @application.application_roles.includes(:user_role_assignments)
|
||||
@available_users = User.active.order(:email_address)
|
||||
render :roles, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def assign_role
|
||||
user = User.find(params[:user_id])
|
||||
role = @application.application_roles.find(params[:role_id])
|
||||
|
||||
@application.assign_role_to_user!(user, role.name, source: 'manual')
|
||||
|
||||
redirect_to roles_admin_application_path(@application), notice: "Role assigned successfully."
|
||||
end
|
||||
|
||||
def remove_role
|
||||
user = User.find(params[:user_id])
|
||||
role = @application.application_roles.find(params[:role_id])
|
||||
|
||||
@application.remove_role_from_user!(user, role.name)
|
||||
|
||||
redirect_to roles_admin_application_path(@application), notice: "Role removed successfully."
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_application
|
||||
@@ -99,12 +146,12 @@ module Admin
|
||||
def application_params
|
||||
params.require(:application).permit(
|
||||
:name, :slug, :app_type, :active, :redirect_uris, :description, :metadata,
|
||||
:domain_pattern, :landing_url, :access_token_ttl, :refresh_token_ttl, :id_token_ttl,
|
||||
headers_config: {}
|
||||
).tap do |whitelisted|
|
||||
# Remove client_secret from params if present (shouldn't be updated via form)
|
||||
whitelisted.delete(:client_secret)
|
||||
end
|
||||
:role_mapping_mode, :role_prefix, :role_claim_name, managed_permissions: {}
|
||||
)
|
||||
end
|
||||
|
||||
def role_params
|
||||
params.require(:application_role).permit(:name, :display_name, :description, :active, permissions: {})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
84
app/controllers/admin/forward_auth_rules_controller.rb
Normal file
84
app/controllers/admin/forward_auth_rules_controller.rb
Normal file
@@ -0,0 +1,84 @@
|
||||
module Admin
|
||||
class ForwardAuthRulesController < BaseController
|
||||
before_action :set_forward_auth_rule, only: [:show, :edit, :update, :destroy]
|
||||
|
||||
def index
|
||||
@forward_auth_rules = ForwardAuthRule.ordered
|
||||
end
|
||||
|
||||
def show
|
||||
@allowed_groups = @forward_auth_rule.allowed_groups
|
||||
end
|
||||
|
||||
def new
|
||||
@forward_auth_rule = ForwardAuthRule.new
|
||||
@available_groups = Group.order(:name)
|
||||
end
|
||||
|
||||
def create
|
||||
@forward_auth_rule = ForwardAuthRule.new(forward_auth_rule_params)
|
||||
# Handle headers configuration
|
||||
@forward_auth_rule.headers_config = process_headers_config(params[:headers_config])
|
||||
|
||||
if @forward_auth_rule.save
|
||||
# Handle group assignments
|
||||
if params[:forward_auth_rule][:group_ids].present?
|
||||
group_ids = params[:forward_auth_rule][:group_ids].reject(&:blank?)
|
||||
@forward_auth_rule.allowed_groups = Group.where(id: group_ids)
|
||||
end
|
||||
|
||||
redirect_to admin_forward_auth_rule_path(@forward_auth_rule), notice: "Forward auth rule created successfully."
|
||||
else
|
||||
@available_groups = Group.order(:name)
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def edit
|
||||
@available_groups = Group.order(:name)
|
||||
end
|
||||
|
||||
def update
|
||||
if @forward_auth_rule.update(forward_auth_rule_params)
|
||||
# Handle headers configuration
|
||||
@forward_auth_rule.headers_config = process_headers_config(params[:headers_config])
|
||||
@forward_auth_rule.save!
|
||||
|
||||
# Handle group assignments
|
||||
if params[:forward_auth_rule][:group_ids].present?
|
||||
group_ids = params[:forward_auth_rule][:group_ids].reject(&:blank?)
|
||||
@forward_auth_rule.allowed_groups = Group.where(id: group_ids)
|
||||
else
|
||||
@forward_auth_rule.allowed_groups = []
|
||||
end
|
||||
|
||||
redirect_to admin_forward_auth_rule_path(@forward_auth_rule), notice: "Forward auth rule updated successfully."
|
||||
else
|
||||
@available_groups = Group.order(:name)
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@forward_auth_rule.destroy
|
||||
redirect_to admin_forward_auth_rules_path, notice: "Forward auth rule deleted successfully."
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_forward_auth_rule
|
||||
@forward_auth_rule = ForwardAuthRule.find(params[:id])
|
||||
end
|
||||
|
||||
def forward_auth_rule_params
|
||||
params.require(:forward_auth_rule).permit(:domain_pattern, :active)
|
||||
end
|
||||
|
||||
def process_headers_config(headers_params)
|
||||
return {} unless headers_params.is_a?(Hash)
|
||||
|
||||
# Clean up headers config - remove empty values, keep only filled ones
|
||||
headers_params.select { |key, value| value.present? }.symbolize_keys
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -67,7 +67,7 @@ module Admin
|
||||
end
|
||||
|
||||
def group_params
|
||||
params.require(:group).permit(:name, :description, custom_claims: {})
|
||||
params.require(:group).permit(:name, :description)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -76,7 +76,7 @@ module Admin
|
||||
end
|
||||
|
||||
def user_params
|
||||
params.require(:user).permit(:email_address, :name, :password, :admin, :status, custom_claims: {})
|
||||
params.require(:user).permit(:email_address, :password, :admin, :status)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
module Api
|
||||
class CspController < ApplicationController
|
||||
# CSP violation reports don't need authentication
|
||||
skip_before_action :verify_authenticity_token
|
||||
allow_unauthenticated_access
|
||||
|
||||
# POST /api/csp-violation-report
|
||||
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: #{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}"
|
||||
|
||||
# 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
|
||||
Rails.logger.error "Invalid CSP violation report: #{e.message}"
|
||||
head :bad_request
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -3,13 +3,12 @@ module Api
|
||||
# ForwardAuth endpoints need session storage for return URL
|
||||
allow_unauthenticated_access
|
||||
skip_before_action :verify_authenticity_token
|
||||
rate_limit to: 100, within: 1.minute, only: :verify, with: -> { head :too_many_requests }
|
||||
|
||||
# GET /api/verify
|
||||
# This endpoint is called by reverse proxies (Traefik, Caddy, nginx)
|
||||
# to verify if a user is authenticated and authorized to access a domain
|
||||
def verify
|
||||
# Note: app_slug parameter is no longer used - we match domains directly with Application (forward_auth type)
|
||||
# Note: app_slug parameter is no longer used - we match domains directly with ForwardAuthRule
|
||||
|
||||
# Check for one-time forward auth token first (to handle race condition)
|
||||
session_id = check_forward_auth_token
|
||||
@@ -44,37 +43,37 @@ module Api
|
||||
return render_unauthorized("User account is not active")
|
||||
end
|
||||
|
||||
# Check for forward auth application authorization
|
||||
# Check for forward auth rule authorization
|
||||
# Get the forwarded host for domain matching
|
||||
forwarded_host = request.headers["X-Forwarded-Host"] || request.headers["Host"]
|
||||
|
||||
if forwarded_host.present?
|
||||
# Load active forward auth applications with their associations for better performance
|
||||
# Load active rules with their associations for better performance
|
||||
# Preload groups to avoid N+1 queries in user_allowed? checks
|
||||
apps = Application.forward_auth.includes(:allowed_groups).active
|
||||
rules = ForwardAuthRule.includes(:groups).active
|
||||
|
||||
# Find matching forward auth application for this domain
|
||||
app = apps.find { |a| a.matches_domain?(forwarded_host) }
|
||||
# Find matching forward auth rule for this domain
|
||||
rule = rules.find { |r| r.matches_domain?(forwarded_host) }
|
||||
|
||||
if app
|
||||
# Check if user is allowed by this application
|
||||
unless app.user_allowed?(user)
|
||||
Rails.logger.info "ForwardAuth: User #{user.email_address} denied access to #{forwarded_host} by app #{app.domain_pattern}"
|
||||
return render_forbidden("You do not have permission to access this domain")
|
||||
end
|
||||
|
||||
Rails.logger.info "ForwardAuth: User #{user.email_address} granted access to #{forwarded_host} by app #{app.domain_pattern} (policy: #{app.policy_for_user(user)})"
|
||||
else
|
||||
# No application found - allow access with default headers (original behavior)
|
||||
Rails.logger.info "ForwardAuth: No application found for domain: #{forwarded_host}, allowing with default headers"
|
||||
unless rule
|
||||
Rails.logger.warn "ForwardAuth: No rule found for domain: #{forwarded_host}"
|
||||
return render_forbidden("No authentication rule configured for this domain")
|
||||
end
|
||||
|
||||
# Check if user is allowed by this rule
|
||||
unless rule.user_allowed?(user)
|
||||
Rails.logger.info "ForwardAuth: User #{user.email_address} denied access to #{forwarded_host} by rule #{rule.domain_pattern}"
|
||||
return render_forbidden("You do not have permission to access this domain")
|
||||
end
|
||||
|
||||
Rails.logger.info "ForwardAuth: User #{user.email_address} granted access to #{forwarded_host} by rule #{rule.domain_pattern} (policy: #{rule.policy_for_user(user)})"
|
||||
else
|
||||
Rails.logger.info "ForwardAuth: User #{user.email_address} authenticated (no domain specified)"
|
||||
end
|
||||
|
||||
# User is authenticated and authorized
|
||||
# Return 200 with user information headers using app-specific configuration
|
||||
headers = app ? app.headers_for_user(user) : Application::DEFAULT_HEADERS.map { |key, header_name|
|
||||
# Return 200 with user information headers using rule-specific configuration
|
||||
headers = rule ? rule.headers_for_user(user) : ForwardAuthRule::DEFAULT_HEADERS.map { |key, header_name|
|
||||
case key
|
||||
when :user, :email, :name
|
||||
[header_name, user.email_address]
|
||||
@@ -127,7 +126,7 @@ module Api
|
||||
end
|
||||
|
||||
def extract_app_from_headers
|
||||
# This method is deprecated since we now use Application (forward_auth type) domain matching
|
||||
# This method is deprecated since we now use ForwardAuthRule domain matching
|
||||
# Keeping it for backward compatibility but it's no longer used
|
||||
nil
|
||||
end
|
||||
@@ -135,9 +134,11 @@ module Api
|
||||
def render_unauthorized(reason = nil)
|
||||
Rails.logger.info "ForwardAuth: Unauthorized - #{reason}"
|
||||
|
||||
# Set header to help with debugging
|
||||
response.headers["X-Auth-Reason"] = reason if reason
|
||||
|
||||
# Get the redirect URL from query params or construct default
|
||||
redirect_url = validate_redirect_url(params[:rd])
|
||||
base_url = determine_base_url(redirect_url)
|
||||
base_url = params[:rd] || "https://clinch.aapamilne.com"
|
||||
|
||||
# Set the original URL that user was trying to access
|
||||
# This will be used after authentication
|
||||
@@ -148,11 +149,11 @@ module Api
|
||||
Rails.logger.info "ForwardAuth Headers: Host=#{request.headers['Host']}, X-Forwarded-Host=#{original_host}, X-Forwarded-Uri=#{request.headers['X-Forwarded-Uri']}, X-Forwarded-Path=#{request.headers['X-Forwarded-Path']}"
|
||||
|
||||
original_url = if original_host
|
||||
# Use the forwarded host and URI (original behavior)
|
||||
# Use the forwarded host and URI
|
||||
"https://#{original_host}#{original_uri}"
|
||||
else
|
||||
# Fallback: use the validated redirect URL or default
|
||||
redirect_url || "https://clinch.aapamilne.com"
|
||||
# Fallback: just redirect to the root of the original host
|
||||
"https://#{request.headers['Host']}"
|
||||
end
|
||||
|
||||
# Debug: log what we're redirecting to after login
|
||||
@@ -176,65 +177,11 @@ module Api
|
||||
def render_forbidden(reason = nil)
|
||||
Rails.logger.info "ForwardAuth: Forbidden - #{reason}"
|
||||
|
||||
# Set header to help with debugging
|
||||
response.headers["X-Auth-Reason"] = reason if reason
|
||||
|
||||
# Return 403 Forbidden
|
||||
head :forbidden
|
||||
end
|
||||
|
||||
def validate_redirect_url(url)
|
||||
return nil unless url.present?
|
||||
|
||||
begin
|
||||
uri = URI.parse(url)
|
||||
|
||||
# Only allow HTTP/HTTPS schemes
|
||||
return nil unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
|
||||
|
||||
# Only allow HTTPS in production
|
||||
return nil unless Rails.env.development? || uri.scheme == 'https'
|
||||
|
||||
redirect_domain = uri.host.downcase
|
||||
return nil unless redirect_domain.present?
|
||||
|
||||
# Check against our ForwardAuth applications
|
||||
matching_app = Application.forward_auth.active.find do |app|
|
||||
app.matches_domain?(redirect_domain)
|
||||
end
|
||||
|
||||
matching_app ? url : nil
|
||||
|
||||
rescue URI::InvalidURIError
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def domain_has_forward_auth_rule?(domain)
|
||||
return false if domain.blank?
|
||||
|
||||
Application.forward_auth.active.any? do |app|
|
||||
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,7 +5,4 @@ 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
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
require 'uri'
|
||||
require 'public_suffix'
|
||||
require 'ipaddr'
|
||||
|
||||
module Authentication
|
||||
extend ActiveSupport::Concern
|
||||
@@ -62,7 +60,7 @@ module Authentication
|
||||
# Set domain for cross-subdomain authentication if we can extract it
|
||||
cookie_options[:domain] = domain if domain.present?
|
||||
|
||||
cookies.signed.permanent[:session_id] = cookie_options
|
||||
cookies.signed.permanent[:session_id] = cookie_options
|
||||
|
||||
# Create a one-time token for immediate forward auth after authentication
|
||||
# This solves the race condition where browser hasn't processed cookie yet
|
||||
@@ -75,43 +73,37 @@ module Authentication
|
||||
cookies.delete(:session_id)
|
||||
end
|
||||
|
||||
# Extract root domain for cross-subdomain cookies in SSO forward_auth system.
|
||||
#
|
||||
# PURPOSE: Enables a single authentication session to work across multiple subdomains
|
||||
# by setting cookies with the domain parameter (e.g., .example.com allows access from
|
||||
# both app.example.com and api.example.com).
|
||||
#
|
||||
# CRITICAL: Returns nil for IP addresses (IPv4 and IPv6) and localhost - this is intentional!
|
||||
# When accessing services by IP, there are no subdomains to share cookies with,
|
||||
# and setting a domain cookie would break authentication.
|
||||
#
|
||||
# Uses the Public Suffix List (industry standard maintained by Mozilla) to
|
||||
# correctly handle complex domain patterns like co.uk, com.au, appspot.com, etc.
|
||||
#
|
||||
# Extract root domain for cross-subdomain cookies
|
||||
# Examples:
|
||||
# - app.example.com -> .example.com (enables cross-subdomain SSO)
|
||||
# - api.example.co.uk -> .example.co.uk (handles complex TLDs)
|
||||
# - myapp.appspot.com -> .myapp.appspot.com (handles platform domains)
|
||||
# - localhost -> nil (local development, no domain cookie)
|
||||
# - 192.168.1.1 -> nil (IP access, no domain cookie - prevents SSO breakage)
|
||||
#
|
||||
# @param host [String] The request host (may include port)
|
||||
# @return [String, nil] Root domain with leading dot for cookies, or nil for no domain setting
|
||||
# - clinch.aapamilne.com -> .aapamilne.com
|
||||
# - app.example.co.uk -> .example.co.uk
|
||||
# - localhost -> nil (no domain setting for local development)
|
||||
def extract_root_domain(host)
|
||||
return nil if host.blank? || host.match?(/^(localhost|127\.0\.0\.1|::1)$/)
|
||||
|
||||
# Strip port number for domain parsing
|
||||
host_without_port = host.split(':').first
|
||||
# Split hostname into parts
|
||||
parts = host.split('.')
|
||||
|
||||
# Check if it's an IP address (IPv4 or IPv6) - if so, don't set domain cookie
|
||||
return nil if IPAddr.new(host_without_port) rescue false
|
||||
# For normal domains like example.com, we need at least 2 parts
|
||||
# For complex domains like co.uk, we need at least 3 parts
|
||||
return nil if parts.length < 2
|
||||
|
||||
# Use Public Suffix List for accurate domain parsing
|
||||
domain = PublicSuffix.parse(host_without_port)
|
||||
".#{domain.domain}"
|
||||
rescue PublicSuffix::DomainInvalid
|
||||
# Fallback for invalid domains or IPs
|
||||
nil
|
||||
# Extract root domain with leading dot for cross-subdomain cookies
|
||||
if parts.length >= 3
|
||||
# Check if it's a known complex TLD
|
||||
complex_tlds = %w[co.uk com.au co.nz co.za co.jp]
|
||||
second_level = "#{parts[-2]}.#{parts[-1]}"
|
||||
|
||||
if complex_tlds.include?(second_level)
|
||||
# For complex TLDs, include more parts: app.example.co.uk -> .example.co.uk
|
||||
root_parts = parts[-3..-1]
|
||||
return ".#{root_parts.join('.')}"
|
||||
end
|
||||
end
|
||||
|
||||
# For regular domains: app.example.com -> .example.com
|
||||
root_parts = parts[-2..-1]
|
||||
".#{root_parts.join('.')}"
|
||||
end
|
||||
|
||||
# Create a one-time token for forward auth to handle the race condition
|
||||
@@ -120,11 +112,11 @@ module Authentication
|
||||
# Generate a secure random token
|
||||
token = SecureRandom.urlsafe_base64(32)
|
||||
|
||||
# Store it with an expiry of 60 seconds
|
||||
# Store it with an expiry of 30 seconds
|
||||
Rails.cache.write(
|
||||
"forward_auth_token:#{token}",
|
||||
session_obj.id,
|
||||
expires_in: 60.seconds
|
||||
expires_in: 30.seconds
|
||||
)
|
||||
|
||||
# Set the token as a query parameter on the redirect URL
|
||||
@@ -134,16 +126,14 @@ 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
|
||||
uri.query = URI.encode_www_form(query_params)
|
||||
# Add token as query parameter
|
||||
query_params = URI.decode_www_form(uri.query || "").to_h
|
||||
query_params['fa_token'] = token
|
||||
uri.query = URI.encode_www_form(query_params)
|
||||
|
||||
# Update the session with the tokenized URL
|
||||
controller_session[:return_to_after_authenticating] = uri.to_s
|
||||
end
|
||||
end
|
||||
# Update the session with the tokenized URL
|
||||
controller_session[:return_to_after_authenticating] = uri.to_s
|
||||
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -8,10 +8,5 @@ class DashboardController < ApplicationController
|
||||
|
||||
# User must be authenticated
|
||||
@user = Current.session.user
|
||||
|
||||
# Load user's accessible applications
|
||||
@applications = Application.active.select do |app|
|
||||
app.user_allowed?(@user)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
class OidcController < ApplicationController
|
||||
# Discovery and JWKS endpoints are public
|
||||
allow_unauthenticated_access only: [:discovery, :jwks, :token, :revoke, :userinfo, :logout]
|
||||
skip_before_action :verify_authenticity_token, only: [:token, :revoke, :logout]
|
||||
allow_unauthenticated_access only: [:discovery, :jwks, :token, :userinfo, :logout]
|
||||
skip_before_action :verify_authenticity_token, only: [:token, :logout]
|
||||
|
||||
# GET /.well-known/openid-configuration
|
||||
def discovery
|
||||
@@ -11,19 +11,15 @@ 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"],
|
||||
code_challenge_methods_supported: ["plain", "S256"]
|
||||
claims_supported: ["sub", "email", "email_verified", "name", "preferred_username", "groups", "admin"]
|
||||
}
|
||||
|
||||
render json: config
|
||||
@@ -36,71 +32,30 @@ class OidcController < ApplicationController
|
||||
|
||||
# GET /oauth/authorize
|
||||
def authorize
|
||||
# Get parameters (ignore forward auth tokens and other unknown params)
|
||||
# Get parameters
|
||||
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"
|
||||
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
|
||||
render plain: "Invalid request: missing required parameters", 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
|
||||
# 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
|
||||
render plain: "Invalid client_id", status: :bad_request
|
||||
return
|
||||
end
|
||||
|
||||
# Validate redirect URI
|
||||
unless @application.parsed_redirect_uris.include?(redirect_uri)
|
||||
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
|
||||
render plain: "Invalid redirect_uri", status: :bad_request
|
||||
return
|
||||
end
|
||||
|
||||
@@ -112,9 +67,7 @@ class OidcController < ApplicationController
|
||||
redirect_uri: redirect_uri,
|
||||
state: state,
|
||||
nonce: nonce,
|
||||
scope: scope,
|
||||
code_challenge: code_challenge,
|
||||
code_challenge_method: code_challenge_method
|
||||
scope: scope
|
||||
}
|
||||
redirect_to signin_path, alert: "Please sign in to continue"
|
||||
return
|
||||
@@ -143,8 +96,6 @@ 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
|
||||
)
|
||||
|
||||
@@ -161,34 +112,12 @@ class OidcController < ApplicationController
|
||||
redirect_uri: redirect_uri,
|
||||
state: state,
|
||||
nonce: nonce,
|
||||
scope: scope,
|
||||
code_challenge: code_challenge,
|
||||
code_challenge_method: code_challenge_method
|
||||
scope: scope
|
||||
}
|
||||
|
||||
# Render consent page with dynamic CSP for OAuth redirect
|
||||
# Render consent page
|
||||
@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
|
||||
|
||||
@@ -236,8 +165,6 @@ 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
|
||||
)
|
||||
|
||||
@@ -255,17 +182,10 @@ class OidcController < ApplicationController
|
||||
def token
|
||||
grant_type = params[:grant_type]
|
||||
|
||||
case grant_type
|
||||
when "authorization_code"
|
||||
handle_authorization_code_grant
|
||||
when "refresh_token"
|
||||
handle_refresh_token_grant
|
||||
else
|
||||
unless grant_type == "authorization_code"
|
||||
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
|
||||
@@ -285,11 +205,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
|
||||
code: code,
|
||||
used: false
|
||||
)
|
||||
|
||||
unless auth_code
|
||||
@@ -297,180 +217,45 @@ 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
|
||||
return
|
||||
end
|
||||
|
||||
# Validate redirect URI matches
|
||||
unless auth_code.redirect_uri == redirect_uri
|
||||
render json: { error: "invalid_grant", error_description: "Redirect URI mismatch" }, status: :bad_request
|
||||
return
|
||||
end
|
||||
|
||||
# Validate PKCE if code challenge is present
|
||||
pkce_result = validate_pkce(auth_code, code_verifier)
|
||||
unless pkce_result[:valid]
|
||||
render json: {
|
||||
error: pkce_result[:error],
|
||||
error_description: pkce_result[:error_description]
|
||||
}, status: pkce_result[:status]
|
||||
return
|
||||
end
|
||||
|
||||
# Mark code as used BEFORE generating tokens (prevents reuse)
|
||||
auth_code.update!(used: true)
|
||||
|
||||
# Get the user
|
||||
user = auth_code.user
|
||||
|
||||
# Generate access token record (opaque token with BCrypt hashing)
|
||||
access_token_record = OidcAccessToken.create!(
|
||||
application: application,
|
||||
user: user,
|
||||
scope: auth_code.scope
|
||||
)
|
||||
|
||||
# Generate refresh token (opaque, with hashing)
|
||||
refresh_token_record = OidcRefreshToken.create!(
|
||||
application: application,
|
||||
user: user,
|
||||
oidc_access_token: access_token_record,
|
||||
scope: auth_code.scope
|
||||
)
|
||||
|
||||
# Generate ID token (JWT)
|
||||
id_token = OidcJwtService.generate_id_token(user, application, nonce: auth_code.nonce)
|
||||
|
||||
# Return tokens
|
||||
render json: {
|
||||
access_token: access_token_record.plaintext_token, # Opaque token
|
||||
token_type: "Bearer",
|
||||
expires_in: application.access_token_ttl || 3600,
|
||||
id_token: id_token, # JWT
|
||||
refresh_token: refresh_token_record.token, # Opaque token
|
||||
scope: auth_code.scope
|
||||
}
|
||||
end
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
render json: { error: "invalid_grant" }, status: :bad_request
|
||||
end
|
||||
end
|
||||
|
||||
def handle_refresh_token_grant
|
||||
# Get client credentials from Authorization header or params
|
||||
client_id, client_secret = extract_client_credentials
|
||||
|
||||
unless client_id && client_secret
|
||||
render json: { error: "invalid_client" }, status: :unauthorized
|
||||
# Check if code is expired
|
||||
if auth_code.expires_at < Time.current
|
||||
render json: { error: "invalid_grant", error_description: "Authorization code expired" }, status: :bad_request
|
||||
return
|
||||
end
|
||||
|
||||
# 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
|
||||
# Validate redirect URI matches
|
||||
unless auth_code.redirect_uri == redirect_uri
|
||||
render json: { error: "invalid_grant", error_description: "Redirect URI mismatch" }, status: :bad_request
|
||||
return
|
||||
end
|
||||
|
||||
# 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
|
||||
# Mark code as used
|
||||
auth_code.update!(used: true)
|
||||
|
||||
# Get the user
|
||||
user = refresh_token_record.user
|
||||
user = auth_code.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!(
|
||||
# Generate access token
|
||||
access_token = SecureRandom.urlsafe_base64(32)
|
||||
OidcAccessToken.create!(
|
||||
application: application,
|
||||
user: user,
|
||||
scope: refresh_token_record.scope
|
||||
token: access_token,
|
||||
scope: auth_code.scope,
|
||||
expires_at: 1.hour.from_now
|
||||
)
|
||||
|
||||
# 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 ID token
|
||||
id_token = OidcJwtService.generate_id_token(user, application, nonce: auth_code.nonce)
|
||||
|
||||
# Generate new ID token (JWT, no nonce for refresh grants)
|
||||
id_token = OidcJwtService.generate_id_token(user, application)
|
||||
|
||||
# Return new tokens
|
||||
# Return tokens
|
||||
render json: {
|
||||
access_token: new_access_token.plaintext_token, # Opaque token
|
||||
access_token: access_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
|
||||
expires_in: 3600,
|
||||
id_token: id_token,
|
||||
scope: auth_code.scope
|
||||
}
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
render json: { error: "invalid_grant" }, status: :bad_request
|
||||
end
|
||||
|
||||
# GET /oauth/userinfo
|
||||
@@ -482,29 +267,31 @@ class OidcController < ApplicationController
|
||||
return
|
||||
end
|
||||
|
||||
token = auth_header.sub("Bearer ", "")
|
||||
access_token = auth_header.sub("Bearer ", "")
|
||||
|
||||
# Find and validate access token (opaque token with BCrypt hashing)
|
||||
access_token = OidcAccessToken.find_by_token(token)
|
||||
unless access_token&.active?
|
||||
# Find the access token
|
||||
token_record = OidcAccessToken.find_by(token: access_token)
|
||||
unless token_record
|
||||
head :unauthorized
|
||||
return
|
||||
end
|
||||
|
||||
# Get the user (with fresh data from database)
|
||||
user = access_token.user
|
||||
unless user
|
||||
# Check if token is expired
|
||||
if token_record.expires_at < Time.current
|
||||
head :unauthorized
|
||||
return
|
||||
end
|
||||
|
||||
# Get the user
|
||||
user = token_record.user
|
||||
|
||||
# Return user claims
|
||||
claims = {
|
||||
sub: user.id.to_s,
|
||||
email: user.email_address,
|
||||
email_verified: true,
|
||||
preferred_username: user.email_address,
|
||||
name: user.name.presence || user.email_address
|
||||
name: user.email_address
|
||||
}
|
||||
|
||||
# Add groups if user has any
|
||||
@@ -515,84 +302,9 @@ class OidcController < ApplicationController
|
||||
# Add admin claim if user is admin
|
||||
claims[:admin] = true if user.admin?
|
||||
|
||||
# Merge custom claims from groups
|
||||
user.groups.each do |group|
|
||||
claims.merge!(group.parsed_custom_claims)
|
||||
end
|
||||
|
||||
# Merge custom claims from user (overrides group claims)
|
||||
claims.merge!(user.parsed_custom_claims)
|
||||
|
||||
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
|
||||
@@ -622,58 +334,6 @@ 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 ")
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
class ProfilesController < ApplicationController
|
||||
def show
|
||||
@user = Current.session.user
|
||||
@active_sessions = @user.sessions.active.order(last_activity_at: :desc)
|
||||
@connected_applications = @user.oidc_user_consents.includes(:application).order(granted_at: :desc)
|
||||
end
|
||||
|
||||
def update
|
||||
@@ -10,6 +12,7 @@ class ProfilesController < ApplicationController
|
||||
# Updating password - requires current password
|
||||
unless @user.authenticate(params[:user][:current_password])
|
||||
@user.errors.add(:current_password, "is incorrect")
|
||||
@active_sessions = @user.sessions.active.order(last_activity_at: :desc)
|
||||
render :show, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
@@ -17,6 +20,7 @@ class ProfilesController < ApplicationController
|
||||
if @user.update(password_params)
|
||||
redirect_to profile_path, notice: "Password updated successfully."
|
||||
else
|
||||
@active_sessions = @user.sessions.active.order(last_activity_at: :desc)
|
||||
render :show, status: :unprocessable_entity
|
||||
end
|
||||
else
|
||||
@@ -24,11 +28,40 @@ class ProfilesController < ApplicationController
|
||||
if @user.update(email_params)
|
||||
redirect_to profile_path, notice: "Email updated successfully."
|
||||
else
|
||||
@active_sessions = @user.sessions.active.order(last_activity_at: :desc)
|
||||
render :show, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def revoke_consent
|
||||
@user = Current.session.user
|
||||
application = Application.find(params[:application_id])
|
||||
|
||||
# Check if user has consent for this application
|
||||
consent = @user.oidc_user_consents.find_by(application: application)
|
||||
unless consent
|
||||
redirect_to profile_path, alert: "No consent found for this application."
|
||||
return
|
||||
end
|
||||
|
||||
# Revoke the consent
|
||||
consent.destroy
|
||||
redirect_to profile_path, notice: "Successfully revoked access to #{application.name}."
|
||||
end
|
||||
|
||||
def revoke_all_consents
|
||||
@user = Current.session.user
|
||||
count = @user.oidc_user_consents.count
|
||||
|
||||
if count > 0
|
||||
@user.oidc_user_consents.destroy_all
|
||||
redirect_to profile_path, notice: "Successfully revoked access to #{count} applications."
|
||||
else
|
||||
redirect_to profile_path, alert: "No applications to revoke."
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def email_params
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
class SessionsController < ApplicationController
|
||||
allow_unauthenticated_access only: %i[ new create verify_totp webauthn_challenge webauthn_verify ]
|
||||
allow_unauthenticated_access only: %i[ new create verify_totp ]
|
||||
rate_limit to: 20, within: 3.minutes, only: :create, with: -> { redirect_to signin_path, alert: "Too many attempts. Try again later." }
|
||||
rate_limit to: 10, within: 3.minutes, only: :verify_totp, with: -> { redirect_to totp_verification_path, alert: "Too many attempts. Try again later." }
|
||||
rate_limit to: 10, within: 3.minutes, only: [:webauthn_challenge, :webauthn_verify], with: -> { render json: { error: "Too many attempts. Try again later." }, status: :too_many_requests }
|
||||
|
||||
def new
|
||||
# Redirect to signup if this is first run
|
||||
@@ -17,10 +16,9 @@ class SessionsController < ApplicationController
|
||||
return
|
||||
end
|
||||
|
||||
# Store the redirect URL from forward auth if present (after validation)
|
||||
# Store the redirect URL from forward auth if present
|
||||
if params[:rd].present?
|
||||
validated_url = validate_redirect_url(params[:rd])
|
||||
session[:return_to_after_authenticating] = validated_url if validated_url
|
||||
session[:return_to_after_authenticating] = params[:rd]
|
||||
end
|
||||
|
||||
# Check if user is active
|
||||
@@ -37,10 +35,9 @@ class SessionsController < ApplicationController
|
||||
if user.totp_enabled?
|
||||
# Store user ID in session temporarily for TOTP verification
|
||||
session[:pending_totp_user_id] = user.id
|
||||
# Preserve the redirect URL through TOTP verification (after validation)
|
||||
# Preserve the redirect URL through TOTP verification
|
||||
if params[:rd].present?
|
||||
validated_url = validate_redirect_url(params[:rd])
|
||||
session[:totp_redirect_url] = validated_url if validated_url
|
||||
session[:totp_redirect_url] = params[:rd]
|
||||
end
|
||||
redirect_to totp_verification_path(rd: params[:rd])
|
||||
return
|
||||
@@ -116,174 +113,6 @@ class SessionsController < ApplicationController
|
||||
def destroy_other
|
||||
session = Current.session.user.sessions.find(params[:id])
|
||||
session.destroy
|
||||
redirect_to active_sessions_path, notice: "Session revoked successfully."
|
||||
end
|
||||
|
||||
# WebAuthn authentication methods
|
||||
def webauthn_challenge
|
||||
email = params[:email]&.strip&.downcase
|
||||
|
||||
if email.blank?
|
||||
render json: { error: "Email is required" }, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
|
||||
user = User.find_by(email_address: email)
|
||||
|
||||
if user.nil? || !user.can_authenticate_with_webauthn?
|
||||
render json: { error: "User not found or WebAuthn not available" }, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
|
||||
# Store user ID in session for verification
|
||||
session[:pending_webauthn_user_id] = user.id
|
||||
|
||||
# Store redirect URL if present
|
||||
if params[:rd].present?
|
||||
validated_url = validate_redirect_url(params[:rd])
|
||||
session[:webauthn_redirect_url] = validated_url if validated_url
|
||||
end
|
||||
|
||||
begin
|
||||
# Generate authentication options
|
||||
# Decode the stored base64url credential IDs before passing to the gem
|
||||
credential_ids = user.webauthn_credentials.pluck(:external_id).map do |encoded_id|
|
||||
Base64.urlsafe_decode64(encoded_id)
|
||||
end
|
||||
|
||||
options = WebAuthn::Credential.options_for_get(
|
||||
allow: credential_ids,
|
||||
user_verification: "preferred"
|
||||
)
|
||||
|
||||
# Store challenge in session
|
||||
session[:webauthn_challenge] = options.challenge
|
||||
|
||||
render json: options
|
||||
|
||||
rescue => e
|
||||
Rails.logger.error "WebAuthn challenge generation error: #{e.message}"
|
||||
render json: { error: "Failed to generate WebAuthn challenge" }, status: :internal_server_error
|
||||
end
|
||||
end
|
||||
|
||||
def webauthn_verify
|
||||
# Get pending user from session
|
||||
user_id = session[:pending_webauthn_user_id]
|
||||
unless user_id
|
||||
render json: { error: "Session expired. Please try again." }, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
|
||||
user = User.find_by(id: user_id)
|
||||
unless user
|
||||
session.delete(:pending_webauthn_user_id)
|
||||
render json: { error: "Session expired. Please try again." }, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
|
||||
# Get the credential and assertion from params
|
||||
credential_data = params[:credential]
|
||||
if credential_data.blank?
|
||||
render json: { error: "Credential data is required" }, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
|
||||
# Get the challenge from session
|
||||
challenge = session.delete(:webauthn_challenge)
|
||||
|
||||
if challenge.blank?
|
||||
render json: { error: "Invalid or expired session" }, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
|
||||
begin
|
||||
# Decode the credential response
|
||||
webauthn_credential = WebAuthn::Credential.from_get(credential_data)
|
||||
|
||||
# Find the stored credential
|
||||
external_id = Base64.urlsafe_encode64(webauthn_credential.id)
|
||||
stored_credential = user.webauthn_credential_for(external_id)
|
||||
|
||||
if stored_credential.nil?
|
||||
render json: { error: "Credential not found" }, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
|
||||
# Verify the assertion
|
||||
stored_public_key = Base64.urlsafe_decode64(stored_credential.public_key)
|
||||
webauthn_credential.verify(
|
||||
challenge,
|
||||
public_key: stored_public_key,
|
||||
sign_count: stored_credential.sign_count
|
||||
)
|
||||
|
||||
# Check for suspicious sign count (possible clone)
|
||||
if stored_credential.suspicious_sign_count?(webauthn_credential.sign_count)
|
||||
Rails.logger.warn "Suspicious WebAuthn sign count for user #{user.id}, credential #{stored_credential.id}"
|
||||
# You might want to notify admins or temporarily disable the credential
|
||||
end
|
||||
|
||||
# Update credential usage
|
||||
stored_credential.update_usage!(
|
||||
sign_count: webauthn_credential.sign_count,
|
||||
ip_address: request.remote_ip,
|
||||
user_agent: request.user_agent
|
||||
)
|
||||
|
||||
# Clean up session
|
||||
session.delete(:pending_webauthn_user_id)
|
||||
if session[:webauthn_redirect_url].present?
|
||||
session[:return_to_after_authenticating] = session.delete(:webauthn_redirect_url)
|
||||
end
|
||||
|
||||
# Create session
|
||||
start_new_session_for user
|
||||
|
||||
render json: {
|
||||
success: true,
|
||||
redirect_to: after_authentication_url,
|
||||
message: "Signed in successfully with passkey"
|
||||
}
|
||||
|
||||
rescue WebAuthn::Error => e
|
||||
Rails.logger.error "WebAuthn verification error: #{e.message}"
|
||||
render json: { error: "Authentication failed: #{e.message}" }, status: :unprocessable_entity
|
||||
rescue JSON::ParserError => e
|
||||
Rails.logger.error "WebAuthn JSON parsing error: #{e.message}"
|
||||
render json: { error: "Invalid credential format" }, status: :unprocessable_entity
|
||||
rescue => e
|
||||
Rails.logger.error "Unexpected WebAuthn verification error: #{e.class} - #{e.message}"
|
||||
render json: { error: "An unexpected error occurred" }, status: :internal_server_error
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_redirect_url(url)
|
||||
return nil unless url.present?
|
||||
|
||||
begin
|
||||
uri = URI.parse(url)
|
||||
|
||||
# Only allow HTTP/HTTPS schemes
|
||||
return nil unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
|
||||
|
||||
# Only allow HTTPS in production
|
||||
return nil unless Rails.env.development? || uri.scheme == 'https'
|
||||
|
||||
redirect_domain = uri.host.downcase
|
||||
return nil unless redirect_domain.present?
|
||||
|
||||
# Check against our ForwardAuthRules
|
||||
matching_rule = ForwardAuthRule.active.find do |rule|
|
||||
rule.matches_domain?(redirect_domain)
|
||||
end
|
||||
|
||||
matching_rule ? url : nil
|
||||
|
||||
rescue URI::InvalidURIError
|
||||
nil
|
||||
end
|
||||
redirect_to profile_path, notice: "Session revoked successfully."
|
||||
end
|
||||
end
|
||||
|
||||
@@ -24,12 +24,9 @@ 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
|
||||
plain_codes = @user.send(:generate_backup_codes) # Use private method from User model
|
||||
@user.backup_codes = generate_backup_codes
|
||||
@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
|
||||
@@ -39,15 +36,8 @@ class TotpController < ApplicationController
|
||||
|
||||
# GET /totp/backup_codes - Show backup codes (requires password)
|
||||
def 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
|
||||
# This will be shown after password verification
|
||||
@backup_codes = @user.parsed_backup_codes
|
||||
end
|
||||
|
||||
# POST /totp/verify_password - Verify password before showing backup codes
|
||||
@@ -59,28 +49,6 @@ 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])
|
||||
@@ -109,4 +77,8 @@ 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
|
||||
|
||||
@@ -1,198 +0,0 @@
|
||||
class WebauthnController < ApplicationController
|
||||
before_action :set_webauthn_credential, only: [:destroy]
|
||||
skip_before_action :require_authentication, only: [:check]
|
||||
|
||||
# GET /webauthn/new
|
||||
def new
|
||||
@webauthn_credential = WebauthnCredential.new
|
||||
end
|
||||
|
||||
# POST /webauthn/challenge
|
||||
# Generate registration challenge for creating a new passkey
|
||||
def challenge
|
||||
user = Current.session&.user
|
||||
return render json: { error: "Not authenticated" }, status: :unauthorized unless user
|
||||
|
||||
registration_options = WebAuthn::Credential.options_for_create(
|
||||
user: {
|
||||
id: user.webauthn_user_handle,
|
||||
name: user.email_address,
|
||||
display_name: user.name || user.email_address
|
||||
},
|
||||
exclude: user.webauthn_credentials.pluck(:external_id),
|
||||
authenticator_selection: {
|
||||
userVerification: "preferred",
|
||||
residentKey: "preferred",
|
||||
authenticatorAttachment: "platform" # Prefer platform authenticators first
|
||||
}
|
||||
)
|
||||
|
||||
# Store challenge in session for verification
|
||||
session[:webauthn_challenge] = registration_options.challenge
|
||||
|
||||
render json: registration_options
|
||||
end
|
||||
|
||||
# POST /webauthn/create
|
||||
# Verify and store the new credential
|
||||
def create
|
||||
credential_data, nickname = extract_credential_params
|
||||
|
||||
if credential_data.blank? || nickname.blank?
|
||||
render json: { error: "Credential and nickname are required" }, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
|
||||
# Retrieve the challenge from session
|
||||
challenge = session.delete(:webauthn_challenge)
|
||||
|
||||
if challenge.blank?
|
||||
render json: { error: "Invalid or expired session" }, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
|
||||
begin
|
||||
# Pass the credential hash directly to WebAuthn gem
|
||||
webauthn_credential = WebAuthn::Credential.from_create(credential_data.to_h)
|
||||
|
||||
# Verify the credential against the challenge
|
||||
webauthn_credential.verify(challenge)
|
||||
|
||||
# Extract credential metadata from the hash
|
||||
response = credential_data.to_h
|
||||
client_extension_results = response["clientExtensionResults"] || {}
|
||||
|
||||
authenticator_type = if response["response"]["authenticatorAttachment"] == "cross-platform"
|
||||
"cross-platform"
|
||||
else
|
||||
"platform"
|
||||
end
|
||||
|
||||
# Determine if this is a backup/synced credential
|
||||
backup_eligible = client_extension_results["credProps"]&.dig("rk") || false
|
||||
backup_state = client_extension_results["credProps"]&.dig("backup") || false
|
||||
|
||||
# Store the credential
|
||||
user = Current.session&.user
|
||||
return render json: { error: "Not authenticated" }, status: :unauthorized unless user
|
||||
|
||||
@webauthn_credential = user.webauthn_credentials.create!(
|
||||
external_id: Base64.urlsafe_encode64(webauthn_credential.id),
|
||||
public_key: Base64.urlsafe_encode64(webauthn_credential.public_key),
|
||||
sign_count: webauthn_credential.sign_count,
|
||||
nickname: nickname,
|
||||
authenticator_type: authenticator_type,
|
||||
backup_eligible: backup_eligible,
|
||||
backup_state: backup_state
|
||||
)
|
||||
|
||||
render json: {
|
||||
success: true,
|
||||
message: "Passkey '#{nickname}' registered successfully",
|
||||
credential_id: @webauthn_credential.id
|
||||
}
|
||||
|
||||
rescue WebAuthn::Error => e
|
||||
Rails.logger.error "WebAuthn registration error: #{e.message}"
|
||||
render json: { error: "Failed to register passkey: #{e.message}" }, status: :unprocessable_entity
|
||||
rescue => e
|
||||
Rails.logger.error "Unexpected WebAuthn registration error: #{e.class} - #{e.message}"
|
||||
render json: { error: "An unexpected error occurred" }, status: :internal_server_error
|
||||
end
|
||||
end
|
||||
|
||||
# DELETE /webauthn/:id
|
||||
# Remove a passkey
|
||||
def destroy
|
||||
user = Current.session&.user
|
||||
return render json: { error: "Not authenticated" }, status: :unauthorized unless user
|
||||
|
||||
if @webauthn_credential.user != user
|
||||
render json: { error: "Unauthorized" }, status: :forbidden
|
||||
return
|
||||
end
|
||||
|
||||
nickname = @webauthn_credential.nickname
|
||||
@webauthn_credential.destroy
|
||||
|
||||
respond_to do |format|
|
||||
format.html {
|
||||
redirect_to profile_path,
|
||||
notice: "Passkey '#{nickname}' has been removed"
|
||||
}
|
||||
format.json {
|
||||
render json: {
|
||||
success: true,
|
||||
message: "Passkey '#{nickname}' has been removed"
|
||||
}
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
# GET /webauthn/check
|
||||
# Check if user has WebAuthn credentials (for login page detection)
|
||||
def check
|
||||
email = params[:email]&.strip&.downcase
|
||||
|
||||
if email.blank?
|
||||
render json: { has_webauthn: false, error: "Email is required" }
|
||||
return
|
||||
end
|
||||
|
||||
user = User.find_by(email_address: email)
|
||||
|
||||
if user.nil?
|
||||
render json: { has_webauthn: false, message: "User not found" }
|
||||
return
|
||||
end
|
||||
|
||||
render json: {
|
||||
has_webauthn: user.can_authenticate_with_webauthn?,
|
||||
user_id: user.id,
|
||||
preferred_method: user.preferred_authentication_method,
|
||||
requires_webauthn: user.require_webauthn?
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def extract_credential_params
|
||||
# Use require.permit which is working and reliable
|
||||
# The JavaScript sends params both directly and wrapped in webauthn key
|
||||
begin
|
||||
# Try direct parameters first
|
||||
credential_params = params.require(:credential).permit(:id, :rawId, :type, response: {}, clientExtensionResults: {})
|
||||
nickname = params.require(:nickname)
|
||||
[credential_params, nickname]
|
||||
rescue ActionController::ParameterMissing
|
||||
Rails.logger.error("Using the fallback parameters")
|
||||
# Fallback to webauthn-wrapped parameters
|
||||
webauthn_params = params.require(:webauthn).permit(:nickname, credential: [:id, :rawId, :type, response: {}, clientExtensionResults: {}])
|
||||
[webauthn_params[:credential], webauthn_params[:nickname]]
|
||||
end
|
||||
end
|
||||
|
||||
def set_webauthn_credential
|
||||
@webauthn_credential = WebauthnCredential.find(params[:id])
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
respond_to do |format|
|
||||
format.html {
|
||||
redirect_to profile_path,
|
||||
alert: "Passkey not found"
|
||||
}
|
||||
format.json {
|
||||
render json: { error: "Passkey not found" }, status: :not_found
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
# Helper method to convert Base64 to Base64URL if needed
|
||||
def base64_to_base64url(str)
|
||||
str.gsub('+', '-').gsub('/', '_').gsub(/=+$/, '')
|
||||
end
|
||||
|
||||
# Helper method to convert Base64URL to Base64 if needed
|
||||
def base64url_to_base64(str)
|
||||
str.gsub('-', '+').gsub('_', '/') + '=' * (4 - str.length % 4) % 4
|
||||
end
|
||||
end
|
||||
@@ -19,14 +19,4 @@ 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
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
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')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
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,68 +0,0 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = [ "submit" ]
|
||||
|
||||
connect() {
|
||||
// Prevent form auto-submission when browser autofills TOTP
|
||||
this.preventAutoSubmit()
|
||||
|
||||
// Add double-click protection
|
||||
this.submitTarget.addEventListener('dblclick', (e) => {
|
||||
e.preventDefault()
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
submit() {
|
||||
if (this.submitTarget.disabled) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Disable submit button and show loading state
|
||||
this.submitTarget.disabled = true
|
||||
this.submitTarget.textContent = 'Verifying...'
|
||||
this.submitTarget.classList.add('opacity-75', 'cursor-not-allowed')
|
||||
|
||||
// Re-enable after 10 seconds in case of network issues
|
||||
setTimeout(() => {
|
||||
this.submitTarget.disabled = false
|
||||
this.submitTarget.textContent = 'Verify'
|
||||
this.submitTarget.classList.remove('opacity-75', 'cursor-not-allowed')
|
||||
}, 10000)
|
||||
|
||||
// Allow the form to submit normally
|
||||
return true
|
||||
}
|
||||
|
||||
preventAutoSubmit() {
|
||||
// Some browsers auto-submit forms when TOTP fields are autofilled
|
||||
// This prevents that behavior while still allowing manual submission
|
||||
const codeInput = this.element.querySelector('input[name="code"]')
|
||||
|
||||
if (codeInput) {
|
||||
let hasAutoSubmitted = false
|
||||
|
||||
codeInput.addEventListener('input', (e) => {
|
||||
// Check if this looks like an auto-fill event
|
||||
// Auto-fill typically fills the entire field at once
|
||||
if (e.target.value.length >= 6 && !hasAutoSubmitted) {
|
||||
// Don't auto-submit, let user click the button manually
|
||||
hasAutoSubmitted = true
|
||||
|
||||
// Optionally, focus the submit button to make it obvious
|
||||
this.submitTarget.focus()
|
||||
}
|
||||
})
|
||||
|
||||
// Also prevent Enter key submission on TOTP field
|
||||
codeInput.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
this.submitTarget.click()
|
||||
return false
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
7
app/javascript/controllers/hello_controller.js
Normal file
7
app/javascript/controllers/hello_controller.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
connect() {
|
||||
this.element.textContent = "Hello World!"
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
// Handles login form UI changes based on WebAuthn availability
|
||||
export default class extends Controller {
|
||||
static targets = ["webauthnSection", "passwordSection", "statusMessage", "loadingOverlay"]
|
||||
|
||||
connect() {
|
||||
// Listen for WebAuthn availability events from the webauthn controller
|
||||
this.element.addEventListener('webauthn:webauthn-available', this.handleWebAuthnAvailable.bind(this));
|
||||
|
||||
// Listen for WebAuthn registration events (from profile page)
|
||||
this.element.addEventListener('webauthn:passkey-registered', this.handlePasskeyRegistered.bind(this));
|
||||
|
||||
// Listen for authentication start/end to show/hide loading
|
||||
document.addEventListener('webauthn:authenticate-start', this.showLoading.bind(this));
|
||||
document.addEventListener('webauthn:authenticate-end', this.hideLoading.bind(this));
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
// Clean up event listeners
|
||||
document.removeEventListener('webauthn:authenticate-start', this.showLoading.bind(this));
|
||||
document.removeEventListener('webauthn:authenticate-end', this.hideLoading.bind(this));
|
||||
}
|
||||
|
||||
handleWebAuthnAvailable(event) {
|
||||
const detail = event.detail;
|
||||
|
||||
if (!this.hasWebauthnSectionTarget || !this.hasPasswordSectionTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (detail.hasWebauthn) {
|
||||
this.webauthnSectionTarget.classList.remove('hidden');
|
||||
|
||||
// If WebAuthn is required, hide password section
|
||||
if (detail.requiresWebauthn) {
|
||||
this.passwordSectionTarget.classList.add('hidden');
|
||||
} else {
|
||||
// Show both options with a divider
|
||||
this.passwordSectionTarget.classList.add('border-t', 'pt-4', 'mt-4');
|
||||
this.addOrDivider();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handlePasskeyRegistered(event) {
|
||||
if (!this.hasStatusMessageTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Show success message
|
||||
this.statusMessageTarget.className = 'mt-4 p-3 rounded-md bg-green-50 text-green-800 border border-green-200';
|
||||
this.statusMessageTarget.textContent = 'Passkey registered successfully!';
|
||||
this.statusMessageTarget.classList.remove('hidden');
|
||||
|
||||
// Hide after 3 seconds
|
||||
setTimeout(() => {
|
||||
this.statusMessageTarget.classList.add('hidden');
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
showLoading() {
|
||||
if (this.hasLoadingOverlayTarget) {
|
||||
this.loadingOverlayTarget.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
hideLoading() {
|
||||
if (this.hasLoadingOverlayTarget) {
|
||||
this.loadingOverlayTarget.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
addOrDivider() {
|
||||
// Check if divider already exists
|
||||
if (this.element.querySelector('.login-divider')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const orDiv = document.createElement('div');
|
||||
orDiv.className = 'relative my-4 login-divider';
|
||||
orDiv.innerHTML = `
|
||||
<div class="absolute inset-0 flex items-center">
|
||||
<div class="w-full border-t border-gray-300"></div>
|
||||
</div>
|
||||
<div class="relative flex justify-center text-sm">
|
||||
<span class="px-2 bg-white text-gray-500">Or</span>
|
||||
</div>
|
||||
`;
|
||||
this.webauthnSectionTarget.parentNode.insertBefore(orDiv, this.passwordSectionTarget);
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
// Generic modal controller for showing/hiding modal dialogs
|
||||
export default class extends Controller {
|
||||
static targets = ["dialog"]
|
||||
|
||||
show(event) {
|
||||
// If called from a button with data-modal-id, find and show that modal
|
||||
const modalId = event.currentTarget?.dataset?.modalId;
|
||||
if (modalId) {
|
||||
const modal = document.getElementById(modalId);
|
||||
if (modal) {
|
||||
modal.classList.remove("hidden");
|
||||
}
|
||||
} else if (this.hasDialogTarget) {
|
||||
// Otherwise show the dialog target
|
||||
this.dialogTarget.classList.remove("hidden");
|
||||
} else {
|
||||
// Or show this element itself
|
||||
this.element.classList.remove("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
hide() {
|
||||
// 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");
|
||||
}
|
||||
}
|
||||
|
||||
// Close modal when clicking backdrop
|
||||
closeOnBackdrop(event) {
|
||||
// Only close if clicking directly on the backdrop (not child elements)
|
||||
if (event.target === this.element || event.target.classList.contains('modal-backdrop')) {
|
||||
this.hide();
|
||||
}
|
||||
}
|
||||
|
||||
// Close modal on Escape key
|
||||
closeOnEscape(event) {
|
||||
if (event.key === "Escape") {
|
||||
this.hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
51
app/javascript/controllers/role_management_controller.js
Normal file
51
app/javascript/controllers/role_management_controller.js
Normal file
@@ -0,0 +1,51 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,317 +0,0 @@
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["nickname", "submitButton", "status", "error"];
|
||||
static values = {
|
||||
challengeUrl: String,
|
||||
createUrl: String,
|
||||
checkUrl: String
|
||||
};
|
||||
|
||||
connect() {
|
||||
// Check if WebAuthn is supported
|
||||
if (!this.isWebAuthnSupported()) {
|
||||
console.warn("WebAuthn is not supported in this browser");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if browser supports WebAuthn
|
||||
isWebAuthnSupported() {
|
||||
return (
|
||||
window.PublicKeyCredential !== undefined &&
|
||||
typeof window.PublicKeyCredential === "function"
|
||||
);
|
||||
}
|
||||
|
||||
// Check if user has passkeys (for login page)
|
||||
async checkWebAuthnSupport(event) {
|
||||
const email = event.target.value.trim();
|
||||
|
||||
if (!email || !this.isValidEmail(email)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.checkUrlValue}?email=${encodeURIComponent(email)}`);
|
||||
const data = await response.json();
|
||||
|
||||
console.debug("WebAuthn check response:", data);
|
||||
|
||||
if (data.has_webauthn) {
|
||||
console.debug("Dispatching webauthn-available event");
|
||||
// Trigger custom event for login form to show passkey option
|
||||
this.dispatch("webauthn-available", {
|
||||
detail: {
|
||||
hasWebauthn: data.has_webauthn,
|
||||
requiresWebauthn: data.requires_webauthn,
|
||||
preferredMethod: data.preferred_method
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-trigger passkey authentication if required
|
||||
if (data.requires_webauthn) {
|
||||
setTimeout(() => this.authenticate(), 100);
|
||||
}
|
||||
} else {
|
||||
console.debug("No WebAuthn credentials found for this email");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error checking WebAuthn support:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Start registration ceremony
|
||||
async register(event) {
|
||||
event.preventDefault();
|
||||
|
||||
if (!this.isWebAuthnSupported()) {
|
||||
this.showError("WebAuthn is not supported in your browser");
|
||||
return;
|
||||
}
|
||||
|
||||
const nickname = this.nicknameTarget.value.trim();
|
||||
if (!nickname) {
|
||||
this.showError("Please enter a nickname for this passkey");
|
||||
return;
|
||||
}
|
||||
|
||||
this.setLoading(true);
|
||||
this.clearMessages();
|
||||
|
||||
try {
|
||||
// Get registration challenge from server
|
||||
const challengeResponse = await fetch(this.challengeUrlValue, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": this.getCSRFToken()
|
||||
}
|
||||
});
|
||||
|
||||
if (!challengeResponse.ok) {
|
||||
throw new Error("Failed to get registration challenge");
|
||||
}
|
||||
|
||||
const credentialCreationOptions = await challengeResponse.json();
|
||||
|
||||
// Use modern Web Authentication API Level 3 to parse options
|
||||
// This automatically handles all base64url encoding/decoding
|
||||
const publicKeyOptions = PublicKeyCredential.parseCreationOptionsFromJSON(
|
||||
credentialCreationOptions
|
||||
);
|
||||
|
||||
// Create credential via WebAuthn API
|
||||
const credential = await navigator.credentials.create({
|
||||
publicKey: publicKeyOptions
|
||||
});
|
||||
|
||||
if (!credential) {
|
||||
throw new Error("Failed to create credential");
|
||||
}
|
||||
|
||||
// Send credential to server for verification
|
||||
// Use toJSON() to properly serialize the credential
|
||||
const credentialResponse = await fetch(this.createUrlValue, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": this.getCSRFToken()
|
||||
},
|
||||
body: JSON.stringify({
|
||||
credential: credential.toJSON(),
|
||||
nickname: nickname
|
||||
})
|
||||
});
|
||||
|
||||
const result = await credentialResponse.json();
|
||||
|
||||
if (result.success) {
|
||||
this.showSuccess(result.message);
|
||||
|
||||
// Clear the form
|
||||
this.nicknameTarget.value = "";
|
||||
|
||||
// Dispatch event to refresh the passkey list
|
||||
this.dispatch("passkey-registered", {
|
||||
detail: {
|
||||
nickname: nickname,
|
||||
credentialId: result.credential_id
|
||||
}
|
||||
});
|
||||
|
||||
// Optionally close modal or redirect
|
||||
setTimeout(() => {
|
||||
if (window.location.pathname === "/webauthn/new") {
|
||||
window.location.href = "/profile";
|
||||
}
|
||||
}, 1500);
|
||||
} else {
|
||||
this.showError(result.error || "Failed to register passkey");
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("WebAuthn registration error:", error);
|
||||
this.showError(this.getErrorMessage(error));
|
||||
} finally {
|
||||
this.setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Start authentication ceremony
|
||||
async authenticate(event) {
|
||||
if (event) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
if (!this.isWebAuthnSupported()) {
|
||||
this.showError("WebAuthn is not supported in your browser");
|
||||
return;
|
||||
}
|
||||
|
||||
this.setLoading(true);
|
||||
this.clearMessages();
|
||||
|
||||
try {
|
||||
// Get authentication challenge from server
|
||||
const response = await fetch("/sessions/webauthn/challenge", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": this.getCSRFToken()
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: this.getUserEmail()
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to get authentication challenge");
|
||||
}
|
||||
|
||||
const credentialRequestOptions = await response.json();
|
||||
|
||||
// Use modern Web Authentication API Level 3 to parse options
|
||||
// This automatically handles all base64url encoding/decoding
|
||||
const publicKeyOptions = PublicKeyCredential.parseRequestOptionsFromJSON(
|
||||
credentialRequestOptions
|
||||
);
|
||||
|
||||
// Get credential via WebAuthn API
|
||||
const credential = await navigator.credentials.get({
|
||||
publicKey: publicKeyOptions
|
||||
});
|
||||
|
||||
if (!credential) {
|
||||
throw new Error("Failed to get credential");
|
||||
}
|
||||
|
||||
// Send assertion to server for verification
|
||||
// Use toJSON() to properly serialize the credential
|
||||
const authResponse = await fetch("/sessions/webauthn/verify", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": this.getCSRFToken()
|
||||
},
|
||||
body: JSON.stringify({
|
||||
credential: credential.toJSON(),
|
||||
email: this.getUserEmail()
|
||||
})
|
||||
});
|
||||
|
||||
const result = await authResponse.json();
|
||||
|
||||
if (result.success) {
|
||||
// Redirect to dashboard or intended URL
|
||||
window.location.href = result.redirect_to || "/";
|
||||
} else {
|
||||
this.showError(result.error || "Authentication failed");
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("WebAuthn authentication error:", error);
|
||||
this.showError(this.getErrorMessage(error));
|
||||
} finally {
|
||||
this.setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// UI helper methods
|
||||
setLoading(isLoading) {
|
||||
if (this.hasSubmitButtonTarget) {
|
||||
this.submitButtonTarget.disabled = isLoading;
|
||||
this.submitButtonTarget.textContent = isLoading ? "Registering..." : "Register Passkey";
|
||||
}
|
||||
}
|
||||
|
||||
showSuccess(message) {
|
||||
if (this.hasStatusTarget) {
|
||||
this.statusTarget.textContent = message;
|
||||
this.statusTarget.className = "mt-2 text-sm text-green-600";
|
||||
this.statusTarget.style.display = "block";
|
||||
}
|
||||
}
|
||||
|
||||
showError(message) {
|
||||
if (this.hasErrorTarget) {
|
||||
this.errorTarget.textContent = message;
|
||||
this.errorTarget.className = "mt-2 text-sm text-red-600";
|
||||
this.errorTarget.style.display = "block";
|
||||
}
|
||||
}
|
||||
|
||||
clearMessages() {
|
||||
if (this.hasStatusTarget) {
|
||||
this.statusTarget.style.display = "none";
|
||||
this.statusTarget.textContent = "";
|
||||
}
|
||||
if (this.hasErrorTarget) {
|
||||
this.errorTarget.style.display = "none";
|
||||
this.errorTarget.textContent = "";
|
||||
}
|
||||
}
|
||||
|
||||
getCSRFToken() {
|
||||
const meta = document.querySelector('meta[name="csrf-token"]');
|
||||
return meta ? meta.getAttribute("content") : "";
|
||||
}
|
||||
|
||||
getUserEmail() {
|
||||
// Try multiple ways to get the user email from login form
|
||||
let emailInput = document.querySelector('input[type="email"]');
|
||||
if (!emailInput) {
|
||||
emailInput = document.querySelector('input[name="email"]');
|
||||
}
|
||||
if (!emailInput) {
|
||||
emailInput = document.querySelector('input[name="session[email_address]"]');
|
||||
}
|
||||
if (!emailInput) {
|
||||
emailInput = document.querySelector('input[name="user[email_address]"]');
|
||||
}
|
||||
return emailInput ? emailInput.value.trim() : "";
|
||||
}
|
||||
|
||||
isValidEmail(email) {
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||
}
|
||||
|
||||
getErrorMessage(error) {
|
||||
// Common WebAuthn errors
|
||||
if (error.name === "NotAllowedError") {
|
||||
return "Authentication was cancelled or timed out. Please try again.";
|
||||
}
|
||||
if (error.name === "SecurityError") {
|
||||
return "Security requirements not met. Make sure you're using HTTPS.";
|
||||
}
|
||||
if (error.name === "NotSupportedError") {
|
||||
return "This device doesn't support the requested authentication method.";
|
||||
}
|
||||
if (error.name === "InvalidStateError") {
|
||||
return "This authenticator has already been registered.";
|
||||
}
|
||||
|
||||
// Fallback to error message
|
||||
return error.message || "An unexpected error occurred";
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
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_FROM_EMAIL', 'clinch@example.com')
|
||||
default from: ENV.fetch('CLINCH_EMAIL_FROM', 'clinch@example.com')
|
||||
layout "mailer"
|
||||
end
|
||||
|
||||
@@ -1,58 +1,53 @@
|
||||
class Application < ApplicationRecord
|
||||
has_secure_password :client_secret, validations: false
|
||||
has_secure_password :client_secret
|
||||
|
||||
has_many :application_groups, dependent: :destroy
|
||||
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
|
||||
has_many :application_roles, dependent: :destroy
|
||||
has_many :user_role_assignments, through: :application_roles
|
||||
|
||||
validates :name, presence: true
|
||||
validates :slug, presence: true, uniqueness: { case_sensitive: false },
|
||||
format: { with: /\A[a-z0-9\-]+\z/, message: "only lowercase letters, numbers, and hyphens" }
|
||||
validates :app_type, presence: true,
|
||||
inclusion: { in: %w[oidc forward_auth] }
|
||||
inclusion: { in: %w[oidc saml] }
|
||||
validates :client_id, uniqueness: { allow_nil: true }
|
||||
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
|
||||
validates :role_mapping_mode, inclusion: { in: %w[disabled oidc_managed hybrid] }, allow_blank: true
|
||||
|
||||
normalizes :slug, with: ->(slug) { slug.strip.downcase }
|
||||
normalizes :domain_pattern, with: ->(pattern) {
|
||||
normalized = pattern&.strip&.downcase
|
||||
normalized.blank? ? nil : normalized
|
||||
}
|
||||
|
||||
before_validation :generate_client_credentials, on: :create, if: :oidc?
|
||||
|
||||
# Default header configuration for ForwardAuth
|
||||
DEFAULT_HEADERS = {
|
||||
user: 'X-Remote-User',
|
||||
email: 'X-Remote-Email',
|
||||
name: 'X-Remote-Name',
|
||||
groups: 'X-Remote-Groups',
|
||||
admin: 'X-Remote-Admin'
|
||||
}.freeze
|
||||
|
||||
# Scopes
|
||||
scope :active, -> { where(active: true) }
|
||||
scope :oidc, -> { where(app_type: "oidc") }
|
||||
scope :forward_auth, -> { where(app_type: "forward_auth") }
|
||||
scope :ordered, -> { order(domain_pattern: :asc) }
|
||||
scope :saml, -> { where(app_type: "saml") }
|
||||
scope :oidc_managed_roles, -> { where(role_mapping_mode: "oidc_managed") }
|
||||
scope :hybrid_roles, -> { where(role_mapping_mode: "hybrid") }
|
||||
|
||||
# Type checks
|
||||
def oidc?
|
||||
app_type == "oidc"
|
||||
end
|
||||
|
||||
def forward_auth?
|
||||
app_type == "forward_auth"
|
||||
def saml?
|
||||
app_type == "saml"
|
||||
end
|
||||
|
||||
# Role mapping checks
|
||||
def role_mapping_enabled?
|
||||
role_mapping_mode.in?(['oidc_managed', 'hybrid'])
|
||||
end
|
||||
|
||||
def oidc_managed_roles?
|
||||
role_mapping_mode == 'oidc_managed'
|
||||
end
|
||||
|
||||
def hybrid_roles?
|
||||
role_mapping_mode == 'hybrid'
|
||||
end
|
||||
|
||||
# Access control
|
||||
@@ -82,74 +77,49 @@ class Application < ApplicationRecord
|
||||
{}
|
||||
end
|
||||
|
||||
# ForwardAuth helpers
|
||||
def parsed_headers_config
|
||||
return {} unless headers_config.present?
|
||||
headers_config.is_a?(Hash) ? headers_config : JSON.parse(headers_config)
|
||||
def parsed_managed_permissions
|
||||
return {} unless managed_permissions.present?
|
||||
managed_permissions.is_a?(Hash) ? managed_permissions : JSON.parse(managed_permissions)
|
||||
rescue JSON::ParserError
|
||||
{}
|
||||
end
|
||||
|
||||
# Check if a domain matches this application's pattern (for ForwardAuth)
|
||||
def matches_domain?(domain)
|
||||
return false if domain.blank? || !forward_auth?
|
||||
|
||||
pattern = domain_pattern.gsub('.', '\.')
|
||||
pattern = pattern.gsub('*', '[^.]*')
|
||||
|
||||
regex = Regexp.new("^#{pattern}$", Regexp::IGNORECASE)
|
||||
regex.match?(domain.downcase)
|
||||
# Role management methods
|
||||
def user_roles(user)
|
||||
application_roles.joins(:user_role_assignments)
|
||||
.where(user_role_assignments: { user: user })
|
||||
.active
|
||||
end
|
||||
|
||||
# Policy determination based on user status (for ForwardAuth)
|
||||
def policy_for_user(user)
|
||||
return 'deny' unless active?
|
||||
return 'deny' unless user.active?
|
||||
|
||||
# If no groups specified, bypass authentication
|
||||
return 'bypass' if allowed_groups.empty?
|
||||
|
||||
# If user is in allowed groups, determine auth level
|
||||
if user_allowed?(user)
|
||||
# Require 2FA if user has TOTP configured, otherwise one factor
|
||||
user.totp_enabled? ? 'two_factor' : 'one_factor'
|
||||
else
|
||||
'deny'
|
||||
end
|
||||
def user_has_role?(user, role_name)
|
||||
user_roles(user).exists?(name: role_name)
|
||||
end
|
||||
|
||||
# Get effective header configuration (for ForwardAuth)
|
||||
def effective_headers
|
||||
DEFAULT_HEADERS.merge(parsed_headers_config.symbolize_keys)
|
||||
def assign_role_to_user!(user, role_name, source: 'manual', metadata: {})
|
||||
role = application_roles.active.find_by!(name: role_name)
|
||||
role.assign_to_user!(user, source: source, metadata: metadata)
|
||||
end
|
||||
|
||||
# Generate headers for a specific user (for ForwardAuth)
|
||||
def headers_for_user(user)
|
||||
headers = {}
|
||||
effective = effective_headers
|
||||
def remove_role_from_user!(user, role_name)
|
||||
role = application_roles.find_by!(name: role_name)
|
||||
role.remove_from_user!(user)
|
||||
end
|
||||
|
||||
# Only generate headers that are configured (not set to nil/false)
|
||||
effective.each do |key, header_name|
|
||||
next unless header_name.present? # Skip disabled headers
|
||||
# Enhanced access control with roles
|
||||
def user_allowed_with_roles?(user)
|
||||
return user_allowed?(user) unless role_mapping_enabled?
|
||||
|
||||
case key
|
||||
when :user, :email
|
||||
headers[header_name] = user.email_address
|
||||
when :name
|
||||
headers[header_name] = user.name.presence || user.email_address
|
||||
when :groups
|
||||
headers[header_name] = user.groups.pluck(:name).join(",") if user.groups.any?
|
||||
when :admin
|
||||
headers[header_name] = user.admin? ? "true" : "false"
|
||||
end
|
||||
# For OIDC managed roles, check if user has any roles assigned
|
||||
if oidc_managed_roles?
|
||||
return user_roles(user).exists?
|
||||
end
|
||||
|
||||
headers
|
||||
end
|
||||
# For hybrid mode, either group-based access or role-based access works
|
||||
if hybrid_roles?
|
||||
return user_allowed?(user) || user_roles(user).exists?
|
||||
end
|
||||
|
||||
# Check if all headers are disabled (for ForwardAuth)
|
||||
def headers_disabled?
|
||||
headers_config.present? && effective_headers.values.all?(&:blank?)
|
||||
user_allowed?(user)
|
||||
end
|
||||
|
||||
# Generate and return a new client secret
|
||||
@@ -160,44 +130,8 @@ 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
|
||||
|
||||
26
app/models/application_role.rb
Normal file
26
app/models/application_role.rb
Normal file
@@ -0,0 +1,26 @@
|
||||
class ApplicationRole < ApplicationRecord
|
||||
belongs_to :application
|
||||
has_many :user_role_assignments, dependent: :destroy
|
||||
has_many :users, through: :user_role_assignments
|
||||
|
||||
validates :name, presence: true, uniqueness: { scope: :application_id }
|
||||
validates :display_name, presence: true
|
||||
|
||||
scope :active, -> { where(active: true) }
|
||||
|
||||
def user_has_role?(user)
|
||||
user_role_assignments.exists?(user: user)
|
||||
end
|
||||
|
||||
def assign_to_user!(user, source: 'oidc', metadata: {})
|
||||
user_role_assignments.find_or_create_by!(user: user) do |assignment|
|
||||
assignment.source = source
|
||||
assignment.metadata = metadata
|
||||
end
|
||||
end
|
||||
|
||||
def remove_from_user!(user)
|
||||
assignment = user_role_assignments.find_by(user: user)
|
||||
assignment&.destroy
|
||||
end
|
||||
end
|
||||
94
app/models/forward_auth_rule.rb
Normal file
94
app/models/forward_auth_rule.rb
Normal file
@@ -0,0 +1,94 @@
|
||||
class ForwardAuthRule < ApplicationRecord
|
||||
has_many :forward_auth_rule_groups, dependent: :destroy
|
||||
has_many :allowed_groups, through: :forward_auth_rule_groups, source: :group
|
||||
|
||||
validates :domain_pattern, presence: true, uniqueness: { case_sensitive: false }
|
||||
validates :active, inclusion: { in: [true, false] }
|
||||
|
||||
normalizes :domain_pattern, with: ->(pattern) { pattern.strip.downcase }
|
||||
|
||||
# Default header configuration
|
||||
DEFAULT_HEADERS = {
|
||||
user: 'X-Remote-User',
|
||||
email: 'X-Remote-Email',
|
||||
name: 'X-Remote-Name',
|
||||
groups: 'X-Remote-Groups',
|
||||
admin: 'X-Remote-Admin'
|
||||
}.freeze
|
||||
|
||||
# Scopes
|
||||
scope :active, -> { where(active: true) }
|
||||
scope :ordered, -> { order(domain_pattern: :asc) }
|
||||
|
||||
# Check if a domain matches this rule
|
||||
def matches_domain?(domain)
|
||||
return false if domain.blank?
|
||||
|
||||
pattern = domain_pattern.gsub('.', '\.')
|
||||
pattern = pattern.gsub('*', '[^.]*')
|
||||
|
||||
regex = Regexp.new("^#{pattern}$", Regexp::IGNORECASE)
|
||||
regex.match?(domain.downcase)
|
||||
end
|
||||
|
||||
# Access control for forward auth
|
||||
def user_allowed?(user)
|
||||
return false unless active?
|
||||
return false unless user.active?
|
||||
|
||||
# If no groups are specified, allow all active users (bypass)
|
||||
return true if allowed_groups.empty?
|
||||
|
||||
# Otherwise, user must be in at least one of the allowed groups
|
||||
(user.groups & allowed_groups).any?
|
||||
end
|
||||
|
||||
# Policy determination based on user status and rule configuration
|
||||
def policy_for_user(user)
|
||||
return 'deny' unless active?
|
||||
return 'deny' unless user.active?
|
||||
|
||||
# If no groups specified, bypass authentication
|
||||
return 'bypass' if allowed_groups.empty?
|
||||
|
||||
# If user is in allowed groups, determine auth level
|
||||
if user_allowed?(user)
|
||||
# Require 2FA if user has TOTP configured, otherwise one factor
|
||||
user.totp_enabled? ? 'two_factor' : 'one_factor'
|
||||
else
|
||||
'deny'
|
||||
end
|
||||
end
|
||||
|
||||
# Get effective header configuration (rule-specific + defaults)
|
||||
def effective_headers
|
||||
DEFAULT_HEADERS.merge((headers_config || {}).symbolize_keys)
|
||||
end
|
||||
|
||||
# Generate headers for a specific user
|
||||
def headers_for_user(user)
|
||||
headers = {}
|
||||
effective = effective_headers
|
||||
|
||||
# Only generate headers that are configured (not set to nil/false)
|
||||
effective.each do |key, header_name|
|
||||
next unless header_name.present? # Skip disabled headers
|
||||
|
||||
case key
|
||||
when :user, :email, :name
|
||||
headers[header_name] = user.email_address
|
||||
when :groups
|
||||
headers[header_name] = user.groups.pluck(:name).join(",") if user.groups.any?
|
||||
when :admin
|
||||
headers[header_name] = user.admin? ? "true" : "false"
|
||||
end
|
||||
end
|
||||
|
||||
headers
|
||||
end
|
||||
|
||||
# Check if all headers are disabled
|
||||
def headers_disabled?
|
||||
headers_config.present? && effective_headers.values.all?(&:blank?)
|
||||
end
|
||||
end
|
||||
6
app/models/forward_auth_rule_group.rb
Normal file
6
app/models/forward_auth_rule_group.rb
Normal file
@@ -0,0 +1,6 @@
|
||||
class ForwardAuthRuleGroup < ApplicationRecord
|
||||
belongs_to :forward_auth_rule
|
||||
belongs_to :group
|
||||
|
||||
validates :forward_auth_rule_id, uniqueness: { scope: :group_id }
|
||||
end
|
||||
@@ -6,9 +6,4 @@ class Group < ApplicationRecord
|
||||
|
||||
validates :name, presence: true, uniqueness: { case_sensitive: false }
|
||||
normalizes :name, with: ->(name) { name.strip.downcase }
|
||||
|
||||
# Parse custom_claims JSON field
|
||||
def parsed_custom_claims
|
||||
custom_claims || {}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,83 +1,34 @@
|
||||
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, uniqueness: true, presence: true
|
||||
validates :token, presence: true, uniqueness: true
|
||||
|
||||
scope :valid, -> { where("expires_at > ?", Time.current).where(revoked_at: nil) }
|
||||
scope :valid, -> { where("expires_at > ?", Time.current) }
|
||||
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? && !revoked?
|
||||
!expired?
|
||||
end
|
||||
|
||||
def revoke!
|
||||
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
|
||||
update!(expires_at: Time.current)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_token
|
||||
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
|
||||
self.token ||= SecureRandom.urlsafe_base64(48)
|
||||
end
|
||||
|
||||
def set_expiry
|
||||
self.expires_at ||= application.access_token_expiry
|
||||
self.expires_at ||= 1.hour.from_now
|
||||
end
|
||||
end
|
||||
|
||||
@@ -7,8 +7,6 @@ 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) }
|
||||
@@ -25,10 +23,6 @@ class OidcAuthorizationCode < ApplicationRecord
|
||||
update!(used: true)
|
||||
end
|
||||
|
||||
def uses_pkce?
|
||||
code_challenge.present?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_code
|
||||
@@ -38,11 +32,4 @@ 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
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
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
|
||||
@@ -3,8 +3,9 @@ class User < ApplicationRecord
|
||||
has_many :sessions, dependent: :destroy
|
||||
has_many :user_groups, dependent: :destroy
|
||||
has_many :groups, through: :user_groups
|
||||
has_many :user_role_assignments, dependent: :destroy
|
||||
has_many :application_roles, through: :user_role_assignments
|
||||
has_many :oidc_user_consents, dependent: :destroy
|
||||
has_many :webauthn_credentials, dependent: :destroy
|
||||
|
||||
# Token generation for passwordless flows
|
||||
generates_token_for :invitation_login, expires_in: 24.hours do
|
||||
@@ -66,101 +67,19 @@ class User < ApplicationRecord
|
||||
def verify_backup_code(code)
|
||||
return false unless backup_codes.present?
|
||||
|
||||
# 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}"
|
||||
codes = JSON.parse(backup_codes)
|
||||
if codes.include?(code)
|
||||
codes.delete(code)
|
||||
update(backup_codes: codes.to_json)
|
||||
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
|
||||
|
||||
# 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
|
||||
def webauthn_enabled?
|
||||
webauthn_credentials.exists?
|
||||
end
|
||||
|
||||
def can_authenticate_with_webauthn?
|
||||
webauthn_enabled? && active?
|
||||
end
|
||||
|
||||
def require_webauthn?
|
||||
webauthn_required? || (webauthn_enabled? && !password_digest.present?)
|
||||
end
|
||||
|
||||
# Generate stable WebAuthn user handle on first use
|
||||
def webauthn_user_handle
|
||||
return webauthn_id if webauthn_id.present?
|
||||
|
||||
# Generate random 64-byte opaque identifier (base64url encoded)
|
||||
handle = SecureRandom.urlsafe_base64(64)
|
||||
update_column(:webauthn_id, handle)
|
||||
handle
|
||||
end
|
||||
|
||||
def platform_authenticators
|
||||
webauthn_credentials.platform_authenticators
|
||||
end
|
||||
|
||||
def roaming_authenticators
|
||||
webauthn_credentials.roaming_authenticators
|
||||
end
|
||||
|
||||
def webauthn_credential_for(external_id)
|
||||
webauthn_credentials.find_by(external_id: external_id)
|
||||
end
|
||||
|
||||
# Check if user has any backed up (synced) passkeys
|
||||
def has_synced_passkeys?
|
||||
webauthn_credentials.exists?(backup_eligible: true, backup_state: true)
|
||||
end
|
||||
|
||||
# Preferred authentication method for login flow
|
||||
def preferred_authentication_method
|
||||
return :webauthn if require_webauthn?
|
||||
return :webauthn if can_authenticate_with_webauthn? && preferred_2fa_method == "webauthn"
|
||||
return :password if password_digest.present?
|
||||
:webauthn
|
||||
def parsed_backup_codes
|
||||
return [] unless backup_codes.present?
|
||||
JSON.parse(backup_codes)
|
||||
end
|
||||
|
||||
def has_oidc_consent?(application, requested_scopes)
|
||||
@@ -178,24 +97,9 @@ class User < ApplicationRecord
|
||||
oidc_user_consents.destroy_all
|
||||
end
|
||||
|
||||
# Parse custom_claims JSON field
|
||||
def parsed_custom_claims
|
||||
custom_claims || {}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_backup_codes
|
||||
# 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
|
||||
Array.new(10) { SecureRandom.alphanumeric(8).upcase }.to_json
|
||||
end
|
||||
end
|
||||
|
||||
15
app/models/user_role_assignment.rb
Normal file
15
app/models/user_role_assignment.rb
Normal file
@@ -0,0 +1,15 @@
|
||||
class UserRoleAssignment < ApplicationRecord
|
||||
belongs_to :user
|
||||
belongs_to :application_role
|
||||
|
||||
validates :user, uniqueness: { scope: :application_role }
|
||||
validates :source, inclusion: { in: %w[oidc manual group_sync] }
|
||||
|
||||
scope :oidc_managed, -> { where(source: 'oidc') }
|
||||
scope :manually_assigned, -> { where(source: 'manual') }
|
||||
scope :group_synced, -> { where(source: 'group_sync') }
|
||||
|
||||
def sync_from_oidc?
|
||||
source == 'oidc'
|
||||
end
|
||||
end
|
||||
@@ -1,96 +0,0 @@
|
||||
class WebauthnCredential < ApplicationRecord
|
||||
belongs_to :user
|
||||
|
||||
# Validations
|
||||
validates :external_id, presence: true, uniqueness: true
|
||||
validates :public_key, presence: true
|
||||
validates :sign_count, presence: true, numericality: { greater_than_or_equal_to: 0, only_integer: true }
|
||||
validates :nickname, presence: true
|
||||
validates :authenticator_type, inclusion: { in: %w[platform cross-platform] }
|
||||
|
||||
# Scopes for querying
|
||||
scope :active, -> { where(nil) } # All credentials are active (we can add revoked_at later if needed)
|
||||
scope :platform_authenticators, -> { where(authenticator_type: "platform") }
|
||||
scope :roaming_authenticators, -> { where(authenticator_type: "cross-platform") }
|
||||
scope :recently_used, -> { where.not(last_used_at: nil).order(last_used_at: :desc) }
|
||||
scope :never_used, -> { where(last_used_at: nil) }
|
||||
|
||||
# Update last used timestamp and sign count after successful authentication
|
||||
def update_usage!(sign_count:, ip_address: nil, user_agent: nil)
|
||||
update!(
|
||||
last_used_at: Time.current,
|
||||
last_used_ip: ip_address,
|
||||
sign_count: sign_count,
|
||||
user_agent: user_agent
|
||||
)
|
||||
end
|
||||
|
||||
# Check if this is a platform authenticator (built-in device)
|
||||
def platform_authenticator?
|
||||
authenticator_type == "platform"
|
||||
end
|
||||
|
||||
# Check if this is a roaming authenticator (USB/NFC/Bluetooth key)
|
||||
def roaming_authenticator?
|
||||
authenticator_type == "cross-platform"
|
||||
end
|
||||
|
||||
# Check if this credential is backed up (synced passkeys)
|
||||
def backed_up?
|
||||
backup_eligible? && backup_state?
|
||||
end
|
||||
|
||||
# Human readable description
|
||||
def description
|
||||
if nickname.present?
|
||||
"#{nickname} (#{authenticator_type.humanize})"
|
||||
else
|
||||
"#{authenticator_type.humanize} Authenticator"
|
||||
end
|
||||
end
|
||||
|
||||
# Check if sign count is suspicious (clone detection)
|
||||
def suspicious_sign_count?(new_sign_count)
|
||||
return false if sign_count.zero? && new_sign_count > 0 # First use
|
||||
return false if new_sign_count > sign_count # Normal increment
|
||||
|
||||
# Sign count didn't increase - possible clone
|
||||
true
|
||||
end
|
||||
|
||||
# Format for display in UI
|
||||
def display_name
|
||||
nickname || "#{authenticator_type&.humanize} Authenticator"
|
||||
end
|
||||
|
||||
# When was this credential created?
|
||||
def created_recently?
|
||||
created_at > 1.week.ago
|
||||
end
|
||||
|
||||
# How long ago was this last used?
|
||||
def last_used_ago
|
||||
return "Never" unless last_used_at
|
||||
|
||||
time_ago_in_words(last_used_at)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def time_ago_in_words(time)
|
||||
seconds = Time.current - time
|
||||
minutes = seconds / 60
|
||||
hours = minutes / 60
|
||||
days = hours / 24
|
||||
|
||||
if days > 0
|
||||
"#{days.floor} day#{'s' if days > 1} ago"
|
||||
elsif hours > 0
|
||||
"#{hours.floor} hour#{'s' if hours > 1} ago"
|
||||
elsif minutes > 0
|
||||
"#{minutes.floor} minute#{'s' if minutes > 1} ago"
|
||||
else
|
||||
"Just now"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -3,19 +3,17 @@ 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 + ttl,
|
||||
exp: now + 3600, # 1 hour
|
||||
iat: now,
|
||||
email: user.email_address,
|
||||
email_verified: true,
|
||||
preferred_username: user.email_address,
|
||||
name: user.name.presence || user.email_address
|
||||
name: user.email_address
|
||||
}
|
||||
|
||||
# Add nonce if provided (OIDC requires this for implicit flow)
|
||||
@@ -29,14 +27,11 @@ class OidcJwtService
|
||||
# Add admin claim if user is admin
|
||||
payload[:admin] = true if user.admin?
|
||||
|
||||
# Merge custom claims from groups
|
||||
user.groups.each do |group|
|
||||
payload.merge!(group.parsed_custom_claims)
|
||||
# Add role-based claims if role mapping is enabled
|
||||
if application.role_mapping_enabled?
|
||||
add_role_claims!(payload, user, application)
|
||||
end
|
||||
|
||||
# Merge custom claims from user (overrides group claims)
|
||||
payload.merge!(user.parsed_custom_claims)
|
||||
|
||||
JWT.encode(payload, private_key, "RS256", { kid: key_id, typ: "JWT" })
|
||||
end
|
||||
|
||||
@@ -65,9 +60,7 @@ 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
|
||||
host = ENV.fetch("CLINCH_HOST", "localhost:3000")
|
||||
# Ensure URL has https:// protocol
|
||||
host.match?(/^https?:\/\//) ? host : "https://#{host}"
|
||||
"https://#{ENV.fetch("CLINCH_HOST", "localhost:3000")}"
|
||||
end
|
||||
|
||||
private
|
||||
@@ -100,5 +93,50 @@ class OidcJwtService
|
||||
def key_id
|
||||
@key_id ||= Digest::SHA256.hexdigest(public_key.to_pem)[0..15]
|
||||
end
|
||||
|
||||
# Add role-based claims to the JWT payload
|
||||
def add_role_claims!(payload, user, application)
|
||||
user_roles = application.user_roles(user)
|
||||
return if user_roles.empty?
|
||||
|
||||
role_names = user_roles.pluck(:name)
|
||||
|
||||
# Filter roles by prefix if configured
|
||||
if application.role_prefix.present?
|
||||
role_names = role_names.select { |role| role.start_with?(application.role_prefix) }
|
||||
end
|
||||
|
||||
return if role_names.empty?
|
||||
|
||||
# Add roles using the configured claim name
|
||||
claim_name = application.role_claim_name.presence || 'roles'
|
||||
payload[claim_name] = role_names
|
||||
|
||||
# Add role permissions if configured
|
||||
managed_permissions = application.parsed_managed_permissions
|
||||
if managed_permissions['include_permissions'] == true
|
||||
role_permissions = user_roles.map do |role|
|
||||
{
|
||||
name: role.name,
|
||||
display_name: role.display_name,
|
||||
permissions: role.permissions
|
||||
}
|
||||
end
|
||||
payload['role_permissions'] = role_permissions
|
||||
end
|
||||
|
||||
# Add role metadata if configured
|
||||
if managed_permissions['include_metadata'] == true
|
||||
role_metadata = user_roles.map do |role|
|
||||
assignment = role.user_role_assignments.find_by(user: user)
|
||||
{
|
||||
name: role.name,
|
||||
source: assignment&.source,
|
||||
assigned_at: assignment&.created_at
|
||||
}
|
||||
end
|
||||
payload['role_metadata'] = role_metadata
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
127
app/services/role_mapping_engine.rb
Normal file
127
app/services/role_mapping_engine.rb
Normal file
@@ -0,0 +1,127 @@
|
||||
class RoleMappingEngine
|
||||
class << self
|
||||
# Sync user roles from OIDC claims
|
||||
def sync_user_roles!(user, application, claims)
|
||||
return unless application.role_mapping_enabled?
|
||||
|
||||
# Extract roles from claims
|
||||
external_roles = extract_roles_from_claims(application, claims)
|
||||
|
||||
case application.role_mapping_mode
|
||||
when 'oidc_managed'
|
||||
sync_oidc_managed_roles!(user, application, external_roles)
|
||||
when 'hybrid'
|
||||
sync_hybrid_roles!(user, application, external_roles)
|
||||
end
|
||||
end
|
||||
|
||||
# Check if user is allowed based on roles
|
||||
def user_allowed_with_roles?(user, application, claims = nil)
|
||||
return application.user_allowed_with_roles?(user) unless claims
|
||||
|
||||
if application.oidc_managed_roles?
|
||||
external_roles = extract_roles_from_claims(application, claims)
|
||||
return false if external_roles.empty?
|
||||
|
||||
# Check if any external role matches configured application roles
|
||||
application.application_roles.active.exists?(name: external_roles)
|
||||
elsif application.hybrid_roles?
|
||||
# Allow access if either group-based or role-based access works
|
||||
application.user_allowed?(user) ||
|
||||
(external_roles.present? &&
|
||||
application.application_roles.active.exists?(name: external_roles))
|
||||
else
|
||||
application.user_allowed?(user)
|
||||
end
|
||||
end
|
||||
|
||||
# Get available roles for a user in an application
|
||||
def user_available_roles(user, application)
|
||||
return [] unless application.role_mapping_enabled?
|
||||
|
||||
application.application_roles.active
|
||||
end
|
||||
|
||||
# Map external roles to internal roles
|
||||
def map_external_to_internal_roles(application, external_roles)
|
||||
return [] if external_roles.empty?
|
||||
|
||||
configured_roles = application.application_roles.active.pluck(:name)
|
||||
|
||||
# Apply role prefix filtering
|
||||
if application.role_prefix.present?
|
||||
external_roles = external_roles.select { |role| role.start_with?(application.role_prefix) }
|
||||
end
|
||||
|
||||
# Find matching internal roles
|
||||
external_roles & configured_roles
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Extract roles from various claim sources
|
||||
def extract_roles_from_claims(application, claims)
|
||||
claim_name = application.role_claim_name.presence || 'roles'
|
||||
|
||||
# Try the configured claim name first
|
||||
roles = claims[claim_name]
|
||||
|
||||
# Fallback to common claim names if not found
|
||||
roles ||= claims['roles']
|
||||
roles ||= claims['groups']
|
||||
roles ||= claims['http://schemas.microsoft.com/ws/2008/06/identity/claims/role']
|
||||
|
||||
# Ensure roles is an array
|
||||
case roles
|
||||
when String
|
||||
[roles]
|
||||
when Array
|
||||
roles
|
||||
else
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
# Sync roles for OIDC managed mode (replace existing roles)
|
||||
def sync_oidc_managed_roles!(user, application, external_roles)
|
||||
# Map external roles to internal roles
|
||||
internal_roles = map_external_to_internal_roles(application, external_roles)
|
||||
|
||||
# Get current OIDC-managed roles
|
||||
current_assignments = user.user_role_assignments
|
||||
.joins(:application_role)
|
||||
.where(application_role: { application: application })
|
||||
.oidc_managed
|
||||
.includes(:application_role)
|
||||
|
||||
current_role_names = current_assignments.map { |assignment| assignment.application_role.name }
|
||||
|
||||
# Remove roles that are no longer in external roles
|
||||
roles_to_remove = current_role_names - internal_roles
|
||||
roles_to_remove.each do |role_name|
|
||||
application.remove_role_from_user!(user, role_name)
|
||||
end
|
||||
|
||||
# Add new roles
|
||||
roles_to_add = internal_roles - current_role_names
|
||||
roles_to_add.each do |role_name|
|
||||
application.assign_role_to_user!(user, role_name, source: 'oidc',
|
||||
metadata: { synced_at: Time.current })
|
||||
end
|
||||
end
|
||||
|
||||
# Sync roles for hybrid mode (merge with existing roles)
|
||||
def sync_hybrid_roles!(user, application, external_roles)
|
||||
# Map external roles to internal roles
|
||||
internal_roles = map_external_to_internal_roles(application, external_roles)
|
||||
|
||||
# Only add new roles, don't remove manually assigned ones
|
||||
internal_roles.each do |role_name|
|
||||
next if application.user_has_role?(user, role_name)
|
||||
|
||||
application.assign_role_to_user!(user, role_name, source: 'oidc',
|
||||
metadata: { synced_at: Time.current })
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,114 +0,0 @@
|
||||
<div class="space-y-8">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900">Sessions</h1>
|
||||
<p class="mt-2 text-sm text-gray-600">Manage your active sessions and connected applications.</p>
|
||||
</div>
|
||||
|
||||
<!-- Connected Applications -->
|
||||
<div class="bg-white shadow sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<h3 class="text-lg font-medium leading-6 text-gray-900">Connected Applications</h3>
|
||||
<div class="mt-2 max-w-xl text-sm text-gray-500">
|
||||
<p>These applications have access to your account. You can revoke access at any time.</p>
|
||||
</div>
|
||||
<div class="mt-5">
|
||||
<% if @connected_applications.any? %>
|
||||
<ul role="list" class="divide-y divide-gray-200">
|
||||
<% @connected_applications.each do |consent| %>
|
||||
<li class="py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex flex-col">
|
||||
<p class="text-sm font-medium text-gray-900">
|
||||
<%= consent.application.name %>
|
||||
</p>
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
Access to: <%= consent.formatted_scopes %>
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-gray-400">
|
||||
Authorized <%= time_ago_in_words(consent.granted_at) %> ago
|
||||
</p>
|
||||
</div>
|
||||
<%= button_to "Revoke Access", revoke_consent_active_sessions_path(application_id: consent.application.id), method: :delete,
|
||||
class: "inline-flex items-center rounded-md border border-red-300 bg-white px-3 py-2 text-sm font-medium text-red-700 shadow-sm hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2",
|
||||
form: { data: { turbo_confirm: "Are you sure you want to revoke access to #{consent.application.name}? You'll need to re-authorize this application to use it again." } } %>
|
||||
</div>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
<% else %>
|
||||
<p class="text-sm text-gray-500">No connected applications.</p>
|
||||
<% end %>
|
||||
|
||||
<% if @connected_applications.any? %>
|
||||
<div class="mt-6 pt-6 border-t border-gray-200">
|
||||
<div class="flex justify-end">
|
||||
<div class="inline-block">
|
||||
<%= button_to "Revoke All App Access", revoke_all_consents_active_sessions_path, method: :delete,
|
||||
class: "inline-flex items-center rounded-md border border-red-300 bg-white px-3 py-2 text-sm font-medium text-red-700 shadow-sm hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 whitespace-nowrap",
|
||||
form: { data: { turbo_confirm: "This will revoke access from all connected applications. You'll need to re-authorize each application to use them again. Are you sure?" } } %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active Sessions -->
|
||||
<div class="bg-white shadow sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<h3 class="text-lg font-medium leading-6 text-gray-900">Active Sessions</h3>
|
||||
<div class="mt-2 max-w-xl text-sm text-gray-500">
|
||||
<p>These devices are currently signed in to your account. Revoke any sessions that you don't recognize.</p>
|
||||
</div>
|
||||
<div class="mt-5">
|
||||
<% if @active_sessions.any? %>
|
||||
<ul role="list" class="divide-y divide-gray-200">
|
||||
<% @active_sessions.each do |session| %>
|
||||
<li class="py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex flex-col">
|
||||
<p class="text-sm font-medium text-gray-900">
|
||||
<%= session.device_name || "Unknown Device" %>
|
||||
<% if session.id == Current.session.id %>
|
||||
<span class="ml-2 inline-flex items-center rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-800">
|
||||
This device
|
||||
</span>
|
||||
<% end %>
|
||||
</p>
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
<%= session.ip_address %>
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-gray-400">
|
||||
Last active <%= time_ago_in_words(session.last_activity_at || session.updated_at) %> ago
|
||||
</p>
|
||||
</div>
|
||||
<% if session.id != Current.session.id %>
|
||||
<%= button_to "Revoke", session_path(session), method: :delete,
|
||||
class: "inline-flex items-center rounded-md border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2",
|
||||
form: { data: { turbo_confirm: "Are you sure you want to revoke this session?" } } %>
|
||||
<% end %>
|
||||
</div>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
<% else %>
|
||||
<p class="text-sm text-gray-500">No other active sessions.</p>
|
||||
<% end %>
|
||||
|
||||
<% if @active_sessions.count > 1 %>
|
||||
<div class="mt-6 pt-6 border-t border-gray-200">
|
||||
<div class="flex justify-end">
|
||||
<div class="inline-block">
|
||||
<%= button_to "Sign Out Everywhere Else", session_path(Current.session), method: :delete,
|
||||
class: "inline-flex items-center rounded-md border border-orange-300 bg-white px-3 py-2 text-sm font-medium text-orange-700 shadow-sm hover:bg-orange-50 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-2 whitespace-nowrap",
|
||||
form: { data: { turbo_confirm: "This will sign you out from all other devices except this one. Are you sure?" } } %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -1,5 +1,22 @@
|
||||
<%= form_with(model: [:admin, application], class: "space-y-6", data: { controller: "application-form form-errors" }) do |form| %>
|
||||
<%= render "shared/form_errors", form: form %>
|
||||
<%= 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 %>
|
||||
|
||||
<div>
|
||||
<%= form.label :name, class: "block text-sm font-medium text-gray-700" %>
|
||||
@@ -17,26 +34,16 @@
|
||||
<%= form.text_area :description, rows: 3, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Optional description of this application" %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= form.label :landing_url, "Landing URL", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.url_field :landing_url, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "https://app.example.com" %>
|
||||
<p class="mt-1 text-sm text-gray-500">The main URL users will visit to access this application. This will be shown as a link on their dashboard.</p>
|
||||
</div>
|
||||
|
||||
<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?,
|
||||
data: { action: "change->application-form#updateFieldVisibility", application_form_target: "appTypeSelect" }
|
||||
} %>
|
||||
<%= form.select :app_type, [["OpenID Connect (OIDC)", "oidc"], ["SAML (Coming Soon)", "saml", { disabled: true }]], {}, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", disabled: application.persisted? %>
|
||||
<% 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 <%= 'hidden' unless application.oidc? || !application.persisted? %>" data-application-form-target="oidcFields">
|
||||
<div id="oidc-fields" class="space-y-6 border-t border-gray-200 pt-6" style="<%= 'display: none;' unless application.oidc? || !application.persisted? %>">
|
||||
<h3 class="text-base font-semibold text-gray-900">OIDC Configuration</h3>
|
||||
|
||||
<div>
|
||||
@@ -45,95 +52,49 @@
|
||||
<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>
|
||||
<!-- Role Mapping Configuration -->
|
||||
<div class="border-t border-gray-200 pt-6">
|
||||
<h4 class="text-base font-semibold text-gray-900 mb-4">Role Mapping Configuration</h4>
|
||||
|
||||
<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>
|
||||
<%= form.label :role_mapping_mode, "Role Mapping Mode", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.select :role_mapping_mode,
|
||||
options_for_select([
|
||||
["Disabled", "disabled"],
|
||||
["OIDC Managed", "oidc_managed"],
|
||||
["Hybrid (Groups + Roles)", "hybrid"]
|
||||
], application.role_mapping_mode || "disabled"),
|
||||
{},
|
||||
{ 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-sm text-gray-500">Controls how external roles are mapped and synchronized.</p>
|
||||
</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 id="role-mapping-advanced" class="mt-4 space-y-4 border-t border-gray-200 pt-4" style="<%= 'display: none;' unless application.role_mapping_enabled? %>">
|
||||
<div>
|
||||
<%= form.label :role_claim_name, "Role Claim Name", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.text_field :role_claim_name, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "roles" %>
|
||||
<p class="mt-1 text-sm text-gray-500">Name of the claim that contains role information (default: 'roles').</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 <%= 'hidden' unless application.forward_auth? %>" data-application-form-target="forwardAuthFields">
|
||||
<h3 class="text-base font-semibold text-gray-900">Forward Auth Configuration</h3>
|
||||
<div>
|
||||
<%= form.label :role_prefix, "Role Prefix (Optional)", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.text_field :role_prefix, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "app-" %>
|
||||
<p class="mt-1 text-sm text-gray-500">Only roles starting with this prefix will be mapped. Useful for multi-tenant scenarios.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= form.label :domain_pattern, "Domain Pattern", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.text_field :domain_pattern, 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: "*.example.com or app.example.com" %>
|
||||
<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 class="space-y-3">
|
||||
<label class="block text-sm font-medium text-gray-700">Managed Permissions</label>
|
||||
|
||||
<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, 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 class="flex items-center">
|
||||
<%= form.check_box :managed_permissions, { multiple: true, class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" }, "include_permissions", "" %>
|
||||
<%= form.label :managed_permissions_include_permissions, "Include role permissions in tokens", class: "ml-2 block text-sm text-gray-900" %>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<%= form.check_box :managed_permissions, { multiple: true, class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" }, "include_metadata", "" %>
|
||||
<%= form.label :managed_permissions_include_metadata, "Include role metadata in tokens", class: "ml-2 block text-sm text-gray-900" %>
|
||||
</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">
|
||||
<p><code class="bg-gray-100 px-1 rounded">user</code> - User's email address</p>
|
||||
<p><code class="bg-gray-100 px-1 rounded">email</code> - User's email address</p>
|
||||
<p><code class="bg-gray-100 px-1 rounded">name</code> - User's display name (falls back to email if not set)</p>
|
||||
<p><code class="bg-gray-100 px-1 rounded">groups</code> - Comma-separated list of group names (e.g., "admin,developers")</p>
|
||||
<p><code class="bg-gray-100 px-1 rounded">admin</code> - "true" or "false" indicating admin status</p>
|
||||
<p class="mt-2 italic">Example: <code class="bg-gray-100 px-1 rounded">{"user": "Remote-User", "groups": "Remote-Groups"}</code></p>
|
||||
<p class="italic">Need custom user fields? Add them to user's custom_claims for OIDC tokens</p>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -167,3 +128,34 @@
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<script>
|
||||
// Show/hide OIDC fields based on app type selection
|
||||
const appTypeSelect = document.querySelector('#application_app_type');
|
||||
const oidcFields = document.querySelector('#oidc-fields');
|
||||
const roleMappingMode = document.querySelector('#application_role_mapping_mode');
|
||||
const roleMappingAdvanced = document.querySelector('#role-mapping-advanced');
|
||||
|
||||
function updateFieldVisibility() {
|
||||
const isOidc = appTypeSelect.value === 'oidc';
|
||||
const roleMappingEnabled = roleMappingMode && ['oidc_managed', 'hybrid'].includes(roleMappingMode.value);
|
||||
|
||||
if (oidcFields) {
|
||||
oidcFields.style.display = isOidc ? 'block' : 'none';
|
||||
}
|
||||
|
||||
if (roleMappingAdvanced) {
|
||||
roleMappingAdvanced.style.display = isOidc && roleMappingEnabled ? 'block' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
if (appTypeSelect && oidcFields) {
|
||||
appTypeSelect.addEventListener('change', updateFieldVisibility);
|
||||
}
|
||||
|
||||
if (roleMappingMode) {
|
||||
roleMappingMode.addEventListener('change', updateFieldVisibility);
|
||||
}
|
||||
|
||||
// Initialize visibility on page load
|
||||
updateFieldVisibility();
|
||||
</script>
|
||||
|
||||
@@ -37,8 +37,6 @@
|
||||
<% 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 %>
|
||||
|
||||
125
app/views/admin/applications/roles.html.erb
Normal file
125
app/views/admin/applications/roles.html.erb
Normal file
@@ -0,0 +1,125 @@
|
||||
<% content_for :title, "Role Management - #{@application.name}" %>
|
||||
|
||||
<div class="bg-white shadow sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h3 class="text-lg font-medium leading-6 text-gray-900">
|
||||
Role Management for <%= @application.name %>
|
||||
</h3>
|
||||
<%= link_to "← Back to Application", admin_application_path(@application), class: "text-sm text-blue-600 hover:text-blue-500" %>
|
||||
</div>
|
||||
|
||||
<% if @application.role_mapping_enabled? %>
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-md p-4 mb-6">
|
||||
<div class="flex">
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-blue-800">Role Mapping Configuration</h3>
|
||||
<div class="mt-2 text-sm text-blue-700">
|
||||
<p>Mode: <strong><%= @application.role_mapping_mode.humanize %></strong></p>
|
||||
<% if @application.role_claim_name.present? %>
|
||||
<p>Role Claim: <strong><%= @application.role_claim_name %></strong></p>
|
||||
<% end %>
|
||||
<% if @application.role_prefix.present? %>
|
||||
<p>Role Prefix: <strong><%= @application.role_prefix %></strong></p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="bg-yellow-50 border border-yellow-200 rounded-md p-4 mb-6">
|
||||
<div class="flex">
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-yellow-800">Role Mapping Disabled</h3>
|
||||
<div class="mt-2 text-sm text-yellow-700">
|
||||
<p>Role mapping is currently disabled for this application. Enable it in the application settings to manage roles.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Create New Role -->
|
||||
<div class="border-b border-gray-200 pb-6 mb-6">
|
||||
<h4 class="text-md font-medium text-gray-900 mb-4">Create New Role</h4>
|
||||
<%= form_with(model: [:admin, @application, ApplicationRole.new], url: create_role_admin_application_path(@application), local: true, class: "space-y-4") do |form| %>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<%= form.label :name, "Role Name", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.text_field :name, required: true, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "admin" %>
|
||||
</div>
|
||||
<div>
|
||||
<%= form.label :display_name, "Display Name", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.text_field :display_name, required: true, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Administrator" %>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<%= form.label :description, class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.text_area :description, rows: 2, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Description of this role's permissions" %>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<%= form.check_box :active, class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" %>
|
||||
<%= form.label :active, "Active", class: "ml-2 block text-sm text-gray-900" %>
|
||||
</div>
|
||||
<div>
|
||||
<%= form.submit "Create Role", class: "rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<!-- Existing Roles -->
|
||||
<div class="space-y-6">
|
||||
<h4 class="text-md font-medium text-gray-900">Existing Roles</h4>
|
||||
|
||||
<% if @application_roles.any? %>
|
||||
<div class="space-y-4">
|
||||
<% @application_roles.each do |role| %>
|
||||
<div class="border border-gray-200 rounded-lg p-4">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center space-x-3">
|
||||
<h5 class="text-sm font-medium text-gray-900"><%= role.name %></h5>
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
<%= role.display_name %>
|
||||
</span>
|
||||
<% unless role.active %>
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
|
||||
Inactive
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<% if role.description.present? %>
|
||||
<p class="mt-1 text-sm text-gray-500"><%= role.description %></p>
|
||||
<% end %>
|
||||
|
||||
<!-- Assigned Users -->
|
||||
<div class="mt-3">
|
||||
<p class="text-xs text-gray-500 mb-2">Assigned Users:</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<% role.users.each do |user| %>
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium bg-blue-100 text-blue-800">
|
||||
<%= user.email_address %>
|
||||
<span class="ml-1 text-blue-600">(<%= role.user_role_assignments.find_by(user: user)&.source %>)</span>
|
||||
<%= link_to "×", remove_role_admin_application_path(@application, user_id: user.id, role_id: role.id),
|
||||
method: :post,
|
||||
data: { confirm: "Remove role from #{user.email_address}?" },
|
||||
class: "ml-1 text-blue-600 hover:text-blue-800" %>
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="text-center py-12">
|
||||
<div class="text-gray-500 text-sm">
|
||||
No roles configured yet. Create your first role above to get started with role-based access control.
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
173
app/views/admin/applications/roles_backup.html.erb
Normal file
173
app/views/admin/applications/roles_backup.html.erb
Normal file
@@ -0,0 +1,173 @@
|
||||
<% content_for :title, "Role Management - #{@application.name}" %>
|
||||
|
||||
<div class="bg-white shadow sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h3 class="text-lg font-medium leading-6 text-gray-900">
|
||||
Role Management for <%= @application.name %>
|
||||
</h3>
|
||||
<%= link_to "← Back to Application", admin_application_path(@application), class: "text-sm text-blue-600 hover:text-blue-500" %>
|
||||
</div>
|
||||
|
||||
<% if @application.role_mapping_enabled? %>
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-md p-4 mb-6">
|
||||
<div class="flex">
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-blue-800">Role Mapping Configuration</h3>
|
||||
<div class="mt-2 text-sm text-blue-700">
|
||||
<p>Mode: <strong><%= @application.role_mapping_mode.humanize %></strong></p>
|
||||
<% if @application.role_claim_name.present? %>
|
||||
<p>Role Claim: <strong><%= @application.role_claim_name %></strong></p>
|
||||
<% end %>
|
||||
<% if @application.role_prefix.present? %>
|
||||
<p>Role Prefix: <strong><%= @application.role_prefix %></strong></p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="bg-yellow-50 border border-yellow-200 rounded-md p-4 mb-6">
|
||||
<div class="flex">
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-yellow-800">Role Mapping Disabled</h3>
|
||||
<div class="mt-2 text-sm text-yellow-700">
|
||||
<p>Role mapping is currently disabled for this application. Enable it in the application settings to manage roles.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Create New Role -->
|
||||
<div class="border-b border-gray-200 pb-6 mb-6">
|
||||
<h4 class="text-md font-medium text-gray-900 mb-4">Create New Role</h4>
|
||||
<%= form_with(model: [:admin, @application, ApplicationRole.new], url: create_role_admin_application_path(@application), local: true, class: "space-y-4") do |form| %>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<%= form.label :name, "Role Name", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.text_field :name, required: true, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "admin" %>
|
||||
</div>
|
||||
<div>
|
||||
<%= form.label :display_name, "Display Name", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.text_field :display_name, required: true, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Administrator" %>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<%= form.label :description, class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.text_area :description, rows: 2, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Description of this role's permissions" %>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<%= form.check_box :active, class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" %>
|
||||
<%= form.label :active, "Active", class: "ml-2 block text-sm text-gray-900" %>
|
||||
</div>
|
||||
<div>
|
||||
<%= form.submit "Create Role", class: "rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<!-- Existing Roles -->
|
||||
<div class="space-y-6">
|
||||
<h4 class="text-md font-medium text-gray-900">Existing Roles</h4>
|
||||
|
||||
<% if @application_roles.any? %>
|
||||
<div class="space-y-4">
|
||||
<% @application_roles.each do |role| %>
|
||||
<div class="border border-gray-200 rounded-lg p-4">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center space-x-3">
|
||||
<h5 class="text-sm font-medium text-gray-900"><%= role.name %></h5>
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
<%= role.display_name %>
|
||||
</span>
|
||||
<% unless role.active %>
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
|
||||
Inactive
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<% if role.description.present? %>
|
||||
<p class="mt-1 text-sm text-gray-500"><%= role.description %></p>
|
||||
<% end %>
|
||||
|
||||
<!-- Assigned Users -->
|
||||
<div class="mt-3">
|
||||
<p class="text-xs text-gray-500 mb-2">Assigned Users:</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<% role.users.each do |user| %>
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium bg-blue-100 text-blue-800">
|
||||
<%= user.email_address %>
|
||||
<span class="ml-1 text-blue-600">(<%= role.user_role_assignments.find_by(user: user)&.source %>)</span>
|
||||
<%= link_to "×", remove_role_admin_application_path(@application, user_id: user.id, role_id: role.id),
|
||||
method: :post,
|
||||
data: { confirm: "Remove role from #{user.email_address}?" },
|
||||
class: "ml-1 text-blue-600 hover:text-blue-800" %>
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="ml-4 flex-shrink-0">
|
||||
<div class="space-y-2">
|
||||
<!-- Assign Role to User -->
|
||||
<div class="flex items-center space-x-2">
|
||||
<select id="assign-user-<%= role.id %>" class="text-xs rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500">
|
||||
<option value="">Assign to user...</option>
|
||||
<% @available_users.each do |user| %>
|
||||
<% unless role.user_has_role?(user) %>
|
||||
<option value="<%= user.id %>"><%= user.email_address %></option>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</select>
|
||||
<%= link_to "Assign", assign_role_admin_application_path(@application, role_id: role.id, user_id: "REPLACE_USER_ID"),
|
||||
method: :post,
|
||||
class: "text-xs bg-blue-600 px-2 py-1 rounded text-white hover:bg-blue-500",
|
||||
onclick: "this.href = this.href.replace('REPLACE_USER_ID', document.getElementById('assign-user-<%= role.id %>').value); if (this.href.includes('undefined')) { alert('Please select a user'); return false; }" %>
|
||||
</div>
|
||||
|
||||
<!-- Edit Role -->
|
||||
<%= link_to "Edit", "#", class: "text-xs text-gray-600 hover:text-gray-800", onclick: "document.getElementById('edit-role-<%= role.id %>').classList.toggle('hidden'); return false;" %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Role Form (Hidden by default) -->
|
||||
<div id="edit-role-<%= role.id %>" class="hidden mt-4 border-t pt-4">
|
||||
<%= form_with(model: [:admin, @application, role], url: update_role_admin_application_path(@application, role_id: role.id), local: true, method: :patch, class: "space-y-3") do |form| %>
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<%= form.label :display_name, "Display Name", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.text_field :display_name, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
||||
</div>
|
||||
<div class="flex items-center pt-6">
|
||||
<%= form.check_box :active, class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" %>
|
||||
<%= form.label :active, "Active", class: "ml-2 block text-sm text-gray-900" %>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<%= form.label :description, class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.text_area :description, rows: 2, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<%= form.submit "Update Role", class: "rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500" %>
|
||||
<%= link_to "Cancel", "#", class: "rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50", onclick: "document.getElementById('edit-role-<%= role.id %>').classList.add('hidden'); return false;" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="text-center py-12">
|
||||
<div class="text-gray-500 text-sm">
|
||||
No roles configured yet. Create your first role above to get started with role-based access control.
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
179
app/views/admin/applications/roles_broken.html.erb
Normal file
179
app/views/admin/applications/roles_broken.html.erb
Normal file
@@ -0,0 +1,179 @@
|
||||
<% content_for :title, "Role Management - #{@application.name}" %>
|
||||
|
||||
<div class="bg-white shadow sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h3 class="text-lg font-medium leading-6 text-gray-900">
|
||||
Role Management for <%= @application.name %>
|
||||
</h3>
|
||||
<%= link_to "← Back to Application", admin_application_path(@application), class: "text-sm text-blue-600 hover:text-blue-500" %>
|
||||
</div>
|
||||
|
||||
<% if @application.role_mapping_enabled? %>
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-md p-4 mb-6">
|
||||
<div class="flex">
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-blue-800">Role Mapping Configuration</h3>
|
||||
<div class="mt-2 text-sm text-blue-700">
|
||||
<p>Mode: <strong><%= @application.role_mapping_mode.humanize %></strong></p>
|
||||
<% if @application.role_claim_name.present? %>
|
||||
<p>Role Claim: <strong><%= @application.role_claim_name %></strong></p>
|
||||
<% end %>
|
||||
<% if @application.role_prefix.present? %>
|
||||
<p>Role Prefix: <strong><%= @application.role_prefix %></strong></p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="bg-yellow-50 border border-yellow-200 rounded-md p-4 mb-6">
|
||||
<div class="flex">
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-yellow-800">Role Mapping Disabled</h3>
|
||||
<div class="mt-2 text-sm text-yellow-700">
|
||||
<p>Role mapping is currently disabled for this application. Enable it in the application settings to manage roles.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Create New Role -->
|
||||
<div class="border-b border-gray-200 pb-6 mb-6">
|
||||
<h4 class="text-md font-medium text-gray-900 mb-4">Create New Role</h4>
|
||||
<%= form_with(model: [:admin, @application, ApplicationRole.new], url: create_role_admin_application_path(@application), local: true, class: "space-y-4") do |form| %>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<%= form.label :name, "Role Name", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.text_field :name, required: true, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "admin" %>
|
||||
</div>
|
||||
<div>
|
||||
<%= form.label :display_name, "Display Name", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.text_field :display_name, required: true, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Administrator" %>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<%= form.label :description, class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.text_area :description, rows: 2, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Description of this role's permissions" %>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<%= form.check_box :active, class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" %>
|
||||
<%= form.label :active, "Active", class: "ml-2 block text-sm text-gray-900" %>
|
||||
</div>
|
||||
<div>
|
||||
<%= form.submit "Create Role", class: "rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<!-- Existing Roles -->
|
||||
<div class="space-y-6" data-controller="role-management">
|
||||
<h4 class="text-md font-medium text-gray-900">Existing Roles</h4>
|
||||
|
||||
<% if @application_roles.any? %>
|
||||
<div class="space-y-4">
|
||||
<% @application_roles.each do |role| %>
|
||||
<div class="border border-gray-200 rounded-lg p-4">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center space-x-3">
|
||||
<h5 class="text-sm font-medium text-gray-900"><%= role.name %></h5>
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
<%= role.display_name %>
|
||||
</span>
|
||||
<% unless role.active %>
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
|
||||
Inactive
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<% if role.description.present? %>
|
||||
<p class="mt-1 text-sm text-gray-500"><%= role.description %></p>
|
||||
<% end %>
|
||||
|
||||
<!-- Assigned Users -->
|
||||
<div class="mt-3">
|
||||
<p class="text-xs text-gray-500 mb-2">Assigned Users:</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<% role.users.each do |user| %>
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium bg-blue-100 text-blue-800">
|
||||
<%= user.email_address %>
|
||||
<span class="ml-1 text-blue-600">(<%= role.user_role_assignments.find_by(user: user)&.source %>)</span>
|
||||
<%= link_to "×", remove_role_admin_application_path(@application, user_id: user.id, role_id: role.id),
|
||||
method: :post,
|
||||
data: { confirm: "Remove role from #{user.email_address}?" },
|
||||
class: "ml-1 text-blue-600 hover:text-blue-800" %>
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="ml-4 flex-shrink-0">
|
||||
<div class="space-y-2">
|
||||
<!-- Assign Role to User -->
|
||||
<div class="flex items-center space-x-2">
|
||||
<select id="assign-user-<%= role.id %>" data-role-target="userSelect" data-role-id="<%= role.id %>" class="text-xs rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500">
|
||||
<option value="">Assign to user...</option>
|
||||
<% @available_users.each do |user| %>
|
||||
<% unless role.user_has_role?(user) %>
|
||||
<option value="<%= user.id %>"><%= user.email_address %></option>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</select>
|
||||
<%= link_to "Assign", assign_role_admin_application_path(@application, role_id: role.id, user_id: "PLACEHOLDER"),
|
||||
method: :post,
|
||||
class: "text-xs bg-blue-600 px-2 py-1 rounded text-white hover:bg-blue-500",
|
||||
data: { role_target: "assignLink", action: "click->role-management#assignRole" } %>
|
||||
</div>
|
||||
|
||||
<!-- Edit Role -->
|
||||
<%= link_to "Edit", "#",
|
||||
class: "text-xs text-gray-600 hover:text-gray-800",
|
||||
data: { action: "click->role-management#toggleEdit" },
|
||||
data: { role_id: role.id } %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Role Form (Hidden by default) -->
|
||||
<div id="edit-role-<%= role.id %>" class="hidden mt-4 border-t pt-4" data-role-target="editForm" data-role-id="<%= role.id %>">
|
||||
<%= form_with(model: [:admin, @application, role], url: update_role_admin_application_path(@application, role_id: role.id), local: true, method: :patch, class: "space-y-3") do |form| %>
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<%= form.label :display_name, "Display Name", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.text_field :display_name, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
||||
</div>
|
||||
<div class="flex items-center pt-6">
|
||||
<%= form.check_box :active, class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" %>
|
||||
<%= form.label :active, "Active", class: "ml-2 block text-sm text-gray-900" %>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<%= form.label :description, class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.text_area :description, rows: 2, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<%= form.submit "Update Role", class: "rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500" %>
|
||||
<%= link_to "Cancel", "#",
|
||||
class: "rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50",
|
||||
data: { action: "click->role-management#hideEdit" },
|
||||
data: { role_id: role.id } %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="text-center py-12">
|
||||
<div class="text-gray-500 text-sm">
|
||||
No roles configured yet. Create your first role above to get started with role-based access control.
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
173
app/views/admin/applications/roles_complex.html.erb
Normal file
173
app/views/admin/applications/roles_complex.html.erb
Normal file
@@ -0,0 +1,173 @@
|
||||
<% content_for :title, "Role Management - #{@application.name}" %>
|
||||
|
||||
<div class="bg-white shadow sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h3 class="text-lg font-medium leading-6 text-gray-900">
|
||||
Role Management for <%= @application.name %>
|
||||
</h3>
|
||||
<%= link_to "← Back to Application", admin_application_path(@application), class: "text-sm text-blue-600 hover:text-blue-500" %>
|
||||
</div>
|
||||
|
||||
<% if @application.role_mapping_enabled? %>
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-md p-4 mb-6">
|
||||
<div class="flex">
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-blue-800">Role Mapping Configuration</h3>
|
||||
<div class="mt-2 text-sm text-blue-700">
|
||||
<p>Mode: <strong><%= @application.role_mapping_mode.humanize %></strong></p>
|
||||
<% if @application.role_claim_name.present? %>
|
||||
<p>Role Claim: <strong><%= @application.role_claim_name %></strong></p>
|
||||
<% end %>
|
||||
<% if @application.role_prefix.present? %>
|
||||
<p>Role Prefix: <strong><%= @application.role_prefix %></strong></p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="bg-yellow-50 border border-yellow-200 rounded-md p-4 mb-6">
|
||||
<div class="flex">
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-yellow-800">Role Mapping Disabled</h3>
|
||||
<div class="mt-2 text-sm text-yellow-700">
|
||||
<p>Role mapping is currently disabled for this application. Enable it in the application settings to manage roles.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Create New Role -->
|
||||
<div class="border-b border-gray-200 pb-6 mb-6">
|
||||
<h4 class="text-md font-medium text-gray-900 mb-4">Create New Role</h4>
|
||||
<%= form_with(model: [:admin, @application, ApplicationRole.new], url: create_role_admin_application_path(@application), local: true, class: "space-y-4") do |form| %>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<%= form.label :name, "Role Name", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.text_field :name, required: true, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "admin" %>
|
||||
</div>
|
||||
<div>
|
||||
<%= form.label :display_name, "Display Name", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.text_field :display_name, required: true, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Administrator" %>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<%= form.label :description, class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.text_area :description, rows: 2, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Description of this role's permissions" %>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<%= form.check_box :active, class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" %>
|
||||
<%= form.label :active, "Active", class: "ml-2 block text-sm text-gray-900" %>
|
||||
</div>
|
||||
<div>
|
||||
<%= form.submit "Create Role", class: "rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<!-- Existing Roles -->
|
||||
<div class="space-y-6">
|
||||
<h4 class="text-md font-medium text-gray-900">Existing Roles</h4>
|
||||
|
||||
<% if @application_roles.any? %>
|
||||
<div class="space-y-4">
|
||||
<% @application_roles.each do |role| %>
|
||||
<div class="border border-gray-200 rounded-lg p-4">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center space-x-3">
|
||||
<h5 class="text-sm font-medium text-gray-900"><%= role.name %></h5>
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
<%= role.display_name %>
|
||||
</span>
|
||||
<% unless role.active %>
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
|
||||
Inactive
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<% if role.description.present? %>
|
||||
<p class="mt-1 text-sm text-gray-500"><%= role.description %></p>
|
||||
<% end %>
|
||||
|
||||
<!-- Assigned Users -->
|
||||
<div class="mt-3">
|
||||
<p class="text-xs text-gray-500 mb-2">Assigned Users:</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<% role.users.each do |user| %>
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium bg-blue-100 text-blue-800">
|
||||
<%= user.email_address %>
|
||||
<span class="ml-1 text-blue-600">(<%= role.user_role_assignments.find_by(user: user)&.source %>)</span>
|
||||
<%= link_to "×", remove_role_admin_application_path(@application, user_id: user.id, role_id: role.id),
|
||||
method: :post,
|
||||
data: { confirm: "Remove role from #{user.email_address}?" },
|
||||
class: "ml-1 text-blue-600 hover:text-blue-800" %>
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="ml-4 flex-shrink-0">
|
||||
<div class="space-y-2">
|
||||
<!-- Assign Role to User -->
|
||||
<div class="flex items-center space-x-2">
|
||||
<select id="assign-user-<%= role.id %>" class="text-xs rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500">
|
||||
<option value="">Assign to user...</option>
|
||||
<% @available_users.each do |user| %>
|
||||
<% unless role.user_has_role?(user) %>
|
||||
<option value="<%= user.id %>"><%= user.email_address %></option>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</select>
|
||||
<%= link_to "Assign", assign_role_admin_application_path(@application, role_id: role.id, user_id: "PLACEHOLDER"),
|
||||
method: :post,
|
||||
class: "text-xs bg-blue-600 px-2 py-1 rounded text-white hover:bg-blue-500",
|
||||
onclick: "var select = document.getElementById('assign-user-<%= role.id %>'); var userId = select.value; if (!userId) { alert('Please select a user'); return false; } this.href = this.href.replace('PLACEHOLDER', userId);" %>
|
||||
</div>
|
||||
|
||||
<!-- Edit Role -->
|
||||
<%= link_to "Edit", "#", class: "text-xs text-gray-600 hover:text-gray-800", onclick: "document.getElementById('edit-role-<%= role.id %>').classList.toggle('hidden'); return false;" %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Role Form (Hidden by default) -->
|
||||
<div id="edit-role-<%= role.id %>" class="hidden mt-4 border-t pt-4">
|
||||
<%= form_with(model: [:admin, @application, role], url: update_role_admin_application_path(@application, role_id: role.id), local: true, method: :patch, class: "space-y-3") do |form| %>
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<%= form.label :display_name, "Display Name", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.text_field :display_name, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
||||
</div>
|
||||
<div class="flex items-center pt-6">
|
||||
<%= form.check_box :active, class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" %>
|
||||
<%= form.label :active, "Active", class: "ml-2 block text-sm text-gray-900" %>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<%= form.label :description, class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.text_area :description, rows: 2, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<%= form.submit "Update Role", class: "rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500" %>
|
||||
<%= link_to "Cancel", "#", class: "rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50", onclick: "document.getElementById('edit-role-<%= role.id %>').classList.add('hidden'); return false;" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="text-center py-12">
|
||||
<div class="text-gray-500 text-sm">
|
||||
No roles configured yet. Create your first role above to get started with role-based access control.
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -23,6 +23,9 @@
|
||||
</div>
|
||||
<div class="mt-4 sm:mt-0 flex gap-3">
|
||||
<%= link_to "Edit", edit_admin_application_path(@application), class: "rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50" %>
|
||||
<% if @application.oidc? %>
|
||||
<%= link_to "Manage Roles", roles_admin_application_path(@application), class: "rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500" %>
|
||||
<% end %>
|
||||
<%= button_to "Delete", admin_application_path(@application), method: :delete, data: { turbo_confirm: "Are you sure?" }, class: "rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500" %>
|
||||
</div>
|
||||
</div>
|
||||
@@ -44,8 +47,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 %>
|
||||
</dd>
|
||||
</div>
|
||||
@@ -59,16 +62,6 @@
|
||||
<% end %>
|
||||
</dd>
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<dt class="text-sm font-medium text-gray-500">Landing URL</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900">
|
||||
<% if @application.landing_url.present? %>
|
||||
<%= link_to @application.landing_url, @application.landing_url, target: "_blank", rel: "noopener noreferrer", class: "text-blue-600 hover:text-blue-800 underline" %>
|
||||
<% else %>
|
||||
<span class="text-gray-400 italic">Not configured</span>
|
||||
<% end %>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
@@ -116,35 +109,6 @@
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Forward Auth Configuration (only for Forward Auth apps) -->
|
||||
<% if @application.forward_auth? %>
|
||||
<div class="bg-white shadow sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<h3 class="text-base font-semibold leading-6 text-gray-900 mb-4">Forward Auth Configuration</h3>
|
||||
<dl class="space-y-4">
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Domain Pattern</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900">
|
||||
<code class="block bg-gray-100 px-3 py-2 rounded font-mono text-xs"><%= @application.domain_pattern %></code>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Headers Configuration</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900">
|
||||
<% if @application.headers_config.present? && @application.headers_config.any? %>
|
||||
<code class="block bg-gray-100 px-3 py-2 rounded font-mono text-xs whitespace-pre-wrap"><%= JSON.pretty_generate(@application.headers_config) %></code>
|
||||
<% else %>
|
||||
<div class="bg-gray-100 px-3 py-2 rounded text-xs text-gray-500">
|
||||
Using default headers: X-Remote-User, X-Remote-Email, X-Remote-Name, X-Remote-Groups, X-Remote-Admin
|
||||
</div>
|
||||
<% end %>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Group Access Control -->
|
||||
<div class="bg-white shadow sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
|
||||
126
app/views/admin/forward_auth_rules/edit.html.erb
Normal file
126
app/views/admin/forward_auth_rules/edit.html.erb
Normal file
@@ -0,0 +1,126 @@
|
||||
<% content_for :title, "Edit Forward Auth Rule" %>
|
||||
|
||||
<div class="md:flex md:items-center md:justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<h2 class="text-2xl font-bold leading-7 text-gray-900 sm:truncate sm:text-3xl sm:tracking-tight">
|
||||
Edit Forward Auth Rule
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<%= form_with(model: [:admin, @forward_auth_rule], local: true, class: "space-y-6") do |form| %>
|
||||
<%= render "shared/form_errors", form: form %>
|
||||
|
||||
<div class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2">
|
||||
<div class="px-4 py-6 sm:p-8">
|
||||
<div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
|
||||
<div class="sm:col-span-4">
|
||||
<%= form.label :domain_pattern, class: "block text-sm font-medium leading-6 text-gray-900" %>
|
||||
<div class="mt-2">
|
||||
<%= form.text_field :domain_pattern, class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6", placeholder: "*.example.com" %>
|
||||
</div>
|
||||
<p class="mt-3 text-sm leading-6 text-gray-600">
|
||||
Use patterns like "*.example.com" or "api.example.com". Wildcards (*) are supported.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-4">
|
||||
<%= form.label :active, class: "block text-sm font-medium leading-6 text-gray-900" %>
|
||||
<div class="mt-2">
|
||||
<%= form.select :active, options_for_select([["Active", true], ["Inactive", false]], @forward_auth_rule.active), { prompt: "Select status" }, { class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:max-w-xs sm:text-sm sm:leading-6" } %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-span-full">
|
||||
<div class="block text-sm font-medium leading-6 text-gray-900 mb-4">
|
||||
Groups
|
||||
</div>
|
||||
<div class="mt-2 space-y-2">
|
||||
<%= form.collection_select :group_ids, @available_groups, :id, :name,
|
||||
{ selected: @forward_auth_rule.allowed_groups.map(&:id), prompt: "Select groups (leave empty for bypass)" },
|
||||
{ multiple: true, class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6" } %>
|
||||
</div>
|
||||
<p class="mt-3 text-sm leading-6 text-gray-600">
|
||||
Select groups that are allowed to access this domain. If no groups are selected, all authenticated users will be allowed access (bypass).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="col-span-full">
|
||||
<div class="block text-sm font-medium leading-6 text-gray-900 mb-4">
|
||||
HTTP Headers Configuration
|
||||
</div>
|
||||
<div class="mt-2 space-y-4">
|
||||
<div class="grid grid-cols-1 gap-y-4 sm:grid-cols-2 sm:gap-x-4">
|
||||
<div>
|
||||
<%= label_tag "headers_config[user]", "User Header", class: "block text-sm font-medium leading-6 text-gray-900" %>
|
||||
<div class="mt-2">
|
||||
<%= text_field_tag "headers_config[user]", @forward_auth_rule.headers_config&.dig(:user) || ForwardAuthRule::DEFAULT_HEADERS[:user],
|
||||
class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6",
|
||||
placeholder: "Remote-User" %>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-500">Header name for user identity</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= label_tag "headers_config[email]", "Email Header", class: "block text-sm font-medium leading-6 text-gray-900" %>
|
||||
<div class="mt-2">
|
||||
<%= text_field_tag "headers_config[email]", @forward_auth_rule.headers_config&.dig(:email) || ForwardAuthRule::DEFAULT_HEADERS[:email],
|
||||
class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6",
|
||||
placeholder: "Remote-Email" %>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-500">Header name for user email</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= label_tag "headers_config[name]", "Name Header", class: "block text-sm font-medium leading-6 text-gray-900" %>
|
||||
<div class="mt-2">
|
||||
<%= text_field_tag "headers_config[name]", @forward_auth_rule.headers_config&.dig(:name) || ForwardAuthRule::DEFAULT_HEADERS[:name],
|
||||
class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6",
|
||||
placeholder: "Remote-Name" %>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-500">Header name for user display name</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= label_tag "headers_config[groups]", "Groups Header", class: "block text-sm font-medium leading-6 text-gray-900" %>
|
||||
<div class="mt-2">
|
||||
<%= text_field_tag "headers_config[groups]", @forward_auth_rule.headers_config&.dig(:groups) || ForwardAuthRule::DEFAULT_HEADERS[:groups],
|
||||
class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6",
|
||||
placeholder: "Remote-Groups" %>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-500">Header name for user groups (comma-separated)</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= label_tag "headers_config[admin]", "Admin Header", class: "block text-sm font-medium leading-6 text-gray-900" %>
|
||||
<div class="mt-2">
|
||||
<%= text_field_tag "headers_config[admin]", @forward_auth_rule.headers_config&.dig(:admin) || ForwardAuthRule::DEFAULT_HEADERS[:admin],
|
||||
class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6",
|
||||
placeholder: "Remote-Admin" %>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-500">Header name for admin status (true/false)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 p-4 bg-blue-50 rounded-lg">
|
||||
<h4 class="text-sm font-medium text-blue-900 mb-2">Header Configuration Options:</h4>
|
||||
<ul class="text-sm text-blue-700 space-y-1">
|
||||
<li>• <strong>Default headers:</strong> Use standard headers like Remote-User, Remote-Email</li>
|
||||
<li>• <strong>X- prefixed:</strong> Use X-Remote-User, X-Remote-Email, etc.</li>
|
||||
<li>• <strong>Custom:</strong> Use application-specific headers</li>
|
||||
<li>• <strong>No headers:</strong> Leave fields empty for access-only (like Metube)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex items-center justify-end gap-x-6">
|
||||
<%= link_to "Cancel", admin_forward_auth_rule_path(@forward_auth_rule), class: "text-sm font-semibold leading-6 text-gray-900 hover:text-gray-700" %>
|
||||
<%= form.submit "Update Rule", class: "rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
68
app/views/admin/forward_auth_rules/index.html.erb
Normal file
68
app/views/admin/forward_auth_rules/index.html.erb
Normal file
@@ -0,0 +1,68 @@
|
||||
<div class="sm:flex sm:items-center">
|
||||
<div class="sm:flex-auto">
|
||||
<h1 class="text-2xl font-semibold text-gray-900">Forward Auth Rules</h1>
|
||||
<p class="mt-2 text-sm text-gray-700">Manage forward authentication rules for domain-based access control.</p>
|
||||
</div>
|
||||
<div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
|
||||
<%= link_to "New Rule", new_admin_forward_auth_rule_path, class: "block rounded-md bg-blue-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 flow-root">
|
||||
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
|
||||
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
|
||||
<table class="min-w-full divide-y divide-gray-300">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-0">Domain Pattern</th>
|
||||
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Headers</th>
|
||||
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Groups</th>
|
||||
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Status</th>
|
||||
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-0">
|
||||
<span class="sr-only">Actions</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
<% @forward_auth_rules.each do |rule| %>
|
||||
<tr>
|
||||
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-0">
|
||||
<%= link_to rule.domain_pattern, admin_forward_auth_rule_path(rule), class: "text-blue-600 hover:text-blue-900" %>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||
<% if rule.headers_config.blank? %>
|
||||
<span class="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700">Default</span>
|
||||
<% elsif rule.headers_config.values.all?(&:blank?) %>
|
||||
<span class="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700">None</span>
|
||||
<% else %>
|
||||
<span class="inline-flex items-center rounded-full bg-blue-100 px-2 py-1 text-xs font-medium text-blue-700">Custom</span>
|
||||
<% end %>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||
<% if rule.allowed_groups.empty? %>
|
||||
<span class="text-gray-400">All users</span>
|
||||
<% else %>
|
||||
<%= rule.allowed_groups.count %> groups
|
||||
<% end %>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||
<% if rule.active? %>
|
||||
<span class="inline-flex items-center rounded-full bg-green-100 px-2 py-1 text-xs font-medium text-green-700">Active</span>
|
||||
<% else %>
|
||||
<span class="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700">Inactive</span>
|
||||
<% end %>
|
||||
</td>
|
||||
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-0">
|
||||
<div class="flex justify-end space-x-3">
|
||||
<%= link_to "View", admin_forward_auth_rule_path(rule), class: "text-blue-600 hover:text-blue-900 whitespace-nowrap" %>
|
||||
<%= link_to "Edit", edit_admin_forward_auth_rule_path(rule), class: "text-blue-600 hover:text-blue-900 whitespace-nowrap" %>
|
||||
<%= button_to "Delete", admin_forward_auth_rule_path(rule), method: :delete, data: { turbo_confirm: "Are you sure you want to delete this forward auth rule?" }, class: "text-red-600 hover:text-red-900 whitespace-nowrap" %>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
126
app/views/admin/forward_auth_rules/new.html.erb
Normal file
126
app/views/admin/forward_auth_rules/new.html.erb
Normal file
@@ -0,0 +1,126 @@
|
||||
<% content_for :title, "New Forward Auth Rule" %>
|
||||
|
||||
<div class="md:flex md:items-center md:justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<h2 class="text-2xl font-bold leading-7 text-gray-900 sm:truncate sm:text-3xl sm:tracking-tight">
|
||||
New Forward Auth Rule
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<%= form_with(model: [:admin, @forward_auth_rule], local: true, class: "space-y-6") do |form| %>
|
||||
<%= render "shared/form_errors", form: form %>
|
||||
|
||||
<div class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2">
|
||||
<div class="px-4 py-6 sm:p-8">
|
||||
<div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
|
||||
<div class="sm:col-span-4">
|
||||
<%= form.label :domain_pattern, class: "block text-sm font-medium leading-6 text-gray-900" %>
|
||||
<div class="mt-2">
|
||||
<%= form.text_field :domain_pattern, class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6", placeholder: "*.example.com" %>
|
||||
</div>
|
||||
<p class="mt-3 text-sm leading-6 text-gray-600">
|
||||
Use patterns like "*.example.com" or "api.example.com". Wildcards (*) are supported.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="sm:col-span-4">
|
||||
<%= form.label :active, class: "block text-sm font-medium leading-6 text-gray-900" %>
|
||||
<div class="mt-2">
|
||||
<%= form.select :active, options_for_select([["Active", true], ["Inactive", false]], @forward_auth_rule.active), { prompt: "Select status" }, { class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:max-w-xs sm:text-sm sm:leading-6" } %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-span-full">
|
||||
<div class="block text-sm font-medium leading-6 text-gray-900 mb-4">
|
||||
Groups
|
||||
</div>
|
||||
<div class="mt-2 space-y-2">
|
||||
<%= form.collection_select :group_ids, @available_groups, :id, :name,
|
||||
{ prompt: "Select groups (leave empty for bypass)" },
|
||||
{ multiple: true, class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6" } %>
|
||||
</div>
|
||||
<p class="mt-3 text-sm leading-6 text-gray-600">
|
||||
Select groups that are allowed to access this domain. If no groups are selected, all authenticated users will be allowed access (bypass).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="col-span-full">
|
||||
<div class="block text-sm font-medium leading-6 text-gray-900 mb-4">
|
||||
HTTP Headers Configuration
|
||||
</div>
|
||||
<div class="mt-2 space-y-4">
|
||||
<div class="grid grid-cols-1 gap-y-4 sm:grid-cols-2 sm:gap-x-4">
|
||||
<div>
|
||||
<%= label_tag "headers_config[user]", "User Header", class: "block text-sm font-medium leading-6 text-gray-900" %>
|
||||
<div class="mt-2">
|
||||
<%= text_field_tag "headers_config[user]", @forward_auth_rule.headers_config&.dig(:user) || ForwardAuthRule::DEFAULT_HEADERS[:user],
|
||||
class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6",
|
||||
placeholder: "Remote-User" %>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-500">Header name for user identity</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= label_tag "headers_config[email]", "Email Header", class: "block text-sm font-medium leading-6 text-gray-900" %>
|
||||
<div class="mt-2">
|
||||
<%= text_field_tag "headers_config[email]", @forward_auth_rule.headers_config&.dig(:email) || ForwardAuthRule::DEFAULT_HEADERS[:email],
|
||||
class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6",
|
||||
placeholder: "Remote-Email" %>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-500">Header name for user email</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= label_tag "headers_config[name]", "Name Header", class: "block text-sm font-medium leading-6 text-gray-900" %>
|
||||
<div class="mt-2">
|
||||
<%= text_field_tag "headers_config[name]", @forward_auth_rule.headers_config&.dig(:name) || ForwardAuthRule::DEFAULT_HEADERS[:name],
|
||||
class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6",
|
||||
placeholder: "Remote-Name" %>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-500">Header name for user display name</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= label_tag "headers_config[groups]", "Groups Header", class: "block text-sm font-medium leading-6 text-gray-900" %>
|
||||
<div class="mt-2">
|
||||
<%= text_field_tag "headers_config[groups]", @forward_auth_rule.headers_config&.dig(:groups) || ForwardAuthRule::DEFAULT_HEADERS[:groups],
|
||||
class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6",
|
||||
placeholder: "Remote-Groups" %>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-500">Header name for user groups (comma-separated)</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= label_tag "headers_config[admin]", "Admin Header", class: "block text-sm font-medium leading-6 text-gray-900" %>
|
||||
<div class="mt-2">
|
||||
<%= text_field_tag "headers_config[admin]", @forward_auth_rule.headers_config&.dig(:admin) || ForwardAuthRule::DEFAULT_HEADERS[:admin],
|
||||
class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6",
|
||||
placeholder: "Remote-Admin" %>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-500">Header name for admin status (true/false)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 p-4 bg-blue-50 rounded-lg">
|
||||
<h4 class="text-sm font-medium text-blue-900 mb-2">Header Configuration Options:</h4>
|
||||
<ul class="text-sm text-blue-700 space-y-1">
|
||||
<li>• <strong>Default headers:</strong> Use standard headers like Remote-User, Remote-Email</li>
|
||||
<li>• <strong>X- prefixed:</strong> Use X-Remote-User, X-Remote-Email, etc.</li>
|
||||
<li>• <strong>Custom:</strong> Use application-specific headers</li>
|
||||
<li>• <strong>No headers:</strong> Leave fields empty for access-only (like Metube)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex items-center justify-end gap-x-6">
|
||||
<%= link_to "Cancel", admin_forward_auth_rules_path, class: "text-sm font-semibold leading-6 text-gray-900 hover:text-gray-700" %>
|
||||
<%= form.submit "Create Rule", class: "rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
116
app/views/admin/forward_auth_rules/show.html.erb
Normal file
116
app/views/admin/forward_auth_rules/show.html.erb
Normal file
@@ -0,0 +1,116 @@
|
||||
<div class="mb-6">
|
||||
<div class="sm:flex sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-gray-900"><%= @forward_auth_rule.domain_pattern %></h1>
|
||||
<p class="mt-1 text-sm text-gray-500">Forward authentication rule for domain-based access control</p>
|
||||
</div>
|
||||
<div class="mt-4 sm:mt-0 flex gap-3">
|
||||
<%= link_to "Edit", edit_admin_forward_auth_rule_path(@forward_auth_rule), class: "rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50" %>
|
||||
<%= button_to "Delete", admin_forward_auth_rule_path(@forward_auth_rule), method: :delete, data: { turbo_confirm: "Are you sure?" }, class: "rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500" %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Basic Information -->
|
||||
<div class="bg-white shadow sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<h3 class="text-base font-semibold leading-6 text-gray-900 mb-4">Basic Information</h3>
|
||||
<dl class="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Domain Pattern</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900"><code class="bg-gray-100 px-2 py-1 rounded"><%= @forward_auth_rule.domain_pattern %></code></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Status</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900">
|
||||
<% if @forward_auth_rule.active? %>
|
||||
<span class="inline-flex items-center rounded-full bg-green-100 px-2 py-1 text-xs font-medium text-green-700">Active</span>
|
||||
<% else %>
|
||||
<span class="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700">Inactive</span>
|
||||
<% end %>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Headers Configuration</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900">
|
||||
<% if @forward_auth_rule.headers_config.blank? %>
|
||||
<span class="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700">Default</span>
|
||||
<% elsif @forward_auth_rule.headers_config.values.all?(&:blank?) %>
|
||||
<span class="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700">None</span>
|
||||
<% else %>
|
||||
<span class="inline-flex items-center rounded-full bg-blue-100 px-2 py-1 text-xs font-medium text-blue-700">Custom</span>
|
||||
<% end %>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Header Configuration -->
|
||||
<div class="bg-white shadow sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<h3 class="text-base font-semibold leading-6 text-gray-900 mb-4">Header Configuration</h3>
|
||||
<div class="space-y-4">
|
||||
<% effective_headers = @forward_auth_rule.effective_headers %>
|
||||
|
||||
<% if effective_headers.empty? %>
|
||||
<div class="rounded-md bg-gray-50 p-4">
|
||||
<div class="flex">
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-gray-700">
|
||||
No headers configured - access control only.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<dl class="space-y-4">
|
||||
<% effective_headers.each do |key, header_name| %>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500"><%= key.to_s.capitalize %></dt>
|
||||
<dd class="mt-1 text-sm text-gray-900">
|
||||
<code class="block bg-gray-100 px-3 py-2 rounded font-mono text-xs break-all"><%= header_name %></code>
|
||||
</dd>
|
||||
</div>
|
||||
<% end %>
|
||||
</dl>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Group Access Control -->
|
||||
<div class="bg-white shadow sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<h3 class="text-base font-semibold leading-6 text-gray-900 mb-4">Access Control</h3>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500 mb-2">Allowed Groups</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900">
|
||||
<% if @allowed_groups.empty? %>
|
||||
<div class="rounded-md bg-blue-50 p-4">
|
||||
<div class="flex">
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-blue-700">
|
||||
No groups assigned - all active users can access this domain.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<ul class="divide-y divide-gray-200 border border-gray-200 rounded-md">
|
||||
<% @allowed_groups.each do |group| %>
|
||||
<li class="px-4 py-3 flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900"><%= group.name %></p>
|
||||
<p class="text-xs text-gray-500"><%= pluralize(group.users.count, "member") %></p>
|
||||
</div>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
<% end %>
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,5 +1,22 @@
|
||||
<%= form_with(model: [:admin, group], class: "space-y-6", data: { controller: "form-errors" }) do |form| %>
|
||||
<%= render "shared/form_errors", form: form %>
|
||||
<%= 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 %>
|
||||
|
||||
<div>
|
||||
<%= form.label :name, class: "block text-sm font-medium text-gray-700" %>
|
||||
@@ -32,27 +49,6 @@
|
||||
<p class="mt-1 text-sm text-gray-500">Select which users should be members of this group.</p>
|
||||
</div>
|
||||
|
||||
<div data-controller="json-validator" data-json-validator-valid-class="border-green-500 focus:border-green-500 focus:ring-green-500" data-json-validator-invalid-class="border-red-500 focus:border-red-500 focus:ring-red-500" data-json-validator-valid-status-class="text-green-600" data-json-validator-invalid-status-class="text-red-600">
|
||||
<%= form.label :custom_claims, "Custom Claims (JSON)", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.text_area :custom_claims, value: (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">
|
||||
<%= form.submit group.persisted? ? "Update Group" : "Create Group", class: "rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" %>
|
||||
<%= link_to "Cancel", admin_groups_path, class: "rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50" %>
|
||||
|
||||
@@ -39,11 +39,9 @@
|
||||
<%= 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">
|
||||
<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>
|
||||
<%= 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" %>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
|
||||
@@ -1,17 +1,28 @@
|
||||
<%= form_with(model: [:admin, user], class: "space-y-6", data: { controller: "form-errors" }) do |form| %>
|
||||
<%= render "shared/form_errors", form: form %>
|
||||
<%= 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 %>
|
||||
|
||||
<div>
|
||||
<%= form.label :email_address, class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.email_field :email_address, required: true, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "user@example.com" %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= form.label :name, "Display Name (Optional)", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.text_field :name, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "John Smith" %>
|
||||
<p class="mt-1 text-sm text-gray-500">Optional: Name shown in applications. Defaults to email address if not set.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= form.label :password, class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.password_field :password, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: user.persisted? ? "Leave blank to keep current password" : "Enter password" %>
|
||||
@@ -35,27 +46,6 @@
|
||||
<% end %>
|
||||
</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"}',
|
||||
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">
|
||||
<%= form.submit user.persisted? ? "Update User" : "Create User", class: "rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" %>
|
||||
<%= link_to "Cancel", admin_users_path, class: "rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50" %>
|
||||
|
||||
@@ -93,64 +93,6 @@
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<!-- Your Applications Section -->
|
||||
<div class="mt-8">
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-4">Your Applications</h2>
|
||||
|
||||
<% if @applications.any? %>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<% @applications.each do |app| %>
|
||||
<div class="bg-white rounded-lg border border-gray-200 shadow-sm hover:shadow-md transition">
|
||||
<div class="p-6">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 class="text-lg font-semibold text-gray-900 truncate">
|
||||
<%= app.name %>
|
||||
</h3>
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
|
||||
<% if app.oidc? %>
|
||||
bg-blue-100 text-blue-800
|
||||
<% else %>
|
||||
bg-green-100 text-green-800
|
||||
<% end %>">
|
||||
<%= app.app_type.humanize %>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-gray-600 mb-4">
|
||||
<% if app.oidc? %>
|
||||
OIDC Application
|
||||
<% else %>
|
||||
ForwardAuth Protected Application
|
||||
<% end %>
|
||||
</p>
|
||||
|
||||
<% if app.landing_url.present? %>
|
||||
<%= link_to "Open Application", app.landing_url,
|
||||
target: "_blank",
|
||||
rel: "noopener noreferrer",
|
||||
class: "w-full flex justify-center items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition" %>
|
||||
<% else %>
|
||||
<div class="text-sm text-gray-500 italic">
|
||||
No landing URL configured
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="bg-gray-50 rounded-lg border border-gray-200 p-8 text-center">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"></path>
|
||||
</svg>
|
||||
<h3 class="mt-4 text-lg font-medium text-gray-900">No applications available</h3>
|
||||
<p class="mt-2 text-sm text-gray-500">
|
||||
You don't have access to any applications yet. Contact your administrator if you think this is an error.
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% if @user.admin? %>
|
||||
<div class="mt-8">
|
||||
<h2 class="text-xl font-semibold text-gray-900 mb-4">Admin Quick Actions</h2>
|
||||
|
||||
@@ -25,29 +25,24 @@
|
||||
|
||||
<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"
|
||||
data-action="click->mobile-sidebar#openSidebar">
|
||||
<span class="sr-only">Open sidebar</span>
|
||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<main class="py-10">
|
||||
<div class="px-4 sm:px-6 lg:px-8">
|
||||
<%= render "shared/flash" %>
|
||||
<%= yield %>
|
||||
</div>
|
||||
</main>
|
||||
<%= 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">
|
||||
<span class="sr-only">Open sidebar</span>
|
||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<main class="py-10">
|
||||
<div class="px-4 sm:px-6 lg:px-8">
|
||||
<%= render "shared/flash" %>
|
||||
<%= yield %>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<% else %>
|
||||
<!-- Public layout (signup/signin) -->
|
||||
@@ -57,5 +52,23 @@
|
||||
</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/authorize/consent", method: :post, class: "space-y-3", data: { turbo: false }, local: true do |form| %>
|
||||
<%= form_with url: oauth_consent_path, method: :post, class: "space-y-3", data: { turbo: false } 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", data: { controller: "form-errors" } do |form| %>
|
||||
<%= form_with url: passwords_path, class: "contents" 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" data-controller="modal">
|
||||
<div class="space-y-8">
|
||||
<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>
|
||||
@@ -102,16 +102,10 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex gap-3">
|
||||
<button type="button"
|
||||
data-action="click->modal#show"
|
||||
data-modal-id="disable-2fa-modal"
|
||||
class="inline-flex items-center rounded-md border border-red-300 bg-white px-4 py-2 text-sm font-medium text-red-700 shadow-sm hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2">
|
||||
<button type="button" onclick="showDisable2FAModal()" class="inline-flex items-center rounded-md border border-red-300 bg-white px-4 py-2 text-sm font-medium text-red-700 shadow-sm hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2">
|
||||
Disable 2FA
|
||||
</button>
|
||||
<button type="button"
|
||||
data-action="click->modal#show"
|
||||
data-modal-id="view-backup-codes-modal"
|
||||
class="inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
|
||||
<button type="button" onclick="showViewBackupCodesModal()" class="inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
|
||||
View Backup Codes
|
||||
</button>
|
||||
</div>
|
||||
@@ -125,9 +119,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Disable 2FA Modal -->
|
||||
<div id="disable-2fa-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 id="disable-2fa-modal" 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 class="sm:flex sm:items-start">
|
||||
<div class="mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10">
|
||||
@@ -151,9 +143,7 @@
|
||||
<div class="mt-4 flex gap-3">
|
||||
<%= form.submit "Disable 2FA",
|
||||
class: "inline-flex justify-center rounded-md border border-transparent bg-red-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2" %>
|
||||
<button type="button"
|
||||
data-action="click->modal#hide"
|
||||
class="inline-flex justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
|
||||
<button type="button" onclick="hideDisable2FAModal()" class="inline-flex justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
@@ -163,27 +153,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Regenerate Backup Codes Modal -->
|
||||
<div id="view-backup-codes-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">
|
||||
<!-- View Backup Codes Modal -->
|
||||
<div id="view-backup-codes-modal" 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">Generate New Backup Codes</h3>
|
||||
<h3 class="text-lg font-medium leading-6 text-gray-900">View Backup Codes</h3>
|
||||
<div class="mt-2">
|
||||
<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>
|
||||
<p class="text-sm text-gray-500">Enter your password to view your backup codes.</p>
|
||||
</div>
|
||||
<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| %>
|
||||
<%= form_with url: verify_password_totp_path, method: :post, class: "mt-4" do |form| %>
|
||||
<div>
|
||||
<%= password_field_tag :password, nil,
|
||||
placeholder: "Enter your password",
|
||||
@@ -192,11 +170,9 @@
|
||||
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 "Generate New Codes",
|
||||
<%= form.submit "View 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"
|
||||
class="inline-flex justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
|
||||
<button type="button" onclick="hideViewBackupCodesModal()" class="inline-flex justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
@@ -205,123 +181,125 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Passkeys (WebAuthn) -->
|
||||
<script>
|
||||
function showDisable2FAModal() {
|
||||
document.getElementById('disable-2fa-modal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function hideDisable2FAModal() {
|
||||
document.getElementById('disable-2fa-modal').classList.add('hidden');
|
||||
}
|
||||
|
||||
function showViewBackupCodesModal() {
|
||||
document.getElementById('view-backup-codes-modal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function hideViewBackupCodesModal() {
|
||||
document.getElementById('view-backup-codes-modal').classList.add('hidden');
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Connected Applications -->
|
||||
<div class="bg-white shadow sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:p-6" data-controller="webauthn" data-webauthn-challenge-url-value="/webauthn/challenge" data-webauthn-create-url-value="/webauthn/create">
|
||||
<h3 class="text-lg font-medium leading-6 text-gray-900">Passkeys</h3>
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<h3 class="text-lg font-medium leading-6 text-gray-900">Connected Applications</h3>
|
||||
<div class="mt-2 max-w-xl text-sm text-gray-500">
|
||||
<p>Use your fingerprint, face recognition, or security key to sign in without passwords.</p>
|
||||
<p>These applications have access to your account. You can revoke access at any time.</p>
|
||||
</div>
|
||||
|
||||
<!-- Add Passkey Form -->
|
||||
<div class="mt-5">
|
||||
<div id="add-passkey-form" class="space-y-4">
|
||||
<div>
|
||||
<label for="passkey-nickname" class="block text-sm font-medium text-gray-700">Passkey Name</label>
|
||||
<input type="text"
|
||||
id="passkey-nickname"
|
||||
data-webauthn-target="nickname"
|
||||
placeholder="e.g., MacBook Touch ID, iPhone Face ID"
|
||||
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-sm text-gray-500">Give this passkey a memorable name so you can identify it later.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button type="button"
|
||||
data-action="click->webauthn#register"
|
||||
data-webauthn-target="submitButton"
|
||||
class="inline-flex items-center rounded-md border border-transparent bg-green-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2">
|
||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
|
||||
</svg>
|
||||
Add New Passkey
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Status Messages -->
|
||||
<div data-webauthn-target="status" class="hidden mt-2 p-3 rounded-md text-sm"></div>
|
||||
<div data-webauthn-target="error" class="hidden mt-2 p-3 rounded-md text-sm"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Existing Passkeys List -->
|
||||
<div class="mt-8">
|
||||
<h4 class="text-md font-medium text-gray-900 mb-4">Your Passkeys</h4>
|
||||
<% if @user.webauthn_credentials.exists? %>
|
||||
<div class="space-y-3">
|
||||
<% @user.webauthn_credentials.order(created_at: :desc).each do |credential| %>
|
||||
<div class="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="flex-shrink-0">
|
||||
<% if credential.platform_authenticator? %>
|
||||
<!-- Platform authenticator icon -->
|
||||
<svg class="w-6 h-6 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path>
|
||||
</svg>
|
||||
<% else %>
|
||||
<!-- Roaming authenticator icon -->
|
||||
<svg class="w-6 h-6 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"></path>
|
||||
</svg>
|
||||
<% end %>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-medium text-gray-900">
|
||||
<%= credential.nickname %>
|
||||
</div>
|
||||
<div class="text-sm text-gray-500">
|
||||
<%= credential.authenticator_type.humanize %> •
|
||||
Last used <%= credential.last_used_ago %>
|
||||
<% if credential.backed_up? %>
|
||||
• <span class="text-green-600">Synced</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<% if @connected_applications.any? %>
|
||||
<ul role="list" class="divide-y divide-gray-200">
|
||||
<% @connected_applications.each do |consent| %>
|
||||
<li class="py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex flex-col">
|
||||
<p class="text-sm font-medium text-gray-900">
|
||||
<%= consent.application.name %>
|
||||
</p>
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
Access to: <%= consent.formatted_scopes %>
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-gray-400">
|
||||
Authorized <%= time_ago_in_words(consent.granted_at) %> ago
|
||||
</p>
|
||||
</div>
|
||||
<%= button_to "Revoke Access", revoke_consent_profile_path(application_id: consent.application.id), method: :delete,
|
||||
class: "inline-flex items-center rounded-md border border-red-300 bg-white px-3 py-2 text-sm font-medium text-red-700 shadow-sm hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2",
|
||||
form: { data: { turbo_confirm: "Are you sure you want to revoke access to #{consent.application.name}? You'll need to re-authorize this application to use it again." } } %>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<% if credential.created_recently? %>
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
New
|
||||
</span>
|
||||
<% end %>
|
||||
<%= link_to webauthn_credential_path(credential),
|
||||
method: :delete,
|
||||
data: {
|
||||
confirm: "Are you sure you want to delete '#{credential.nickname}'? You'll need to set it up again to sign in with this device.",
|
||||
turbo_method: :delete
|
||||
},
|
||||
class: "text-red-600 hover:text-red-800 text-sm font-medium" do %>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
|
||||
</svg>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 p-3 bg-blue-50 rounded-lg">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-blue-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-blue-800">
|
||||
<strong>Tip:</strong> Add passkeys on multiple devices for easy access. Platform authenticators (like Touch ID) are synced across your devices if you use iCloud Keychain or Google Password Manager.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ul>
|
||||
<% else %>
|
||||
<div class="text-center py-8">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"></path>
|
||||
</svg>
|
||||
<h3 class="mt-2 text-sm font-medium text-gray-900">No passkeys</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">Get started by adding your first passkey for passwordless sign-in.</p>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500">No connected applications.</p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active Sessions -->
|
||||
<div class="bg-white shadow sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<h3 class="text-lg font-medium leading-6 text-gray-900">Active Sessions</h3>
|
||||
<div class="mt-2 max-w-xl text-sm text-gray-500">
|
||||
<p>These devices are currently signed in to your account. Revoke any sessions that you don't recognize.</p>
|
||||
</div>
|
||||
<div class="mt-5">
|
||||
<% if @active_sessions.any? %>
|
||||
<ul role="list" class="divide-y divide-gray-200">
|
||||
<% @active_sessions.each do |session| %>
|
||||
<li class="py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex flex-col">
|
||||
<p class="text-sm font-medium text-gray-900">
|
||||
<%= session.device_name || "Unknown Device" %>
|
||||
<% if session.id == Current.session.id %>
|
||||
<span class="ml-2 inline-flex items-center rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-800">
|
||||
This device
|
||||
</span>
|
||||
<% end %>
|
||||
</p>
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
<%= session.ip_address %>
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-gray-400">
|
||||
Last active <%= time_ago_in_words(session.last_activity_at || session.updated_at) %> ago
|
||||
</p>
|
||||
</div>
|
||||
<% if session.id != Current.session.id %>
|
||||
<%= button_to "Revoke", session_path(session), method: :delete,
|
||||
class: "inline-flex items-center rounded-md border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2",
|
||||
form: { data: { turbo_confirm: "Are you sure you want to revoke this session?" } } %>
|
||||
<% end %>
|
||||
</div>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
<% else %>
|
||||
<p class="text-sm text-gray-500">No other active sessions.</p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Global Security Actions -->
|
||||
<div class="bg-white shadow sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<h3 class="text-lg font-medium leading-6 text-gray-900">Security Actions</h3>
|
||||
<div class="mt-2 max-w-xl text-sm text-gray-500">
|
||||
<p>Use these actions to quickly secure your account. Be careful - these actions cannot be undone.</p>
|
||||
</div>
|
||||
<div class="mt-5 flex flex-wrap gap-4">
|
||||
<% if @active_sessions.count > 1 %>
|
||||
<%= button_to "Sign Out Everywhere Else", session_path(Current.session), method: :delete,
|
||||
class: "inline-flex items-center rounded-md border border-orange-300 bg-white px-4 py-2 text-sm font-medium text-orange-700 shadow-sm hover:bg-orange-50 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-2",
|
||||
form: { data: { turbo_confirm: "This will sign you out from all other devices except this one. Are you sure?" } } %>
|
||||
<% end %>
|
||||
|
||||
<% if @connected_applications.any? %>
|
||||
<%= button_to "Revoke All App Access", revoke_all_consents_profile_path, method: :delete,
|
||||
class: "inline-flex items-center rounded-md border border-red-300 bg-white px-4 py-2 text-sm font-medium text-red-700 shadow-sm hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2",
|
||||
form: { data: { turbo_confirm: "This will revoke access from all connected applications. You'll need to re-authorize each application to use them again. Are you sure?" } } %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<div class="mx-auto md:w-2/3 w-full" data-controller="webauthn login-form" data-webauthn-check-url-value="/webauthn/check">
|
||||
<div class="mx-auto md:w-2/3 w-full">
|
||||
<div class="mb-8">
|
||||
<h1 class="font-bold text-4xl">Sign in to Clinch</h1>
|
||||
</div>
|
||||
|
||||
<%= form_with url: signin_path, class: "contents", data: { controller: "form-errors" } do |form| %>
|
||||
<%= form_with url: signin_path, class: "contents" 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" %>
|
||||
@@ -13,67 +13,26 @@
|
||||
autocomplete: "username",
|
||||
placeholder: "your@email.com",
|
||||
value: params[:email_address],
|
||||
data: { action: "blur->webauthn#checkWebAuthnSupport change->webauthn#checkWebAuthnSupport" },
|
||||
class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
|
||||
</div>
|
||||
|
||||
<!-- WebAuthn section - initially hidden -->
|
||||
<div id="webauthn-section" data-login-form-target="webauthnSection" class="my-5 hidden">
|
||||
<div class="bg-green-50 border border-green-200 rounded-lg p-4 mb-4">
|
||||
<div class="flex items-center">
|
||||
<svg class="w-5 h-5 text-green-600 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
<p class="text-sm text-green-800">
|
||||
<strong>Passkey detected!</strong> You can sign in without a password.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button"
|
||||
data-action="click->webauthn#authenticate"
|
||||
class="w-full rounded-md px-3.5 py-2.5 bg-green-600 hover:bg-green-500 text-white font-medium cursor-pointer flex items-center justify-center">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"></path>
|
||||
</svg>
|
||||
Continue with Passkey
|
||||
</button>
|
||||
<div class="my-5">
|
||||
<%= form.label :password, class: "block font-medium text-sm text-gray-700" %>
|
||||
<%= form.password_field :password,
|
||||
required: true,
|
||||
autocomplete: "current-password",
|
||||
placeholder: "Enter your password",
|
||||
maxlength: 72,
|
||||
class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
|
||||
</div>
|
||||
|
||||
<!-- Password section - shown by default, hidden if WebAuthn is required -->
|
||||
<div id="password-section" data-login-form-target="passwordSection">
|
||||
<div class="my-5">
|
||||
<%= form.label :password, class: "block font-medium text-sm text-gray-700" %>
|
||||
<%= form.password_field :password,
|
||||
required: true,
|
||||
autocomplete: "current-password",
|
||||
placeholder: "Enter your password",
|
||||
maxlength: 72,
|
||||
class: "block shadow-sm rounded-md border border-gray-400 focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
|
||||
</div>
|
||||
|
||||
<div class="my-5">
|
||||
<%= form.submit "Sign in",
|
||||
class: "w-full rounded-md px-3.5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white font-medium cursor-pointer" %>
|
||||
</div>
|
||||
<div class="my-5">
|
||||
<%= form.submit "Sign in",
|
||||
class: "w-full rounded-md px-3.5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white font-medium cursor-pointer" %>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 text-sm text-gray-600 text-center">
|
||||
<%= link_to "Forgot your password?", new_password_path, class: "text-blue-600 hover:text-blue-500 underline" %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Loading overlay -->
|
||||
<div id="loading-overlay" data-login-form-target="loadingOverlay" class="hidden fixed inset-0 bg-gray-600 bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div class="bg-white rounded-lg p-6 flex items-center">
|
||||
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-blue-600" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<span>Authenticating...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status messages -->
|
||||
<div id="status-message" data-login-form-target="statusMessage" class="hidden mt-4 p-3 rounded-md"></div>
|
||||
</div>
|
||||
|
||||
@@ -1,73 +1,29 @@
|
||||
<%# Enhanced Flash Messages with Support for Multiple Types and Auto-Dismiss %>
|
||||
<% flash.each do |type, message| %>
|
||||
<% next if message.blank? %>
|
||||
|
||||
<%
|
||||
# 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 %>">
|
||||
<% if flash[:alert] %>
|
||||
<div class="mb-4 rounded-lg bg-red-50 p-4" role="alert">
|
||||
<div class="flex">
|
||||
<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"/>
|
||||
<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 flex-1">
|
||||
<p class="text-sm font-medium <%= text_class %>"><%= message %></p>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm font-medium text-red-800"><%= flash[:alert] %></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% if flash[:notice] %>
|
||||
<div class="mb-4 rounded-lg bg-green-50 p-4" role="alert">
|
||||
<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"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm font-medium text-green-800"><%= flash[:notice] %></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,36 +1,23 @@
|
||||
<%# 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">
|
||||
<% if form.object.errors.any? %>
|
||||
<div class="rounded-md bg-red-50 p-4">
|
||||
<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.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"/>
|
||||
<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" />
|
||||
</svg>
|
||||
</div>
|
||||
<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:
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-red-800">
|
||||
There were <%= pluralize(form.object.errors.count, "error") %> with your submission:
|
||||
</h3>
|
||||
<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| %>
|
||||
<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| %>
|
||||
<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 %>
|
||||
@@ -57,6 +57,16 @@
|
||||
<% end %>
|
||||
</li>
|
||||
|
||||
<!-- Admin: Forward Auth Rules -->
|
||||
<li>
|
||||
<%= link_to admin_forward_auth_rules_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path.start_with?('/admin/forward_auth_rules') ? 'bg-gray-50 text-blue-600' : 'text-gray-700 hover:text-blue-600 hover:bg-gray-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="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
Forward Auth Rules
|
||||
<% end %>
|
||||
</li>
|
||||
|
||||
<!-- Admin: Groups -->
|
||||
<li>
|
||||
<%= 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' }" do %>
|
||||
@@ -78,19 +88,9 @@
|
||||
<% end %>
|
||||
</li>
|
||||
|
||||
<!-- Sessions -->
|
||||
<li>
|
||||
<%= 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' }" 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>
|
||||
Sessions
|
||||
<% end %>
|
||||
</li>
|
||||
|
||||
<!-- Sign Out -->
|
||||
<li>
|
||||
<%= 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 %>
|
||||
<%= 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 %>
|
||||
<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,18 +105,12 @@
|
||||
</div>
|
||||
|
||||
<!-- 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="relative z-50 lg:hidden hidden" id="mobile-sidebar-overlay">
|
||||
<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"
|
||||
data-action="click->mobile-sidebar#closeSidebar">
|
||||
<button type="button" class="-m-2.5 p-2.5" id="mobile-menu-close">
|
||||
<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" />
|
||||
@@ -144,7 +138,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 #{ 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 %>
|
||||
<%= 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 %>
|
||||
<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>
|
||||
@@ -153,7 +147,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 #{ 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 %>
|
||||
<%= 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 %>
|
||||
<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>
|
||||
@@ -161,7 +155,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 #{ 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 %>
|
||||
<%= 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 %>
|
||||
<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>
|
||||
@@ -169,16 +163,24 @@
|
||||
<% end %>
|
||||
</li>
|
||||
<li>
|
||||
<%= 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 %>
|
||||
<%= 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 %>
|
||||
<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>
|
||||
Groups
|
||||
<% end %>
|
||||
</li>
|
||||
<li>
|
||||
<%= link_to admin_forward_auth_rules_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 %>
|
||||
<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 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
Forward Auth Rules
|
||||
<% end %>
|
||||
</li>
|
||||
<% end %>
|
||||
<li>
|
||||
<%= 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 %>
|
||||
<%= 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 %>
|
||||
<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>
|
||||
@@ -186,15 +188,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 #{ 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>
|
||||
Sessions
|
||||
<% end %>
|
||||
</li>
|
||||
<li>
|
||||
<%= 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 %>
|
||||
<%= 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 %>
|
||||
<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" data-controller="backup-codes" data-backup-codes-codes-value="<%= @backup_codes.to_json %>">
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<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 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">
|
||||
<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">
|
||||
<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 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">
|
||||
<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">
|
||||
<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,3 +52,27 @@
|
||||
</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>
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
<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,8 +4,17 @@
|
||||
<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", data: { controller: "form-errors" } do |form| %>
|
||||
<%= render "shared/form_errors", form: form %>
|
||||
<%= 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 %>
|
||||
|
||||
<div class="my-5">
|
||||
<%= form.label :email_address, class: "block font-medium text-sm text-gray-700" %>
|
||||
|
||||
@@ -83,14 +83,4 @@ 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,85 +80,12 @@ 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 = [
|
||||
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 = extract_domain(ENV.fetch('CLINCH_HOST', 'auth.example.com'))
|
||||
if host_domain.present?
|
||||
begin
|
||||
# Use PublicSuffix to properly extract the domain
|
||||
domain = PublicSuffix.parse(host_domain)
|
||||
registrable_domain = domain.domain # Gets "example.com" from "auth.example.com"
|
||||
|
||||
if registrable_domain.present?
|
||||
# Create regex to allow any subdomain of the registrable domain
|
||||
allowed_hosts << /.*#{Regexp.escape(registrable_domain)}/
|
||||
end
|
||||
rescue PublicSuffix::DomainInvalid
|
||||
# Fallback to simple domain extraction if PublicSuffix fails
|
||||
Rails.logger.warn "Could not parse domain '#{host_domain}' with PublicSuffix, using fallback"
|
||||
base_domain = host_domain.split('.').last(2).join('.')
|
||||
allowed_hosts << /.*#{Regexp.escape(base_domain)}/
|
||||
end
|
||||
end
|
||||
|
||||
# Allow Docker service names if running in same compose
|
||||
if ENV['CLINCH_DOCKER_SERVICE_NAME']
|
||||
allowed_hosts << ENV['CLINCH_DOCKER_SERVICE_NAME']
|
||||
end
|
||||
|
||||
# Allow internal IP access for cross-compose or host networking
|
||||
if ENV['CLINCH_ALLOW_INTERNAL_IPS'] == 'true'
|
||||
# Specific host IP
|
||||
allowed_hosts << '192.168.2.246'
|
||||
|
||||
# Private IP ranges for internal network access
|
||||
allowed_hosts += [
|
||||
/192\.168\.\d+\.\d+/, # 192.168.0.0/16 private network
|
||||
/10\.\d+\.\d+\.\d+/, # 10.0.0.0/8 private network
|
||||
/172\.(1[6-9]|2[0-9]|3[0-1])\.\d+\.\d+/ # 172.16.0.0/12 private network
|
||||
]
|
||||
end
|
||||
|
||||
# Local development fallbacks
|
||||
if ENV['CLINCH_ALLOW_LOCALHOST'] == 'true'
|
||||
allowed_hosts += ['localhost', '127.0.0.1', '0.0.0.0']
|
||||
end
|
||||
|
||||
config.hosts = allowed_hosts
|
||||
|
||||
# config.hosts = [
|
||||
# "example.com", # Allow requests from example.com
|
||||
# /.*\.example\.com/ # Allow requests from subdomains like `www.example.com`
|
||||
# ]
|
||||
#
|
||||
# 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
|
||||
# config.host_authorization = { exclude: ->(request) { request.path == "/up" } }
|
||||
end
|
||||
|
||||
@@ -50,8 +50,4 @@ 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
|
||||
|
||||
@@ -4,66 +4,26 @@
|
||||
# See the Securing Rails Applications Guide for more information:
|
||||
# https://guides.rubyonrails.org/security.html#content-security-policy-header
|
||||
|
||||
Rails.application.configure do
|
||||
config.content_security_policy do |policy|
|
||||
# Default to self for everything, plus blob: for file downloads
|
||||
policy.default_src :self, "blob:"
|
||||
|
||||
# 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 self and unsafe_inline for TailwindCSS dynamic classes
|
||||
# and Stimulus controller style manipulations
|
||||
policy.style_src :self, :unsafe_inline
|
||||
|
||||
# Images: Allow self, data URLs, and https for external images
|
||||
policy.img_src :self, :data, :https
|
||||
|
||||
# Fonts: Allow self and data URLs
|
||||
policy.font_src :self, :data
|
||||
|
||||
# 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:"
|
||||
|
||||
# Media: Allow self
|
||||
policy.media_src :self
|
||||
|
||||
# Object and embed sources: Disallow for security (no Flash/etc)
|
||||
policy.object_src :none
|
||||
policy.frame_src :none
|
||||
policy.frame_ancestors :none
|
||||
|
||||
# Base URI: Restricted to self
|
||||
policy.base_uri :self
|
||||
|
||||
# Form actions: Allow self for all form submissions
|
||||
# Note: OAuth redirects will be handled dynamically in the consent page
|
||||
policy.form_action :self
|
||||
|
||||
# Manifest sources: Allow self for PWA manifest
|
||||
policy.manifest_src :self
|
||||
|
||||
# Worker sources: Allow self for potential Web Workers
|
||||
policy.worker_src :self
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
# 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
|
||||
# Rails.application.configure do
|
||||
# config.content_security_policy do |policy|
|
||||
# policy.default_src :self, :https
|
||||
# policy.font_src :self, :https, :data
|
||||
# policy.img_src :self, :https, :data
|
||||
# policy.object_src :none
|
||||
# policy.script_src :self, :https
|
||||
# policy.style_src :self, :https
|
||||
# # Specify URI for violation reports
|
||||
# # policy.report_uri "/csp-violation-report-endpoint"
|
||||
# end
|
||||
#
|
||||
# # Generate session nonces for permitted importmap, inline scripts, and inline styles.
|
||||
# config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s }
|
||||
# config.content_security_policy_nonce_directives = %w(script-src style-src)
|
||||
#
|
||||
# # Automatically add `nonce` to `javascript_tag`, `javascript_include_tag`, and `stylesheet_link_tag`
|
||||
# # if the corresponding directives are specified in `content_security_policy_nonce_directives`.
|
||||
# # config.content_security_policy_nonce_auto = true
|
||||
#
|
||||
# # Report violations without enforcing the policy.
|
||||
# # config.content_security_policy_report_only = true
|
||||
# end
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
# 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
|
||||
@@ -1,140 +0,0 @@
|
||||
# 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
|
||||
@@ -1,120 +0,0 @@
|
||||
# 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,71 +0,0 @@
|
||||
# WebAuthn configuration for Clinch Identity Provider
|
||||
WebAuthn.configure do |config|
|
||||
# Relying Party name (displayed in authenticator prompts)
|
||||
# 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 without protocol)
|
||||
# Extract domain from origin for RP ID if CLINCH_RP_ID not set
|
||||
if ENV["CLINCH_RP_ID"].present?
|
||||
config.rp_id = ENV["CLINCH_RP_ID"]
|
||||
else
|
||||
# Extract registrable domain from CLINCH_HOST using PublicSuffix
|
||||
origin_uri = URI.parse(origin_host)
|
||||
if origin_uri.host
|
||||
begin
|
||||
# Use PublicSuffix to get the registrable domain (e.g., "aapamilne.com" from "auth.aapamilne.com")
|
||||
domain = PublicSuffix.parse(origin_uri.host)
|
||||
config.rp_id = domain.domain || origin_uri.host
|
||||
rescue PublicSuffix::DomainInvalid => e
|
||||
Rails.logger.warn "WebAuthn: Failed to parse domain '#{origin_uri.host}': #{e.message}, using host as fallback"
|
||||
config.rp_id = origin_uri.host
|
||||
end
|
||||
else
|
||||
Rails.logger.error "WebAuthn: Could not extract host from CLINCH_HOST '#{origin_host}'"
|
||||
config.rp_id = "localhost"
|
||||
end
|
||||
end
|
||||
|
||||
# For development, we also allow localhost with common ports and without port
|
||||
if Rails.env.development?
|
||||
config.allowed_origins += [
|
||||
"http://localhost",
|
||||
"http://localhost:3000",
|
||||
"http://localhost:3035",
|
||||
"http://127.0.0.1",
|
||||
"http://127.0.0.1:3000",
|
||||
"http://127.0.0.1:3035"
|
||||
]
|
||||
end
|
||||
|
||||
# Relying Party name shown in authenticator prompts
|
||||
config.rp_name = ENV.fetch("CLINCH_RP_NAME", "Clinch Identity Provider")
|
||||
|
||||
# Credential timeout in milliseconds (60 seconds)
|
||||
# Users have 60 seconds to complete the authentication ceremony
|
||||
config.credential_options_timeout = 60_000
|
||||
|
||||
# Supported algorithms for credential creation
|
||||
# ES256: ECDSA with P-256 and SHA-256 (most common, secure)
|
||||
# RS256: RSASSA-PKCS1-v1_5 with SHA-256 (hardware keys often use this)
|
||||
config.algorithms = ["ES256", "RS256"]
|
||||
|
||||
# Encoding for credential IDs and other data
|
||||
config.encoding = :base64url
|
||||
|
||||
# Custom verifier for additional security checks if needed
|
||||
# config.verifier = MyCustomVerifier.new
|
||||
end
|
||||
|
||||
# Security note: WebAuthn requires HTTPS in production
|
||||
# The WebAuthn API will not work on non-secure origins in production browsers
|
||||
# Ensure CLINCH_HOST uses https:// in production environments
|
||||
|
||||
# Example environment variables:
|
||||
# CLINCH_HOST=https://auth.example.com
|
||||
# CLINCH_RP_ID=example.com
|
||||
# CLINCH_RP_NAME="Example Company Identity Provider"
|
||||
# CLINCH_WEBAUTHN_ATTESTATION=none
|
||||
# CLINCH_WEBAUTHN_USER_VERIFICATION=preferred
|
||||
# CLINCH_WEBAUTHN_RESIDENT_KEY=preferred
|
||||
@@ -19,24 +19,18 @@ Rails.application.routes.draw do
|
||||
get "/totp-verification", to: "sessions#verify_totp", as: :totp_verification
|
||||
post "/totp-verification", to: "sessions#verify_totp"
|
||||
|
||||
# WebAuthn authentication routes
|
||||
post "/sessions/webauthn/challenge", to: "sessions#webauthn_challenge"
|
||||
post "/sessions/webauthn/verify", to: "sessions#webauthn_verify"
|
||||
|
||||
# OIDC (OpenID Connect) routes
|
||||
get "/.well-known/openid-configuration", to: "oidc#discovery"
|
||||
get "/.well-known/jwks.json", to: "oidc#jwks"
|
||||
get "/oauth/authorize", to: "oidc#authorize"
|
||||
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"
|
||||
|
||||
# ForwardAuth / Trusted Header SSO
|
||||
namespace :api do
|
||||
get "/verify", to: "forward_auth#verify"
|
||||
post "/csp-violation-report", to: "csp#violation_report"
|
||||
end
|
||||
|
||||
# Authenticated routes
|
||||
@@ -47,12 +41,6 @@ Rails.application.routes.draw do
|
||||
delete :revoke_all_consents
|
||||
end
|
||||
end
|
||||
resource :active_sessions, only: [:show] do
|
||||
member do
|
||||
delete :revoke_consent
|
||||
delete :revoke_all_consents
|
||||
end
|
||||
end
|
||||
resources :sessions, only: [] do
|
||||
member do
|
||||
delete :destroy, action: :destroy_other
|
||||
@@ -65,15 +53,6 @@ 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
|
||||
post '/webauthn/challenge', to: 'webauthn#challenge'
|
||||
post '/webauthn/create', to: 'webauthn#create'
|
||||
delete '/webauthn/:id', to: 'webauthn#destroy', as: :webauthn_credential
|
||||
get '/webauthn/check', to: 'webauthn#check'
|
||||
|
||||
# Admin routes
|
||||
namespace :admin do
|
||||
@@ -86,9 +65,15 @@ Rails.application.routes.draw do
|
||||
resources :applications do
|
||||
member do
|
||||
post :regenerate_credentials
|
||||
get :roles
|
||||
post :create_role
|
||||
patch :update_role
|
||||
post :assign_role
|
||||
post :remove_role
|
||||
end
|
||||
end
|
||||
resources :groups
|
||||
resources :forward_auth_rules
|
||||
end
|
||||
|
||||
# Render dynamic PWA files from app/views/pwa/* (remember to link manifest in application.html.erb)
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
class AddCustomClaimsToGroupsAndUsers < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
add_column :groups, :custom_claims, :json, default: {}, null: false
|
||||
add_column :users, :custom_claims, :json, default: {}, null: false
|
||||
end
|
||||
end
|
||||
@@ -1,10 +0,0 @@
|
||||
class AddForwardAuthFieldsToApplications < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
# Add ForwardAuth-specific fields
|
||||
add_column :applications, :domain_pattern, :string
|
||||
add_column :applications, :headers_config, :json, default: {}, null: false
|
||||
|
||||
# Add index on domain_pattern for lookup performance
|
||||
add_index :applications, :domain_pattern, unique: true, where: "domain_pattern IS NOT NULL"
|
||||
end
|
||||
end
|
||||
@@ -1,71 +0,0 @@
|
||||
class MigrateForwardAuthRulesToApplications < ActiveRecord::Migration[8.1]
|
||||
def up
|
||||
# Temporarily define models for migration
|
||||
forward_auth_rule_class = Class.new(ActiveRecord::Base) do
|
||||
self.table_name = "forward_auth_rules"
|
||||
has_many :forward_auth_rule_groups, foreign_key: :forward_auth_rule_id, dependent: :destroy
|
||||
has_many :allowed_groups, through: :forward_auth_rule_groups, source: :group, class_name: "MigrateForwardAuthRulesToApplications::Group"
|
||||
end
|
||||
|
||||
forward_auth_rule_group_class = Class.new(ActiveRecord::Base) do
|
||||
self.table_name = "forward_auth_rule_groups"
|
||||
belongs_to :forward_auth_rule, class_name: "MigrateForwardAuthRulesToApplications::ForwardAuthRule"
|
||||
belongs_to :group, class_name: "MigrateForwardAuthRulesToApplications::Group"
|
||||
end
|
||||
|
||||
group_class = Class.new(ActiveRecord::Base) do
|
||||
self.table_name = "groups"
|
||||
end
|
||||
|
||||
application_class = Class.new(ActiveRecord::Base) do
|
||||
self.table_name = "applications"
|
||||
has_many :application_groups, foreign_key: :application_id, dependent: :destroy
|
||||
end
|
||||
|
||||
application_group_class = Class.new(ActiveRecord::Base) do
|
||||
self.table_name = "application_groups"
|
||||
belongs_to :application, class_name: "MigrateForwardAuthRulesToApplications::Application"
|
||||
belongs_to :group, class_name: "MigrateForwardAuthRulesToApplications::Group"
|
||||
end
|
||||
|
||||
# Assign to constants so we can reference them
|
||||
stub_const("MigrateForwardAuthRulesToApplications::ForwardAuthRule", forward_auth_rule_class)
|
||||
stub_const("MigrateForwardAuthRulesToApplications::ForwardAuthRuleGroup", forward_auth_rule_group_class)
|
||||
stub_const("MigrateForwardAuthRulesToApplications::Group", group_class)
|
||||
stub_const("MigrateForwardAuthRulesToApplications::Application", application_class)
|
||||
stub_const("MigrateForwardAuthRulesToApplications::ApplicationGroup", application_group_class)
|
||||
|
||||
# Migrate each ForwardAuthRule to an Application
|
||||
forward_auth_rule_class.find_each do |rule|
|
||||
# Create Application from ForwardAuthRule
|
||||
app = application_class.create!(
|
||||
name: rule.domain_pattern.titleize,
|
||||
slug: rule.domain_pattern.parameterize.presence || "forward-auth-#{rule.id}",
|
||||
app_type: 'forward_auth',
|
||||
domain_pattern: rule.domain_pattern,
|
||||
headers_config: rule.headers_config || {},
|
||||
active: rule.active
|
||||
)
|
||||
|
||||
# Migrate group associations
|
||||
forward_auth_rule_group_class.where(forward_auth_rule_id: rule.id).find_each do |far_group|
|
||||
application_group_class.create!(
|
||||
application_id: app.id,
|
||||
group_id: far_group.group_id
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def down
|
||||
# Remove all forward_auth applications created by this migration
|
||||
Application.where(app_type: 'forward_auth').destroy_all
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def stub_const(name, value)
|
||||
parts = name.split("::")
|
||||
parts[0..-2].inject(Object) { |mod, part| mod.const_get(part) }.const_set(parts.last, value)
|
||||
end
|
||||
end
|
||||
@@ -1,15 +0,0 @@
|
||||
class RemoveRoleRelatedTablesAndColumns < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
# Remove join table first (due to foreign keys)
|
||||
drop_table :user_role_assignments if table_exists?(:user_role_assignments)
|
||||
|
||||
# Remove application_roles table
|
||||
drop_table :application_roles if table_exists?(:application_roles)
|
||||
|
||||
# Remove role-related columns from applications
|
||||
remove_column :applications, :role_mapping_mode, :string if column_exists?(:applications, :role_mapping_mode)
|
||||
remove_column :applications, :role_prefix, :string if column_exists?(:applications, :role_prefix)
|
||||
remove_column :applications, :role_claim_name, :string if column_exists?(:applications, :role_claim_name)
|
||||
remove_column :applications, :managed_permissions, :json if column_exists?(:applications, :managed_permissions)
|
||||
end
|
||||
end
|
||||
@@ -1,9 +0,0 @@
|
||||
class RemoveForwardAuthTables < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
# Remove join table first (due to foreign keys)
|
||||
drop_table :forward_auth_rule_groups if table_exists?(:forward_auth_rule_groups)
|
||||
|
||||
# Remove forward_auth_rules table
|
||||
drop_table :forward_auth_rules if table_exists?(:forward_auth_rules)
|
||||
end
|
||||
end
|
||||
@@ -1,5 +0,0 @@
|
||||
class AddNameToUsers < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
add_column :users, :name, :string
|
||||
end
|
||||
end
|
||||
@@ -1,32 +0,0 @@
|
||||
class CreateWebauthnCredentials < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
create_table :webauthn_credentials do |t|
|
||||
# Reference to the user who owns this credential
|
||||
t.references :user, null: false, foreign_key: true, index: true
|
||||
|
||||
# WebAuthn specification fields
|
||||
t.string :external_id, null: false, index: { unique: true } # credential ID (base64)
|
||||
t.string :public_key, null: false # public key (base64)
|
||||
t.integer :sign_count, null: false, default: 0 # signature counter (clone detection)
|
||||
|
||||
# Metadata
|
||||
t.string :nickname # User-friendly name ("MacBook Touch ID")
|
||||
t.string :authenticator_type # "platform" or "cross-platform"
|
||||
t.boolean :backup_eligible, default: false # Can be backed up (passkey sync)
|
||||
t.boolean :backup_state, default: false # Currently backed up
|
||||
|
||||
# Tracking
|
||||
t.datetime :last_used_at
|
||||
t.string :last_used_ip
|
||||
t.string :user_agent # Browser/OS info
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
# Add composite index for user-specific queries
|
||||
add_index :webauthn_credentials, [:user_id, :external_id], unique: true
|
||||
add_index :webauthn_credentials, [:user_id, :last_used_at]
|
||||
add_index :webauthn_credentials, :authenticator_type
|
||||
add_index :webauthn_credentials, :last_used_at
|
||||
end
|
||||
end
|
||||
@@ -1,16 +0,0 @@
|
||||
class AddWebauthnToUsers < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
# WebAuthn user handle - stable, opaque identifier for the user
|
||||
# Must be unique and never change once assigned
|
||||
add_column :users, :webauthn_id, :string
|
||||
add_index :users, :webauthn_id, unique: true
|
||||
|
||||
# Policy enforcement - whether this user MUST use WebAuthn
|
||||
# Can be set by admins for high-security accounts
|
||||
add_column :users, :webauthn_required, :boolean, default: false, null: false
|
||||
|
||||
# User preference for 2FA method (if both TOTP and WebAuthn are available)
|
||||
# :totp, :webauthn, or nil for system default
|
||||
add_column :users, :preferred_2fa_method, :string
|
||||
end
|
||||
end
|
||||
@@ -1,5 +0,0 @@
|
||||
class AddLandingUrlToApplications < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
add_column :applications, :landing_url, :string
|
||||
end
|
||||
end
|
||||
@@ -1,13 +0,0 @@
|
||||
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
|
||||
@@ -1,12 +0,0 @@
|
||||
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
|
||||
@@ -1,9 +0,0 @@
|
||||
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
|
||||
@@ -1,17 +0,0 @@
|
||||
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
|
||||
@@ -1,22 +0,0 @@
|
||||
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
|
||||
@@ -1,9 +0,0 @@
|
||||
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
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user