Compare commits
5 Commits
v0.16.2
...
94785dbfe7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94785dbfe7 | ||
|
|
10bbbc8c40 | ||
|
|
02e46a7168 | ||
|
|
a2a954b4c3 | ||
|
|
0ce38e3202 |
78
.env.example
78
.env.example
@@ -1,21 +1,5 @@
|
|||||||
# Rails Configuration
|
# Rails Configuration
|
||||||
# SECRET_KEY_BASE is used for:
|
SECRET_KEY_BASE=generate-with-bin-rails-secret
|
||||||
# - Session cookie encryption
|
|
||||||
# - Signed token verification
|
|
||||||
# - ActiveRecord encryption (currently: TOTP secrets)
|
|
||||||
# - OIDC token prefix HMAC derivation
|
|
||||||
#
|
|
||||||
# CRITICAL: Do NOT change SECRET_KEY_BASE after deployment. Changing it will:
|
|
||||||
# - Invalidate all user sessions (users must re-login)
|
|
||||||
# - Break encrypted data (users must re-setup 2FA)
|
|
||||||
# - Invalidate all OIDC access/refresh tokens (clients must re-authenticate)
|
|
||||||
#
|
|
||||||
# Optional: Override encryption keys with env vars for key rotation:
|
|
||||||
# - ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY
|
|
||||||
# - ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY
|
|
||||||
# - ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT
|
|
||||||
# - OIDC_TOKEN_PREFIX_HMAC
|
|
||||||
SECRET_KEY_BASE=generate-with-bin/rails/secret
|
|
||||||
RAILS_ENV=development
|
RAILS_ENV=development
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
@@ -32,43 +16,9 @@ SMTP_AUTHENTICATION=plain
|
|||||||
SMTP_ENABLE_STARTTLS=true
|
SMTP_ENABLE_STARTTLS=true
|
||||||
|
|
||||||
# Application Configuration
|
# Application Configuration
|
||||||
CLINCH_HOST=http://localhost:3000
|
CLINCH_HOST=http://localhost:9000
|
||||||
CLINCH_FROM_EMAIL=noreply@example.com
|
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
|
# OIDC Configuration
|
||||||
# RSA private key for signing ID tokens (JWT)
|
# RSA private key for signing ID tokens (JWT)
|
||||||
# Generate with: openssl genrsa 2048
|
# Generate with: openssl genrsa 2048
|
||||||
@@ -84,27 +34,3 @@ CLINCH_ALLOW_LOCALHOST=true
|
|||||||
|
|
||||||
# Optional: Set custom port
|
# Optional: Set custom port
|
||||||
# PORT=9000
|
# 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
|
|
||||||
|
|||||||
56
.github/workflows/build.yml
vendored
56
.github/workflows/build.yml
vendored
@@ -1,56 +0,0 @@
|
|||||||
name: Build and publish image
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [ main ]
|
|
||||||
tags: [ 'v*' ]
|
|
||||||
|
|
||||||
# Only one build per ref at a time; cancel superseded main builds.
|
|
||||||
concurrency:
|
|
||||||
group: build-${{ github.ref }}
|
|
||||||
cancel-in-progress: ${{ github.ref == 'refs/heads/main' }}
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write # Required to push to GHCR
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v5
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Log in to GitHub Container Registry
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Extract image metadata (tags, labels)
|
|
||||||
id: meta
|
|
||||||
uses: docker/metadata-action@v5
|
|
||||||
with:
|
|
||||||
images: ghcr.io/${{ github.repository }}
|
|
||||||
tags: |
|
|
||||||
type=edge,branch=main
|
|
||||||
type=sha,prefix=sha-,format=short,enable={{is_default_branch}}
|
|
||||||
type=semver,pattern=v{{version}}
|
|
||||||
type=semver,pattern=v{{major}}.{{minor}}
|
|
||||||
flavor: |
|
|
||||||
latest=auto
|
|
||||||
|
|
||||||
- name: Build and push
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
platforms: linux/amd64
|
|
||||||
push: true
|
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
|
||||||
cache-from: type=gha
|
|
||||||
cache-to: type=gha,mode=max
|
|
||||||
46
.github/workflows/ci.yml
vendored
46
.github/workflows/ci.yml
vendored
@@ -19,9 +19,7 @@ jobs:
|
|||||||
bundler-cache: true
|
bundler-cache: true
|
||||||
|
|
||||||
- name: Scan for common Rails security vulnerabilities using static analysis
|
- name: Scan for common Rails security vulnerabilities using static analysis
|
||||||
run: bin/brakeman --no-pager --no-exit-on-warn
|
run: bin/brakeman --no-pager
|
||||||
# Note: 2 weak warnings exist and are documented as acceptable
|
|
||||||
# See docs/beta-checklist.md for details
|
|
||||||
|
|
||||||
- name: Scan for known security vulnerabilities in gems used
|
- name: Scan for known security vulnerabilities in gems used
|
||||||
run: bin/bundler-audit
|
run: bin/bundler-audit
|
||||||
@@ -41,36 +39,10 @@ jobs:
|
|||||||
- name: Scan for security vulnerabilities in JavaScript dependencies
|
- name: Scan for security vulnerabilities in JavaScript dependencies
|
||||||
run: bin/importmap audit
|
run: bin/importmap audit
|
||||||
|
|
||||||
scan_container:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
security-events: write # Required for uploading SARIF results
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v5
|
|
||||||
|
|
||||||
- name: Build Docker image
|
|
||||||
run: docker build -t clinch:${{ github.sha }} .
|
|
||||||
|
|
||||||
- name: Run Trivy vulnerability scanner
|
|
||||||
uses: aquasecurity/trivy-action@master
|
|
||||||
with:
|
|
||||||
image-ref: clinch:${{ github.sha }}
|
|
||||||
format: 'sarif'
|
|
||||||
output: 'trivy-results.sarif'
|
|
||||||
severity: 'CRITICAL,HIGH'
|
|
||||||
scanners: 'vuln' # Only scan vulnerabilities, not secrets (avoids false positives in vendored gems)
|
|
||||||
|
|
||||||
- name: Upload Trivy results to GitHub Security tab
|
|
||||||
uses: github/codeql-action/upload-sarif@v3
|
|
||||||
if: always()
|
|
||||||
with:
|
|
||||||
sarif_file: 'trivy-results.sarif'
|
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
RUBOCOP_CACHE_ROOT: tmp/rubocop
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v5
|
||||||
@@ -80,8 +52,18 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
bundler-cache: true
|
bundler-cache: true
|
||||||
|
|
||||||
|
- name: Prepare RuboCop cache
|
||||||
|
uses: actions/cache@v4
|
||||||
|
env:
|
||||||
|
DEPENDENCIES_HASH: ${{ hashFiles('.ruby-version', '**/.rubocop.yml', '**/.rubocop_todo.yml', 'Gemfile.lock') }}
|
||||||
|
with:
|
||||||
|
path: ${{ env.RUBOCOP_CACHE_ROOT }}
|
||||||
|
key: rubocop-${{ runner.os }}-${{ env.DEPENDENCIES_HASH }}-${{ github.ref_name == github.event.repository.default_branch && github.run_id || 'default' }}
|
||||||
|
restore-keys: |
|
||||||
|
rubocop-${{ runner.os }}-${{ env.DEPENDENCIES_HASH }}-
|
||||||
|
|
||||||
- name: Lint code for consistent style
|
- name: Lint code for consistent style
|
||||||
run: bin/standardrb
|
run: bin/rubocop -f github
|
||||||
|
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
4.0.5
|
3.4.6
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
ignore:
|
|
||||||
- 'test_*.rb' # Ignore test files in root directory
|
|
||||||
- 'tmp/**/*'
|
|
||||||
- 'vendor/**/*'
|
|
||||||
- 'node_modules/**/*'
|
|
||||||
- 'config/initializers/csp_local_logger.rb' # Complex CSP logger with intentional block structure
|
|
||||||
- 'config/initializers/sentry_subscriber.rb' # Sentry subscriber with module structure
|
|
||||||
48
.trivyignore
48
.trivyignore
@@ -1,48 +0,0 @@
|
|||||||
# Trivy ignore file
|
|
||||||
# This file tells Trivy to skip specific vulnerabilities or files
|
|
||||||
# See: https://aquasecurity.github.io/trivy/latest/docs/configuration/filtering/
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# False Positives - Test Fixtures
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
# Capybara test fixture - not a real private key
|
|
||||||
# Ignore secrets in test fixtures
|
|
||||||
# Format: secret:<rule-id>:<exact-file-path>
|
|
||||||
secret:private-key:/usr/local/bundle/ruby/3.4.0/gems/capybara-3.40.0/spec/fixtures/key.pem
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Unfixable CVEs - No Patches Available (Status: affected/fix_deferred)
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
# GnuPG vulnerabilities - not used by Clinch at runtime
|
|
||||||
# Low risk: dirmngr/gpg tools not invoked during normal operation
|
|
||||||
CVE-2025-68973
|
|
||||||
|
|
||||||
# Image processing library vulnerabilities
|
|
||||||
# Low risk for Clinch: Only admins upload images (app icons), not untrusted users
|
|
||||||
# Waiting on Debian security team to release patches
|
|
||||||
|
|
||||||
# ImageMagick - Integer overflow (32-bit only)
|
|
||||||
CVE-2025-66628
|
|
||||||
|
|
||||||
# glib - Integer overflow in URI escaping
|
|
||||||
CVE-2025-13601
|
|
||||||
|
|
||||||
# HDF5 - Critical vulnerabilities in scientific data format library
|
|
||||||
CVE-2025-2153
|
|
||||||
CVE-2025-2308
|
|
||||||
CVE-2025-2309
|
|
||||||
CVE-2025-2310
|
|
||||||
|
|
||||||
# libmatio - MATLAB file format library
|
|
||||||
CVE-2025-2338
|
|
||||||
|
|
||||||
# OpenEXR - Image format vulnerabilities
|
|
||||||
CVE-2025-12495
|
|
||||||
CVE-2025-12839
|
|
||||||
CVE-2025-12840
|
|
||||||
CVE-2025-64181
|
|
||||||
|
|
||||||
# libvips - Image processing library
|
|
||||||
CVE-2025-59933
|
|
||||||
65
Claude.md
65
Claude.md
@@ -1,65 +0,0 @@
|
|||||||
# Claude Code Guidelines for Clinch
|
|
||||||
|
|
||||||
This document provides guidelines for AI assistants (Claude, ChatGPT, etc.) working on this codebase.
|
|
||||||
|
|
||||||
## Project Context
|
|
||||||
|
|
||||||
Clinch is a lightweight identity provider (IdP) supporting:
|
|
||||||
- **OIDC/OAuth2** - Standard OAuth flows for modern apps
|
|
||||||
- **ForwardAuth** - Trusted-header SSO for reverse proxies (Traefik, Caddy, Nginx)
|
|
||||||
- **WebAuthn/Passkeys** - Passwordless authentication
|
|
||||||
- Group-based access control
|
|
||||||
|
|
||||||
Key characteristics:
|
|
||||||
- Rails 8 application with SQLite database
|
|
||||||
- Focus on simplicity and self-hosting
|
|
||||||
- No external dependencies for core functionality
|
|
||||||
|
|
||||||
## Testing Guidelines
|
|
||||||
|
|
||||||
### Do Not Test Rails Framework Functionality
|
|
||||||
|
|
||||||
When writing tests, focus on testing **our application's specific behavior and logic**, not standard Rails framework functionality.
|
|
||||||
|
|
||||||
**Examples of what NOT to test:**
|
|
||||||
- Session isolation between users (Rails handles this)
|
|
||||||
- Basic ActiveRecord associations (Rails handles this)
|
|
||||||
- Standard cookie signing/verification (Rails handles this)
|
|
||||||
- Default controller rendering behavior (Rails handles this)
|
|
||||||
- Infrastructure-level error handling (database connection failures, network issues, etc.)
|
|
||||||
|
|
||||||
**Examples of what TO test:**
|
|
||||||
- Forward auth business logic (group-based access control, header configuration, etc.)
|
|
||||||
- Custom authentication flows
|
|
||||||
- Application-specific session expiration behavior
|
|
||||||
- Domain pattern matching logic
|
|
||||||
- Custom response header generation
|
|
||||||
|
|
||||||
**Why:**
|
|
||||||
Testing Rails framework functionality adds no value and can create maintenance burden. Trust that Rails works correctly and focus tests on verifying our application's unique behavior.
|
|
||||||
|
|
||||||
### Integration Test Patterns
|
|
||||||
|
|
||||||
**Session handling:**
|
|
||||||
- Do NOT manually manipulate cookies in integration tests
|
|
||||||
- Use the session provided by the test framework
|
|
||||||
- To get the actual session ID, use `Session.last.id` after sign-in, not `cookies[:session_id]` (which is signed)
|
|
||||||
|
|
||||||
**Application setup:**
|
|
||||||
- Always create Application records for the domains you're testing
|
|
||||||
- Use wildcard patterns (e.g., `*.example.com`) when testing multiple subdomains
|
|
||||||
- Remember: `*` matches one level only (`*.example.com` matches `app.example.com` but NOT `sub.app.example.com`)
|
|
||||||
|
|
||||||
**Header assertions:**
|
|
||||||
- Always normalize header names to lowercase when asserting (HTTP headers are case-insensitive)
|
|
||||||
- Use `response.headers["x-remote-user"]` not `response.headers["X-Remote-User"]`
|
|
||||||
|
|
||||||
**Avoid threading in integration tests:**
|
|
||||||
- Rails integration tests use a single cookie jar
|
|
||||||
- Convert threaded tests to sequential requests instead
|
|
||||||
|
|
||||||
### Common Testing Pitfalls
|
|
||||||
|
|
||||||
1. **Don't test concurrent users with manual cookie manipulation** - Integration tests can't properly simulate multiple concurrent sessions
|
|
||||||
2. **Don't expect `cookies[:session_id]` to be the actual ID** - It's a signed cookie value
|
|
||||||
3. **Don't assume wildcard patterns match multiple levels** - `*.domain.com` only matches one level
|
|
||||||
@@ -8,17 +8,14 @@
|
|||||||
# For a containerized dev environment, see Dev Containers: https://guides.rubyonrails.org/getting_started_with_devcontainer.html
|
# For a containerized dev environment, see Dev Containers: https://guides.rubyonrails.org/getting_started_with_devcontainer.html
|
||||||
|
|
||||||
# Make sure RUBY_VERSION matches the Ruby version in .ruby-version
|
# Make sure RUBY_VERSION matches the Ruby version in .ruby-version
|
||||||
ARG RUBY_VERSION=4.0.5
|
ARG RUBY_VERSION=3.4.6
|
||||||
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base
|
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base
|
||||||
|
|
||||||
LABEL org.opencontainers.image.source=https://github.com/dkam/clinch
|
|
||||||
|
|
||||||
# Rails app lives here
|
# Rails app lives here
|
||||||
WORKDIR /rails
|
WORKDIR /rails
|
||||||
|
|
||||||
# Install base packages and upgrade to latest security patches
|
# Install base packages
|
||||||
RUN apt-get update -qq && \
|
RUN apt-get update -qq && \
|
||||||
apt-get upgrade -y && \
|
|
||||||
apt-get install --no-install-recommends -y curl libjemalloc2 libvips sqlite3 && \
|
apt-get install --no-install-recommends -y curl libjemalloc2 libvips sqlite3 && \
|
||||||
ln -s /usr/lib/$(uname -m)-linux-gnu/libjemalloc.so.2 /usr/local/lib/libjemalloc.so && \
|
ln -s /usr/lib/$(uname -m)-linux-gnu/libjemalloc.so.2 /usr/local/lib/libjemalloc.so && \
|
||||||
rm -rf /var/lib/apt/lists /var/cache/apt/archives
|
rm -rf /var/lib/apt/lists /var/cache/apt/archives
|
||||||
@@ -35,7 +32,7 @@ FROM base AS build
|
|||||||
|
|
||||||
# Install packages needed to build gems
|
# Install packages needed to build gems
|
||||||
RUN apt-get update -qq && \
|
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
|
rm -rf /var/lib/apt/lists /var/cache/apt/archives
|
||||||
|
|
||||||
# Install application gems
|
# Install application gems
|
||||||
|
|||||||
27
Gemfile
27
Gemfile
@@ -1,7 +1,7 @@
|
|||||||
source "https://rubygems.org"
|
source "https://rubygems.org"
|
||||||
|
|
||||||
# Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main"
|
# Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main"
|
||||||
gem "rails", "~> 8.1.3"
|
gem "rails", "~> 8.1.0"
|
||||||
# The modern asset pipeline for Rails [https://github.com/rails/propshaft]
|
# The modern asset pipeline for Rails [https://github.com/rails/propshaft]
|
||||||
gem "propshaft"
|
gem "propshaft"
|
||||||
# Use sqlite3 as the database for Active Record
|
# Use sqlite3 as the database for Active Record
|
||||||
@@ -31,23 +31,12 @@ gem "rqrcode", "~> 3.1"
|
|||||||
# JWT for OIDC ID tokens
|
# JWT for OIDC ID tokens
|
||||||
gem "jwt", "~> 3.1"
|
gem "jwt", "~> 3.1"
|
||||||
|
|
||||||
# WebAuthn for passkey support
|
|
||||||
gem "webauthn", "~> 3.0"
|
|
||||||
|
|
||||||
# Public Suffix List for domain parsing
|
|
||||||
gem "public_suffix", "~> 7.0"
|
|
||||||
|
|
||||||
# Error tracking and performance monitoring (optional, configured via SENTRY_DSN)
|
|
||||||
gem "sentry-ruby", "~> 6.2"
|
|
||||||
gem "sentry-rails", "~> 6.2"
|
|
||||||
|
|
||||||
# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
|
# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
|
||||||
gem "tzinfo-data", platforms: %i[windows jruby]
|
gem "tzinfo-data", platforms: %i[ windows jruby ]
|
||||||
|
|
||||||
# Use the database-backed adapters for Rails.cache and Action Cable
|
# Use the database-backed adapters for Rails.cache and Action Cable
|
||||||
gem "solid_cache"
|
gem "solid_cache"
|
||||||
gem "solid_cable"
|
gem "solid_cable"
|
||||||
gem "solid_queue", "~> 1.2"
|
|
||||||
|
|
||||||
# Reduces boot times through caching; required in config/boot.rb
|
# Reduces boot times through caching; required in config/boot.rb
|
||||||
gem "bootsnap", require: false
|
gem "bootsnap", require: false
|
||||||
@@ -63,7 +52,7 @@ gem "image_processing", "~> 1.2"
|
|||||||
|
|
||||||
group :development, :test do
|
group :development, :test do
|
||||||
# See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem
|
# See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem
|
||||||
gem "debug", platforms: %i[mri windows], require: "debug/prelude"
|
gem "debug", platforms: %i[ mri windows ], require: "debug/prelude"
|
||||||
|
|
||||||
# Audits gems for known security defects (use config/bundler-audit.yml to ignore issues)
|
# Audits gems for known security defects (use config/bundler-audit.yml to ignore issues)
|
||||||
gem "bundler-audit", require: false
|
gem "bundler-audit", require: false
|
||||||
@@ -71,8 +60,8 @@ group :development, :test do
|
|||||||
# Static analysis for security vulnerabilities [https://brakemanscanner.org/]
|
# Static analysis for security vulnerabilities [https://brakemanscanner.org/]
|
||||||
gem "brakeman", require: false
|
gem "brakeman", require: false
|
||||||
|
|
||||||
# Standard Ruby style guide, linter, and formatter [https://github.com/standardrb/standard]
|
# Omakase Ruby styling [https://github.com/rails/rubocop-rails-omakase/]
|
||||||
gem "standard", require: false
|
gem "rubocop-rails-omakase", require: false
|
||||||
end
|
end
|
||||||
|
|
||||||
group :development do
|
group :development do
|
||||||
@@ -87,10 +76,4 @@ group :test do
|
|||||||
# Use system testing [https://guides.rubyonrails.org/testing.html#system-testing]
|
# Use system testing [https://guides.rubyonrails.org/testing.html#system-testing]
|
||||||
gem "capybara"
|
gem "capybara"
|
||||||
gem "selenium-webdriver"
|
gem "selenium-webdriver"
|
||||||
|
|
||||||
# Code coverage analysis
|
|
||||||
gem "simplecov", require: false
|
|
||||||
|
|
||||||
# Pin minitest to < 6.0 until Rails 8.1 supports the new API
|
|
||||||
gem "minitest", "< 6.0"
|
|
||||||
end
|
end
|
||||||
|
|||||||
400
Gemfile.lock
400
Gemfile.lock
@@ -1,31 +1,31 @@
|
|||||||
GEM
|
GEM
|
||||||
remote: https://rubygems.org/
|
remote: https://rubygems.org/
|
||||||
specs:
|
specs:
|
||||||
action_text-trix (2.1.19)
|
action_text-trix (2.1.15)
|
||||||
railties
|
railties
|
||||||
actioncable (8.1.3)
|
actioncable (8.1.0)
|
||||||
actionpack (= 8.1.3)
|
actionpack (= 8.1.0)
|
||||||
activesupport (= 8.1.3)
|
activesupport (= 8.1.0)
|
||||||
nio4r (~> 2.0)
|
nio4r (~> 2.0)
|
||||||
websocket-driver (>= 0.6.1)
|
websocket-driver (>= 0.6.1)
|
||||||
zeitwerk (~> 2.6)
|
zeitwerk (~> 2.6)
|
||||||
actionmailbox (8.1.3)
|
actionmailbox (8.1.0)
|
||||||
actionpack (= 8.1.3)
|
actionpack (= 8.1.0)
|
||||||
activejob (= 8.1.3)
|
activejob (= 8.1.0)
|
||||||
activerecord (= 8.1.3)
|
activerecord (= 8.1.0)
|
||||||
activestorage (= 8.1.3)
|
activestorage (= 8.1.0)
|
||||||
activesupport (= 8.1.3)
|
activesupport (= 8.1.0)
|
||||||
mail (>= 2.8.0)
|
mail (>= 2.8.0)
|
||||||
actionmailer (8.1.3)
|
actionmailer (8.1.0)
|
||||||
actionpack (= 8.1.3)
|
actionpack (= 8.1.0)
|
||||||
actionview (= 8.1.3)
|
actionview (= 8.1.0)
|
||||||
activejob (= 8.1.3)
|
activejob (= 8.1.0)
|
||||||
activesupport (= 8.1.3)
|
activesupport (= 8.1.0)
|
||||||
mail (>= 2.8.0)
|
mail (>= 2.8.0)
|
||||||
rails-dom-testing (~> 2.2)
|
rails-dom-testing (~> 2.2)
|
||||||
actionpack (8.1.3)
|
actionpack (8.1.0)
|
||||||
actionview (= 8.1.3)
|
actionview (= 8.1.0)
|
||||||
activesupport (= 8.1.3)
|
activesupport (= 8.1.0)
|
||||||
nokogiri (>= 1.8.5)
|
nokogiri (>= 1.8.5)
|
||||||
rack (>= 2.2.4)
|
rack (>= 2.2.4)
|
||||||
rack-session (>= 1.0.1)
|
rack-session (>= 1.0.1)
|
||||||
@@ -33,36 +33,36 @@ GEM
|
|||||||
rails-dom-testing (~> 2.2)
|
rails-dom-testing (~> 2.2)
|
||||||
rails-html-sanitizer (~> 1.6)
|
rails-html-sanitizer (~> 1.6)
|
||||||
useragent (~> 0.16)
|
useragent (~> 0.16)
|
||||||
actiontext (8.1.3)
|
actiontext (8.1.0)
|
||||||
action_text-trix (~> 2.1.15)
|
action_text-trix (~> 2.1.15)
|
||||||
actionpack (= 8.1.3)
|
actionpack (= 8.1.0)
|
||||||
activerecord (= 8.1.3)
|
activerecord (= 8.1.0)
|
||||||
activestorage (= 8.1.3)
|
activestorage (= 8.1.0)
|
||||||
activesupport (= 8.1.3)
|
activesupport (= 8.1.0)
|
||||||
globalid (>= 0.6.0)
|
globalid (>= 0.6.0)
|
||||||
nokogiri (>= 1.8.5)
|
nokogiri (>= 1.8.5)
|
||||||
actionview (8.1.3)
|
actionview (8.1.0)
|
||||||
activesupport (= 8.1.3)
|
activesupport (= 8.1.0)
|
||||||
builder (~> 3.1)
|
builder (~> 3.1)
|
||||||
erubi (~> 1.11)
|
erubi (~> 1.11)
|
||||||
rails-dom-testing (~> 2.2)
|
rails-dom-testing (~> 2.2)
|
||||||
rails-html-sanitizer (~> 1.6)
|
rails-html-sanitizer (~> 1.6)
|
||||||
activejob (8.1.3)
|
activejob (8.1.0)
|
||||||
activesupport (= 8.1.3)
|
activesupport (= 8.1.0)
|
||||||
globalid (>= 0.3.6)
|
globalid (>= 0.3.6)
|
||||||
activemodel (8.1.3)
|
activemodel (8.1.0)
|
||||||
activesupport (= 8.1.3)
|
activesupport (= 8.1.0)
|
||||||
activerecord (8.1.3)
|
activerecord (8.1.0)
|
||||||
activemodel (= 8.1.3)
|
activemodel (= 8.1.0)
|
||||||
activesupport (= 8.1.3)
|
activesupport (= 8.1.0)
|
||||||
timeout (>= 0.4.0)
|
timeout (>= 0.4.0)
|
||||||
activestorage (8.1.3)
|
activestorage (8.1.0)
|
||||||
actionpack (= 8.1.3)
|
actionpack (= 8.1.0)
|
||||||
activejob (= 8.1.3)
|
activejob (= 8.1.0)
|
||||||
activerecord (= 8.1.3)
|
activerecord (= 8.1.0)
|
||||||
activesupport (= 8.1.3)
|
activesupport (= 8.1.0)
|
||||||
marcel (~> 1.0)
|
marcel (~> 1.0)
|
||||||
activesupport (8.1.3)
|
activesupport (8.1.0)
|
||||||
base64
|
base64
|
||||||
bigdecimal
|
bigdecimal
|
||||||
concurrent-ruby (~> 1.0, >= 1.3.1)
|
concurrent-ruby (~> 1.0, >= 1.3.1)
|
||||||
@@ -75,23 +75,21 @@ GEM
|
|||||||
securerandom (>= 0.3)
|
securerandom (>= 0.3)
|
||||||
tzinfo (~> 2.0, >= 2.0.5)
|
tzinfo (~> 2.0, >= 2.0.5)
|
||||||
uri (>= 0.13.1)
|
uri (>= 0.13.1)
|
||||||
addressable (2.9.0)
|
addressable (2.8.7)
|
||||||
public_suffix (>= 2.0.2, < 8.0)
|
public_suffix (>= 2.0.2, < 7.0)
|
||||||
android_key_attestation (0.3.0)
|
|
||||||
ast (2.4.3)
|
ast (2.4.3)
|
||||||
base64 (0.3.0)
|
base64 (0.3.0)
|
||||||
bcrypt (3.1.22)
|
bcrypt (3.1.20)
|
||||||
bcrypt_pbkdf (1.1.2)
|
bcrypt_pbkdf (1.1.1)
|
||||||
bigdecimal (4.1.2)
|
bigdecimal (3.3.1)
|
||||||
bindata (2.5.1)
|
|
||||||
bindex (0.8.1)
|
bindex (0.8.1)
|
||||||
bootsnap (1.24.6)
|
bootsnap (1.18.6)
|
||||||
msgpack (~> 1.2)
|
msgpack (~> 1.2)
|
||||||
brakeman (8.0.5)
|
brakeman (7.1.0)
|
||||||
racc
|
racc
|
||||||
builder (3.3.0)
|
builder (3.3.0)
|
||||||
bundler-audit (0.9.3)
|
bundler-audit (0.9.2)
|
||||||
bundler (>= 1.2.0)
|
bundler (>= 1.2.0, < 3)
|
||||||
thor (~> 1.0)
|
thor (~> 1.0)
|
||||||
capybara (3.40.0)
|
capybara (3.40.0)
|
||||||
addressable
|
addressable
|
||||||
@@ -102,62 +100,51 @@ GEM
|
|||||||
rack-test (>= 0.6.3)
|
rack-test (>= 0.6.3)
|
||||||
regexp_parser (>= 1.5, < 3.0)
|
regexp_parser (>= 1.5, < 3.0)
|
||||||
xpath (~> 3.2)
|
xpath (~> 3.2)
|
||||||
cbor (0.5.10.3)
|
|
||||||
childprocess (5.1.0)
|
childprocess (5.1.0)
|
||||||
logger (~> 1.5)
|
logger (~> 1.5)
|
||||||
chunky_png (1.4.0)
|
chunky_png (1.4.0)
|
||||||
concurrent-ruby (1.3.7)
|
concurrent-ruby (1.3.5)
|
||||||
connection_pool (3.0.2)
|
connection_pool (2.5.4)
|
||||||
cose (1.3.1)
|
|
||||||
cbor (~> 0.5.9)
|
|
||||||
openssl-signature_algorithm (~> 1.0)
|
|
||||||
crass (1.0.6)
|
crass (1.0.6)
|
||||||
date (3.5.1)
|
date (3.4.1)
|
||||||
debug (1.11.1)
|
debug (1.11.0)
|
||||||
irb (~> 1.10)
|
irb (~> 1.10)
|
||||||
reline (>= 0.3.8)
|
reline (>= 0.3.8)
|
||||||
docile (1.4.1)
|
dotenv (3.1.8)
|
||||||
dotenv (3.2.0)
|
|
||||||
drb (2.2.3)
|
drb (2.2.3)
|
||||||
ed25519 (1.4.0)
|
ed25519 (1.4.0)
|
||||||
erb (6.0.4)
|
erb (5.1.1)
|
||||||
erubi (1.13.1)
|
erubi (1.13.1)
|
||||||
et-orbi (1.4.0)
|
ffi (1.17.2-aarch64-linux-gnu)
|
||||||
tzinfo
|
ffi (1.17.2-aarch64-linux-musl)
|
||||||
ffi (1.17.4-aarch64-linux-gnu)
|
ffi (1.17.2-arm-linux-gnu)
|
||||||
ffi (1.17.4-aarch64-linux-musl)
|
ffi (1.17.2-arm-linux-musl)
|
||||||
ffi (1.17.4-arm-linux-gnu)
|
ffi (1.17.2-arm64-darwin)
|
||||||
ffi (1.17.4-arm-linux-musl)
|
ffi (1.17.2-x86_64-linux-gnu)
|
||||||
ffi (1.17.4-arm64-darwin)
|
ffi (1.17.2-x86_64-linux-musl)
|
||||||
ffi (1.17.4-x86_64-linux-gnu)
|
|
||||||
ffi (1.17.4-x86_64-linux-musl)
|
|
||||||
fugit (1.12.2)
|
|
||||||
et-orbi (~> 1.4)
|
|
||||||
raabro (~> 1.4)
|
|
||||||
globalid (1.3.0)
|
globalid (1.3.0)
|
||||||
activesupport (>= 6.1)
|
activesupport (>= 6.1)
|
||||||
i18n (1.15.2)
|
i18n (1.14.7)
|
||||||
concurrent-ruby (~> 1.0)
|
concurrent-ruby (~> 1.0)
|
||||||
image_processing (1.14.0)
|
image_processing (1.14.0)
|
||||||
mini_magick (>= 4.9.5, < 6)
|
mini_magick (>= 4.9.5, < 6)
|
||||||
ruby-vips (>= 2.0.17, < 3)
|
ruby-vips (>= 2.0.17, < 3)
|
||||||
importmap-rails (2.2.3)
|
importmap-rails (2.2.2)
|
||||||
actionpack (>= 6.0.0)
|
actionpack (>= 6.0.0)
|
||||||
activesupport (>= 6.0.0)
|
activesupport (>= 6.0.0)
|
||||||
railties (>= 6.0.0)
|
railties (>= 6.0.0)
|
||||||
io-console (0.8.2)
|
io-console (0.8.1)
|
||||||
irb (1.18.0)
|
irb (1.15.2)
|
||||||
pp (>= 0.6.0)
|
pp (>= 0.6.0)
|
||||||
prism (>= 1.3.0)
|
|
||||||
rdoc (>= 4.0.0)
|
rdoc (>= 4.0.0)
|
||||||
reline (>= 0.4.2)
|
reline (>= 0.4.2)
|
||||||
jbuilder (2.15.1)
|
jbuilder (2.14.1)
|
||||||
actionview (>= 7.0.0)
|
actionview (>= 7.0.0)
|
||||||
activesupport (>= 7.0.0)
|
activesupport (>= 7.0.0)
|
||||||
json (2.19.9)
|
json (2.15.1)
|
||||||
jwt (3.2.0)
|
jwt (3.1.2)
|
||||||
base64
|
base64
|
||||||
kamal (2.12.0)
|
kamal (2.8.1)
|
||||||
activesupport (>= 7.0)
|
activesupport (>= 7.0)
|
||||||
base64 (~> 0.2)
|
base64 (~> 0.2)
|
||||||
bcrypt_pbkdf (~> 1.0)
|
bcrypt_pbkdf (~> 1.0)
|
||||||
@@ -177,7 +164,7 @@ GEM
|
|||||||
launchy (>= 2.2, < 4)
|
launchy (>= 2.2, < 4)
|
||||||
lint_roller (1.1.0)
|
lint_roller (1.1.0)
|
||||||
logger (1.7.0)
|
logger (1.7.0)
|
||||||
loofah (2.25.1)
|
loofah (2.24.1)
|
||||||
crass (~> 1.0.2)
|
crass (~> 1.0.2)
|
||||||
nokogiri (>= 1.12.0)
|
nokogiri (>= 1.12.0)
|
||||||
mail (2.9.0)
|
mail (2.9.0)
|
||||||
@@ -186,14 +173,14 @@ GEM
|
|||||||
net-imap
|
net-imap
|
||||||
net-pop
|
net-pop
|
||||||
net-smtp
|
net-smtp
|
||||||
marcel (1.2.1)
|
marcel (1.1.0)
|
||||||
matrix (0.4.3)
|
matrix (0.4.3)
|
||||||
mini_magick (5.3.1)
|
mini_magick (5.3.1)
|
||||||
logger
|
logger
|
||||||
mini_mime (1.1.5)
|
mini_mime (1.1.5)
|
||||||
minitest (5.27.0)
|
minitest (5.26.0)
|
||||||
msgpack (1.8.3)
|
msgpack (1.8.0)
|
||||||
net-imap (0.6.4.1)
|
net-imap (0.5.12)
|
||||||
date
|
date
|
||||||
net-protocol
|
net-protocol
|
||||||
net-pop (0.1.2)
|
net-pop (0.1.2)
|
||||||
@@ -206,78 +193,74 @@ GEM
|
|||||||
net-ssh (>= 5.0.0, < 8.0.0)
|
net-ssh (>= 5.0.0, < 8.0.0)
|
||||||
net-smtp (0.5.1)
|
net-smtp (0.5.1)
|
||||||
net-protocol
|
net-protocol
|
||||||
net-ssh (7.3.2)
|
net-ssh (7.3.0)
|
||||||
nio4r (2.7.5)
|
nio4r (2.7.4)
|
||||||
nokogiri (1.19.4-aarch64-linux-gnu)
|
nokogiri (1.18.10-aarch64-linux-gnu)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.19.4-aarch64-linux-musl)
|
nokogiri (1.18.10-aarch64-linux-musl)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.19.4-arm-linux-gnu)
|
nokogiri (1.18.10-arm-linux-gnu)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.19.4-arm-linux-musl)
|
nokogiri (1.18.10-arm-linux-musl)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.19.4-arm64-darwin)
|
nokogiri (1.18.10-arm64-darwin)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.19.4-x86_64-linux-gnu)
|
nokogiri (1.18.10-x86_64-linux-gnu)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.19.4-x86_64-linux-musl)
|
nokogiri (1.18.10-x86_64-linux-musl)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
openssl (4.0.2)
|
|
||||||
openssl-signature_algorithm (1.3.0)
|
|
||||||
openssl (> 2.0)
|
|
||||||
ostruct (0.6.3)
|
ostruct (0.6.3)
|
||||||
parallel (2.1.0)
|
parallel (1.27.0)
|
||||||
parser (3.3.11.1)
|
parser (3.3.9.0)
|
||||||
ast (~> 2.4.1)
|
ast (~> 2.4.1)
|
||||||
racc
|
racc
|
||||||
pp (0.6.3)
|
pp (0.6.3)
|
||||||
prettyprint
|
prettyprint
|
||||||
prettyprint (0.2.0)
|
prettyprint (0.2.0)
|
||||||
prism (1.9.0)
|
prism (1.6.0)
|
||||||
propshaft (1.3.2)
|
propshaft (1.3.1)
|
||||||
actionpack (>= 7.0.0)
|
actionpack (>= 7.0.0)
|
||||||
activesupport (>= 7.0.0)
|
activesupport (>= 7.0.0)
|
||||||
rack
|
rack
|
||||||
psych (5.4.0)
|
psych (5.2.6)
|
||||||
date
|
date
|
||||||
stringio
|
stringio
|
||||||
public_suffix (7.0.5)
|
public_suffix (6.0.2)
|
||||||
puma (8.0.2)
|
puma (7.1.0)
|
||||||
nio4r (~> 2.0)
|
nio4r (~> 2.0)
|
||||||
raabro (1.4.0)
|
|
||||||
racc (1.8.1)
|
racc (1.8.1)
|
||||||
rack (3.2.6)
|
rack (3.2.3)
|
||||||
rack-session (2.1.2)
|
rack-session (2.1.1)
|
||||||
base64 (>= 0.1.0)
|
base64 (>= 0.1.0)
|
||||||
rack (>= 3.0.0)
|
rack (>= 3.0.0)
|
||||||
rack-test (2.2.0)
|
rack-test (2.2.0)
|
||||||
rack (>= 1.3)
|
rack (>= 1.3)
|
||||||
rackup (2.3.1)
|
rackup (2.2.1)
|
||||||
rack (>= 3)
|
rack (>= 3)
|
||||||
rails (8.1.3)
|
rails (8.1.0)
|
||||||
actioncable (= 8.1.3)
|
actioncable (= 8.1.0)
|
||||||
actionmailbox (= 8.1.3)
|
actionmailbox (= 8.1.0)
|
||||||
actionmailer (= 8.1.3)
|
actionmailer (= 8.1.0)
|
||||||
actionpack (= 8.1.3)
|
actionpack (= 8.1.0)
|
||||||
actiontext (= 8.1.3)
|
actiontext (= 8.1.0)
|
||||||
actionview (= 8.1.3)
|
actionview (= 8.1.0)
|
||||||
activejob (= 8.1.3)
|
activejob (= 8.1.0)
|
||||||
activemodel (= 8.1.3)
|
activemodel (= 8.1.0)
|
||||||
activerecord (= 8.1.3)
|
activerecord (= 8.1.0)
|
||||||
activestorage (= 8.1.3)
|
activestorage (= 8.1.0)
|
||||||
activesupport (= 8.1.3)
|
activesupport (= 8.1.0)
|
||||||
bundler (>= 1.15.0)
|
bundler (>= 1.15.0)
|
||||||
railties (= 8.1.3)
|
railties (= 8.1.0)
|
||||||
rails-dom-testing (2.3.0)
|
rails-dom-testing (2.3.0)
|
||||||
activesupport (>= 5.0.0)
|
activesupport (>= 5.0.0)
|
||||||
minitest
|
minitest
|
||||||
nokogiri (>= 1.6)
|
nokogiri (>= 1.6)
|
||||||
rails-html-sanitizer (1.7.0)
|
rails-html-sanitizer (1.6.2)
|
||||||
loofah (~> 2.25)
|
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)
|
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.3)
|
railties (8.1.0)
|
||||||
actionpack (= 8.1.3)
|
actionpack (= 8.1.0)
|
||||||
activesupport (= 8.1.3)
|
activesupport (= 8.1.0)
|
||||||
irb (~> 1.13)
|
irb (~> 1.13)
|
||||||
rackup (>= 1.0.0)
|
rackup (>= 1.0.0)
|
||||||
rake (>= 12.2)
|
rake (>= 12.2)
|
||||||
@@ -285,160 +268,125 @@ GEM
|
|||||||
tsort (>= 0.2)
|
tsort (>= 0.2)
|
||||||
zeitwerk (~> 2.6)
|
zeitwerk (~> 2.6)
|
||||||
rainbow (3.1.1)
|
rainbow (3.1.1)
|
||||||
rake (13.4.2)
|
rake (13.3.0)
|
||||||
rdoc (7.2.0)
|
rdoc (6.15.0)
|
||||||
erb
|
erb
|
||||||
psych (>= 4.0.0)
|
psych (>= 4.0.0)
|
||||||
tsort
|
tsort
|
||||||
regexp_parser (2.12.0)
|
regexp_parser (2.11.3)
|
||||||
reline (0.6.3)
|
reline (0.6.2)
|
||||||
io-console (~> 0.5)
|
io-console (~> 0.5)
|
||||||
rexml (3.4.4)
|
rexml (3.4.4)
|
||||||
rotp (6.3.0)
|
rotp (6.3.0)
|
||||||
rqrcode (3.2.0)
|
rqrcode (3.1.0)
|
||||||
chunky_png (~> 1.0)
|
chunky_png (~> 1.0)
|
||||||
rqrcode_core (~> 2.0)
|
rqrcode_core (~> 2.0)
|
||||||
rqrcode_core (2.1.0)
|
rqrcode_core (2.0.0)
|
||||||
rubocop (1.87.0)
|
rubocop (1.81.6)
|
||||||
json (~> 2.3)
|
json (~> 2.3)
|
||||||
language_server-protocol (~> 3.17.0.2)
|
language_server-protocol (~> 3.17.0.2)
|
||||||
lint_roller (~> 1.1.0)
|
lint_roller (~> 1.1.0)
|
||||||
parallel (>= 1.10)
|
parallel (~> 1.10)
|
||||||
parser (>= 3.3.0.2)
|
parser (>= 3.3.0.2)
|
||||||
rainbow (>= 2.2.2, < 4.0)
|
rainbow (>= 2.2.2, < 4.0)
|
||||||
regexp_parser (>= 2.9.3, < 3.0)
|
regexp_parser (>= 2.9.3, < 3.0)
|
||||||
rubocop-ast (>= 1.49.0, < 2.0)
|
rubocop-ast (>= 1.47.1, < 2.0)
|
||||||
ruby-progressbar (~> 1.7)
|
ruby-progressbar (~> 1.7)
|
||||||
unicode-display_width (>= 2.4.0, < 4.0)
|
unicode-display_width (>= 2.4.0, < 4.0)
|
||||||
rubocop-ast (1.49.1)
|
rubocop-ast (1.47.1)
|
||||||
parser (>= 3.3.7.2)
|
parser (>= 3.3.7.2)
|
||||||
prism (~> 1.7)
|
prism (~> 1.4)
|
||||||
rubocop-performance (1.26.1)
|
rubocop-performance (1.26.1)
|
||||||
lint_roller (~> 1.1)
|
lint_roller (~> 1.1)
|
||||||
rubocop (>= 1.75.0, < 2.0)
|
rubocop (>= 1.75.0, < 2.0)
|
||||||
rubocop-ast (>= 1.47.1, < 2.0)
|
rubocop-ast (>= 1.47.1, < 2.0)
|
||||||
|
rubocop-rails (2.33.4)
|
||||||
|
activesupport (>= 4.2.0)
|
||||||
|
lint_roller (~> 1.1)
|
||||||
|
rack (>= 1.1)
|
||||||
|
rubocop (>= 1.75.0, < 2.0)
|
||||||
|
rubocop-ast (>= 1.44.0, < 2.0)
|
||||||
|
rubocop-rails-omakase (1.1.0)
|
||||||
|
rubocop (>= 1.72)
|
||||||
|
rubocop-performance (>= 1.24)
|
||||||
|
rubocop-rails (>= 2.30)
|
||||||
ruby-progressbar (1.13.0)
|
ruby-progressbar (1.13.0)
|
||||||
ruby-vips (2.3.0)
|
ruby-vips (2.2.5)
|
||||||
ffi (~> 1.12)
|
ffi (~> 1.12)
|
||||||
logger
|
logger
|
||||||
rubyzip (3.4.0)
|
rubyzip (3.2.1)
|
||||||
safety_net_attestation (0.5.0)
|
|
||||||
jwt (>= 2.0, < 4.0)
|
|
||||||
securerandom (0.4.1)
|
securerandom (0.4.1)
|
||||||
selenium-webdriver (4.45.0)
|
selenium-webdriver (4.38.0)
|
||||||
base64 (~> 0.2)
|
base64 (~> 0.2)
|
||||||
logger (~> 1.4)
|
logger (~> 1.4)
|
||||||
rexml (~> 3.2, >= 3.2.5)
|
rexml (~> 3.2, >= 3.2.5)
|
||||||
rubyzip (>= 1.2.2, < 4.0)
|
rubyzip (>= 1.2.2, < 4.0)
|
||||||
websocket (~> 1.0)
|
websocket (~> 1.0)
|
||||||
sentry-rails (6.6.2)
|
solid_cable (3.0.12)
|
||||||
railties (>= 5.2.0)
|
|
||||||
sentry-ruby (~> 6.6.2)
|
|
||||||
sentry-ruby (6.6.2)
|
|
||||||
bigdecimal
|
|
||||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
|
||||||
logger
|
|
||||||
simplecov (0.22.0)
|
|
||||||
docile (~> 1.1)
|
|
||||||
simplecov-html (~> 0.11)
|
|
||||||
simplecov_json_formatter (~> 0.1)
|
|
||||||
simplecov-html (0.13.2)
|
|
||||||
simplecov_json_formatter (0.1.4)
|
|
||||||
solid_cable (4.0.0)
|
|
||||||
actioncable (>= 7.2)
|
actioncable (>= 7.2)
|
||||||
activejob (>= 7.2)
|
activejob (>= 7.2)
|
||||||
activerecord (>= 7.2)
|
activerecord (>= 7.2)
|
||||||
railties (>= 7.2)
|
railties (>= 7.2)
|
||||||
solid_cache (1.0.10)
|
solid_cache (1.0.8)
|
||||||
activejob (>= 7.2)
|
activejob (>= 7.2)
|
||||||
activerecord (>= 7.2)
|
activerecord (>= 7.2)
|
||||||
railties (>= 7.2)
|
railties (>= 7.2)
|
||||||
solid_queue (1.4.0)
|
sqlite3 (2.7.4-aarch64-linux-gnu)
|
||||||
activejob (>= 7.1)
|
sqlite3 (2.7.4-aarch64-linux-musl)
|
||||||
activerecord (>= 7.1)
|
sqlite3 (2.7.4-arm-linux-gnu)
|
||||||
concurrent-ruby (>= 1.3.1)
|
sqlite3 (2.7.4-arm-linux-musl)
|
||||||
fugit (~> 1.11)
|
sqlite3 (2.7.4-arm64-darwin)
|
||||||
railties (>= 7.1)
|
sqlite3 (2.7.4-x86_64-linux-gnu)
|
||||||
thor (>= 1.3.1)
|
sqlite3 (2.7.4-x86_64-linux-musl)
|
||||||
sqlite3 (2.9.5-aarch64-linux-gnu)
|
sshkit (1.24.0)
|
||||||
sqlite3 (2.9.5-aarch64-linux-musl)
|
|
||||||
sqlite3 (2.9.5-arm-linux-gnu)
|
|
||||||
sqlite3 (2.9.5-arm-linux-musl)
|
|
||||||
sqlite3 (2.9.5-arm64-darwin)
|
|
||||||
sqlite3 (2.9.5-x86_64-linux-gnu)
|
|
||||||
sqlite3 (2.9.5-x86_64-linux-musl)
|
|
||||||
sshkit (1.25.0)
|
|
||||||
base64
|
base64
|
||||||
logger
|
logger
|
||||||
net-scp (>= 1.1.2)
|
net-scp (>= 1.1.2)
|
||||||
net-sftp (>= 2.1.2)
|
net-sftp (>= 2.1.2)
|
||||||
net-ssh (>= 2.8.0)
|
net-ssh (>= 2.8.0)
|
||||||
ostruct
|
ostruct
|
||||||
standard (1.55.0)
|
|
||||||
language_server-protocol (~> 3.17.0.2)
|
|
||||||
lint_roller (~> 1.0)
|
|
||||||
rubocop (~> 1.87.0)
|
|
||||||
standard-custom (~> 1.0.0)
|
|
||||||
standard-performance (~> 1.8)
|
|
||||||
standard-custom (1.0.2)
|
|
||||||
lint_roller (~> 1.0)
|
|
||||||
rubocop (~> 1.50)
|
|
||||||
standard-performance (1.9.0)
|
|
||||||
lint_roller (~> 1.1)
|
|
||||||
rubocop-performance (~> 1.26.0)
|
|
||||||
stimulus-rails (1.3.4)
|
stimulus-rails (1.3.4)
|
||||||
railties (>= 6.0.0)
|
railties (>= 6.0.0)
|
||||||
stringio (3.2.0)
|
stringio (3.1.7)
|
||||||
tailwindcss-rails (4.6.0)
|
tailwindcss-rails (4.3.0)
|
||||||
railties (>= 7.0.0)
|
railties (>= 7.0.0)
|
||||||
tailwindcss-ruby (~> 4.0)
|
tailwindcss-ruby (~> 4.0)
|
||||||
tailwindcss-ruby (4.3.1)
|
tailwindcss-ruby (4.1.13)
|
||||||
tailwindcss-ruby (4.3.1-aarch64-linux-gnu)
|
tailwindcss-ruby (4.1.13-aarch64-linux-gnu)
|
||||||
tailwindcss-ruby (4.3.1-aarch64-linux-musl)
|
tailwindcss-ruby (4.1.13-aarch64-linux-musl)
|
||||||
tailwindcss-ruby (4.3.1-arm64-darwin)
|
tailwindcss-ruby (4.1.13-arm64-darwin)
|
||||||
tailwindcss-ruby (4.3.1-x86_64-linux-gnu)
|
tailwindcss-ruby (4.1.13-x86_64-linux-gnu)
|
||||||
tailwindcss-ruby (4.3.1-x86_64-linux-musl)
|
tailwindcss-ruby (4.1.13-x86_64-linux-musl)
|
||||||
thor (1.5.0)
|
thor (1.4.0)
|
||||||
thruster (0.1.21)
|
thruster (0.1.16)
|
||||||
thruster (0.1.21-aarch64-linux)
|
thruster (0.1.16-aarch64-linux)
|
||||||
thruster (0.1.21-arm64-darwin)
|
thruster (0.1.16-arm64-darwin)
|
||||||
thruster (0.1.21-x86_64-linux)
|
thruster (0.1.16-x86_64-linux)
|
||||||
timeout (0.6.1)
|
timeout (0.4.3)
|
||||||
tpm-key_attestation (0.14.1)
|
|
||||||
bindata (~> 2.4)
|
|
||||||
openssl (> 2.0)
|
|
||||||
openssl-signature_algorithm (~> 1.0)
|
|
||||||
tsort (0.2.0)
|
tsort (0.2.0)
|
||||||
turbo-rails (2.0.23)
|
turbo-rails (2.0.17)
|
||||||
actionpack (>= 7.1.0)
|
actionpack (>= 7.1.0)
|
||||||
railties (>= 7.1.0)
|
railties (>= 7.1.0)
|
||||||
tzinfo (2.0.6)
|
tzinfo (2.0.6)
|
||||||
concurrent-ruby (~> 1.0)
|
concurrent-ruby (~> 1.0)
|
||||||
unicode-display_width (3.2.0)
|
unicode-display_width (3.2.0)
|
||||||
unicode-emoji (~> 4.1)
|
unicode-emoji (~> 4.1)
|
||||||
unicode-emoji (4.2.0)
|
unicode-emoji (4.1.0)
|
||||||
uri (1.1.1)
|
uri (1.0.4)
|
||||||
useragent (0.16.11)
|
useragent (0.16.11)
|
||||||
web-console (4.3.0)
|
web-console (4.2.1)
|
||||||
actionview (>= 8.0.0)
|
actionview (>= 6.0.0)
|
||||||
|
activemodel (>= 6.0.0)
|
||||||
bindex (>= 0.4.0)
|
bindex (>= 0.4.0)
|
||||||
railties (>= 8.0.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 (1.2.11)
|
||||||
websocket-driver (0.8.1)
|
websocket-driver (0.8.0)
|
||||||
base64
|
base64
|
||||||
websocket-extensions (>= 0.1.0)
|
websocket-extensions (>= 0.1.0)
|
||||||
websocket-extensions (0.1.5)
|
websocket-extensions (0.1.5)
|
||||||
xpath (3.2.0)
|
xpath (3.2.0)
|
||||||
nokogiri (~> 1.8)
|
nokogiri (~> 1.8)
|
||||||
zeitwerk (2.8.2)
|
zeitwerk (2.7.3)
|
||||||
|
|
||||||
PLATFORMS
|
PLATFORMS
|
||||||
aarch64-linux
|
aarch64-linux
|
||||||
@@ -447,7 +395,6 @@ PLATFORMS
|
|||||||
arm-linux-gnu
|
arm-linux-gnu
|
||||||
arm-linux-musl
|
arm-linux-musl
|
||||||
arm64-darwin-24
|
arm64-darwin-24
|
||||||
arm64-darwin-25
|
|
||||||
x86_64-linux
|
x86_64-linux
|
||||||
x86_64-linux-gnu
|
x86_64-linux-gnu
|
||||||
x86_64-linux-musl
|
x86_64-linux-musl
|
||||||
@@ -465,29 +412,22 @@ DEPENDENCIES
|
|||||||
jwt (~> 3.1)
|
jwt (~> 3.1)
|
||||||
kamal
|
kamal
|
||||||
letter_opener
|
letter_opener
|
||||||
minitest (< 6.0)
|
|
||||||
propshaft
|
propshaft
|
||||||
public_suffix (~> 7.0)
|
|
||||||
puma (>= 5.0)
|
puma (>= 5.0)
|
||||||
rails (~> 8.1.3)
|
rails (~> 8.1.0)
|
||||||
rotp (~> 6.3)
|
rotp (~> 6.3)
|
||||||
rqrcode (~> 3.1)
|
rqrcode (~> 3.1)
|
||||||
|
rubocop-rails-omakase
|
||||||
selenium-webdriver
|
selenium-webdriver
|
||||||
sentry-rails (~> 6.2)
|
|
||||||
sentry-ruby (~> 6.2)
|
|
||||||
simplecov
|
|
||||||
solid_cable
|
solid_cable
|
||||||
solid_cache
|
solid_cache
|
||||||
solid_queue (~> 1.2)
|
|
||||||
sqlite3 (>= 2.1)
|
sqlite3 (>= 2.1)
|
||||||
standard
|
|
||||||
stimulus-rails
|
stimulus-rails
|
||||||
tailwindcss-rails
|
tailwindcss-rails
|
||||||
thruster
|
thruster
|
||||||
turbo-rails
|
turbo-rails
|
||||||
tzinfo-data
|
tzinfo-data
|
||||||
web-console
|
web-console
|
||||||
webauthn (~> 3.0)
|
|
||||||
|
|
||||||
BUNDLED WITH
|
BUNDLED WITH
|
||||||
4.0.6
|
2.7.2
|
||||||
|
|||||||
725
README.md
725
README.md
@@ -1,65 +1,25 @@
|
|||||||
# Clinch
|
# Clinch
|
||||||
## Position and Control for your Authentication
|
|
||||||
> [!NOTE]
|
|
||||||
> This software is experimental. If you'd like to try it out, find bugs, security flaws and improvements, please do.
|
|
||||||
|
|
||||||
We do these things not because they're easy, but because we thought they'd be easy.
|
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 managing its own users.
|
Clinch gives you one place to manage users and lets any web app authenticate against it without maintaining its own user table.
|
||||||
|
|
||||||
## Why Clinch?
|
## Why Clinch?
|
||||||
|
|
||||||
Do you host your own web apps? MeTube, Kavita, Audiobookshelf, Gitea, Grafana, Proxmox? Rather than managing all those separate user accounts, set everyone up on Clinch and let it do the authentication and user management.
|
Do you host your own web apps? MeTube, Kavita, Audiobookshelf, Gitea? Rather than managing all those separate user accounts, set everyone up on Clinch and let it do the authentication and user management.
|
||||||
|
|
||||||
Clinch runs as a single Docker container, using SQLite as the database, the job queue (Solid Queue) and the shared cache (Solid Cache). The webserver, Puma, runs the job queue in-process, avoiding the need for another container.
|
Clinch sits in a sweet spot between two excellent open-source identity solutions:
|
||||||
|
|
||||||
Clinch sits in a sweet spot among several excellent open-source identity solutions:
|
|
||||||
|
|
||||||
**[Authelia](https://www.authelia.com)** is a fantastic choice for those who prefer external user management through LDAP and enjoy comprehensive YAML-based configuration. It's lightweight, secure, and works beautifully with reverse proxies.
|
**[Authelia](https://www.authelia.com)** is a fantastic choice for those who prefer external user management through LDAP and enjoy comprehensive YAML-based configuration. It's lightweight, secure, and works beautifully with reverse proxies.
|
||||||
|
|
||||||
**[VoidAuth](https://voidauth.app/)** is an open-source SSO provider with a similar feature set to Clinch — OIDC, ForwardAuth, passkeys, user management, and easy Docker deployment. If you're evaluating self-hosted auth solutions, it's well worth a look.
|
|
||||||
|
|
||||||
**[Authentik](https://goauthentik.io)** is an enterprise-grade powerhouse offering extensive protocol support (OAuth2, SAML, LDAP, RADIUS), advanced policy engines, and distributed "outpost" architecture for complex deployments.
|
**[Authentik](https://goauthentik.io)** is an enterprise-grade powerhouse offering extensive protocol support (OAuth2, SAML, LDAP, RADIUS), advanced policy engines, and distributed "outpost" architecture for complex deployments.
|
||||||
|
|
||||||
**Clinch** offers a middle ground with built-in user management, a modern web interface, and focused SSO capabilities (OIDC + ForwardAuth). It's perfect for users who want self-hosted simplicity without external dependencies or enterprise complexity.
|
**Clinch** offers a middle ground with built-in user management, a modern web interface, and focused SSO capabilities (OIDC + ForwardAuth). It's perfect for users who want self-hosted simplicity without external dependencies or enterprise complexity.
|
||||||
|
|
||||||
- **[Passes the OpenID Connect Conformance Tests](https://www.certification.openid.net/plan-detail.html?plan=FbQNTJuYVzrzs&public=true)** — verified against the official OIDC test suite
|
|
||||||
- **450+ tests, 1800+ assertions** — comprehensive test coverage across integration, model, controller, and security tests
|
|
||||||
- **Single Docker container** — SQLite, job queue, and cache all in one process
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 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
|
## Features
|
||||||
|
|
||||||
### User Management
|
### User Management
|
||||||
@@ -69,102 +29,38 @@ Clinch sits in a sweet spot among several excellent open-source identity solutio
|
|||||||
- **User statuses** - Active, disabled, or pending invitation
|
- **User statuses** - Active, disabled, or pending invitation
|
||||||
|
|
||||||
### Authentication Methods
|
### Authentication Methods
|
||||||
- **WebAuthn/Passkeys** - Modern passwordless authentication using FIDO2 standards
|
|
||||||
- **Password authentication** - Secure bcrypt-based password storage
|
- **Password authentication** - Secure bcrypt-based password storage
|
||||||
|
- **Magic login links** - Passwordless login via email (15-minute expiry)
|
||||||
- **TOTP 2FA** - Optional time-based one-time passwords with QR code setup
|
- **TOTP 2FA** - Optional time-based one-time passwords with QR code setup
|
||||||
- **Backup codes** - 10 single-use recovery codes per user
|
- **Backup codes** - 10 single-use recovery codes per user
|
||||||
- **Configurable 2FA enforcement** - Admins can require TOTP for specific users
|
- **Configurable 2FA enforcement** - Admins can require TOTP for specific users/groups
|
||||||
|
|
||||||
### SSO Protocols
|
### SSO Protocols
|
||||||
|
|
||||||
Apps that speak OIDC use the OIDC flow.
|
|
||||||
Apps that only need "who is it?", or you want available from the internet behind authentication (MeTube, Jellyfin) use ForwardAuth.
|
|
||||||
|
|
||||||
#### OpenID Connect (OIDC)
|
#### OpenID Connect (OIDC)
|
||||||
|
|
||||||
Standard OAuth2/OIDC provider with endpoints:
|
Standard OAuth2/OIDC provider with endpoints:
|
||||||
- `/.well-known/openid-configuration` - Discovery endpoint
|
- `/.well-known/openid-configuration` - Discovery endpoint
|
||||||
- `/authorize` - Authorization endpoint with PKCE support
|
- `/authorize` - Authorization endpoint
|
||||||
- `/token` - Token endpoint (authorization_code and refresh_token grants)
|
- `/token` - Token endpoint
|
||||||
- `/userinfo` - User info endpoint
|
- `/userinfo` - User info endpoint
|
||||||
- `/revoke` - Token revocation endpoint (RFC 7009)
|
|
||||||
|
|
||||||
Features:
|
Client apps (Audiobookshelf, Kavita, Grafana, etc.) redirect to Clinch for login and receive ID tokens and access tokens.
|
||||||
- **Refresh tokens** - Long-lived tokens (30 days default) with automatic rotation and revocation
|
|
||||||
- **Token family tracking** - Advanced security detects token replay attacks and revokes compromised token families
|
|
||||||
- **Configurable token expiry** - Set access token (5min-24hr), refresh token (1-90 days), and ID token TTL per application
|
|
||||||
- **Token security** - All tokens HMAC-SHA256 hashed (suitable for 256-bit random data), automatic cleanup of expired tokens
|
|
||||||
- **Pairwise subject identifiers** - Each user gets a unique, stable `sub` claim per application for enhanced privacy
|
|
||||||
|
|
||||||
**ID Token Claims** (JWT with RS256 signature):
|
|
||||||
|
|
||||||
| Claim | Description | Notes |
|
|
||||||
|-------|-------------|-------|
|
|
||||||
| Standard Claims | | |
|
|
||||||
| `iss` | Issuer (Clinch URL) | From `CLINCH_HOST` |
|
|
||||||
| `sub` | Subject (user identifier) | Pairwise SID - unique per app |
|
|
||||||
| `aud` | Audience | OAuth client_id |
|
|
||||||
| `exp` | Expiration timestamp | Configurable TTL |
|
|
||||||
| `iat` | Issued-at timestamp | Token creation time |
|
|
||||||
| `email` | User email | |
|
|
||||||
| `email_verified` | Email verification | Always `true` |
|
|
||||||
| `preferred_username` | Username/email | Fallback to email |
|
|
||||||
| `name` | Display name | User's name or email |
|
|
||||||
| `nonce` | Random value | From auth request (prevents replay) |
|
|
||||||
| **Security Claims** | | |
|
|
||||||
| `at_hash` | Access token hash | SHA-256 hash of access_token (OIDC Core §3.1.3.6) |
|
|
||||||
| `auth_time` | Authentication time | Unix timestamp of when user logged in (OIDC Core §2) |
|
|
||||||
| `acr` | Auth context class | `"1"` = password, `"2"` = 2FA/passkey (OIDC Core §2) |
|
|
||||||
| `azp` | Authorized party | OAuth client_id (OIDC Core §2) |
|
|
||||||
| Custom Claims | | |
|
|
||||||
| `groups` | User's groups | Array of group names |
|
|
||||||
| *custom* | Arbitrary key-values | From groups, users, or app-specific config |
|
|
||||||
|
|
||||||
**Authentication Context Class Reference (`acr`):**
|
|
||||||
- `"1"` - Something you know (password only)
|
|
||||||
- `"2"` - Two-factor or phishing-resistant (TOTP, backup codes, WebAuthn/passkey)
|
|
||||||
|
|
||||||
Client apps (Audiobookshelf, Kavita, Proxmox, Grafana, etc.) redirect to Clinch for login and receive ID tokens, access tokens, and refresh tokens.
|
|
||||||
|
|
||||||
#### Trusted-Header SSO (ForwardAuth)
|
#### Trusted-Header SSO (ForwardAuth)
|
||||||
Works with reverse proxies (Caddy, Traefik, Nginx):
|
Works with reverse proxies (Caddy, Traefik, Nginx):
|
||||||
1. Proxy sends every request to `/api/verify`
|
1. Proxy sends every request to `/api/verify`
|
||||||
2. Response handling:
|
2. **200 OK** → Proxy injects headers (`Remote-User`, `Remote-Groups`, `Remote-Email`) and forwards to app
|
||||||
- **200 OK** → Proxy injects headers (`Remote-User`, `Remote-Groups`, `Remote-Email`) and forwards to app
|
3. **401/403** → Proxy redirects to Clinch login; after login, user returns to original URL
|
||||||
- **Any other status** → Proxy returns that response directly to client (typically 302 redirect to login page)
|
|
||||||
|
Apps that speak OIDC use the OIDC flow; apps that only need "who is it?" headers use ForwardAuth.
|
||||||
|
|
||||||
**Note:** ForwardAuth requires applications to run on the same domain as Clinch (e.g., `app.yourdomain.com` with Clinch at `auth.yourdomain.com`) for secure session cookie sharing. Take a look at Authentik if you need multi domain support.
|
**Note:** ForwardAuth requires applications to run on the same domain as Clinch (e.g., `app.yourdomain.com` with Clinch at `auth.yourdomain.com`) for secure session cookie sharing. Take a look at Authentik if you need multi domain support.
|
||||||
|
|
||||||
#### API Keys (Bearer Tokens)
|
|
||||||
|
|
||||||
For server-to-server access to ForwardAuth-protected services (e.g., a video player accessing WebDAV, rclone syncing files), Clinch supports API keys that work as bearer tokens — no browser or cookies needed.
|
|
||||||
|
|
||||||
- **Token format:** `clk_<base64>` prefix for easy identification
|
|
||||||
- **Storage:** HMAC-SHA256 hashed (plaintext shown once at creation, never stored)
|
|
||||||
- **Scope:** Each key is tied to one ForwardAuth application and one user
|
|
||||||
- **Expiration:** Optional — set a date or leave blank for no expiry
|
|
||||||
- **Auth flow:** `Authorization: Bearer clk_...` header checked before cookie auth
|
|
||||||
- **Failure response:** 401 JSON `{"error": "..."}` (no redirect)
|
|
||||||
|
|
||||||
**Creating an API key:**
|
|
||||||
1. Go to **Dashboard → Manage API Keys** (or `/api_keys`)
|
|
||||||
2. Click **New API Key**, select a ForwardAuth application, and give it a name
|
|
||||||
3. Copy the `clk_...` token — it's shown only once
|
|
||||||
|
|
||||||
**Usage:**
|
|
||||||
```bash
|
|
||||||
curl -H "Authorization: Bearer clk_..." \
|
|
||||||
-H "X-Forwarded-Host: webdav.example.com" \
|
|
||||||
https://auth.example.com/api/verify
|
|
||||||
# Returns 200 with X-Remote-User headers on success
|
|
||||||
```
|
|
||||||
|
|
||||||
API keys respect the same access controls as browser sessions — the user must have access to the application, the application must be active, and the user's account must be active.
|
|
||||||
|
|
||||||
### SMTP Integration
|
### SMTP Integration
|
||||||
Send emails for:
|
Send emails for:
|
||||||
- Invitation links (one-time token, 7-day expiry)
|
- Invitation links (one-time token, 7-day expiry)
|
||||||
- Password reset links (one-time token, 1-hour expiry)
|
- Password reset links (one-time token, 1-hour expiry)
|
||||||
|
- 2FA backup codes
|
||||||
|
|
||||||
### Session Management
|
### Session Management
|
||||||
- **Device tracking** - See all active sessions with device names and IPs
|
- **Device tracking** - See all active sessions with device names and IPs
|
||||||
@@ -172,54 +68,9 @@ Send emails for:
|
|||||||
- **Session revocation** - Users and admins can revoke individual sessions
|
- **Session revocation** - Users and admins can revoke individual sessions
|
||||||
|
|
||||||
### Access Control
|
### Access Control
|
||||||
|
- **Group-based allowlists** - Restrict applications to specific user groups
|
||||||
#### Group-Based Application Access
|
- **Per-application access** - Each app defines which groups can access it
|
||||||
Clinch uses groups to control which users can access which applications:
|
- **Automatic enforcement** - Access checks during OIDC authorization and ForwardAuth
|
||||||
|
|
||||||
- **Create groups** - Organize users into logical groups (readers, editors, family, developers, etc.)
|
|
||||||
- **Assign groups to applications** - Each app defines which groups are allowed to access it
|
|
||||||
- Example: Kavita app allows the "readers" group → only users in the "readers" group can sign in
|
|
||||||
- If no groups are assigned to an app → all active users can access it
|
|
||||||
- **Automatic enforcement** - Access checks happen automatically:
|
|
||||||
- During OIDC authorization flow (before consent)
|
|
||||||
- During ForwardAuth verification (before proxying requests)
|
|
||||||
- Users not in allowed groups receive a "You do not have permission" error
|
|
||||||
|
|
||||||
#### Group Claims in Tokens
|
|
||||||
- **OIDC tokens include group membership** - ID tokens contain a `groups` claim with all user's groups
|
|
||||||
- **Custom claims** - Add arbitrary key-value pairs to tokens via groups and users
|
|
||||||
- Group claims apply to all members (e.g., `{"role": "viewer"}`)
|
|
||||||
- User claims override group claims for fine-grained control
|
|
||||||
- Perfect for app-specific authorization (e.g., admin vs. read-only roles)
|
|
||||||
|
|
||||||
#### Custom Claims Merging
|
|
||||||
Custom claims from groups and users are merged into OIDC ID tokens with the following precedence:
|
|
||||||
|
|
||||||
1. **Default OIDC claims** - Standard claims (`iss`, `sub`, `aud`, `exp`, `email`, etc.)
|
|
||||||
2. **Standard Clinch claims** - `groups` array (list of user's group names)
|
|
||||||
3. **Group custom claims** - Merged in order; later groups override earlier ones
|
|
||||||
4. **User custom claims** - Override all group claims
|
|
||||||
5. **Application-specific claims** - Highest priority; override all other claims
|
|
||||||
|
|
||||||
**Example:**
|
|
||||||
- Group "readers" has `{"role": "viewer", "max_items": 10}`
|
|
||||||
- Group "premium" has `{"role": "subscriber", "max_items": 100}`
|
|
||||||
- User (in both groups) has `{"max_items": 500}`
|
|
||||||
- **Result:** `{"role": "subscriber", "max_items": 500}` (user overrides max_items, premium overrides role)
|
|
||||||
|
|
||||||
#### Application-Specific Claims
|
|
||||||
Configure different claims for different applications on a per-user basis:
|
|
||||||
|
|
||||||
- **Per-app customization** - Each application can have unique claims for each user
|
|
||||||
- **Highest precedence** - App-specific claims override group and user global claims
|
|
||||||
- **Use case** - Different roles in different apps (e.g., admin in Kavita, user in Audiobookshelf)
|
|
||||||
- **Admin UI** - Configure via Admin → Users → Edit User → App-Specific Claim Overrides
|
|
||||||
|
|
||||||
**Example:**
|
|
||||||
- User Alice, global claims: `{"theme": "dark"}`
|
|
||||||
- Kavita app-specific: `{"kavita_groups": ["admin"]}`
|
|
||||||
- Audiobookshelf app-specific: `{"abs_groups": ["user"]}`
|
|
||||||
- **Result:** Kavita receives `{"theme": "dark", "kavita_groups": ["admin"]}`, Audiobookshelf receives `{"theme": "dark", "abs_groups": ["user"]}`
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -234,13 +85,11 @@ Configure different claims for different applications on a per-user basis:
|
|||||||
- TOTP secret and backup codes (encrypted)
|
- TOTP secret and backup codes (encrypted)
|
||||||
- TOTP enforcement flag
|
- TOTP enforcement flag
|
||||||
- Status (active, disabled, pending_invitation)
|
- 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
|
- Token generation for invitations, password resets, and magic logins
|
||||||
|
|
||||||
**Group**
|
**Group**
|
||||||
- Name (unique, normalized to lowercase)
|
- Name (unique, normalized to lowercase)
|
||||||
- Description
|
- Description
|
||||||
- Custom claims (JSON) - shared claims for all members (merged with user claims)
|
|
||||||
- Many-to-many with Users and Applications
|
- Many-to-many with Users and Applications
|
||||||
|
|
||||||
**Session**
|
**Session**
|
||||||
@@ -253,34 +102,28 @@ Configure different claims for different applications on a per-user basis:
|
|||||||
|
|
||||||
**Application**
|
**Application**
|
||||||
- Name and slug (URL-safe identifier)
|
- Name and slug (URL-safe identifier)
|
||||||
- Type (oidc or forward_auth)
|
- Type (oidc, trusted_header, saml)
|
||||||
- Client ID and secret (for OIDC apps)
|
- Client ID and secret (for OIDC)
|
||||||
- Redirect URIs (for OIDC apps)
|
- Redirect URIs (JSON array)
|
||||||
- Domain pattern (for ForwardAuth apps, supports wildcards like *.example.com)
|
|
||||||
- Headers config (for ForwardAuth apps, JSON configuration for custom header names)
|
|
||||||
- Token TTL configuration (access_token_ttl, refresh_token_ttl, id_token_ttl)
|
|
||||||
- Metadata (flexible JSON storage)
|
- Metadata (flexible JSON storage)
|
||||||
- Active flag
|
- Active flag
|
||||||
- Many-to-many with Groups (allowlist)
|
- Many-to-many with Groups (allowlist)
|
||||||
|
|
||||||
**OIDC Tokens**
|
**OIDC Tokens**
|
||||||
- Authorization codes (opaque, HMAC-SHA256 hashed, 10-minute expiry, one-time use, PKCE support)
|
- Authorization codes (10-minute expiry, one-time use)
|
||||||
- Access tokens (opaque, HMAC-SHA256 hashed, configurable expiry 5min-24hr, revocable)
|
- Access tokens (1-hour expiry, revocable)
|
||||||
- Refresh tokens (opaque, HMAC-SHA256 hashed, configurable expiry 1-90 days, single-use with rotation)
|
|
||||||
- ID tokens (JWT, signed with RS256, configurable expiry 5min-24hr)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Authentication Flows
|
## Authentication Flows
|
||||||
|
|
||||||
### OIDC Authorization Flow
|
### 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)
|
2. User authenticates with Clinch (username/password + optional TOTP)
|
||||||
3. Access control check: Is user in an allowed group for this app?
|
3. Access control check: Is user in an allowed group for this app?
|
||||||
4. If allowed, generate authorization code and redirect to client
|
4. If allowed, generate authorization code and redirect to client
|
||||||
5. Client exchanges code at `/token` for ID token, access token, and refresh token
|
5. Client exchanges code for access token at `/token`
|
||||||
6. Client uses access token to fetch fresh user info from `/userinfo`
|
6. Client uses access token to fetch user info from `/userinfo`
|
||||||
7. When access token expires, client uses refresh token to get new tokens (no re-authentication)
|
|
||||||
|
|
||||||
### ForwardAuth Flow
|
### ForwardAuth Flow
|
||||||
1. User requests protected resource at `https://app.example.com/dashboard`
|
1. User requests protected resource at `https://app.example.com/dashboard`
|
||||||
@@ -294,30 +137,12 @@ Configure different claims for different applications on a per-user basis:
|
|||||||
- Proxy redirects to Clinch login page
|
- Proxy redirects to Clinch login page
|
||||||
- After login, redirect back to original URL
|
- After login, redirect back to original URL
|
||||||
|
|
||||||
#### Race Condition Handling
|
|
||||||
|
|
||||||
After successful login, you may notice an `fa_token` query parameter appended to redirect URLs (e.g., `https://app.example.com/dashboard?fa_token=...`). This solves a timing issue:
|
|
||||||
|
|
||||||
**The Problem:**
|
|
||||||
1. User signs in → session cookie is set
|
|
||||||
2. Browser gets redirected to protected resource
|
|
||||||
3. Browser may not have processed the `Set-Cookie` header yet
|
|
||||||
4. Reverse proxy checks `/api/verify` → no cookie yet → auth fails ❌
|
|
||||||
|
|
||||||
**The Solution:**
|
|
||||||
- A one-time token (`fa_token`) is added to the redirect URL as a query parameter
|
|
||||||
- `/api/verify` checks for this token first, before checking cookies
|
|
||||||
- Token is cached for 60 seconds and deleted immediately after use
|
|
||||||
- This gives the browser's cookie handling time to catch up
|
|
||||||
|
|
||||||
This is transparent to end users and requires no configuration.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Setup & Installation
|
## Setup & Installation
|
||||||
|
|
||||||
### Requirements
|
### Requirements
|
||||||
- Ruby 4.0+
|
- Ruby 3.3+
|
||||||
- SQLite 3.8+
|
- SQLite 3.8+
|
||||||
- SMTP server (for sending emails)
|
- SMTP server (for sending emails)
|
||||||
|
|
||||||
@@ -337,207 +162,52 @@ bin/rails db:migrate
|
|||||||
bin/dev
|
bin/dev
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
### Docker Deployment
|
||||||
|
|
||||||
## Production Deployment
|
|
||||||
|
|
||||||
### Docker Compose (Recommended)
|
|
||||||
|
|
||||||
Create a `docker-compose.yml` file:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
clinch:
|
|
||||||
image: ghcr.io/dkam/clinch:latest
|
|
||||||
ports:
|
|
||||||
- "127.0.0.1:3000:3000" # Bind to localhost only (reverse proxy on same host)
|
|
||||||
# Use "3000:3000" if reverse proxy is in Docker network or different host
|
|
||||||
environment:
|
|
||||||
# Rails Configuration
|
|
||||||
RAILS_ENV: production
|
|
||||||
SECRET_KEY_BASE: ${SECRET_KEY_BASE}
|
|
||||||
|
|
||||||
# Application Configuration
|
|
||||||
CLINCH_HOST: ${CLINCH_HOST}
|
|
||||||
CLINCH_FROM_EMAIL: ${CLINCH_FROM_EMAIL:-noreply@example.com}
|
|
||||||
|
|
||||||
# SMTP Configuration
|
|
||||||
SMTP_ADDRESS: ${SMTP_ADDRESS}
|
|
||||||
SMTP_PORT: ${SMTP_PORT}
|
|
||||||
SMTP_DOMAIN: ${SMTP_DOMAIN}
|
|
||||||
SMTP_USERNAME: ${SMTP_USERNAME}
|
|
||||||
SMTP_PASSWORD: ${SMTP_PASSWORD}
|
|
||||||
SMTP_AUTHENTICATION: ${SMTP_AUTHENTICATION:-plain}
|
|
||||||
SMTP_ENABLE_STARTTLS: ${SMTP_ENABLE_STARTTLS:-true}
|
|
||||||
|
|
||||||
# OIDC Configuration (optional - generates temporary key if not provided)
|
|
||||||
OIDC_PRIVATE_KEY: ${OIDC_PRIVATE_KEY}
|
|
||||||
|
|
||||||
# Optional Configuration
|
|
||||||
FORCE_SSL: ${FORCE_SSL:-false}
|
|
||||||
volumes:
|
|
||||||
- ./storage:/rails/storage
|
|
||||||
restart: unless-stopped
|
|
||||||
```
|
|
||||||
|
|
||||||
Create a `.env` file in the same directory:
|
|
||||||
|
|
||||||
**Generate required secrets first:**
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Generate SECRET_KEY_BASE (required)
|
# Build image
|
||||||
openssl rand -hex 64
|
docker build -t clinch .
|
||||||
|
|
||||||
# Generate OIDC private key (optional - auto-generated if not provided)
|
# Run container
|
||||||
openssl genpkey -algorithm RSA -out private_key.pem -pkeyopt rsa_keygen_bits:2048
|
docker run -p 3000:3000 \
|
||||||
cat private_key.pem # Copy the output into OIDC_PRIVATE_KEY below
|
-v clinch-storage:/rails/storage \
|
||||||
|
-e SECRET_KEY_BASE=your-secret-key \
|
||||||
|
-e SMTP_ADDRESS=smtp.example.com \
|
||||||
|
-e SMTP_PORT=587 \
|
||||||
|
-e SMTP_USERNAME=your-username \
|
||||||
|
-e SMTP_PASSWORD=your-password \
|
||||||
|
clinch
|
||||||
```
|
```
|
||||||
|
|
||||||
**Then create `.env`:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Rails Secret (REQUIRED)
|
|
||||||
SECRET_KEY_BASE=paste-output-from-openssl-rand-hex-64-here
|
|
||||||
|
|
||||||
# Application URLs (REQUIRED)
|
|
||||||
CLINCH_HOST=https://auth.yourdomain.com
|
|
||||||
CLINCH_FROM_EMAIL=noreply@yourdomain.com
|
|
||||||
|
|
||||||
# SMTP Settings (REQUIRED for invitations and password resets)
|
|
||||||
SMTP_ADDRESS=smtp.example.com
|
|
||||||
SMTP_PORT=587
|
|
||||||
SMTP_DOMAIN=yourdomain.com
|
|
||||||
SMTP_USERNAME=your-smtp-username
|
|
||||||
SMTP_PASSWORD=your-smtp-password
|
|
||||||
|
|
||||||
# OIDC Private Key (OPTIONAL - generates temporary key if not provided)
|
|
||||||
# For production, generate a persistent key and paste the ENTIRE contents here
|
|
||||||
OIDC_PRIVATE_KEY=
|
|
||||||
|
|
||||||
# Optional: Force SSL redirects (only if NOT behind a reverse proxy handling SSL)
|
|
||||||
FORCE_SSL=false
|
|
||||||
```
|
|
||||||
|
|
||||||
Start Clinch:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
**First Run:**
|
|
||||||
1. Visit `http://localhost:3000` (or your configured domain)
|
|
||||||
2. Complete the first-run wizard to create your admin account
|
|
||||||
3. Configure applications and invite users
|
|
||||||
|
|
||||||
**Upgrading:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Pull latest image
|
|
||||||
docker compose pull
|
|
||||||
|
|
||||||
# Restart with new image (migrations run automatically)
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
**Logs:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# View logs
|
|
||||||
docker compose logs -f clinch
|
|
||||||
|
|
||||||
# View last 100 lines
|
|
||||||
docker compose logs --tail=100 clinch
|
|
||||||
```
|
|
||||||
|
|
||||||
### Backup & Restore
|
|
||||||
|
|
||||||
Clinch stores all persistent data in the `storage/` directory (or `/rails/storage` in Docker):
|
|
||||||
- SQLite database (`production.sqlite3`)
|
|
||||||
- Uploaded files via ActiveStorage (application icons)
|
|
||||||
|
|
||||||
**Database Backup:**
|
|
||||||
|
|
||||||
Use SQLite's `VACUUM INTO` command for safe, atomic backups of a running database:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Local development
|
|
||||||
sqlite3 storage/production.sqlite3 "VACUUM INTO 'backup.sqlite3';"
|
|
||||||
```
|
|
||||||
|
|
||||||
This creates an optimized copy of the database that's safe to make even while Clinch is running.
|
|
||||||
|
|
||||||
**Full Backup (Database + Uploads):**
|
|
||||||
|
|
||||||
For complete backups including uploaded files, backup the database and uploads separately:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Backup database (safe while running)
|
|
||||||
sqlite3 storage/production.sqlite3 "VACUUM INTO 'backup-$(date +%Y%m%d).sqlite3';"
|
|
||||||
|
|
||||||
# 2. Backup uploaded files (ActiveStorage files are immutable)
|
|
||||||
tar -czf uploads-backup-$(date +%Y%m%d).tar.gz storage/uploads/
|
|
||||||
|
|
||||||
# Docker Compose equivalent
|
|
||||||
docker compose exec clinch sqlite3 /rails/storage/production.sqlite3 "VACUUM INTO '/rails/storage/backup-$(date +%Y%m%d).sqlite3';"
|
|
||||||
docker compose exec clinch tar -czf /rails/storage/uploads-backup-$(date +%Y%m%d).tar.gz /rails/storage/uploads/
|
|
||||||
```
|
|
||||||
|
|
||||||
**Restore:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Stop Clinch first
|
|
||||||
# Then restore database
|
|
||||||
cp backup-YYYYMMDD.sqlite3 storage/production.sqlite3
|
|
||||||
|
|
||||||
# Restore uploads
|
|
||||||
tar -xzf uploads-backup-YYYYMMDD.tar.gz -C storage/
|
|
||||||
```
|
|
||||||
|
|
||||||
**Docker Volume Backup:**
|
|
||||||
|
|
||||||
**Option 1: While Running (Online Backup)**
|
|
||||||
|
|
||||||
a) **Mapped volumes** (recommended, e.g., `-v /host/path:/rails/storage`):
|
|
||||||
```bash
|
|
||||||
# Database backup (safe while running)
|
|
||||||
sqlite3 /host/path/production.sqlite3 "VACUUM INTO '/host/path/backup-$(date +%Y%m%d).sqlite3';"
|
|
||||||
|
|
||||||
# Then sync to off-server storage
|
|
||||||
rsync -av /host/path/backup-*.sqlite3 /host/path/uploads/ remote:/backups/clinch/
|
|
||||||
```
|
|
||||||
|
|
||||||
b) **Docker volumes** (e.g., using named volumes in compose):
|
|
||||||
```bash
|
|
||||||
# Database backup (safe while running)
|
|
||||||
docker compose exec clinch sqlite3 /rails/storage/production.sqlite3 "VACUUM INTO '/rails/storage/backup.sqlite3';"
|
|
||||||
|
|
||||||
# Copy out of container
|
|
||||||
docker compose cp clinch:/rails/storage/backup.sqlite3 ./backup-$(date +%Y%m%d).sqlite3
|
|
||||||
```
|
|
||||||
|
|
||||||
**Option 2: While Stopped (Offline Backup)**
|
|
||||||
|
|
||||||
If Docker is stopped, you can copy the entire storage:
|
|
||||||
```bash
|
|
||||||
docker compose down
|
|
||||||
|
|
||||||
# For mapped volumes
|
|
||||||
tar -czf clinch-backup-$(date +%Y%m%d).tar.gz /host/path/
|
|
||||||
|
|
||||||
# For docker volumes
|
|
||||||
docker run --rm -v clinch_storage:/data -v $(pwd):/backup ubuntu \
|
|
||||||
tar czf /backup/clinch-backup-$(date +%Y%m%d).tar.gz /data
|
|
||||||
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
**Important:** Do not use tar/snapshots on a running database - use `VACUUM INTO` instead or stop the container first.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
All configuration is handled via environment variables (see the `.env` file in the Docker Compose section above).
|
### Environment Variables
|
||||||
|
|
||||||
|
Create a `.env` file (see `.env.example`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Rails
|
||||||
|
SECRET_KEY_BASE=generate-with-bin-rails-secret
|
||||||
|
RAILS_ENV=production
|
||||||
|
|
||||||
|
# Database
|
||||||
|
# SQLite database stored in storage/ directory (Docker volume mount point)
|
||||||
|
|
||||||
|
# SMTP (for sending emails)
|
||||||
|
SMTP_ADDRESS=smtp.example.com
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_DOMAIN=example.com
|
||||||
|
SMTP_USERNAME=your-username
|
||||||
|
SMTP_PASSWORD=your-password
|
||||||
|
SMTP_AUTHENTICATION=plain
|
||||||
|
SMTP_ENABLE_STARTTLS=true
|
||||||
|
|
||||||
|
# Application
|
||||||
|
CLINCH_HOST=https://auth.example.com
|
||||||
|
CLINCH_FROM_EMAIL=noreply@example.com
|
||||||
|
```
|
||||||
|
|
||||||
### First Run
|
### First Run
|
||||||
1. Visit Clinch at `http://localhost:3000` (or your configured domain)
|
1. Visit Clinch at `http://localhost:3000` (or your configured domain)
|
||||||
@@ -550,255 +220,24 @@ All configuration is handled via environment variables (see the `.env` file in t
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Rails Console
|
## Roadmap
|
||||||
|
|
||||||
One advantage of being a Rails application is direct access to the Rails console for administrative tasks. This is particularly useful for debugging, emergency access, or bulk operations.
|
### In Progress
|
||||||
|
- OIDC provider implementation
|
||||||
### Starting the Console
|
- ForwardAuth endpoint
|
||||||
|
- Admin UI for user/group/app management
|
||||||
```bash
|
- First-run wizard
|
||||||
# Docker / Docker Compose
|
|
||||||
docker exec -it clinch bin/rails console
|
### Planned Features
|
||||||
# or
|
- **Audit logging** - Track all authentication events
|
||||||
docker compose exec -it clinch bin/rails console
|
- **WebAuthn/Passkeys** - Hardware key support
|
||||||
|
|
||||||
# Local development
|
#### Maybe
|
||||||
bin/rails console
|
- **SAML support** - SAML 2.0 identity provider
|
||||||
```
|
- **Policy engine** - Rule-based access control
|
||||||
|
- Example: `IF user.email =~ "*@gmail.com" AND app.slug == "kavita" THEN DENY`
|
||||||
### Finding Users
|
- Stored as JSON, evaluated after auth but before consent
|
||||||
|
- **LDAP sync** - Import users from LDAP/Active Directory
|
||||||
```ruby
|
|
||||||
# Find by email
|
|
||||||
user = User.find_by(email_address: 'alice@example.com')
|
|
||||||
|
|
||||||
# Find by username
|
|
||||||
user = User.find_by(username: 'alice')
|
|
||||||
|
|
||||||
# List all users
|
|
||||||
User.all.pluck(:id, :email_address, :status)
|
|
||||||
|
|
||||||
# Find admins
|
|
||||||
User.admins.pluck(:email_address)
|
|
||||||
|
|
||||||
# Find users in a specific status
|
|
||||||
User.active.count
|
|
||||||
User.disabled.pluck(:email_address)
|
|
||||||
User.pending_invitation.pluck(:email_address)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Creating Users
|
|
||||||
|
|
||||||
```ruby
|
|
||||||
# Create a regular user
|
|
||||||
User.create!(
|
|
||||||
email_address: 'newuser@example.com',
|
|
||||||
password: 'secure-password-here',
|
|
||||||
status: :active
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create an admin user
|
|
||||||
User.create!(
|
|
||||||
email_address: 'admin@example.com',
|
|
||||||
password: 'secure-password-here',
|
|
||||||
status: :active,
|
|
||||||
admin: true
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Managing Passwords
|
|
||||||
|
|
||||||
```ruby
|
|
||||||
user = User.find_by(email_address: 'alice@example.com')
|
|
||||||
user.password = 'new-secure-password'
|
|
||||||
user.save!
|
|
||||||
```
|
|
||||||
|
|
||||||
### Two-Factor Authentication (TOTP)
|
|
||||||
|
|
||||||
```ruby
|
|
||||||
user = User.find_by(email_address: 'alice@example.com')
|
|
||||||
|
|
||||||
# Check if TOTP is enabled
|
|
||||||
user.totp_enabled?
|
|
||||||
|
|
||||||
# Get current TOTP code (useful for testing/debugging)
|
|
||||||
puts user.console_totp
|
|
||||||
|
|
||||||
# Enable TOTP (generates secret and backup codes)
|
|
||||||
backup_codes = user.enable_totp!
|
|
||||||
puts backup_codes # Display backup codes to give to user
|
|
||||||
|
|
||||||
# Disable TOTP
|
|
||||||
user.disable_totp!
|
|
||||||
|
|
||||||
# Force user to set up TOTP on next login
|
|
||||||
user.update!(totp_required: true)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Managing User Status
|
|
||||||
|
|
||||||
```ruby
|
|
||||||
user = User.find_by(email_address: 'alice@example.com')
|
|
||||||
|
|
||||||
# Disable a user (prevents login)
|
|
||||||
user.disabled!
|
|
||||||
|
|
||||||
# Re-enable a user
|
|
||||||
user.active!
|
|
||||||
|
|
||||||
# Check current status
|
|
||||||
user.status # => "active", "disabled", or "pending_invitation"
|
|
||||||
|
|
||||||
# Grant admin privileges
|
|
||||||
user.update!(admin: true)
|
|
||||||
|
|
||||||
# Revoke admin privileges
|
|
||||||
user.update!(admin: false)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Managing Groups
|
|
||||||
|
|
||||||
```ruby
|
|
||||||
user = User.find_by(email_address: 'alice@example.com')
|
|
||||||
|
|
||||||
# View user's groups
|
|
||||||
user.groups.pluck(:name)
|
|
||||||
|
|
||||||
# Add user to a group
|
|
||||||
family = Group.find_by(name: 'family')
|
|
||||||
user.groups << family
|
|
||||||
|
|
||||||
# Remove user from a group
|
|
||||||
user.groups.delete(family)
|
|
||||||
|
|
||||||
# Create a new group
|
|
||||||
Group.create!(name: 'developers', description: 'Development team')
|
|
||||||
```
|
|
||||||
|
|
||||||
### Managing Sessions
|
|
||||||
|
|
||||||
```ruby
|
|
||||||
user = User.find_by(email_address: 'alice@example.com')
|
|
||||||
|
|
||||||
# View active sessions
|
|
||||||
user.sessions.pluck(:id, :device_name, :client_ip, :created_at)
|
|
||||||
|
|
||||||
# Revoke all sessions (force logout everywhere)
|
|
||||||
user.sessions.destroy_all
|
|
||||||
|
|
||||||
# Revoke a specific session
|
|
||||||
user.sessions.find(123).destroy
|
|
||||||
```
|
|
||||||
|
|
||||||
### Managing Applications
|
|
||||||
|
|
||||||
```ruby
|
|
||||||
# List all OIDC applications
|
|
||||||
Application.oidc.pluck(:name, :client_id)
|
|
||||||
|
|
||||||
# Find an application
|
|
||||||
app = Application.find_by(slug: 'kavita')
|
|
||||||
|
|
||||||
# Regenerate client secret
|
|
||||||
new_secret = app.generate_new_client_secret!
|
|
||||||
puts new_secret # Display once - not stored in plain text
|
|
||||||
|
|
||||||
# Check which users can access an app
|
|
||||||
app.allowed_groups.flat_map(&:users).uniq.pluck(:email_address)
|
|
||||||
|
|
||||||
# Revoke all tokens for an application
|
|
||||||
app.oidc_access_tokens.destroy_all
|
|
||||||
app.oidc_refresh_tokens.destroy_all
|
|
||||||
```
|
|
||||||
|
|
||||||
### Revoking OIDC Consents
|
|
||||||
|
|
||||||
```ruby
|
|
||||||
user = User.find_by(email_address: 'alice@example.com')
|
|
||||||
app = Application.find_by(slug: 'kavita')
|
|
||||||
|
|
||||||
# Revoke consent for a specific app
|
|
||||||
user.revoke_consent!(app)
|
|
||||||
|
|
||||||
# Revoke all OIDC consents
|
|
||||||
user.revoke_all_consents!
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Testing & Security
|
|
||||||
|
|
||||||
### Running Tests
|
|
||||||
|
|
||||||
Clinch has comprehensive test coverage with 450 tests covering integration, models, controllers, services, and system tests.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Run all tests
|
|
||||||
bin/rails test
|
|
||||||
|
|
||||||
# Run specific test types
|
|
||||||
bin/rails test:integration
|
|
||||||
bin/rails test:models
|
|
||||||
bin/rails test:controllers
|
|
||||||
bin/rails test:system
|
|
||||||
|
|
||||||
# Run with code coverage report
|
|
||||||
COVERAGE=1 bin/rails test
|
|
||||||
# View coverage report at coverage/index.html
|
|
||||||
```
|
|
||||||
|
|
||||||
### Security Scanning
|
|
||||||
|
|
||||||
Clinch uses multiple automated security tools to ensure code quality and security:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Run all security checks
|
|
||||||
bin/rake security
|
|
||||||
|
|
||||||
# Individual security scans
|
|
||||||
bin/brakeman --no-pager # Static security analysis
|
|
||||||
bin/bundler-audit check --update # Dependency vulnerability scan
|
|
||||||
bin/importmap audit # JavaScript dependency scan
|
|
||||||
```
|
|
||||||
|
|
||||||
**Container Image Scanning:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Install Trivy
|
|
||||||
brew install trivy # macOS
|
|
||||||
# or use Docker: alias trivy='docker run --rm -v /var/run/docker.sock:/var/run/docker.sock aquasec/trivy'
|
|
||||||
|
|
||||||
# Build and scan image (CRITICAL and HIGH severity only, like CI)
|
|
||||||
docker build -t clinch:local .
|
|
||||||
trivy image --severity CRITICAL,HIGH --scanners vuln clinch:local
|
|
||||||
|
|
||||||
# Scan only for fixable vulnerabilities
|
|
||||||
trivy image --severity CRITICAL,HIGH --scanners vuln --ignore-unfixed clinch:local
|
|
||||||
```
|
|
||||||
|
|
||||||
**CI/CD Integration:**
|
|
||||||
All security scans run automatically on every pull request and push to main via GitHub Actions.
|
|
||||||
|
|
||||||
**Security Tools:**
|
|
||||||
- **Brakeman** - Static analysis for Rails security vulnerabilities
|
|
||||||
- **bundler-audit** - Checks gems for known CVEs
|
|
||||||
- **Trivy** - Container image vulnerability scanning (OS/system packages)
|
|
||||||
- **Dependabot** - Automated dependency updates
|
|
||||||
- **GitHub Secret Scanning** - Detects leaked credentials with push protection
|
|
||||||
- **SimpleCov** - Code coverage tracking
|
|
||||||
- **RuboCop** - Code style and quality enforcement
|
|
||||||
|
|
||||||
**Current Status:**
|
|
||||||
- ✅ All security scans passing
|
|
||||||
- ✅ 450 tests, 1818 assertions, 0 failures
|
|
||||||
- ✅ No known dependency vulnerabilities
|
|
||||||
- ✅ Phases 1-4 security hardening complete (18+ vulnerabilities fixed)
|
|
||||||
- 🟡 3 outstanding security issues (all MEDIUM/LOW priority)
|
|
||||||
|
|
||||||
**Security Documentation:**
|
|
||||||
- [docs/security-todo.md](docs/security-todo.md) - Detailed vulnerability tracking and remediation history
|
|
||||||
- [docs/beta-checklist.md](docs/beta-checklist.md) - Beta release readiness criteria
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,110 +0,0 @@
|
|||||||
# Security Review — Tracking
|
|
||||||
|
|
||||||
Status of findings from the multi-surface security review (OIDC/OAuth2, ForwardAuth,
|
|
||||||
WebAuthn/TOTP, sessions, admin/config). Work landed on branch
|
|
||||||
`security/forward-auth-and-consent-csrf`.
|
|
||||||
|
|
||||||
## ✅ Done (branch `security/forward-auth-and-consent-csrf`)
|
|
||||||
|
|
||||||
All HIGH findings are closed. Each fix has tests; suite is green.
|
|
||||||
|
|
||||||
| Commit | Fix | Sev |
|
|
||||||
|--------|-----|-----|
|
|
||||||
| `703d24e` | ForwardAuth fail-open when no host header; consent endpoint CSRF | HIGH ×2 |
|
|
||||||
| `8a095e4` | Bearer API-key skipped group check at use-time | HIGH |
|
|
||||||
| `96a657e` | Open redirect via unvalidated `X-Forwarded-Host` in login redirect | HIGH |
|
|
||||||
| `84ed462` | `CLINCH_HOST` made mandatory in deployed envs; dropped request-host fallback | MEDIUM |
|
|
||||||
| `f38ac2e` | TOTP code replay within drift window (+ latent plaintext backup-code bug) | HIGH |
|
|
||||||
| `406a79d` | SSRF via `backchannel_logout_uri` (metadata/loopback/RFC1918) | HIGH |
|
|
||||||
| `57d7d1f` | Host-auth regex unanchored (`evil-example.com` matched) | HIGH |
|
|
||||||
| `89bd5f1` | Disabled user could complete 2FA mid-flow / keep session; enforce active status | HIGH |
|
|
||||||
| `cd862c7` | TOTP/backup/OAuth/PKCE `code` params not filtered from logs | MEDIUM |
|
|
||||||
| `2426687` | `revoke_family!` didn't revoke access tokens on refresh-token reuse | HIGH |
|
|
||||||
| `44892e3` | WebAuthn clone detection logged but didn't block; false-positive on synced passkeys | HIGH |
|
|
||||||
| `d49e7ce` | CSP `unsafe-inline` removed (script-src + style-src → nonces) | HIGH |
|
|
||||||
|
|
||||||
**Verified false positive (no change):** PKCE *is* required by default —
|
|
||||||
`require_pkce` column defaults to `true` (`db/schema.rb`), token endpoint enforces
|
|
||||||
it, admin UI exposes the opt-out. Operational check: confirm no legacy confidential
|
|
||||||
apps sit on `require_pkce = false`.
|
|
||||||
|
|
||||||
**Follow-up before relying on CSP change:** do one manual browser pass (DevTools
|
|
||||||
console) on `/signin`, OAuth consent, a Turbo navigation, dark-mode toggle, and a
|
|
||||||
WebAuthn sign-in — expect zero CSP violations. Dev is report-only so violations
|
|
||||||
surface as warnings without breaking. Fallback if style-src surprises: keep
|
|
||||||
`style-src 'unsafe-inline'`, ship script-src only.
|
|
||||||
|
|
||||||
## ☐ Remaining — MEDIUM
|
|
||||||
|
|
||||||
- [ ] **`id_token_hint` ignored at OIDC logout** — any client can redirect logout to
|
|
||||||
any other registered client's post-logout URI. Validate the hint's `aud` and
|
|
||||||
scope the redirect to that app. `app/controllers/oidc_controller.rb` (logout).
|
|
||||||
- [ ] **`offline_access` doesn't gate refresh-token issuance** — refresh tokens are
|
|
||||||
minted unconditionally; gate on the granted scope.
|
|
||||||
`app/controllers/oidc_controller.rb` (authorization_code grant, ~line 564).
|
|
||||||
- [ ] **CSP-report endpoint hardening** — unauthenticated, no rate limit / body-size
|
|
||||||
cap, logs raw CRLF (log injection). Sanitize values, cap size, rate-limit.
|
|
||||||
`app/controllers/api/csp_controller.rb`.
|
|
||||||
- [ ] **Port not stripped from `X-Forwarded-Host`** in main verify + bearer paths →
|
|
||||||
403 outages on non-standard ports (also a correctness bug). Reuse the
|
|
||||||
port-stripping done in `check_forward_auth_token`.
|
|
||||||
`app/controllers/api/forward_auth_controller.rb`.
|
|
||||||
- [ ] **WebAuthn `acr:"2"` without enforced user verification** — `user_verification:
|
|
||||||
"preferred"` lets a PIN-less key authenticate yet reports verified 2FA. Use
|
|
||||||
`"required"`, or downgrade `acr` to `"1"` when the UV flag is absent.
|
|
||||||
`app/controllers/sessions_controller.rb` (webauthn_challenge/verify),
|
|
||||||
`app/controllers/webauthn_controller.rb`.
|
|
||||||
- [ ] **`RESERVED_CLAIMS` incomplete** — missing `at_hash`/`auth_time`/`acr`; and
|
|
||||||
`ApplicationUserClaims` has no reserved-name validation (User/Group do). Could
|
|
||||||
let a custom claim overwrite a security claim. `app/services/oidc_jwt_service.rb`,
|
|
||||||
`app/models/application_user_claim.rb`.
|
|
||||||
- [ ] **`reset_session` not called on login** — defensive best practice for an IdP;
|
|
||||||
clears pre-auth session state. `app/controllers/concerns/authentication.rb`
|
|
||||||
(`start_new_session_for`).
|
|
||||||
- [x] **Hardcoded private IP `192.168.2.246`** in `config/environments/production.rb`
|
|
||||||
— removed; it was redundant with the `192.168.0.0/16` regex already in the
|
|
||||||
`CLINCH_ALLOW_INTERNAL_IPS` block.
|
|
||||||
- [ ] **CSP `form-action` widened by unvalidated `redirect_uri`** before auth — only
|
|
||||||
add to `form-action` if the client_id+redirect_uri is a registered pair.
|
|
||||||
`app/controllers/concerns/authentication.rb` (`allow_oauth_redirect_in_csp`).
|
|
||||||
- [ ] **SVG `style` attribute permits `url()`/`expression()`** — mitigated today by
|
|
||||||
`Content-Disposition: attachment`, but fragile. Sanitize CSS values or drop
|
|
||||||
`style` from the allowlist. `app/models/svg_scrubber.rb`.
|
|
||||||
- [ ] **WebAuthn error messages leak internals** — return generic errors to client,
|
|
||||||
log detail server-side. `app/controllers/sessions_controller.rb`,
|
|
||||||
`app/controllers/webauthn_controller.rb`.
|
|
||||||
- [ ] **Account enumeration via webauthn challenge** — distinguishes "user not found"
|
|
||||||
vs "no passkey". Return a uniform message. `app/controllers/sessions_controller.rb`
|
|
||||||
(`webauthn_challenge`).
|
|
||||||
- [ ] **`token_family_id` only 31 bits** (`SecureRandom.random_number(2**31)`) —
|
|
||||||
birthday collision ~46k; use a UUID/string. `app/models/oidc_refresh_token.rb`.
|
|
||||||
- [ ] **Session cookie uses sequential integer DB id** — HMAC-signed so not forgeable,
|
|
||||||
but consider a random `token` column (Rails 8 generator default).
|
|
||||||
`app/models/session.rb`, `app/controllers/concerns/authentication.rb`.
|
|
||||||
- [ ] **Login rate-limit is IP-only** — no account lockout (distributed brute force /
|
|
||||||
credential stuffing). Add failed-count + `locked_until` on users.
|
|
||||||
- [ ] **Backup-code rate limit not reset on success** and is cache-based (resets on
|
|
||||||
cache flush). Reset on success; consider DB-backed counter. `app/models/user.rb`.
|
|
||||||
|
|
||||||
## ☐ Remaining — LOW / INFO
|
|
||||||
|
|
||||||
- [ ] Public clients can't revoke their own tokens (revoke endpoint requires secret).
|
|
||||||
- [ ] Basic-auth client creds not URL-decoded per RFC 6749 §2.3.1.
|
|
||||||
- [ ] `token_hmac` columns nullable at DB level despite model `presence: true`.
|
|
||||||
- [ ] Group names allow commas → injection into `X-Remote-Groups` (false memberships
|
|
||||||
downstream). Add a format validator. `app/models/group.rb`.
|
|
||||||
- [ ] `fa_token` leaks in redirect URL / Referer / history (60s TTL, host-bound).
|
|
||||||
- [ ] Admin `domain_pattern` allows ReDoS — add a format validator.
|
|
||||||
`app/models/application.rb`.
|
|
||||||
- [ ] Forced-TOTP-setup login path can redirect-loop (`totp_required` + no TOTP).
|
|
||||||
- [ ] `complete_setup` creates an unprompted session for any authenticated user.
|
|
||||||
- [ ] Password min length only 8 — consider 12 + a max (bcrypt 72-byte truncation).
|
|
||||||
- [ ] `support_unencrypted_data: true` left enabled (TOTP secret encryption migration).
|
|
||||||
`config/initializers/active_record_encryption.rb`.
|
|
||||||
- [ ] All crypto keys derived from a single `SECRET_KEY_BASE` root — document setting
|
|
||||||
independent `ACTIVE_RECORD_ENCRYPTION_*` keys in production.
|
|
||||||
- [ ] Log injection via user `email_address` in ForwardAuth logs (strip CRLF / use
|
|
||||||
structured logging). `app/controllers/api/forward_auth_controller.rb`.
|
|
||||||
- [ ] WebAuthn RP ID is the registrable domain (cross-subdomain credential roaming) —
|
|
||||||
set `CLINCH_RP_ID` to the exact host unless roaming is intended.
|
|
||||||
`config/initializers/webauthn.rb`.
|
|
||||||
@@ -1,29 +1 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@plugin "@tailwindcss/forms";
|
|
||||||
@custom-variant dark (&:where(.dark, .dark *));
|
|
||||||
|
|
||||||
@layer base {
|
|
||||||
input:where([type="text"], [type="email"], [type="password"], [type="number"], [type="url"], [type="tel"], [type="search"]),
|
|
||||||
textarea,
|
|
||||||
select {
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark input:where([type="text"], [type="email"], [type="password"], [type="number"], [type="url"], [type="tel"], [type="search"]),
|
|
||||||
.dark textarea,
|
|
||||||
.dark select {
|
|
||||||
background-color: var(--color-gray-800);
|
|
||||||
border-color: var(--color-gray-600);
|
|
||||||
color: var(--color-gray-100);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark input::placeholder,
|
|
||||||
.dark textarea::placeholder {
|
|
||||||
color: var(--color-gray-400);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark input:where([type="checkbox"], [type="radio"]) {
|
|
||||||
background-color: var(--color-gray-700);
|
|
||||||
border-color: var(--color-gray-500);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -7,9 +7,8 @@ module ApplicationCable
|
|||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def set_current_user
|
def set_current_user
|
||||||
if (session = Session.find_by(id: cookies.signed[:session_id]))
|
if session = Session.find_by(id: cookies.signed[:session_id])
|
||||||
self.current_user = session.user
|
self.current_user = session.user
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,101 +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
|
|
||||||
|
|
||||||
# Send backchannel logout notification before revoking consent
|
|
||||||
if application.supports_backchannel_logout?
|
|
||||||
BackchannelLogoutJob.perform_later(
|
|
||||||
user_id: @user.id,
|
|
||||||
application_id: application.id,
|
|
||||||
consent_sid: consent.sid
|
|
||||||
)
|
|
||||||
Rails.logger.info "ActiveSessionsController: Enqueued backchannel logout for #{application.name}"
|
|
||||||
end
|
|
||||||
|
|
||||||
# Revoke all tokens for this user-application pair
|
|
||||||
now = Time.current
|
|
||||||
revoked_access_tokens = OidcAccessToken.where(application: application, user: @user, revoked_at: nil)
|
|
||||||
.update_all(revoked_at: now)
|
|
||||||
revoked_refresh_tokens = OidcRefreshToken.where(application: application, user: @user, revoked_at: nil)
|
|
||||||
.update_all(revoked_at: now)
|
|
||||||
|
|
||||||
Rails.logger.info "ActiveSessionsController: Revoked #{revoked_access_tokens} access tokens and #{revoked_refresh_tokens} refresh tokens for #{application.name}"
|
|
||||||
|
|
||||||
# Revoke the consent
|
|
||||||
consent.destroy
|
|
||||||
redirect_to active_sessions_path, notice: "Successfully revoked access to #{application.name}."
|
|
||||||
end
|
|
||||||
|
|
||||||
def logout_from_app
|
|
||||||
@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 root_path, alert: "No active session found for this application."
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
# Send backchannel logout notification
|
|
||||||
if application.supports_backchannel_logout?
|
|
||||||
BackchannelLogoutJob.perform_later(
|
|
||||||
user_id: @user.id,
|
|
||||||
application_id: application.id,
|
|
||||||
consent_sid: consent.sid
|
|
||||||
)
|
|
||||||
Rails.logger.info "ActiveSessionsController: Enqueued backchannel logout for #{application.name}"
|
|
||||||
end
|
|
||||||
|
|
||||||
# Revoke all tokens for this user-application pair
|
|
||||||
now = Time.current
|
|
||||||
revoked_access_tokens = OidcAccessToken.where(application: application, user: @user, revoked_at: nil)
|
|
||||||
.update_all(revoked_at: now)
|
|
||||||
revoked_refresh_tokens = OidcRefreshToken.where(application: application, user: @user, revoked_at: nil)
|
|
||||||
.update_all(revoked_at: now)
|
|
||||||
|
|
||||||
Rails.logger.info "ActiveSessionsController: Logged out from #{application.name} - revoked #{revoked_access_tokens} access tokens and #{revoked_refresh_tokens} refresh tokens"
|
|
||||||
|
|
||||||
# Keep the consent intact - this is the key difference from revoke_consent
|
|
||||||
redirect_to root_path, notice: "Revoked access tokens for #{application.name}. Re-authentication will be required on next use."
|
|
||||||
end
|
|
||||||
|
|
||||||
def revoke_all_consents
|
|
||||||
@user = Current.session.user
|
|
||||||
consents = @user.oidc_user_consents.includes(:application)
|
|
||||||
count = consents.count
|
|
||||||
|
|
||||||
if count > 0
|
|
||||||
# Send backchannel logout notifications before revoking consents
|
|
||||||
consents.each do |consent|
|
|
||||||
next unless consent.application.supports_backchannel_logout?
|
|
||||||
|
|
||||||
BackchannelLogoutJob.perform_later(
|
|
||||||
user_id: @user.id,
|
|
||||||
application_id: consent.application.id,
|
|
||||||
consent_sid: consent.sid
|
|
||||||
)
|
|
||||||
end
|
|
||||||
Rails.logger.info "ActiveSessionsController: Enqueued #{count} backchannel logout notifications"
|
|
||||||
|
|
||||||
@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,20 +0,0 @@
|
|||||||
module Admin
|
|
||||||
class AccessChecksController < BaseController
|
|
||||||
def new
|
|
||||||
load_options
|
|
||||||
@user = User.find_by(id: params[:user_id])
|
|
||||||
@application = Application.find_by(id: params[:application_id])
|
|
||||||
return unless @user && @application
|
|
||||||
|
|
||||||
@allowed = @application.user_allowed?(@user)
|
|
||||||
@via = @user.groups & @application.allowed_groups
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def load_options
|
|
||||||
@users = User.order(:email_address)
|
|
||||||
@applications = Application.order(:name)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,33 +1,13 @@
|
|||||||
module Admin
|
module Admin
|
||||||
class ApplicationsController < BaseController
|
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
|
def index
|
||||||
@applications = Application.order(created_at: :desc).includes(:allowed_groups)
|
@applications = Application.order(created_at: :desc)
|
||||||
|
|
||||||
# Distinct active users that have access to each app, preloaded to avoid N+1.
|
|
||||||
@user_count_by_app = User.where(status: User.statuses[:active])
|
|
||||||
.joins(groups: :applications)
|
|
||||||
.group("applications.id")
|
|
||||||
.distinct
|
|
||||||
.count("users.id")
|
|
||||||
|
|
||||||
# Top-of-page summary
|
|
||||||
@total_users_with_access = User.where(status: User.statuses[:active])
|
|
||||||
.joins(groups: :applications)
|
|
||||||
.distinct
|
|
||||||
.count("users.id")
|
|
||||||
@total_groups_granting_access = Group.joins(:applications).distinct.count
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def show
|
def show
|
||||||
@allowed_groups = @application.allowed_groups
|
@allowed_groups = @application.allowed_groups
|
||||||
@users_with_access = User.where(status: User.statuses[:active])
|
|
||||||
.joins(groups: :applications)
|
|
||||||
.where(applications: {id: @application.id})
|
|
||||||
.distinct
|
|
||||||
.includes(:groups)
|
|
||||||
.order(:email_address)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def new
|
def new
|
||||||
@@ -46,17 +26,18 @@ module Admin
|
|||||||
@application.allowed_groups = Group.where(id: group_ids)
|
@application.allowed_groups = Group.where(id: group_ids)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Get the plain text client secret to show one time (confidential clients only)
|
# Get the plain text client secret to show one time
|
||||||
client_secret = nil
|
client_secret = nil
|
||||||
if @application.oidc? && @application.confidential_client?
|
if @application.oidc?
|
||||||
client_secret = @application.generate_new_client_secret!
|
client_secret = @application.generate_new_client_secret!
|
||||||
end
|
end
|
||||||
|
|
||||||
|
if @application.oidc? && client_secret
|
||||||
flash[:notice] = "Application created successfully."
|
flash[:notice] = "Application created successfully."
|
||||||
if @application.oidc?
|
|
||||||
flash[:client_id] = @application.client_id
|
flash[:client_id] = @application.client_id
|
||||||
flash[:client_secret] = client_secret if client_secret
|
flash[:client_secret] = client_secret
|
||||||
flash[:public_client] = true if @application.public_client?
|
else
|
||||||
|
flash[:notice] = "Application created successfully."
|
||||||
end
|
end
|
||||||
|
|
||||||
redirect_to admin_application_path(@application)
|
redirect_to admin_application_path(@application)
|
||||||
@@ -93,20 +74,15 @@ module Admin
|
|||||||
|
|
||||||
def regenerate_credentials
|
def regenerate_credentials
|
||||||
if @application.oidc?
|
if @application.oidc?
|
||||||
# Generate new client ID (always)
|
# Generate new client ID and secret
|
||||||
new_client_id = SecureRandom.urlsafe_base64(32)
|
new_client_id = SecureRandom.urlsafe_base64(32)
|
||||||
|
client_secret = @application.generate_new_client_secret!
|
||||||
|
|
||||||
@application.update!(client_id: new_client_id)
|
@application.update!(client_id: new_client_id)
|
||||||
|
|
||||||
flash[:notice] = "Credentials regenerated successfully."
|
flash[:notice] = "Credentials regenerated successfully."
|
||||||
flash[:client_id] = @application.client_id
|
flash[:client_id] = @application.client_id
|
||||||
|
|
||||||
# Generate new client secret only for confidential clients
|
|
||||||
if @application.confidential_client?
|
|
||||||
client_secret = @application.generate_new_client_secret!
|
|
||||||
flash[:client_secret] = client_secret
|
flash[:client_secret] = client_secret
|
||||||
else
|
|
||||||
flash[:public_client] = true
|
|
||||||
end
|
|
||||||
|
|
||||||
redirect_to admin_application_path(@application)
|
redirect_to admin_application_path(@application)
|
||||||
else
|
else
|
||||||
@@ -114,6 +90,53 @@ module Admin
|
|||||||
end
|
end
|
||||||
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
|
private
|
||||||
|
|
||||||
def set_application
|
def set_application
|
||||||
@@ -121,24 +144,14 @@ module Admin
|
|||||||
end
|
end
|
||||||
|
|
||||||
def application_params
|
def application_params
|
||||||
permitted = params.require(:application).permit(
|
params.require(:application).permit(
|
||||||
:name, :slug, :app_type, :active, :redirect_uris, :description, :metadata,
|
:name, :slug, :app_type, :active, :redirect_uris, :description, :metadata,
|
||||||
:domain_pattern, :landing_url, :access_token_ttl, :refresh_token_ttl, :id_token_ttl,
|
:role_mapping_mode, :role_prefix, :role_claim_name, managed_permissions: {}
|
||||||
:icon, :icon_dark, :backchannel_logout_uri, :is_public_client, :require_pkce, :skip_consent
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Handle headers_config - it comes as a JSON string from the text area
|
|
||||||
if params[:application][:headers_config].present?
|
|
||||||
begin
|
|
||||||
permitted[:headers_config] = JSON.parse(params[:application][:headers_config])
|
|
||||||
rescue JSON::ParserError
|
|
||||||
permitted[:headers_config] = {}
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Remove client_secret from params if present (shouldn't be updated via form)
|
def role_params
|
||||||
permitted.delete(:client_secret)
|
params.require(:application_role).permit(:name, :display_name, :description, :active, permissions: {})
|
||||||
permitted
|
|
||||||
end
|
end
|
||||||
end
|
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
|
||||||
@@ -15,30 +15,10 @@ module Admin
|
|||||||
def new
|
def new
|
||||||
@group = Group.new
|
@group = Group.new
|
||||||
@available_users = User.order(:email_address)
|
@available_users = User.order(:email_address)
|
||||||
@available_applications = Application.order(:name)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
create_params = group_params
|
@group = Group.new(group_params)
|
||||||
|
|
||||||
# Parse custom_claims JSON if provided
|
|
||||||
if create_params[:custom_claims].present?
|
|
||||||
begin
|
|
||||||
create_params[:custom_claims] = JSON.parse(create_params[:custom_claims])
|
|
||||||
rescue JSON::ParserError
|
|
||||||
@group = Group.new
|
|
||||||
@group.errors.add(:custom_claims, "must be valid JSON")
|
|
||||||
@available_users = User.order(:email_address)
|
|
||||||
@available_applications = Application.order(:name)
|
|
||||||
render :new, status: :unprocessable_entity
|
|
||||||
return
|
|
||||||
end
|
|
||||||
else
|
|
||||||
# If empty or blank, set to empty hash (NOT NULL constraint)
|
|
||||||
create_params[:custom_claims] = {}
|
|
||||||
end
|
|
||||||
|
|
||||||
@group = Group.new(create_params)
|
|
||||||
|
|
||||||
if @group.save
|
if @group.save
|
||||||
# Handle user assignments
|
# Handle user assignments
|
||||||
@@ -47,45 +27,19 @@ module Admin
|
|||||||
@group.users = User.where(id: user_ids)
|
@group.users = User.where(id: user_ids)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Handle application assignments
|
|
||||||
if params[:group][:application_ids].present?
|
|
||||||
application_ids = params[:group][:application_ids].reject(&:blank?)
|
|
||||||
@group.applications = Application.where(id: application_ids)
|
|
||||||
end
|
|
||||||
|
|
||||||
redirect_to admin_group_path(@group), notice: "Group created successfully."
|
redirect_to admin_group_path(@group), notice: "Group created successfully."
|
||||||
else
|
else
|
||||||
@available_users = User.order(:email_address)
|
@available_users = User.order(:email_address)
|
||||||
@available_applications = Application.order(:name)
|
|
||||||
render :new, status: :unprocessable_entity
|
render :new, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def edit
|
def edit
|
||||||
@available_users = User.order(:email_address)
|
@available_users = User.order(:email_address)
|
||||||
@available_applications = Application.order(:name)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
update_params = group_params
|
if @group.update(group_params)
|
||||||
|
|
||||||
# Parse custom_claims JSON if provided
|
|
||||||
if update_params[:custom_claims].present?
|
|
||||||
begin
|
|
||||||
update_params[:custom_claims] = JSON.parse(update_params[:custom_claims])
|
|
||||||
rescue JSON::ParserError
|
|
||||||
@group.errors.add(:custom_claims, "must be valid JSON")
|
|
||||||
@available_users = User.order(:email_address)
|
|
||||||
@available_applications = Application.order(:name)
|
|
||||||
render :edit, status: :unprocessable_entity
|
|
||||||
return
|
|
||||||
end
|
|
||||||
else
|
|
||||||
# If empty or blank, set to empty hash (NOT NULL constraint)
|
|
||||||
update_params[:custom_claims] = {}
|
|
||||||
end
|
|
||||||
|
|
||||||
if @group.update(update_params)
|
|
||||||
# Handle user assignments
|
# Handle user assignments
|
||||||
if params[:group][:user_ids].present?
|
if params[:group][:user_ids].present?
|
||||||
user_ids = params[:group][:user_ids].reject(&:blank?)
|
user_ids = params[:group][:user_ids].reject(&:blank?)
|
||||||
@@ -94,18 +48,9 @@ module Admin
|
|||||||
@group.users = []
|
@group.users = []
|
||||||
end
|
end
|
||||||
|
|
||||||
# Handle application assignments
|
|
||||||
if params[:group][:application_ids].present?
|
|
||||||
application_ids = params[:group][:application_ids].reject(&:blank?)
|
|
||||||
@group.applications = Application.where(id: application_ids)
|
|
||||||
else
|
|
||||||
@group.applications = []
|
|
||||||
end
|
|
||||||
|
|
||||||
redirect_to admin_group_path(@group), notice: "Group updated successfully."
|
redirect_to admin_group_path(@group), notice: "Group updated successfully."
|
||||||
else
|
else
|
||||||
@available_users = User.order(:email_address)
|
@available_users = User.order(:email_address)
|
||||||
@available_applications = Application.order(:name)
|
|
||||||
render :edit, status: :unprocessable_entity
|
render :edit, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -122,7 +67,7 @@ module Admin
|
|||||||
end
|
end
|
||||||
|
|
||||||
def group_params
|
def group_params
|
||||||
params.require(:group).permit(:name, :description, :custom_claims, :auto_assign, :admin)
|
params.require(:group).permit(:name, :description)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,79 +1,49 @@
|
|||||||
module Admin
|
module Admin
|
||||||
class UsersController < BaseController
|
class UsersController < BaseController
|
||||||
before_action :set_user, only: [:show, :edit, :update, :destroy, :resend_invitation, :update_application_claims, :delete_application_claims]
|
before_action :set_user, only: [:show, :edit, :update, :destroy, :resend_invitation]
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@users = User.order(created_at: :desc)
|
@users = User.order(created_at: :desc)
|
||||||
end
|
end
|
||||||
|
|
||||||
def show
|
def show
|
||||||
@accessible_applications = Application.where(active: true)
|
|
||||||
.joins(:allowed_groups)
|
|
||||||
.where(groups: {id: @user.groups})
|
|
||||||
.distinct
|
|
||||||
.includes(:allowed_groups)
|
|
||||||
.order(:name)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def new
|
def new
|
||||||
@user = User.new
|
@user = User.new
|
||||||
@available_groups = Group.order(:name)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
@user = User.new(user_params)
|
@user = User.new(user_params)
|
||||||
@user.password = SecureRandom.alphanumeric(16) if user_params[:password].blank?
|
@user.password = SecureRandom.alphanumeric(16) if user_params[:password].blank?
|
||||||
@user.status = :pending_invitation
|
@user.status = :pending_invitation
|
||||||
@user.skip_auto_assign = true if params[:auto_assign] == "0"
|
|
||||||
|
|
||||||
if @user.save
|
if @user.save
|
||||||
assign_groups_from_params(@user)
|
|
||||||
InvitationsMailer.invite_user(@user).deliver_later
|
InvitationsMailer.invite_user(@user).deliver_later
|
||||||
redirect_to admin_users_path, notice: "User created successfully. Invitation email sent to #{@user.email_address}."
|
redirect_to admin_users_path, notice: "User created successfully. Invitation email sent to #{@user.email_address}."
|
||||||
else
|
else
|
||||||
@available_groups = Group.order(:name)
|
|
||||||
render :new, status: :unprocessable_entity
|
render :new, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def edit
|
def edit
|
||||||
@applications = Application.active.order(:name)
|
|
||||||
@available_groups = Group.order(:name)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
update_params = user_params
|
# Prevent changing params for the current user's email and admin status
|
||||||
|
# to avoid locking themselves out
|
||||||
|
update_params = user_params.dup
|
||||||
|
|
||||||
|
if @user == Current.session.user
|
||||||
|
update_params.delete(:admin)
|
||||||
|
end
|
||||||
|
|
||||||
# Only update password if provided
|
# Only update password if provided
|
||||||
update_params.delete(:password) if update_params[:password].blank?
|
update_params.delete(:password) if update_params[:password].blank?
|
||||||
|
|
||||||
# Parse custom_claims JSON if provided
|
|
||||||
if update_params[:custom_claims].present?
|
|
||||||
begin
|
|
||||||
update_params[:custom_claims] = JSON.parse(update_params[:custom_claims])
|
|
||||||
rescue JSON::ParserError
|
|
||||||
@user.errors.add(:custom_claims, "must be valid JSON")
|
|
||||||
@applications = Application.active.order(:name)
|
|
||||||
@available_groups = Group.order(:name)
|
|
||||||
render :edit, status: :unprocessable_entity
|
|
||||||
return
|
|
||||||
end
|
|
||||||
else
|
|
||||||
# If empty or blank, set to empty hash (NOT NULL constraint)
|
|
||||||
update_params[:custom_claims] = {}
|
|
||||||
end
|
|
||||||
|
|
||||||
if @user.update(update_params)
|
if @user.update(update_params)
|
||||||
unless assign_groups_from_params(@user)
|
|
||||||
@applications = Application.active.order(:name)
|
|
||||||
@available_groups = Group.order(:name)
|
|
||||||
render :edit, status: :unprocessable_entity
|
|
||||||
return
|
|
||||||
end
|
|
||||||
redirect_to admin_users_path, notice: "User updated successfully."
|
redirect_to admin_users_path, notice: "User updated successfully."
|
||||||
else
|
else
|
||||||
@applications = Application.active.order(:name)
|
|
||||||
@available_groups = Group.order(:name)
|
|
||||||
render :edit, status: :unprocessable_entity
|
render :edit, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -99,41 +69,6 @@ module Admin
|
|||||||
redirect_to admin_users_path, notice: "User deleted successfully."
|
redirect_to admin_users_path, notice: "User deleted successfully."
|
||||||
end
|
end
|
||||||
|
|
||||||
# POST /admin/users/:id/update_application_claims
|
|
||||||
def update_application_claims
|
|
||||||
application = Application.find(params[:application_id])
|
|
||||||
|
|
||||||
claims_json = params[:custom_claims].presence || "{}"
|
|
||||||
begin
|
|
||||||
claims = JSON.parse(claims_json)
|
|
||||||
rescue JSON::ParserError
|
|
||||||
redirect_to edit_admin_user_path(@user), alert: "Invalid JSON format for claims."
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
app_claim = @user.application_user_claims.find_or_initialize_by(application: application)
|
|
||||||
app_claim.custom_claims = claims
|
|
||||||
|
|
||||||
if app_claim.save
|
|
||||||
redirect_to edit_admin_user_path(@user), notice: "App-specific claims updated for #{application.name}."
|
|
||||||
else
|
|
||||||
error_message = app_claim.errors.full_messages.join(", ")
|
|
||||||
redirect_to edit_admin_user_path(@user), alert: "Failed to update claims: #{error_message}"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# DELETE /admin/users/:id/delete_application_claims
|
|
||||||
def delete_application_claims
|
|
||||||
application = Application.find(params[:application_id])
|
|
||||||
app_claim = @user.application_user_claims.find_by(application: application)
|
|
||||||
|
|
||||||
if app_claim&.destroy
|
|
||||||
redirect_to edit_admin_user_path(@user), notice: "App-specific claims removed for #{application.name}."
|
|
||||||
else
|
|
||||||
redirect_to edit_admin_user_path(@user), alert: "No claims found to remove."
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def set_user
|
def set_user
|
||||||
@@ -141,28 +76,7 @@ module Admin
|
|||||||
end
|
end
|
||||||
|
|
||||||
def user_params
|
def user_params
|
||||||
params.require(:user).permit(:email_address, :username, :name, :password, :status, :totp_required, :custom_claims)
|
params.require(:user).permit(:email_address, :password, :admin, :status)
|
||||||
end
|
|
||||||
|
|
||||||
# Apply group_ids from the form, with a guard preventing self-demotion when
|
|
||||||
# the user is the last member of the last admin group. Returns true on
|
|
||||||
# success, false if a guard fired (caller should re-render).
|
|
||||||
def assign_groups_from_params(user)
|
|
||||||
return true unless params[:user].key?(:group_ids)
|
|
||||||
|
|
||||||
raw_ids = Array(params[:user][:group_ids]).reject(&:blank?).map(&:to_i)
|
|
||||||
new_groups = Group.where(id: raw_ids)
|
|
||||||
|
|
||||||
if user == Current.session.user
|
|
||||||
losing_admin = user.groups.where(admin: true).any? && new_groups.none?(&:admin?)
|
|
||||||
if losing_admin
|
|
||||||
user.errors.add(:base, "you cannot remove yourself from all administrator groups")
|
|
||||||
return false
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
user.groups = new_groups
|
|
||||||
true
|
|
||||||
end
|
end
|
||||||
end
|
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
|
|
||||||
@@ -1,255 +1,187 @@
|
|||||||
module Api
|
module Api
|
||||||
class ForwardAuthController < ApplicationController
|
class ForwardAuthController < ApplicationController
|
||||||
|
# ForwardAuth endpoints need session storage for return URL
|
||||||
allow_unauthenticated_access
|
allow_unauthenticated_access
|
||||||
skip_before_action :verify_authenticity_token
|
skip_before_action :verify_authenticity_token
|
||||||
|
|
||||||
before_action :check_forward_auth_rate_limit
|
|
||||||
after_action :track_failed_forward_auth_attempt
|
|
||||||
|
|
||||||
# GET /api/verify
|
# GET /api/verify
|
||||||
# Called by reverse proxies (Traefik, Caddy, nginx) to verify authentication and authorization.
|
# 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
|
def verify
|
||||||
bearer_result = authenticate_bearer_token
|
# Note: app_slug parameter is no longer used - we match domains directly with ForwardAuthRule
|
||||||
return bearer_result if bearer_result
|
|
||||||
|
|
||||||
|
# Check for one-time forward auth token first (to handle race condition)
|
||||||
session_id = check_forward_auth_token
|
session_id = check_forward_auth_token
|
||||||
|
|
||||||
|
# If no token found, try to get session from cookie
|
||||||
session_id ||= extract_session_id
|
session_id ||= extract_session_id
|
||||||
|
|
||||||
unless session_id
|
unless session_id
|
||||||
|
# No session cookie or token - user is not authenticated
|
||||||
return render_unauthorized("No session cookie")
|
return render_unauthorized("No session cookie")
|
||||||
end
|
end
|
||||||
|
|
||||||
session = Session.includes(user: :groups).find_by(id: session_id)
|
# Find the session with user association (eager loading for performance)
|
||||||
|
session = Session.includes(:user).find_by(id: session_id)
|
||||||
unless session
|
unless session
|
||||||
|
# Invalid session
|
||||||
return render_unauthorized("Invalid session")
|
return render_unauthorized("Invalid session")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Check if session is expired
|
||||||
if session.expired?
|
if session.expired?
|
||||||
session.destroy
|
session.destroy
|
||||||
return render_unauthorized("Session expired")
|
return render_unauthorized("Session expired")
|
||||||
end
|
end
|
||||||
|
|
||||||
# Debounce last_activity_at updates (at most once per minute)
|
# Update last activity (skip validations for performance)
|
||||||
if session.last_activity_at.nil? || session.last_activity_at < 1.minute.ago
|
|
||||||
session.update_column(:last_activity_at, Time.current)
|
session.update_column(:last_activity_at, Time.current)
|
||||||
end
|
|
||||||
|
|
||||||
|
# Get the user (already loaded via includes(:user))
|
||||||
user = session.user
|
user = session.user
|
||||||
unless user.active?
|
unless user.active?
|
||||||
return render_unauthorized("User account is not active")
|
return render_unauthorized("User account is not active")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Check for forward auth rule authorization
|
||||||
|
# Get the forwarded host for domain matching
|
||||||
forwarded_host = request.headers["X-Forwarded-Host"] || request.headers["Host"]
|
forwarded_host = request.headers["X-Forwarded-Host"] || request.headers["Host"]
|
||||||
app = nil
|
|
||||||
|
|
||||||
if forwarded_host.present?
|
if forwarded_host.present?
|
||||||
apps = cached_forward_auth_apps
|
# Load active rules with their associations for better performance
|
||||||
|
# Preload groups to avoid N+1 queries in user_allowed? checks
|
||||||
|
rules = ForwardAuthRule.includes(:groups).active
|
||||||
|
|
||||||
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
|
unless rule
|
||||||
unless app.active?
|
Rails.logger.warn "ForwardAuth: No rule found for domain: #{forwarded_host}"
|
||||||
Rails.logger.info "ForwardAuth: Access denied to #{forwarded_host} - application is inactive"
|
|
||||||
return render_forbidden("No authentication rule configured for this domain")
|
return render_forbidden("No authentication rule configured for this domain")
|
||||||
end
|
end
|
||||||
|
|
||||||
unless app.user_allowed?(user)
|
# Check if user is allowed by this rule
|
||||||
Rails.logger.info "ForwardAuth: User #{user.email_address} denied access to #{forwarded_host} by app #{app.domain_pattern}"
|
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")
|
return render_forbidden("You do not have permission to access this domain")
|
||||||
end
|
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)})"
|
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
|
else
|
||||||
Rails.logger.info "ForwardAuth: Access denied to #{forwarded_host} - no authentication rule configured"
|
Rails.logger.info "ForwardAuth: User #{user.email_address} authenticated (no domain specified)"
|
||||||
return render_forbidden("No authentication rule configured for this domain")
|
|
||||||
end
|
|
||||||
else
|
|
||||||
# Fail closed: with no host we cannot resolve an application or evaluate its
|
|
||||||
# group policy. Emitting identity headers here would bypass all per-domain
|
|
||||||
# access control, so reject instead.
|
|
||||||
Rails.logger.info "ForwardAuth: Access denied - no host header present"
|
|
||||||
return render_forbidden("No host header present")
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Reaching here implies a matching, active application was resolved above
|
# User is authenticated and authorized
|
||||||
# (every other path returns forbidden), so headers are always scoped to it.
|
# Return 200 with user information headers using rule-specific configuration
|
||||||
headers = app.headers_for_user(user)
|
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]
|
||||||
|
when :groups
|
||||||
|
user.groups.any? ? [header_name, user.groups.pluck(:name).join(",")] : nil
|
||||||
|
when :admin
|
||||||
|
[header_name, user.admin? ? "true" : "false"]
|
||||||
|
end
|
||||||
|
}.compact.to_h
|
||||||
|
|
||||||
headers.each { |key, value| response.headers[key] = value }
|
headers.each { |key, value| response.headers[key] = value }
|
||||||
Rails.logger.debug "ForwardAuth: Headers sent: #{headers.keys.join(", ")}" if headers.any?
|
|
||||||
|
|
||||||
|
# Log what headers we're sending (helpful for debugging)
|
||||||
|
if headers.any?
|
||||||
|
Rails.logger.debug "ForwardAuth: Headers sent: #{headers.keys.join(', ')}"
|
||||||
|
else
|
||||||
|
Rails.logger.debug "ForwardAuth: No headers sent (access only)"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Return 200 OK with no body
|
||||||
head :ok
|
head :ok
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def fa_cache
|
|
||||||
Rails.application.config.forward_auth_cache
|
|
||||||
end
|
|
||||||
|
|
||||||
def cached_forward_auth_apps
|
|
||||||
fa_cache.fetch("fa_apps", expires_in: 5.minutes) do
|
|
||||||
Application.forward_auth.includes(:allowed_groups).to_a
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
RATE_LIMIT_MAX_FAILURES = 50
|
|
||||||
RATE_LIMIT_WINDOW = 1.minute
|
|
||||||
|
|
||||||
def check_forward_auth_rate_limit
|
|
||||||
count = fa_cache.read("fa_fail:#{request.remote_ip}")
|
|
||||||
return unless count && count >= RATE_LIMIT_MAX_FAILURES
|
|
||||||
|
|
||||||
response.headers["Retry-After"] = "60"
|
|
||||||
head :too_many_requests
|
|
||||||
end
|
|
||||||
|
|
||||||
def track_failed_forward_auth_attempt
|
|
||||||
return unless response.status.in?([401, 403, 302])
|
|
||||||
return if response.status == 302 && !response.headers["X-Auth-Reason"]
|
|
||||||
|
|
||||||
cache_key = "fa_fail:#{request.remote_ip}"
|
|
||||||
# Use increment to avoid resetting TTL on each failure (fixed window)
|
|
||||||
unless fa_cache.increment(cache_key)
|
|
||||||
fa_cache.write(cache_key, 1, expires_in: RATE_LIMIT_WINDOW)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def authenticate_bearer_token
|
|
||||||
auth_header = request.headers["Authorization"]
|
|
||||||
return nil unless auth_header&.start_with?("Bearer ")
|
|
||||||
|
|
||||||
token = auth_header.delete_prefix("Bearer ").strip
|
|
||||||
return render_bearer_error("Missing token") if token.blank?
|
|
||||||
|
|
||||||
api_key = ApiKey.find_by_token(token)
|
|
||||||
return render_bearer_error("Invalid or expired API key") unless api_key&.active?
|
|
||||||
|
|
||||||
user = api_key.user
|
|
||||||
return render_bearer_error("User account is not active") unless user.active?
|
|
||||||
|
|
||||||
forwarded_host = request.headers["X-Forwarded-Host"] || request.headers["Host"]
|
|
||||||
app = api_key.application
|
|
||||||
|
|
||||||
if forwarded_host.present? && !app.matches_domain?(forwarded_host)
|
|
||||||
return render_bearer_error("API key not valid for this domain")
|
|
||||||
end
|
|
||||||
|
|
||||||
unless app.active?
|
|
||||||
return render_bearer_error("Application is inactive")
|
|
||||||
end
|
|
||||||
|
|
||||||
# Re-check group membership at use-time. The ApiKey model only validates
|
|
||||||
# access on creation, so a user removed from the app's allowed groups
|
|
||||||
# afterwards must not keep access via an existing key.
|
|
||||||
unless app.user_allowed?(user)
|
|
||||||
Rails.logger.info "ForwardAuth: API key '#{api_key.name}' denied - user #{user.email_address} lacks group access to #{app.domain_pattern}"
|
|
||||||
return render_bearer_error("Access denied: insufficient group membership")
|
|
||||||
end
|
|
||||||
|
|
||||||
api_key.touch_last_used!
|
|
||||||
|
|
||||||
headers = app.headers_for_user(user)
|
|
||||||
headers.each { |key, value| response.headers[key] = value }
|
|
||||||
|
|
||||||
Rails.logger.info "ForwardAuth: API key '#{api_key.name}' authenticated user #{user.email_address} for #{forwarded_host}"
|
|
||||||
head :ok
|
|
||||||
end
|
|
||||||
|
|
||||||
def render_bearer_error(message)
|
|
||||||
render json: {error: message}, status: :unauthorized
|
|
||||||
end
|
|
||||||
|
|
||||||
def check_forward_auth_token
|
def check_forward_auth_token
|
||||||
|
# Check for one-time token in query parameters (for race condition handling)
|
||||||
token = params[:fa_token]
|
token = params[:fa_token]
|
||||||
return nil if token.blank?
|
return nil unless token.present?
|
||||||
|
|
||||||
cached = Rails.cache.read("forward_auth_token:#{token}")
|
# Try to get session ID from cache
|
||||||
return nil unless cached.is_a?(Hash)
|
session_id = Rails.cache.read("forward_auth_token:#{token}")
|
||||||
|
return nil unless session_id
|
||||||
|
|
||||||
# The token is bound to the host that created it. If the request is
|
# Verify the session exists and is valid
|
||||||
# arriving at a different host, refuse — and do NOT burn the cache
|
session = Session.find_by(id: session_id)
|
||||||
# entry, so that the legitimate destination can still redeem within
|
|
||||||
# the 60s TTL.
|
|
||||||
request_host = (request.headers["X-Forwarded-Host"] || request.headers["Host"])
|
|
||||||
.to_s.sub(/:\d+\z/, "").downcase
|
|
||||||
return nil if request_host.blank?
|
|
||||||
return nil unless cached[:host] == request_host
|
|
||||||
|
|
||||||
session = Session.find_by(id: cached[:session_id])
|
|
||||||
return nil unless session && !session.expired?
|
return nil unless session && !session.expired?
|
||||||
|
|
||||||
|
# Delete the token immediately (one-time use)
|
||||||
Rails.cache.delete("forward_auth_token:#{token}")
|
Rails.cache.delete("forward_auth_token:#{token}")
|
||||||
cached[:session_id]
|
|
||||||
|
session_id
|
||||||
end
|
end
|
||||||
|
|
||||||
def extract_session_id
|
def extract_session_id
|
||||||
cookies.signed[:session_id]
|
# Extract session ID from cookie
|
||||||
|
# Rails uses signed cookies by default
|
||||||
|
session_id = cookies.signed[:session_id]
|
||||||
|
session_id
|
||||||
|
end
|
||||||
|
|
||||||
|
def extract_app_from_headers
|
||||||
|
# This method is deprecated since we now use ForwardAuthRule domain matching
|
||||||
|
# Keeping it for backward compatibility but it's no longer used
|
||||||
|
nil
|
||||||
end
|
end
|
||||||
|
|
||||||
def render_unauthorized(reason = nil)
|
def render_unauthorized(reason = nil)
|
||||||
Rails.logger.info "ForwardAuth: Unauthorized - #{reason}"
|
Rails.logger.info "ForwardAuth: Unauthorized - #{reason}"
|
||||||
response.headers["X-Auth-Reason"] = reason if reason.present?
|
|
||||||
|
|
||||||
redirect_url = validate_redirect_url(params[:rd])
|
# Set header to help with debugging
|
||||||
base_url = determine_base_url(redirect_url)
|
response.headers["X-Auth-Reason"] = reason if reason
|
||||||
|
|
||||||
|
# Get the redirect URL from query params or construct default
|
||||||
|
base_url = params[:rd] || "https://clinch.aapamilne.com"
|
||||||
|
|
||||||
|
# Set the original URL that user was trying to access
|
||||||
|
# This will be used after authentication
|
||||||
original_host = request.headers["X-Forwarded-Host"]
|
original_host = request.headers["X-Forwarded-Host"]
|
||||||
original_uri = request.headers["X-Forwarded-Uri"] || request.headers["X-Forwarded-Path"] || "/"
|
original_uri = request.headers["X-Forwarded-Uri"] || request.headers["X-Forwarded-Path"] || "/"
|
||||||
|
|
||||||
# X-Forwarded-Host is attacker-influenceable, so only honour the forwarded
|
# Debug logging to see what headers we're getting
|
||||||
# URL as a post-login redirect target if it resolves to a known, active
|
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']}"
|
||||||
# forward-auth application. Otherwise this is an open redirect: a spoofed
|
|
||||||
# host would be stored and reflected into the signin `rd`, then followed
|
original_url = if original_host
|
||||||
# (with allow_other_host) after the user authenticates. Fall back to a
|
# Use the forwarded host and URI
|
||||||
# validated `rd` or, failing that, the IdP's own base URL.
|
"https://#{original_host}#{original_uri}"
|
||||||
forwarded_url = "https://#{original_host}#{original_uri}" if original_host.present?
|
else
|
||||||
original_url = validate_redirect_url(forwarded_url) || redirect_url || base_url
|
# Fallback: just redirect to the root of the original host
|
||||||
|
"https://#{request.headers['Host']}"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Debug: log what we're redirecting to after login
|
||||||
|
Rails.logger.info "ForwardAuth: Will redirect to after login: #{original_url}"
|
||||||
|
|
||||||
session[:return_to_after_authenticating] = original_url
|
session[:return_to_after_authenticating] = original_url
|
||||||
|
|
||||||
login_params = {rd: original_url, rm: request.method}
|
# Build login URL with redirect parameters like Authelia
|
||||||
|
login_params = {
|
||||||
|
rd: original_url,
|
||||||
|
rm: request.method
|
||||||
|
}
|
||||||
login_url = "#{base_url}/signin?#{login_params.to_query}"
|
login_url = "#{base_url}/signin?#{login_params.to_query}"
|
||||||
|
|
||||||
|
# Return 302 Found directly to login page (matching Authelia)
|
||||||
|
# This is the same as Authelia's StatusFound response
|
||||||
|
Rails.logger.info "Setting 302 redirect to: #{login_url}"
|
||||||
redirect_to login_url, allow_other_host: true, status: :found
|
redirect_to login_url, allow_other_host: true, status: :found
|
||||||
end
|
end
|
||||||
|
|
||||||
def render_forbidden(reason = nil)
|
def render_forbidden(reason = nil)
|
||||||
Rails.logger.info "ForwardAuth: Forbidden - #{reason}"
|
Rails.logger.info "ForwardAuth: Forbidden - #{reason}"
|
||||||
response.headers["X-Auth-Reason"] = reason if reason.present?
|
|
||||||
|
# Set header to help with debugging
|
||||||
|
response.headers["X-Auth-Reason"] = reason if reason
|
||||||
|
|
||||||
|
# Return 403 Forbidden
|
||||||
head :forbidden
|
head :forbidden
|
||||||
end
|
end
|
||||||
|
|
||||||
def validate_redirect_url(url)
|
|
||||||
return nil unless url.present?
|
|
||||||
|
|
||||||
begin
|
|
||||||
uri = URI.parse(url)
|
|
||||||
return nil unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
|
|
||||||
return nil unless Rails.env.development? || uri.scheme == "https"
|
|
||||||
|
|
||||||
redirect_domain = uri.host.downcase
|
|
||||||
return nil unless redirect_domain.present?
|
|
||||||
|
|
||||||
matching_app = cached_forward_auth_apps.find do |app|
|
|
||||||
app.active? && app.matches_domain?(redirect_domain)
|
|
||||||
end
|
|
||||||
|
|
||||||
matching_app ? url : nil
|
|
||||||
rescue URI::InvalidURIError
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def determine_base_url(redirect_url)
|
|
||||||
return redirect_url if redirect_url.present?
|
|
||||||
|
|
||||||
# CLINCH_HOST is the IdP's canonical origin and is mandatory in deployed
|
|
||||||
# environments (enforced at boot in config/initializers/clinch_host.rb).
|
|
||||||
# We never fall back to the request host: a spoofed X-Forwarded-Host would
|
|
||||||
# otherwise redirect the login flow to an attacker-controlled origin. The
|
|
||||||
# localhost default only applies to local dev/test.
|
|
||||||
host = ENV["CLINCH_HOST"].presence || "http://localhost:3000"
|
|
||||||
host.match?(%r{\Ahttps?://}) ? host : "https://#{host}"
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
class ApiKeysController < ApplicationController
|
|
||||||
before_action :set_api_key, only: :destroy
|
|
||||||
|
|
||||||
def index
|
|
||||||
@api_keys = Current.session.user.api_keys.includes(:application).order(created_at: :desc)
|
|
||||||
end
|
|
||||||
|
|
||||||
def new
|
|
||||||
@api_key = ApiKey.new
|
|
||||||
@applications = forward_auth_apps_for_user
|
|
||||||
end
|
|
||||||
|
|
||||||
def create
|
|
||||||
@api_key = Current.session.user.api_keys.build(api_key_params)
|
|
||||||
|
|
||||||
if @api_key.save
|
|
||||||
SecurityMailer.api_key_created(Current.session.user, name: @api_key.name, **security_event_context).deliver_later
|
|
||||||
flash[:api_key_token] = @api_key.plaintext_token
|
|
||||||
redirect_to api_key_path(@api_key)
|
|
||||||
else
|
|
||||||
@applications = forward_auth_apps_for_user
|
|
||||||
render :new, status: :unprocessable_entity
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def show
|
|
||||||
@api_key = Current.session.user.api_keys.find(params[:id])
|
|
||||||
@plaintext_token = flash[:api_key_token]
|
|
||||||
|
|
||||||
redirect_to api_keys_path unless @plaintext_token
|
|
||||||
end
|
|
||||||
|
|
||||||
def destroy
|
|
||||||
@api_key.revoke!
|
|
||||||
SecurityMailer.api_key_revoked(@api_key.user, name: @api_key.name, **security_event_context).deliver_later
|
|
||||||
redirect_to api_keys_path, notice: "API key revoked."
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def set_api_key
|
|
||||||
@api_key = Current.session.user.api_keys.find(params[:id])
|
|
||||||
end
|
|
||||||
|
|
||||||
def api_key_params
|
|
||||||
params.require(:api_key).permit(:name, :application_id, :expires_at)
|
|
||||||
end
|
|
||||||
|
|
||||||
def forward_auth_apps_for_user
|
|
||||||
user = Current.session.user
|
|
||||||
Application.forward_auth.active.select { |app| app.user_allowed?(user) }
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,43 +1,8 @@
|
|||||||
class ApplicationController < ActionController::Base
|
class ApplicationController < ActionController::Base
|
||||||
include Authentication
|
include Authentication
|
||||||
|
|
||||||
# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
|
# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
|
||||||
allow_browser versions: :modern
|
allow_browser versions: :modern
|
||||||
|
|
||||||
# Changes to the importmap will invalidate the etag for HTML responses
|
# Changes to the importmap will invalidate the etag for HTML responses
|
||||||
stale_when_importmap_changes
|
stale_when_importmap_changes
|
||||||
|
|
||||||
# CSRF protection
|
|
||||||
protect_from_forgery with: :exception
|
|
||||||
|
|
||||||
helper_method :remove_query_param
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def security_event_context
|
|
||||||
{ip: request.remote_ip, user_agent: request.user_agent, occurred_at: Time.current}
|
|
||||||
end
|
|
||||||
|
|
||||||
# Remove a query parameter from a URL using proper URI parsing
|
|
||||||
# More robust than regex - handles URL encoding, edge cases, etc.
|
|
||||||
#
|
|
||||||
# @param url [String] The URL to modify
|
|
||||||
# @param param_name [String] The query parameter name to remove
|
|
||||||
# @return [String] The URL with the parameter removed
|
|
||||||
#
|
|
||||||
# @example
|
|
||||||
# remove_query_param("https://example.com?foo=bar&baz=qux", "foo")
|
|
||||||
# # => "https://example.com?baz=qux"
|
|
||||||
def remove_query_param(url, param_name)
|
|
||||||
uri = URI.parse(url)
|
|
||||||
return url unless uri.query
|
|
||||||
|
|
||||||
params = Rack::Utils.parse_query(uri.query)
|
|
||||||
params.delete(param_name)
|
|
||||||
|
|
||||||
uri.query = params.any? ? Rack::Utils.build_query(params) : nil
|
|
||||||
uri.to_s
|
|
||||||
rescue URI::InvalidURIError
|
|
||||||
url
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
require "uri"
|
require 'uri'
|
||||||
require "public_suffix"
|
|
||||||
require "ipaddr"
|
|
||||||
|
|
||||||
module Authentication
|
module Authentication
|
||||||
extend ActiveSupport::Concern
|
extend ActiveSupport::Concern
|
||||||
@@ -17,7 +15,6 @@ module Authentication
|
|||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def authenticated?
|
def authenticated?
|
||||||
resume_session
|
resume_session
|
||||||
end
|
end
|
||||||
@@ -31,7 +28,7 @@ module Authentication
|
|||||||
end
|
end
|
||||||
|
|
||||||
def find_session_by_cookie
|
def find_session_by_cookie
|
||||||
Session.active.for_active_user.find_by(id: cookies.signed[:session_id]) if cookies.signed[:session_id]
|
Session.find_by(id: cookies.signed[:session_id]) if cookies.signed[:session_id]
|
||||||
end
|
end
|
||||||
|
|
||||||
def request_authentication
|
def request_authentication
|
||||||
@@ -40,79 +37,30 @@ module Authentication
|
|||||||
end
|
end
|
||||||
|
|
||||||
def after_authentication_url
|
def after_authentication_url
|
||||||
session.delete(:return_to_after_authenticating) || root_url
|
return_url = session[:return_to_after_authenticating]
|
||||||
|
final_url = session.delete(:return_to_after_authenticating) || root_url
|
||||||
|
final_url
|
||||||
end
|
end
|
||||||
|
|
||||||
# When a sign-in form will eventually redirect through /oauth/authorize to an
|
def start_new_session_for(user)
|
||||||
# external client, Safari enforces CSP form-action against every hop in the
|
|
||||||
# redirect chain. With the default form-action 'self', the final cross-origin
|
|
||||||
# hop to the OAuth client's redirect_uri gets blocked. Add the redirect_uri
|
|
||||||
# host to form-action so the chain completes.
|
|
||||||
def allow_oauth_redirect_in_csp
|
|
||||||
stored = session[:return_to_after_authenticating]
|
|
||||||
return if stored.blank?
|
|
||||||
|
|
||||||
uri = URI.parse(stored)
|
|
||||||
return unless uri.path&.start_with?("/oauth/")
|
|
||||||
|
|
||||||
redirect_uri = Rack::Utils.parse_query(uri.query.to_s)["redirect_uri"]
|
|
||||||
return if redirect_uri.blank?
|
|
||||||
|
|
||||||
redirect_host = URI.parse(redirect_uri).host
|
|
||||||
return if redirect_host.blank?
|
|
||||||
|
|
||||||
csp = request.content_security_policy
|
|
||||||
return unless csp
|
|
||||||
|
|
||||||
# NOTE: `csp.form_action` (no args) is destructive — it deletes the directive
|
|
||||||
# and returns its old value, so reading it twice yields nil. Mutate the
|
|
||||||
# underlying `directives` hash (a public reader of the real values) instead.
|
|
||||||
form_action = (csp.directives["form-action"] ||= ["'self'"])
|
|
||||||
host = "https://#{redirect_host}"
|
|
||||||
form_action << host unless form_action.include?(host)
|
|
||||||
rescue URI::InvalidURIError
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
|
|
||||||
def start_new_session_for(user, acr: "1", remember_me: false)
|
|
||||||
user.update!(last_sign_in_at: Time.current)
|
user.update!(last_sign_in_at: Time.current)
|
||||||
user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip, acr: acr, remember_me: remember_me).tap do |session|
|
user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session|
|
||||||
Current.session = session
|
Current.session = session
|
||||||
|
|
||||||
# Extract root domain for cross-subdomain cookies (required for forward auth)
|
# Extract root domain for cross-subdomain cookies (required for forward auth)
|
||||||
domain = extract_root_domain(request.host)
|
domain = extract_root_domain(request.host)
|
||||||
|
|
||||||
# Set cookie options based on environment
|
cookie_options = {
|
||||||
# Production: Use SameSite=None to allow cross-site cookies (needed for OIDC conformance testing)
|
|
||||||
# Development: Use SameSite=Lax since HTTPS might not be available
|
|
||||||
cookie_options = if Rails.env.production?
|
|
||||||
{
|
|
||||||
value: session.id,
|
value: session.id,
|
||||||
httponly: true,
|
httponly: true,
|
||||||
same_site: :lax,
|
same_site: :lax,
|
||||||
secure: true
|
secure: Rails.env.production?
|
||||||
}
|
}
|
||||||
else
|
|
||||||
{
|
|
||||||
value: session.id,
|
|
||||||
httponly: true,
|
|
||||||
same_site: :lax,
|
|
||||||
secure: false
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
# Set domain for cross-subdomain authentication if we can extract it
|
# Set domain for cross-subdomain authentication if we can extract it
|
||||||
cookie_options[:domain] = domain if domain.present?
|
cookie_options[:domain] = domain if domain.present?
|
||||||
|
|
||||||
# When "Remember me" is off, issue a browser-session cookie (no Expires)
|
|
||||||
# so closing the browser signs the user out — especially important on
|
|
||||||
# shared devices. The server Session#expires_at still enforces the
|
|
||||||
# 24h / 30d window regardless.
|
|
||||||
if remember_me
|
|
||||||
cookies.signed.permanent[:session_id] = cookie_options
|
cookies.signed.permanent[:session_id] = cookie_options
|
||||||
else
|
|
||||||
cookies.signed[:session_id] = cookie_options
|
|
||||||
end
|
|
||||||
|
|
||||||
# Create a one-time token for immediate forward auth after authentication
|
# Create a one-time token for immediate forward auth after authentication
|
||||||
# This solves the race condition where browser hasn't processed cookie yet
|
# This solves the race condition where browser hasn't processed cookie yet
|
||||||
@@ -125,79 +73,67 @@ module Authentication
|
|||||||
cookies.delete(:session_id)
|
cookies.delete(:session_id)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Extract root domain for cross-subdomain cookies in SSO forward_auth system.
|
# Extract root domain for cross-subdomain cookies
|
||||||
#
|
|
||||||
# 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.
|
|
||||||
#
|
|
||||||
# Examples:
|
# Examples:
|
||||||
# - app.example.com -> .example.com (enables cross-subdomain SSO)
|
# - clinch.aapamilne.com -> .aapamilne.com
|
||||||
# - api.example.co.uk -> .example.co.uk (handles complex TLDs)
|
# - app.example.co.uk -> .example.co.uk
|
||||||
# - myapp.appspot.com -> .myapp.appspot.com (handles platform domains)
|
# - localhost -> nil (no domain setting for local development)
|
||||||
# - 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
|
|
||||||
def extract_root_domain(host)
|
def extract_root_domain(host)
|
||||||
return nil if host.blank? || host.match?(/^(localhost|127\.0\.0\.1|::1)$/)
|
return nil if host.blank? || host.match?(/^(localhost|127\.0\.0\.1|::1)$/)
|
||||||
|
|
||||||
# Strip port number for domain parsing
|
# Split hostname into parts
|
||||||
host_without_port = host.split(":").first
|
parts = host.split('.')
|
||||||
|
|
||||||
# Check if it's an IP address (IPv4 or IPv6) - if so, don't set domain cookie
|
# For normal domains like example.com, we need at least 2 parts
|
||||||
begin
|
# For complex domains like co.uk, we need at least 3 parts
|
||||||
return nil if IPAddr.new(host_without_port)
|
return nil if parts.length < 2
|
||||||
rescue
|
|
||||||
false
|
# 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
|
end
|
||||||
|
|
||||||
# Use Public Suffix List for accurate domain parsing
|
# For regular domains: app.example.com -> .example.com
|
||||||
domain = PublicSuffix.parse(host_without_port)
|
root_parts = parts[-2..-1]
|
||||||
".#{domain.domain}"
|
".#{root_parts.join('.')}"
|
||||||
rescue PublicSuffix::DomainInvalid
|
|
||||||
# Fallback for invalid domains or IPs
|
|
||||||
nil
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Create a one-time token for forward auth to handle the race condition
|
# Create a one-time token for forward auth to handle the race condition
|
||||||
# where the browser hasn't processed the session cookie yet.
|
# where the browser hasn't processed the session cookie yet
|
||||||
#
|
|
||||||
# The token is bound to the destination host so that anyone who observes
|
|
||||||
# the token (Referer leaks, access logs, JS monitors) cannot redeem it for
|
|
||||||
# a different application within the 60-second TTL.
|
|
||||||
def create_forward_auth_token(session_obj)
|
def create_forward_auth_token(session_obj)
|
||||||
controller_session = session
|
# Generate a secure random token
|
||||||
return unless controller_session[:return_to_after_authenticating].present?
|
|
||||||
|
|
||||||
uri = URI.parse(controller_session[:return_to_after_authenticating])
|
|
||||||
|
|
||||||
# OAuth flow handles its own session propagation — no fa_token needed.
|
|
||||||
return if uri.path&.start_with?("/oauth/")
|
|
||||||
|
|
||||||
# Path-only URLs are same-origin on Clinch; the cookie race doesn't apply
|
|
||||||
# and we have no destination host to bind against.
|
|
||||||
bound_host = uri.hostname&.downcase
|
|
||||||
return if bound_host.blank?
|
|
||||||
|
|
||||||
token = SecureRandom.urlsafe_base64(32)
|
token = SecureRandom.urlsafe_base64(32)
|
||||||
|
|
||||||
|
# Store it with an expiry of 30 seconds
|
||||||
Rails.cache.write(
|
Rails.cache.write(
|
||||||
"forward_auth_token:#{token}",
|
"forward_auth_token:#{token}",
|
||||||
{session_id: session_obj.id, host: bound_host},
|
session_obj.id,
|
||||||
expires_in: 60.seconds
|
expires_in: 30.seconds
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Set the token as a query parameter on the redirect URL
|
||||||
|
# We need to store this in the controller's session
|
||||||
|
controller_session = session
|
||||||
|
if controller_session[:return_to_after_authenticating].present?
|
||||||
|
original_url = controller_session[:return_to_after_authenticating]
|
||||||
|
uri = URI.parse(original_url)
|
||||||
|
|
||||||
|
# Add token as query parameter
|
||||||
query_params = URI.decode_www_form(uri.query || "").to_h
|
query_params = URI.decode_www_form(uri.query || "").to_h
|
||||||
query_params["fa_token"] = token
|
query_params['fa_token'] = token
|
||||||
uri.query = URI.encode_www_form(query_params)
|
uri.query = URI.encode_www_form(query_params)
|
||||||
|
|
||||||
|
# Update the session with the tokenized URL
|
||||||
controller_session[:return_to_after_authenticating] = uri.to_s
|
controller_session[:return_to_after_authenticating] = uri.to_s
|
||||||
|
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -8,10 +8,5 @@ class DashboardController < ApplicationController
|
|||||||
|
|
||||||
# User must be authenticated
|
# User must be authenticated
|
||||||
@user = Current.session.user
|
@user = Current.session.user
|
||||||
|
|
||||||
# Load user's accessible applications
|
|
||||||
@applications = Application.active.select do |app|
|
|
||||||
app.user_allowed?(@user)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
class InvitationsController < ApplicationController
|
class InvitationsController < ApplicationController
|
||||||
include Authentication
|
include Authentication
|
||||||
|
|
||||||
allow_unauthenticated_access
|
allow_unauthenticated_access
|
||||||
before_action :set_user_by_invitation_token, only: %i[show update]
|
before_action :set_user_by_invitation_token, only: %i[ show update ]
|
||||||
rate_limit to: 10, within: 10.minutes, only: :update, with: -> { redirect_to signin_path, alert: "Too many attempts. Try again later." }
|
|
||||||
|
|
||||||
def show
|
def show
|
||||||
# Show the password setup form
|
# Show the password setup form
|
||||||
@@ -37,16 +35,16 @@ class InvitationsController < ApplicationController
|
|||||||
# Check if user is still pending invitation
|
# Check if user is still pending invitation
|
||||||
if @user.nil?
|
if @user.nil?
|
||||||
redirect_to signin_path, alert: "Invitation link is invalid or has expired."
|
redirect_to signin_path, alert: "Invitation link is invalid or has expired."
|
||||||
false
|
return false
|
||||||
elsif @user.pending_invitation?
|
elsif @user.pending_invitation?
|
||||||
# User is valid and pending - proceed
|
# User is valid and pending - proceed
|
||||||
true
|
return true
|
||||||
else
|
else
|
||||||
redirect_to signin_path, alert: "This invitation has already been used or is no longer valid."
|
redirect_to signin_path, alert: "This invitation has already been used or is no longer valid."
|
||||||
false
|
return false
|
||||||
end
|
end
|
||||||
rescue ActiveSupport::MessageVerifier::InvalidSignature
|
rescue ActiveSupport::MessageVerifier::InvalidSignature
|
||||||
redirect_to signin_path, alert: "Invitation link is invalid or has expired."
|
redirect_to signin_path, alert: "Invitation link is invalid or has expired."
|
||||||
false
|
return false
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,18 +1,17 @@
|
|||||||
class PasswordsController < ApplicationController
|
class PasswordsController < ApplicationController
|
||||||
allow_unauthenticated_access
|
allow_unauthenticated_access
|
||||||
before_action :set_user_by_token, only: %i[edit update]
|
before_action :set_user_by_token, only: %i[ edit update ]
|
||||||
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_password_path, alert: "Try again later." }
|
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_password_path, alert: "Try again later." }
|
||||||
rate_limit to: 10, within: 10.minutes, only: :update, with: -> { redirect_to new_password_path, alert: "Too many attempts. Try again later." }
|
|
||||||
|
|
||||||
def new
|
def new
|
||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
if (user = User.find_by(email_address: params[:email_address]))
|
if user = User.find_by(email_address: params[:email_address])
|
||||||
PasswordsMailer.reset(user).deliver_later
|
PasswordsMailer.reset(user).deliver_later
|
||||||
end
|
end
|
||||||
|
|
||||||
redirect_to signin_path, notice: "Password reset instructions sent (if user with that email address exists)."
|
redirect_to new_session_path, notice: "Password reset instructions sent (if user with that email address exists)."
|
||||||
end
|
end
|
||||||
|
|
||||||
def edit
|
def edit
|
||||||
@@ -20,19 +19,16 @@ class PasswordsController < ApplicationController
|
|||||||
|
|
||||||
def update
|
def update
|
||||||
if @user.update(params.permit(:password, :password_confirmation))
|
if @user.update(params.permit(:password, :password_confirmation))
|
||||||
SecurityMailer.password_changed(@user, **security_event_context).deliver_later
|
|
||||||
@user.sessions.destroy_all
|
@user.sessions.destroy_all
|
||||||
redirect_to signin_path, notice: "Password has been reset."
|
redirect_to new_session_path, notice: "Password has been reset."
|
||||||
else
|
else
|
||||||
redirect_to edit_password_path(params[:token]), alert: "Passwords did not match."
|
redirect_to edit_password_path(params[:token]), alert: "Passwords did not match."
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def set_user_by_token
|
def set_user_by_token
|
||||||
@user = User.find_by_token_for(:password_reset, params[:token])
|
@user = User.find_by_token_for(:password_reset, params[:token])
|
||||||
redirect_to new_password_path, alert: "Password reset link is invalid or has expired." if @user.nil?
|
|
||||||
rescue ActiveSupport::MessageVerifier::InvalidSignature
|
rescue ActiveSupport::MessageVerifier::InvalidSignature
|
||||||
redirect_to new_password_path, alert: "Password reset link is invalid or has expired."
|
redirect_to new_password_path, alert: "Password reset link is invalid or has expired."
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
class ProfilesController < ApplicationController
|
class ProfilesController < ApplicationController
|
||||||
def show
|
def show
|
||||||
@user = Current.session.user
|
@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
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
@@ -10,39 +12,53 @@ class ProfilesController < ApplicationController
|
|||||||
# Updating password - requires current password
|
# Updating password - requires current password
|
||||||
unless @user.authenticate(params[:user][:current_password])
|
unless @user.authenticate(params[:user][:current_password])
|
||||||
@user.errors.add(:current_password, "is incorrect")
|
@user.errors.add(:current_password, "is incorrect")
|
||||||
|
@active_sessions = @user.sessions.active.order(last_activity_at: :desc)
|
||||||
render :show, status: :unprocessable_entity
|
render :show, status: :unprocessable_entity
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
if @user.update(password_params)
|
if @user.update(password_params)
|
||||||
SecurityMailer.password_changed(@user, **security_event_context).deliver_later
|
|
||||||
redirect_to profile_path, notice: "Password updated successfully."
|
redirect_to profile_path, notice: "Password updated successfully."
|
||||||
else
|
else
|
||||||
|
@active_sessions = @user.sessions.active.order(last_activity_at: :desc)
|
||||||
render :show, status: :unprocessable_entity
|
render :show, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
elsif params[:user][:email_address].present?
|
else
|
||||||
# Updating email - requires current password (security: prevents account takeover)
|
# Updating email
|
||||||
unless @user.authenticate(params[:user][:current_password])
|
if @user.update(email_params)
|
||||||
@user.errors.add(:current_password, "is required to change email")
|
redirect_to profile_path, notice: "Email updated successfully."
|
||||||
|
else
|
||||||
|
@active_sessions = @user.sessions.active.order(last_activity_at: :desc)
|
||||||
render :show, status: :unprocessable_entity
|
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
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
old_email = @user.email_address
|
# Revoke the consent
|
||||||
if @user.update(email_params)
|
consent.destroy
|
||||||
new_email = @user.email_address
|
redirect_to profile_path, notice: "Successfully revoked access to #{application.name}."
|
||||||
if old_email != new_email
|
|
||||||
context = security_event_context
|
|
||||||
[old_email, new_email].uniq.each do |recipient|
|
|
||||||
SecurityMailer.email_address_changed(@user, recipient: recipient, old_email: old_email, new_email: new_email, **context).deliver_later
|
|
||||||
end
|
end
|
||||||
end
|
|
||||||
redirect_to profile_path, notice: "Email updated successfully."
|
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
|
else
|
||||||
render :show, status: :unprocessable_entity
|
redirect_to profile_path, alert: "No applications to revoke."
|
||||||
end
|
|
||||||
else
|
|
||||||
render :show, status: :unprocessable_entity
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -1,39 +1,11 @@
|
|||||||
class SessionsController < ApplicationController
|
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: 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: :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
|
def new
|
||||||
# Redirect to signup if this is first run
|
# Redirect to signup if this is first run
|
||||||
if User.count.zero?
|
redirect_to signup_path if User.count.zero?
|
||||||
respond_to do |format|
|
|
||||||
format.html { redirect_to signup_path }
|
|
||||||
format.json { render json: {error: "No users exist. Please complete initial setup."}, status: :service_unavailable }
|
|
||||||
end
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
# Extract login_hint from the return URL for pre-filling the email field (OIDC spec)
|
|
||||||
@login_hint = nil
|
|
||||||
if session[:return_to_after_authenticating].present?
|
|
||||||
begin
|
|
||||||
uri = URI.parse(session[:return_to_after_authenticating])
|
|
||||||
if uri.query.present?
|
|
||||||
query_params = Rack::Utils.parse_query(uri.query)
|
|
||||||
@login_hint = query_params["login_hint"]
|
|
||||||
end
|
|
||||||
rescue URI::InvalidURIError
|
|
||||||
# Ignore parsing errors
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
allow_oauth_redirect_in_csp
|
|
||||||
|
|
||||||
respond_to do |format|
|
|
||||||
format.html # render HTML login page
|
|
||||||
format.json { render json: {error: "Authentication required"}, status: :unauthorized }
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
@@ -44,10 +16,9 @@ class SessionsController < ApplicationController
|
|||||||
return
|
return
|
||||||
end
|
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?
|
if params[:rd].present?
|
||||||
validated_url = validate_redirect_url(params[:rd])
|
session[:return_to_after_authenticating] = params[:rd]
|
||||||
session[:return_to_after_authenticating] = validated_url if validated_url
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Check if user is active
|
# Check if user is active
|
||||||
@@ -60,40 +31,21 @@ class SessionsController < ApplicationController
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
# Check if TOTP is required or enabled
|
# Check if TOTP is required
|
||||||
if user.totp_required? || user.totp_enabled?
|
if user.totp_enabled?
|
||||||
# If TOTP is required but not yet set up, redirect to setup
|
|
||||||
if user.totp_required? && !user.totp_enabled?
|
|
||||||
# Store user ID in session for TOTP setup
|
|
||||||
session[:pending_totp_setup_user_id] = user.id
|
|
||||||
# Preserve the redirect URL through TOTP setup
|
|
||||||
if params[:rd].present?
|
|
||||||
validated_url = validate_redirect_url(params[:rd])
|
|
||||||
session[:totp_redirect_url] = validated_url if validated_url
|
|
||||||
end
|
|
||||||
redirect_to new_totp_path, alert: "Your administrator requires two-factor authentication. Please set it up now to continue."
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
# TOTP is enabled, proceed to verification
|
|
||||||
# Store user ID in session temporarily for TOTP verification
|
# Store user ID in session temporarily for TOTP verification
|
||||||
session[:pending_totp_user_id] = user.id
|
session[:pending_totp_user_id] = user.id
|
||||||
session[:pending_remember_me] = remember_me?
|
# Preserve the redirect URL through TOTP verification
|
||||||
# Preserve the redirect URL through TOTP verification (after validation)
|
|
||||||
if params[:rd].present?
|
if params[:rd].present?
|
||||||
validated_url = validate_redirect_url(params[:rd])
|
session[:totp_redirect_url] = params[:rd]
|
||||||
session[:totp_redirect_url] = validated_url if validated_url
|
|
||||||
end
|
end
|
||||||
redirect_to totp_verification_path(rd: params[:rd])
|
redirect_to totp_verification_path(rd: params[:rd])
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
# Sign in successful (password only)
|
# Sign in successful
|
||||||
start_new_session_for user, acr: "1", remember_me: remember_me?
|
start_new_session_for user
|
||||||
|
redirect_to after_authentication_url, notice: "Signed in successfully.", allow_other_host: true
|
||||||
# Use status: :see_other to ensure browser makes a GET request
|
|
||||||
# This prevents Turbo from converting it to a TURBO_STREAM request
|
|
||||||
redirect_to after_authentication_url, notice: "Signed in successfully.", allow_other_host: true, status: :see_other
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def verify_totp
|
def verify_totp
|
||||||
@@ -121,63 +73,39 @@ class SessionsController < ApplicationController
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
# Re-check account status: active? was verified at the password step, but an
|
# Try TOTP verification first
|
||||||
# admin may have disabled the account while the user sat on this 2FA screen.
|
|
||||||
# Without this, a disabled account could still mint a valid session here.
|
|
||||||
unless user.active?
|
|
||||||
session.delete(:pending_totp_user_id)
|
|
||||||
session.delete(:pending_remember_me)
|
|
||||||
redirect_to signin_path, alert: "Your account is not active. Please contact an administrator."
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
remember_me = session.delete(:pending_remember_me) || false
|
|
||||||
|
|
||||||
# Try TOTP verification first (password + TOTP = 2FA)
|
|
||||||
if user.verify_totp(code)
|
if user.verify_totp(code)
|
||||||
session.delete(:pending_totp_user_id)
|
session.delete(:pending_totp_user_id)
|
||||||
# Restore redirect URL if it was preserved
|
# Restore redirect URL if it was preserved
|
||||||
if session[:totp_redirect_url].present?
|
if session[:totp_redirect_url].present?
|
||||||
session[:return_to_after_authenticating] = session.delete(:totp_redirect_url)
|
session[:return_to_after_authenticating] = session.delete(:totp_redirect_url)
|
||||||
end
|
end
|
||||||
start_new_session_for user, acr: "2", remember_me: remember_me
|
start_new_session_for user
|
||||||
redirect_to after_authentication_url, notice: "Signed in successfully.", allow_other_host: true
|
redirect_to after_authentication_url, notice: "Signed in successfully.", allow_other_host: true
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
# Try backup code verification (password + backup code = 2FA)
|
# Try backup code verification
|
||||||
if user.verify_backup_code(code)
|
if user.verify_backup_code(code)
|
||||||
session.delete(:pending_totp_user_id)
|
session.delete(:pending_totp_user_id)
|
||||||
# Restore redirect URL if it was preserved
|
# Restore redirect URL if it was preserved
|
||||||
if session[:totp_redirect_url].present?
|
if session[:totp_redirect_url].present?
|
||||||
session[:return_to_after_authenticating] = session.delete(:totp_redirect_url)
|
session[:return_to_after_authenticating] = session.delete(:totp_redirect_url)
|
||||||
end
|
end
|
||||||
start_new_session_for user, acr: "2", remember_me: remember_me
|
start_new_session_for user
|
||||||
redirect_to after_authentication_url, notice: "Signed in successfully using backup code.", allow_other_host: true
|
redirect_to after_authentication_url, notice: "Signed in successfully using backup code.", allow_other_host: true
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
# Invalid code
|
# Invalid code
|
||||||
redirect_to totp_verification_path, alert: "Invalid verification code. Please try again."
|
redirect_to totp_verification_path, alert: "Invalid verification code. Please try again."
|
||||||
nil
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
# Pass data to the view for passkey option
|
|
||||||
@user_has_webauthn = user&.can_authenticate_with_webauthn?
|
|
||||||
@pending_email = user&.email_address
|
|
||||||
|
|
||||||
allow_oauth_redirect_in_csp
|
|
||||||
|
|
||||||
# Just render the form
|
# Just render the form
|
||||||
end
|
end
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
# Send backchannel logout notifications before terminating session
|
|
||||||
if authenticated?
|
|
||||||
user = Current.session.user
|
|
||||||
send_backchannel_logout_notifications(user)
|
|
||||||
end
|
|
||||||
|
|
||||||
terminate_session
|
terminate_session
|
||||||
redirect_to signin_path, status: :see_other, notice: "Signed out successfully."
|
redirect_to signin_path, status: :see_other, notice: "Signed out successfully."
|
||||||
end
|
end
|
||||||
@@ -185,211 +113,6 @@ class SessionsController < ApplicationController
|
|||||||
def destroy_other
|
def destroy_other
|
||||||
session = Current.session.user.sessions.find(params[:id])
|
session = Current.session.user.sessions.find(params[:id])
|
||||||
session.destroy
|
session.destroy
|
||||||
redirect_to active_sessions_path, notice: "Session revoked successfully."
|
redirect_to profile_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
|
|
||||||
session[:pending_remember_me] = remember_me?
|
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|
||||||
# Re-check account status: an admin may have disabled the account between the
|
|
||||||
# password step and this passkey verification. Reject before creating a session.
|
|
||||||
unless user.active?
|
|
||||||
session.delete(:pending_webauthn_user_id)
|
|
||||||
render json: {error: "Your account is not active."}, status: :unauthorized
|
|
||||||
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
|
|
||||||
)
|
|
||||||
|
|
||||||
# Clone detection: a non-advancing signature counter signals the credential
|
|
||||||
# may have been copied. Reject the sign-in (do NOT create a session or update
|
|
||||||
# the stored counter) and alert the user, per WebAuthn §6.1.1.
|
|
||||||
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} (stored=#{stored_credential.sign_count}, presented=#{webauthn_credential.sign_count})"
|
|
||||||
SecurityMailer.suspicious_passkey_used(user, nickname: stored_credential.display_name, **security_event_context).deliver_later
|
|
||||||
render json: {error: "Passkey authentication could not be completed. Please contact support."}, status: :unprocessable_entity
|
|
||||||
return
|
|
||||||
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)
|
|
||||||
remember_me = session.delete(:pending_remember_me) || false
|
|
||||||
if session[:webauthn_redirect_url].present?
|
|
||||||
session[:return_to_after_authenticating] = session.delete(:webauthn_redirect_url)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Create session (WebAuthn/passkey = phishing-resistant, ACR = "2")
|
|
||||||
start_new_session_for user, acr: "2", remember_me: remember_me
|
|
||||||
|
|
||||||
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 remember_me?
|
|
||||||
ActiveModel::Type::Boolean.new.cast(params[:remember_me]) || false
|
|
||||||
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 forward auth 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 send_backchannel_logout_notifications(user)
|
|
||||||
# Find all active OIDC consents for this user
|
|
||||||
consents = OidcUserConsent.where(user: user).includes(:application)
|
|
||||||
|
|
||||||
consents.each do |consent|
|
|
||||||
# Skip if application doesn't support backchannel logout
|
|
||||||
next unless consent.application.supports_backchannel_logout?
|
|
||||||
|
|
||||||
# Enqueue background job to send logout notification
|
|
||||||
BackchannelLogoutJob.perform_later(
|
|
||||||
user_id: user.id,
|
|
||||||
application_id: consent.application.id,
|
|
||||||
consent_sid: consent.sid
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
Rails.logger.info "SessionsController: Enqueued #{consents.count} backchannel logout notifications for user #{user.id}"
|
|
||||||
rescue => e
|
|
||||||
# Log error but don't block logout
|
|
||||||
Rails.logger.error "SessionsController: Failed to enqueue backchannel logout: #{e.class} - #{e.message}"
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -5,17 +5,10 @@ class TotpController < ApplicationController
|
|||||||
|
|
||||||
# GET /totp/new - Show QR code to set up TOTP
|
# GET /totp/new - Show QR code to set up TOTP
|
||||||
def new
|
def new
|
||||||
# Check if user is being forced to set up TOTP by admin
|
|
||||||
@totp_setup_required = session[:pending_totp_setup_user_id].present?
|
|
||||||
|
|
||||||
# Generate TOTP secret but don't save yet
|
# Generate TOTP secret but don't save yet
|
||||||
@totp_secret = ROTP::Base32.random
|
@totp_secret = ROTP::Base32.random
|
||||||
@provisioning_uri = ROTP::TOTP.new(@totp_secret, issuer: "Clinch").provisioning_uri(@user.email_address)
|
@provisioning_uri = ROTP::TOTP.new(@totp_secret, issuer: "Clinch").provisioning_uri(@user.email_address)
|
||||||
|
|
||||||
# Hold the secret server-side until the user confirms it with a valid code,
|
|
||||||
# so an attacker with session access cannot substitute one they control.
|
|
||||||
session[:pending_totp_secret] = @totp_secret
|
|
||||||
|
|
||||||
# Generate QR code
|
# Generate QR code
|
||||||
require "rqrcode"
|
require "rqrcode"
|
||||||
@qr_code = RQRCode::QRCode.new(@provisioning_uri)
|
@qr_code = RQRCode::QRCode.new(@provisioning_uri)
|
||||||
@@ -23,38 +16,19 @@ class TotpController < ApplicationController
|
|||||||
|
|
||||||
# POST /totp - Verify TOTP code and enable 2FA
|
# POST /totp - Verify TOTP code and enable 2FA
|
||||||
def create
|
def create
|
||||||
totp_secret = session[:pending_totp_secret]
|
totp_secret = params[:totp_secret]
|
||||||
code = params[:code]
|
code = params[:code]
|
||||||
|
|
||||||
unless totp_secret
|
|
||||||
redirect_to new_totp_path, alert: "Your TOTP setup session expired. Please start again."
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
# Verify the code works
|
# Verify the code works
|
||||||
totp = ROTP::TOTP.new(totp_secret)
|
totp = ROTP::TOTP.new(totp_secret)
|
||||||
if totp.verify(code, drift_behind: 30, drift_ahead: 30)
|
if totp.verify(code, drift_behind: 30, drift_ahead: 30)
|
||||||
# Save the secret and generate backup codes
|
# Save the secret and generate backup codes
|
||||||
@user.totp_secret = totp_secret
|
@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!
|
@user.save!
|
||||||
|
|
||||||
session.delete(:pending_totp_secret)
|
# Redirect to backup codes page with success message
|
||||||
TotpMailer.enabled(@user).deliver_later
|
|
||||||
|
|
||||||
# Store plain codes temporarily in session for display after redirect
|
|
||||||
session[:temp_backup_codes] = plain_codes
|
|
||||||
|
|
||||||
# Check if this was a required setup from login
|
|
||||||
if session[:pending_totp_setup_user_id].present?
|
|
||||||
session.delete(:pending_totp_setup_user_id)
|
|
||||||
# Mark that user should be auto-signed in after viewing backup codes
|
|
||||||
session[:auto_signin_after_forced_totp] = true
|
|
||||||
redirect_to backup_codes_totp_path, notice: "Two-factor authentication has been enabled successfully! Save these backup codes, then you'll be signed in."
|
|
||||||
else
|
|
||||||
# Regular setup from profile
|
|
||||||
redirect_to backup_codes_totp_path, notice: "Two-factor authentication has been enabled successfully! Save these backup codes now."
|
redirect_to backup_codes_totp_path, notice: "Two-factor authentication has been enabled successfully! Save these backup codes now."
|
||||||
end
|
|
||||||
else
|
else
|
||||||
redirect_to new_totp_path, alert: "Invalid verification code. Please try again."
|
redirect_to new_totp_path, alert: "Invalid verification code. Please try again."
|
||||||
end
|
end
|
||||||
@@ -62,21 +36,8 @@ class TotpController < ApplicationController
|
|||||||
|
|
||||||
# GET /totp/backup_codes - Show backup codes (requires password)
|
# GET /totp/backup_codes - Show backup codes (requires password)
|
||||||
def backup_codes
|
def backup_codes
|
||||||
# Check if we have temporary codes from TOTP setup
|
# This will be shown after password verification
|
||||||
if session[:temp_backup_codes].present?
|
@backup_codes = @user.parsed_backup_codes
|
||||||
@backup_codes = session[:temp_backup_codes]
|
|
||||||
session.delete(:temp_backup_codes) # Clear after use
|
|
||||||
|
|
||||||
# Check if this was a forced TOTP setup during login
|
|
||||||
@auto_signin_pending = session[:auto_signin_after_forced_totp].present?
|
|
||||||
if @auto_signin_pending
|
|
||||||
session.delete(:auto_signin_after_forced_totp)
|
|
||||||
end
|
|
||||||
else
|
|
||||||
# This will be shown after password verification for existing users
|
|
||||||
# Since we can't display BCrypt hashes, redirect to regenerate
|
|
||||||
redirect_to regenerate_backup_codes_totp_path
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# POST /totp/verify_password - Verify password before showing backup codes
|
# POST /totp/verify_password - Verify password before showing backup codes
|
||||||
@@ -88,41 +49,6 @@ class TotpController < ApplicationController
|
|||||||
end
|
end
|
||||||
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!
|
|
||||||
SecurityMailer.backup_codes_regenerated(@user, **security_event_context).deliver_later
|
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|
||||||
# POST /totp/complete_setup - Complete forced TOTP setup and sign in
|
|
||||||
def complete_setup
|
|
||||||
# Sign in the user after they've saved their backup codes
|
|
||||||
# This is only used when admin requires TOTP and user just set it up during login
|
|
||||||
if session[:totp_redirect_url].present?
|
|
||||||
session[:return_to_after_authenticating] = session.delete(:totp_redirect_url)
|
|
||||||
end
|
|
||||||
|
|
||||||
start_new_session_for @user
|
|
||||||
redirect_to after_authentication_url, notice: "Two-factor authentication enabled. Signed in successfully.", allow_other_host: true
|
|
||||||
end
|
|
||||||
|
|
||||||
# DELETE /totp - Disable TOTP (requires password)
|
# DELETE /totp - Disable TOTP (requires password)
|
||||||
def destroy
|
def destroy
|
||||||
unless @user.authenticate(params[:password])
|
unless @user.authenticate(params[:password])
|
||||||
@@ -130,14 +56,7 @@ class TotpController < ApplicationController
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
# Prevent disabling if admin requires TOTP
|
|
||||||
if @user.totp_required?
|
|
||||||
redirect_to profile_path, alert: "Two-factor authentication is required by your administrator and cannot be disabled."
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
@user.disable_totp!
|
@user.disable_totp!
|
||||||
SecurityMailer.totp_disabled(@user, **security_event_context).deliver_later
|
|
||||||
redirect_to profile_path, notice: "Two-factor authentication has been disabled."
|
redirect_to profile_path, notice: "Two-factor authentication has been disabled."
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -148,8 +67,7 @@ class TotpController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def redirect_if_totp_enabled
|
def redirect_if_totp_enabled
|
||||||
# Allow setup if admin requires it, even if already enabled (for regeneration)
|
if @user.totp_enabled?
|
||||||
if @user.totp_enabled? && !session[:pending_totp_setup_user_id].present?
|
|
||||||
redirect_to profile_path, alert: "Two-factor authentication is already enabled."
|
redirect_to profile_path, alert: "Two-factor authentication is already enabled."
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -159,4 +77,8 @@ class TotpController < ApplicationController
|
|||||||
redirect_to profile_path, alert: "Two-factor authentication is not enabled."
|
redirect_to profile_path, alert: "Two-factor authentication is not enabled."
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def generate_backup_codes
|
||||||
|
Array.new(10) { SecureRandom.alphanumeric(8).upcase }.to_json
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
class UsersController < ApplicationController
|
class UsersController < ApplicationController
|
||||||
allow_unauthenticated_access only: %i[new create]
|
allow_unauthenticated_access only: %i[ new create ]
|
||||||
before_action :ensure_first_run, only: %i[new create]
|
before_action :ensure_first_run, only: %i[ new create ]
|
||||||
|
|
||||||
def new
|
def new
|
||||||
@user = User.new
|
@user = User.new
|
||||||
@@ -8,16 +8,12 @@ class UsersController < ApplicationController
|
|||||||
|
|
||||||
def create
|
def create
|
||||||
@user = User.new(user_params)
|
@user = User.new(user_params)
|
||||||
|
|
||||||
|
# First user becomes admin automatically
|
||||||
|
@user.admin = true if User.count.zero?
|
||||||
@user.status = "active"
|
@user.status = "active"
|
||||||
first_user = User.count.zero?
|
|
||||||
|
|
||||||
if @user.save
|
if @user.save
|
||||||
# First user automatically becomes a member of every admin group, so they
|
|
||||||
# can reach the admin panel without an existing admin to grant access.
|
|
||||||
if first_user
|
|
||||||
Group.where(admin: true).each { |g| @user.groups << g }
|
|
||||||
end
|
|
||||||
|
|
||||||
start_new_session_for @user
|
start_new_session_for @user
|
||||||
redirect_to root_path, notice: "Welcome to Clinch! Your account has been created."
|
redirect_to root_path, notice: "Welcome to Clinch! Your account has been created."
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -1,198 +0,0 @@
|
|||||||
class WebauthnController < ApplicationController
|
|
||||||
before_action :set_webauthn_credential, only: [:destroy]
|
|
||||||
skip_before_action :require_authentication, only: [:check]
|
|
||||||
|
|
||||||
# Rate limit check endpoint to prevent enumeration attacks
|
|
||||||
rate_limit to: 10, within: 1.minute, only: [:check], with: -> {
|
|
||||||
render json: {error: "Too many requests. Try again later."}, status: :too_many_requests
|
|
||||||
}
|
|
||||||
|
|
||||||
# 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
|
|
||||||
)
|
|
||||||
|
|
||||||
SecurityMailer.passkey_added(user, nickname: @webauthn_credential.nickname, **security_event_context).deliver_later
|
|
||||||
|
|
||||||
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
|
|
||||||
nickname = @webauthn_credential.nickname
|
|
||||||
user = @webauthn_credential.user
|
|
||||||
@webauthn_credential.destroy
|
|
||||||
|
|
||||||
SecurityMailer.passkey_removed(user, nickname: nickname, **security_event_context).deliver_later
|
|
||||||
|
|
||||||
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)
|
|
||||||
# Security: Returns identical responses for non-existent users to prevent enumeration
|
|
||||||
def check
|
|
||||||
email = params[:email]&.strip&.downcase
|
|
||||||
|
|
||||||
if email.blank?
|
|
||||||
render json: {has_webauthn: false, requires_webauthn: false}
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
user = User.find_by(email_address: email)
|
|
||||||
|
|
||||||
# Security: Return identical response for non-existent users
|
|
||||||
# Combined with rate limiting (10/min), this prevents account enumeration
|
|
||||||
if user.nil?
|
|
||||||
render json: {has_webauthn: false, requires_webauthn: false}
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
# Only return minimal necessary info - no user_id or preferred_method
|
|
||||||
render json: {
|
|
||||||
has_webauthn: user.can_authenticate_with_webauthn?,
|
|
||||||
requires_webauthn: user.require_webauthn?,
|
|
||||||
has_totp: user.totp_enabled?
|
|
||||||
}
|
|
||||||
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
|
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|
||||||
def set_webauthn_credential
|
|
||||||
user = Current.session&.user
|
|
||||||
return render json: {error: "Not authenticated"}, status: :unauthorized unless user
|
|
||||||
@webauthn_credential = user.webauthn_credentials.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.tr("+", "-").tr("/", "_").gsub(/=+$/, "")
|
|
||||||
end
|
|
||||||
|
|
||||||
# Helper method to convert Base64URL to Base64 if needed
|
|
||||||
def base64url_to_base64(str)
|
|
||||||
str.tr("-", "+").tr("_", "/") + "=" * (4 - str.length % 4) % 4
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -19,74 +19,4 @@ module ApplicationHelper
|
|||||||
:smtp
|
:smtp
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def oidc_env_lines(application, client_secret: nil)
|
|
||||||
lines = ["OIDC_CLIENT_ID=#{application.client_id}"]
|
|
||||||
lines << if client_secret
|
|
||||||
"OIDC_CLIENT_SECRET=#{client_secret}"
|
|
||||||
elsif application.public_client?
|
|
||||||
"OIDC_CLIENT_SECRET="
|
|
||||||
else
|
|
||||||
"OIDC_CLIENT_SECRET=<your-client-secret>"
|
|
||||||
end
|
|
||||||
lines << "OIDC_DISCOVERY_URL=#{OidcJwtService.issuer_url}"
|
|
||||||
lines << "OIDC_PROVIDER_NAME='Clinch'"
|
|
||||||
lines << "OIDC_REQUIRE_PKCE=#{application.requires_pkce? ? "true" : "false"}"
|
|
||||||
lines
|
|
||||||
end
|
|
||||||
|
|
||||||
def border_class_for(type)
|
|
||||||
case type.to_s
|
|
||||||
when "notice" then "border-green-200 dark:border-green-700"
|
|
||||||
when "alert", "error" then "border-red-200 dark:border-red-700"
|
|
||||||
when "warning" then "border-yellow-200 dark:border-yellow-700"
|
|
||||||
when "info" then "border-blue-200 dark:border-blue-700"
|
|
||||||
else "border-gray-200 dark:border-gray-700"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Picks 1-2 character initials for a monogram fallback when an Application
|
|
||||||
# has no icon. Prefers capital letters (ShelfLife -> SL); falls back to the
|
|
||||||
# first two letters of the name (Audiobookshelf -> AU).
|
|
||||||
MONOGRAM_PALETTE = %w[
|
|
||||||
#4f46e5 #0891b2 #16a34a #ca8a04
|
|
||||||
#db2777 #9333ea #ea580c #475569
|
|
||||||
].freeze
|
|
||||||
|
|
||||||
def monogram_initials(name)
|
|
||||||
return "?" if name.blank?
|
|
||||||
caps = name.scan(/[A-Z]/)
|
|
||||||
initials = if caps.size >= 2
|
|
||||||
caps.first(2).join
|
|
||||||
else
|
|
||||||
name.upcase.gsub(/[^A-Z0-9]/, "").first(2)
|
|
||||||
end
|
|
||||||
initials.presence || "?"
|
|
||||||
end
|
|
||||||
|
|
||||||
def monogram_color(name)
|
|
||||||
return MONOGRAM_PALETTE.first if name.blank?
|
|
||||||
index = Digest::MD5.hexdigest(name).to_i(16) % MONOGRAM_PALETTE.size
|
|
||||||
MONOGRAM_PALETTE[index]
|
|
||||||
end
|
|
||||||
|
|
||||||
# Renders an application icon with optional dark-mode variant. If
|
|
||||||
# `icon_dark` is attached, we render both <img> tags and Tailwind's class-
|
|
||||||
# based `dark:` modifier hides the inactive one — so it follows the in-app
|
|
||||||
# theme toggle (.dark on <html>), not the OS preference. If only `icon` is
|
|
||||||
# attached, the same image is used in both modes. Caller must ensure at
|
|
||||||
# least app.icon is attached; the monogram fallback handles no-icon.
|
|
||||||
def app_icon_picture(app, class:, alt: nil)
|
|
||||||
img_class = binding.local_variable_get(:class)
|
|
||||||
alt ||= "#{app.name} icon"
|
|
||||||
|
|
||||||
if app.icon_dark.attached?
|
|
||||||
safe_join([
|
|
||||||
image_tag(app.icon, class: "#{img_class} dark:hidden", alt: alt),
|
|
||||||
image_tag(app.icon_dark, class: "#{img_class} hidden dark:block", alt: alt)
|
|
||||||
])
|
|
||||||
else
|
|
||||||
image_tag(app.icon, class: img_class, alt: alt)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
module ClaimsHelper
|
|
||||||
include ClaimsMerger
|
|
||||||
|
|
||||||
# Preview final merged claims for a user accessing an application
|
|
||||||
def preview_user_claims(user, application)
|
|
||||||
claims = {
|
|
||||||
# Standard OIDC claims
|
|
||||||
email: user.email_address,
|
|
||||||
email_verified: true,
|
|
||||||
preferred_username: user.username.presence || user.email_address,
|
|
||||||
name: user.name.presence || user.email_address
|
|
||||||
}
|
|
||||||
|
|
||||||
# Add groups
|
|
||||||
if user.groups.any?
|
|
||||||
claims[:groups] = user.groups.pluck(:name)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Merge group custom claims (arrays are combined, not overwritten)
|
|
||||||
user.groups.each do |group|
|
|
||||||
claims = deep_merge_claims(claims, group.parsed_custom_claims)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Merge user custom claims (arrays are combined, other values override)
|
|
||||||
claims = deep_merge_claims(claims, user.parsed_custom_claims)
|
|
||||||
|
|
||||||
# Merge app-specific claims (arrays are combined)
|
|
||||||
deep_merge_claims(claims, application.custom_claims_for_user(user))
|
|
||||||
end
|
|
||||||
|
|
||||||
# Get claim sources breakdown for display
|
|
||||||
def claim_sources(user, application)
|
|
||||||
sources = []
|
|
||||||
|
|
||||||
# Group claims
|
|
||||||
user.groups.each do |group|
|
|
||||||
if group.parsed_custom_claims.any?
|
|
||||||
sources << {
|
|
||||||
type: :group,
|
|
||||||
name: group.name,
|
|
||||||
claims: group.parsed_custom_claims
|
|
||||||
}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# User claims
|
|
||||||
if user.parsed_custom_claims.any?
|
|
||||||
sources << {
|
|
||||||
type: :user,
|
|
||||||
name: "User Override",
|
|
||||||
claims: user.parsed_custom_claims
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
# App-specific claims
|
|
||||||
app_claims = application.custom_claims_for_user(user)
|
|
||||||
if app_claims.any?
|
|
||||||
sources << {
|
|
||||||
type: :application,
|
|
||||||
name: "App-Specific (#{application.name})",
|
|
||||||
claims: app_claims
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
sources
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import { Controller } from "@hotwired/stimulus"
|
|
||||||
|
|
||||||
export default class extends Controller {
|
|
||||||
static targets = ["appTypeSelect", "oidcFields", "forwardAuthFields", "pkceOptions"]
|
|
||||||
|
|
||||||
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')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updatePkceVisibility(event) {
|
|
||||||
// Show PKCE options for confidential clients, hide for public clients
|
|
||||||
const isPublicClient = event.target.value === "true"
|
|
||||||
|
|
||||||
if (this.hasPkceOptionsTarget) {
|
|
||||||
if (isPublicClient) {
|
|
||||||
this.pkceOptionsTarget.classList.add('hidden')
|
|
||||||
} else {
|
|
||||||
this.pkceOptionsTarget.classList.remove('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,15 +0,0 @@
|
|||||||
import { Controller } from "@hotwired/stimulus"
|
|
||||||
|
|
||||||
export default class extends Controller {
|
|
||||||
static targets = ["source", "label"]
|
|
||||||
|
|
||||||
async copy() {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(this.sourceTarget.value)
|
|
||||||
this.labelTarget.textContent = "Copied!"
|
|
||||||
setTimeout(() => { this.labelTarget.textContent = "Copy" }, 2000)
|
|
||||||
} catch {
|
|
||||||
this.sourceTarget.select()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import { Controller } from "@hotwired/stimulus"
|
|
||||||
|
|
||||||
export default class extends Controller {
|
|
||||||
static targets = ["icon"]
|
|
||||||
|
|
||||||
connect() {
|
|
||||||
this.updateIcon()
|
|
||||||
}
|
|
||||||
|
|
||||||
toggle() {
|
|
||||||
document.documentElement.classList.toggle("dark")
|
|
||||||
const isDark = document.documentElement.classList.contains("dark")
|
|
||||||
localStorage.setItem("theme", isDark ? "dark" : "light")
|
|
||||||
this.updateIcon()
|
|
||||||
}
|
|
||||||
|
|
||||||
updateIcon() {
|
|
||||||
const isDark = document.documentElement.classList.contains("dark")
|
|
||||||
this.iconTargets.forEach(icon => {
|
|
||||||
if (icon.dataset.mode === "dark") {
|
|
||||||
icon.classList.toggle("hidden", !isDark)
|
|
||||||
} else {
|
|
||||||
icon.classList.toggle("hidden", isDark)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
import { Controller } from "@hotwired/stimulus"
|
|
||||||
|
|
||||||
export default class extends Controller {
|
|
||||||
static targets = ["input", "dropzone", "preview", "previewImage", "filename", "filesize"]
|
|
||||||
|
|
||||||
connect() {
|
|
||||||
// Prevent default drag behaviors on the whole document
|
|
||||||
["dragenter", "dragover", "dragleave", "drop"].forEach(eventName => {
|
|
||||||
document.body.addEventListener(eventName, this.preventDefaults, false)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
disconnect() {
|
|
||||||
["dragenter", "dragover", "dragleave", "drop"].forEach(eventName => {
|
|
||||||
document.body.removeEventListener(eventName, this.preventDefaults, false)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
preventDefaults(e) {
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
}
|
|
||||||
|
|
||||||
dragover(e) {
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
this.dropzoneTarget.classList.add("border-blue-500", "bg-blue-50")
|
|
||||||
}
|
|
||||||
|
|
||||||
dragleave(e) {
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
this.dropzoneTarget.classList.remove("border-blue-500", "bg-blue-50")
|
|
||||||
}
|
|
||||||
|
|
||||||
drop(e) {
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
this.dropzoneTarget.classList.remove("border-blue-500", "bg-blue-50")
|
|
||||||
|
|
||||||
const files = e.dataTransfer.files
|
|
||||||
if (files.length > 0) {
|
|
||||||
// Set the file to the input element
|
|
||||||
this.inputTarget.files = files
|
|
||||||
this.handleFiles()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleFiles() {
|
|
||||||
const file = this.inputTarget.files[0]
|
|
||||||
if (!file) return
|
|
||||||
|
|
||||||
// Validate file type
|
|
||||||
const validTypes = ["image/png", "image/jpg", "image/jpeg", "image/gif", "image/svg+xml"]
|
|
||||||
if (!validTypes.includes(file.type)) {
|
|
||||||
alert("Please upload a PNG, JPG, GIF, or SVG image")
|
|
||||||
this.clear()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate file size (2MB)
|
|
||||||
if (file.size > 2 * 1024 * 1024) {
|
|
||||||
alert("File size must be less than 2MB")
|
|
||||||
this.clear()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show preview
|
|
||||||
this.filenameTarget.textContent = file.name
|
|
||||||
this.filesizeTarget.textContent = this.formatFileSize(file.size)
|
|
||||||
|
|
||||||
// Create preview image
|
|
||||||
const reader = new FileReader()
|
|
||||||
reader.onload = (e) => {
|
|
||||||
this.previewImageTarget.src = e.target.result
|
|
||||||
this.previewTarget.classList.remove("hidden")
|
|
||||||
}
|
|
||||||
reader.readAsDataURL(file)
|
|
||||||
}
|
|
||||||
|
|
||||||
clear(e) {
|
|
||||||
if (e) {
|
|
||||||
e.preventDefault()
|
|
||||||
}
|
|
||||||
this.inputTarget.value = ""
|
|
||||||
this.previewTarget.classList.add("hidden")
|
|
||||||
}
|
|
||||||
|
|
||||||
formatFileSize(bytes) {
|
|
||||||
if (bytes === 0) return "0 Bytes"
|
|
||||||
const k = 1024
|
|
||||||
const sizes = ["Bytes", "KB", "MB"]
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
|
||||||
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + " " + sizes[i]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,121 +0,0 @@
|
|||||||
import { Controller } from "@hotwired/stimulus"
|
|
||||||
|
|
||||||
export default class extends Controller {
|
|
||||||
static targets = ["input", "dropzone"]
|
|
||||||
|
|
||||||
connect() {
|
|
||||||
// Listen for paste events on the dropzone
|
|
||||||
this.dropzoneTarget.addEventListener("paste", this.handlePaste.bind(this))
|
|
||||||
}
|
|
||||||
|
|
||||||
disconnect() {
|
|
||||||
this.dropzoneTarget.removeEventListener("paste", this.handlePaste.bind(this))
|
|
||||||
}
|
|
||||||
|
|
||||||
handlePaste(e) {
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
|
|
||||||
const clipboardData = e.clipboardData || e.originalEvent.clipboardData
|
|
||||||
|
|
||||||
// First, try to get image data
|
|
||||||
for (let item of clipboardData.items) {
|
|
||||||
if (item.type.indexOf("image") !== -1) {
|
|
||||||
const blob = item.getAsFile()
|
|
||||||
this.handleImageBlob(blob)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no image found, check for SVG text
|
|
||||||
const text = clipboardData.getData("text/plain")
|
|
||||||
if (text && this.isSVG(text)) {
|
|
||||||
this.handleSVGText(text)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
isSVG(text) {
|
|
||||||
// Check if the text looks like SVG code
|
|
||||||
const trimmed = text.trim()
|
|
||||||
return trimmed.startsWith("<svg") && trimmed.includes("</svg>")
|
|
||||||
}
|
|
||||||
|
|
||||||
handleSVGText(svgText) {
|
|
||||||
// Validate file size (2MB)
|
|
||||||
const size = new Blob([svgText]).size
|
|
||||||
if (size > 2 * 1024 * 1024) {
|
|
||||||
alert("SVG code is too large (must be less than 2MB)")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a blob from the SVG text
|
|
||||||
const blob = new Blob([svgText], { type: "image/svg+xml" })
|
|
||||||
|
|
||||||
// Create a File object
|
|
||||||
const file = new File([blob], `pasted-svg-${Date.now()}.svg`, {
|
|
||||||
type: "image/svg+xml"
|
|
||||||
})
|
|
||||||
|
|
||||||
// Create a DataTransfer object to set files on the input
|
|
||||||
const dataTransfer = new DataTransfer()
|
|
||||||
dataTransfer.items.add(file)
|
|
||||||
this.inputTarget.files = dataTransfer.files
|
|
||||||
|
|
||||||
// Trigger change event to update preview (file-drop controller will handle it)
|
|
||||||
const event = new Event("change", { bubbles: true })
|
|
||||||
this.inputTarget.dispatchEvent(event)
|
|
||||||
|
|
||||||
// Visual feedback
|
|
||||||
this.dropzoneTarget.classList.add("border-green-500", "bg-green-50")
|
|
||||||
setTimeout(() => {
|
|
||||||
this.dropzoneTarget.classList.remove("border-green-500", "bg-green-50")
|
|
||||||
}, 500)
|
|
||||||
}
|
|
||||||
|
|
||||||
handleImageBlob(blob) {
|
|
||||||
// Validate file type
|
|
||||||
const validTypes = ["image/png", "image/jpg", "image/jpeg", "image/gif", "image/svg+xml"]
|
|
||||||
if (!validTypes.includes(blob.type)) {
|
|
||||||
alert("Please paste a PNG, JPG, GIF, or SVG image")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate file size (2MB)
|
|
||||||
if (blob.size > 2 * 1024 * 1024) {
|
|
||||||
alert("Image size must be less than 2MB")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a File object from the blob with a default name
|
|
||||||
const file = new File([blob], `pasted-image-${Date.now()}.${this.getExtension(blob.type)}`, {
|
|
||||||
type: blob.type
|
|
||||||
})
|
|
||||||
|
|
||||||
// Create a DataTransfer object to set files on the input
|
|
||||||
const dataTransfer = new DataTransfer()
|
|
||||||
dataTransfer.items.add(file)
|
|
||||||
this.inputTarget.files = dataTransfer.files
|
|
||||||
|
|
||||||
// Trigger change event to update preview (file-drop controller will handle it)
|
|
||||||
const event = new Event("change", { bubbles: true })
|
|
||||||
this.inputTarget.dispatchEvent(event)
|
|
||||||
|
|
||||||
// Visual feedback
|
|
||||||
this.dropzoneTarget.classList.add("border-green-500", "bg-green-50")
|
|
||||||
setTimeout(() => {
|
|
||||||
this.dropzoneTarget.classList.remove("border-green-500", "bg-green-50")
|
|
||||||
}, 500)
|
|
||||||
}
|
|
||||||
|
|
||||||
getExtension(mimeType) {
|
|
||||||
const extensions = {
|
|
||||||
"image/png": "png",
|
|
||||||
"image/jpeg": "jpg",
|
|
||||||
"image/jpg": "jpg",
|
|
||||||
"image/gif": "gif",
|
|
||||||
"image/svg+xml": "svg"
|
|
||||||
}
|
|
||||||
return extensions[mimeType] || "png"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,326 +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
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Don't auto-trigger navigator.credentials.get() — Safari's WebAuthn
|
|
||||||
// dialog can become undismissable when invoked without a user gesture.
|
|
||||||
// Always let the user click "Continue with Passkey" instead.
|
|
||||||
} 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(),
|
|
||||||
remember_me: this.getRememberMe()
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
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]"]');
|
|
||||||
}
|
|
||||||
// Fallback to hidden webauthn_email field (e.g., on TOTP verification page)
|
|
||||||
if (!emailInput) {
|
|
||||||
emailInput = document.querySelector('input[name="webauthn_email"]');
|
|
||||||
}
|
|
||||||
return emailInput ? emailInput.value.trim() : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
getRememberMe() {
|
|
||||||
const checkbox = document.querySelector('input[name="remember_me"][type="checkbox"]');
|
|
||||||
return checkbox ? checkbox.checked : false;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 a user-friendly message
|
|
||||||
return "Passkey authentication failed. A browser extension may be interfering — try using your password instead.";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
class BackchannelLogoutJob < ApplicationJob
|
|
||||||
queue_as :default
|
|
||||||
|
|
||||||
# Retry with exponential backoff: 1s, 5s, 25s
|
|
||||||
retry_on StandardError, wait: :exponentially_longer, attempts: 3
|
|
||||||
|
|
||||||
def perform(user_id:, application_id:, consent_sid:)
|
|
||||||
# Find the records
|
|
||||||
user = User.find_by(id: user_id)
|
|
||||||
application = Application.find_by(id: application_id)
|
|
||||||
consent = OidcUserConsent.find_by(sid: consent_sid)
|
|
||||||
|
|
||||||
# Validate we have all required data
|
|
||||||
unless user && application && consent
|
|
||||||
Rails.logger.warn "BackchannelLogout: Missing data - user: #{user.present?}, app: #{application.present?}, consent: #{consent.present?}"
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
# Skip if application doesn't support backchannel logout
|
|
||||||
unless application.supports_backchannel_logout?
|
|
||||||
Rails.logger.debug "BackchannelLogout: Application #{application.name} doesn't support backchannel logout"
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
# Generate the logout token
|
|
||||||
logout_token = OidcJwtService.generate_logout_token(user, application, consent)
|
|
||||||
|
|
||||||
# Send HTTP POST to the application's backchannel logout URI
|
|
||||||
uri = URI.parse(application.backchannel_logout_uri)
|
|
||||||
|
|
||||||
# SSRF guard: re-check at request time (with DNS resolution) in case the URI
|
|
||||||
# predates the validation, or a public hostname now resolves to an internal
|
|
||||||
# address. Abort without retrying — retries would not change the outcome.
|
|
||||||
if PrivateAddressCheck.internal_host?(uri.host) || PrivateAddressCheck.resolves_to_internal?(uri.host)
|
|
||||||
Rails.logger.error "BackchannelLogout: Refusing to send logout to #{application.name} - #{uri.host} is or resolves to a non-public address (SSRF guard)"
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
begin
|
|
||||||
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: 5, read_timeout: 5) do |http|
|
|
||||||
request = Net::HTTP::Post.new(uri.path.presence || "/")
|
|
||||||
request["Content-Type"] = "application/x-www-form-urlencoded"
|
|
||||||
request.set_form_data({logout_token: logout_token})
|
|
||||||
http.request(request)
|
|
||||||
end
|
|
||||||
|
|
||||||
if response.code.to_i == 200
|
|
||||||
Rails.logger.info "BackchannelLogout: Successfully sent logout notification to #{application.name} (#{application.backchannel_logout_uri})"
|
|
||||||
else
|
|
||||||
Rails.logger.warn "BackchannelLogout: Application #{application.name} returned HTTP #{response.code} from #{application.backchannel_logout_uri}"
|
|
||||||
end
|
|
||||||
rescue Net::OpenTimeout, Net::ReadTimeout => e
|
|
||||||
Rails.logger.warn "BackchannelLogout: Timeout sending logout to #{application.name} (#{application.backchannel_logout_uri}): #{e.message}"
|
|
||||||
raise # Retry on timeout
|
|
||||||
rescue => e
|
|
||||||
Rails.logger.error "BackchannelLogout: Failed to send logout to #{application.name} (#{application.backchannel_logout_uri}): #{e.class} - #{e.message}"
|
|
||||||
raise # Retry on error
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -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,45 +0,0 @@
|
|||||||
class DurationParser
|
|
||||||
UNITS = {
|
|
||||||
"s" => 1, # seconds
|
|
||||||
"m" => 60, # minutes
|
|
||||||
"h" => 3600, # hours
|
|
||||||
"d" => 86400, # days
|
|
||||||
"w" => 604800, # weeks
|
|
||||||
"M" => 2592000, # months (30 days)
|
|
||||||
"y" => 31536000 # years (365 days)
|
|
||||||
}
|
|
||||||
|
|
||||||
# Parse a duration string into seconds
|
|
||||||
# Accepts formats: "1h", "30m", "1d", "1M" (month), "3600" (plain number)
|
|
||||||
# Returns integer seconds or nil if invalid
|
|
||||||
# Case-sensitive: 1s, 1m, 1h, 1d, 1w, 1M (month), 1y
|
|
||||||
def self.parse(input)
|
|
||||||
# Handle integers directly
|
|
||||||
return input if input.is_a?(Integer)
|
|
||||||
|
|
||||||
# Convert to string and strip whitespace
|
|
||||||
str = input.to_s.strip
|
|
||||||
|
|
||||||
# Return nil for blank input
|
|
||||||
return nil if str.blank?
|
|
||||||
|
|
||||||
# Try to parse as plain number (already in seconds)
|
|
||||||
if str.match?(/^\d+$/)
|
|
||||||
return str.to_i
|
|
||||||
end
|
|
||||||
|
|
||||||
# Try to parse with unit (e.g., "1h", "30m", "1M")
|
|
||||||
# Allow optional space between number and unit
|
|
||||||
# Case-sensitive to avoid confusion (1m = minute, 1M = month)
|
|
||||||
match = str.match(/^(\d+)\s*([smhdwMy])$/)
|
|
||||||
return nil unless match
|
|
||||||
|
|
||||||
number = match[1].to_i
|
|
||||||
unit = match[2]
|
|
||||||
|
|
||||||
multiplier = UNITS[unit]
|
|
||||||
return nil unless multiplier
|
|
||||||
|
|
||||||
number * multiplier
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
require "ipaddr"
|
|
||||||
require "resolv"
|
|
||||||
|
|
||||||
# SSRF guard for outbound requests to admin-configured URLs (currently the OIDC
|
|
||||||
# backchannel logout endpoint). Blocks hosts that are, or resolve to, private,
|
|
||||||
# loopback, link-local (incl. the cloud metadata address 169.254.169.254) or
|
|
||||||
# otherwise non-public address space.
|
|
||||||
module PrivateAddressCheck
|
|
||||||
module_function
|
|
||||||
|
|
||||||
# Hostnames that are internal by definition and must never be dialled.
|
|
||||||
BLOCKED_HOSTNAMES = %w[localhost metadata.google.internal].freeze
|
|
||||||
|
|
||||||
# Fast, DNS-free check: catches IP literals and well-known internal hostnames.
|
|
||||||
# Suitable for model validation (deterministic, immediate admin feedback).
|
|
||||||
def internal_host?(host)
|
|
||||||
host = host.to_s.downcase
|
|
||||||
return true if host.blank?
|
|
||||||
return true if BLOCKED_HOSTNAMES.include?(host)
|
|
||||||
return true if host.end_with?(".localhost")
|
|
||||||
|
|
||||||
ip = parse_ip(host)
|
|
||||||
ip ? internal_ip?(ip) : false
|
|
||||||
end
|
|
||||||
|
|
||||||
# Authoritative check: resolves the hostname and blocks if ANY address is
|
|
||||||
# internal. Suitable for request time — also defeats a public hostname that
|
|
||||||
# has been pointed at an internal IP (DNS rebinding to internal space).
|
|
||||||
def resolves_to_internal?(host)
|
|
||||||
addresses(host).any? { |ip| internal_ip?(ip) }
|
|
||||||
end
|
|
||||||
|
|
||||||
def addresses(host)
|
|
||||||
ip = parse_ip(host)
|
|
||||||
return [ip] if ip
|
|
||||||
|
|
||||||
Resolv.getaddresses(host.to_s).filter_map { |a| parse_ip(a) }
|
|
||||||
rescue
|
|
||||||
# Resolution failure: surface no addresses. Callers treat "can't resolve" as
|
|
||||||
# not-provably-internal; the dial itself will then fail safely.
|
|
||||||
[]
|
|
||||||
end
|
|
||||||
|
|
||||||
def internal_ip?(ip)
|
|
||||||
ip.loopback? || ip.private? || ip.link_local? || unspecified?(ip)
|
|
||||||
end
|
|
||||||
|
|
||||||
def parse_ip(str)
|
|
||||||
IPAddr.new(str.to_s)
|
|
||||||
rescue IPAddr::Error
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
|
|
||||||
def unspecified?(ip)
|
|
||||||
ip == IPAddr.new("0.0.0.0") || ip == IPAddr.new("::")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
class ApplicationMailer < ActionMailer::Base
|
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"
|
layout "mailer"
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
class SecurityMailer < ApplicationMailer
|
|
||||||
SUBJECT_PREFIX = "[Clinch security alert] ".freeze
|
|
||||||
|
|
||||||
def password_changed(user, ip:, user_agent:, occurred_at:)
|
|
||||||
assign_context(user, ip, user_agent, occurred_at)
|
|
||||||
mail subject: "#{SUBJECT_PREFIX}Your password was changed", to: user.email_address
|
|
||||||
end
|
|
||||||
|
|
||||||
def totp_disabled(user, ip:, user_agent:, occurred_at:)
|
|
||||||
assign_context(user, ip, user_agent, occurred_at)
|
|
||||||
mail subject: "#{SUBJECT_PREFIX}Two-factor authentication was disabled", to: user.email_address
|
|
||||||
end
|
|
||||||
|
|
||||||
def backup_codes_regenerated(user, ip:, user_agent:, occurred_at:)
|
|
||||||
assign_context(user, ip, user_agent, occurred_at)
|
|
||||||
mail subject: "#{SUBJECT_PREFIX}Two-factor backup codes were regenerated", to: user.email_address
|
|
||||||
end
|
|
||||||
|
|
||||||
def passkey_added(user, nickname:, ip:, user_agent:, occurred_at:)
|
|
||||||
assign_context(user, ip, user_agent, occurred_at)
|
|
||||||
@nickname = nickname
|
|
||||||
mail subject: "#{SUBJECT_PREFIX}A passkey was added to your account", to: user.email_address
|
|
||||||
end
|
|
||||||
|
|
||||||
def passkey_removed(user, nickname:, ip:, user_agent:, occurred_at:)
|
|
||||||
assign_context(user, ip, user_agent, occurred_at)
|
|
||||||
@nickname = nickname
|
|
||||||
mail subject: "#{SUBJECT_PREFIX}A passkey was removed from your account", to: user.email_address
|
|
||||||
end
|
|
||||||
|
|
||||||
def api_key_created(user, name:, ip:, user_agent:, occurred_at:)
|
|
||||||
assign_context(user, ip, user_agent, occurred_at)
|
|
||||||
@api_key_name = name
|
|
||||||
mail subject: "#{SUBJECT_PREFIX}An API key was created on your account", to: user.email_address
|
|
||||||
end
|
|
||||||
|
|
||||||
def api_key_revoked(user, name:, ip:, user_agent:, occurred_at:)
|
|
||||||
assign_context(user, ip, user_agent, occurred_at)
|
|
||||||
@api_key_name = name
|
|
||||||
mail subject: "#{SUBJECT_PREFIX}An API key was revoked on your account", to: user.email_address
|
|
||||||
end
|
|
||||||
|
|
||||||
def suspicious_passkey_used(user, nickname:, ip:, user_agent:, occurred_at:)
|
|
||||||
assign_context(user, ip, user_agent, occurred_at)
|
|
||||||
@nickname = nickname
|
|
||||||
mail subject: "#{SUBJECT_PREFIX}A passkey sign-in was blocked", to: user.email_address
|
|
||||||
end
|
|
||||||
|
|
||||||
def email_address_changed(user, recipient:, old_email:, new_email:, ip:, user_agent:, occurred_at:)
|
|
||||||
assign_context(user, ip, user_agent, occurred_at)
|
|
||||||
@recipient = recipient
|
|
||||||
@old_email = old_email
|
|
||||||
@new_email = new_email
|
|
||||||
mail subject: "#{SUBJECT_PREFIX}Your account email address was changed", to: recipient
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def assign_context(user, ip, user_agent, occurred_at)
|
|
||||||
@user = user
|
|
||||||
@ip = ip
|
|
||||||
@user_agent = user_agent
|
|
||||||
@occurred_at = occurred_at
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
class TotpMailer < ApplicationMailer
|
|
||||||
def enabled(user)
|
|
||||||
@user = user
|
|
||||||
mail subject: "Two-factor authentication enabled on your account",
|
|
||||||
to: user.email_address
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
class ApiKey < ApplicationRecord
|
|
||||||
belongs_to :user
|
|
||||||
belongs_to :application
|
|
||||||
|
|
||||||
before_validation :generate_token, on: :create
|
|
||||||
|
|
||||||
validates :name, presence: true
|
|
||||||
validates :token_hmac, presence: true, uniqueness: true
|
|
||||||
validate :application_must_be_forward_auth
|
|
||||||
validate :user_must_have_access
|
|
||||||
|
|
||||||
scope :active, -> { where(revoked_at: nil).where("expires_at IS NULL OR expires_at > ?", Time.current) }
|
|
||||||
scope :revoked, -> { where.not(revoked_at: nil) }
|
|
||||||
|
|
||||||
attr_accessor :plaintext_token
|
|
||||||
|
|
||||||
def self.find_by_token(plaintext_token)
|
|
||||||
return nil if plaintext_token.blank?
|
|
||||||
|
|
||||||
token_hmac = compute_token_hmac(plaintext_token)
|
|
||||||
find_by(token_hmac: token_hmac)
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.compute_token_hmac(plaintext_token)
|
|
||||||
OpenSSL::HMAC.hexdigest("SHA256", TokenHmac::KEY, plaintext_token)
|
|
||||||
end
|
|
||||||
|
|
||||||
def expired?
|
|
||||||
expires_at.present? && expires_at <= Time.current
|
|
||||||
end
|
|
||||||
|
|
||||||
def revoked?
|
|
||||||
revoked_at.present?
|
|
||||||
end
|
|
||||||
|
|
||||||
def active?
|
|
||||||
!expired? && !revoked?
|
|
||||||
end
|
|
||||||
|
|
||||||
def revoke!
|
|
||||||
update!(revoked_at: Time.current)
|
|
||||||
end
|
|
||||||
|
|
||||||
def touch_last_used!
|
|
||||||
update_column(:last_used_at, Time.current)
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def generate_token
|
|
||||||
self.plaintext_token ||= "clk_#{SecureRandom.urlsafe_base64(48)}"
|
|
||||||
self.token_hmac ||= self.class.compute_token_hmac(plaintext_token)
|
|
||||||
end
|
|
||||||
|
|
||||||
def application_must_be_forward_auth
|
|
||||||
if application && !application.forward_auth?
|
|
||||||
errors.add(:application, "must be a forward auth application")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def user_must_have_access
|
|
||||||
if user && application && !application.user_allowed?(user)
|
|
||||||
errors.add(:user, "does not have access to this application")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,133 +1,64 @@
|
|||||||
class Application < ApplicationRecord
|
class Application < ApplicationRecord
|
||||||
has_secure_password :client_secret, validations: false
|
has_secure_password :client_secret
|
||||||
|
|
||||||
# Virtual attribute to control client type during creation
|
|
||||||
# When true, no client_secret will be generated (public client)
|
|
||||||
attr_accessor :is_public_client
|
|
||||||
|
|
||||||
# Virtual setters for TTL fields - accept human-friendly durations
|
|
||||||
# e.g., "1h", "30m", "1d", or plain numbers "3600"
|
|
||||||
def access_token_ttl=(value)
|
|
||||||
parsed = DurationParser.parse(value)
|
|
||||||
super(parsed)
|
|
||||||
end
|
|
||||||
|
|
||||||
def refresh_token_ttl=(value)
|
|
||||||
parsed = DurationParser.parse(value)
|
|
||||||
super(parsed)
|
|
||||||
end
|
|
||||||
|
|
||||||
def id_token_ttl=(value)
|
|
||||||
parsed = DurationParser.parse(value)
|
|
||||||
super(parsed)
|
|
||||||
end
|
|
||||||
|
|
||||||
after_commit :bust_forward_auth_cache, if: :forward_auth?
|
|
||||||
|
|
||||||
has_one_attached :icon
|
|
||||||
has_one_attached :icon_dark
|
|
||||||
|
|
||||||
ICON_ATTACHMENTS = %i[icon icon_dark].freeze
|
|
||||||
|
|
||||||
before_validation :sanitize_svg_icons
|
|
||||||
after_save :fix_icon_content_types
|
|
||||||
|
|
||||||
has_many :application_groups, dependent: :destroy
|
has_many :application_groups, dependent: :destroy
|
||||||
has_many :allowed_groups, through: :application_groups, source: :group
|
has_many :allowed_groups, through: :application_groups, source: :group
|
||||||
has_many :application_user_claims, dependent: :destroy
|
|
||||||
has_many :oidc_authorization_codes, dependent: :destroy
|
has_many :oidc_authorization_codes, dependent: :destroy
|
||||||
has_many :oidc_access_tokens, 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 :oidc_user_consents, dependent: :destroy
|
||||||
has_many :api_keys, dependent: :destroy
|
has_many :application_roles, dependent: :destroy
|
||||||
|
has_many :user_role_assignments, through: :application_roles
|
||||||
|
|
||||||
validates :name, presence: true
|
validates :name, presence: true
|
||||||
validates :slug, presence: true, uniqueness: {case_sensitive: false},
|
validates :slug, presence: true, uniqueness: { case_sensitive: false },
|
||||||
format: {with: /\A[a-z0-9-]+\z/, message: "only lowercase letters, numbers, and hyphens"}
|
format: { with: /\A[a-z0-9\-]+\z/, message: "only lowercase letters, numbers, and hyphens" }
|
||||||
validates :app_type, presence: true,
|
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_id, uniqueness: { allow_nil: true }
|
||||||
validates :client_secret, presence: true, on: :create, if: -> { oidc? && confidential_client? }
|
validates :role_mapping_mode, inclusion: { in: %w[disabled oidc_managed hybrid] }, allow_blank: true
|
||||||
validates :domain_pattern, presence: true, uniqueness: {case_sensitive: false}, if: :forward_auth?
|
|
||||||
validates :landing_url, format: {with: URI::RFC2396_PARSER.make_regexp(%w[http https]), allow_nil: true, message: "must be a valid URL"}
|
|
||||||
validates :backchannel_logout_uri, format: {
|
|
||||||
with: URI::RFC2396_PARSER.make_regexp(%w[http https]),
|
|
||||||
allow_nil: true,
|
|
||||||
message: "must be a valid HTTP or HTTPS URL"
|
|
||||||
}
|
|
||||||
validate :backchannel_logout_uri_must_be_https_in_production, if: -> { backchannel_logout_uri.present? }
|
|
||||||
validate :backchannel_logout_uri_not_internal, if: -> { backchannel_logout_uri.present? }
|
|
||||||
|
|
||||||
# Icon validation using ActiveStorage validators
|
|
||||||
validate :icon_validation
|
|
||||||
|
|
||||||
# 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: 300, less_than_or_equal_to: 7776000}, if: :oidc? # 5 min - 90 days
|
|
||||||
validates :id_token_ttl, numericality: {greater_than_or_equal_to: 300, less_than_or_equal_to: 86400}, if: :oidc? # 5 min - 24 hours
|
|
||||||
|
|
||||||
normalizes :slug, with: ->(slug) { slug.strip.downcase }
|
normalizes :slug, with: ->(slug) { slug.strip.downcase }
|
||||||
normalizes :domain_pattern, with: ->(pattern) {
|
|
||||||
normalized = pattern&.strip&.downcase
|
|
||||||
normalized.blank? ? nil : normalized
|
|
||||||
}
|
|
||||||
normalizes :backchannel_logout_uri, with: ->(uri) {
|
|
||||||
normalized = uri&.strip
|
|
||||||
normalized.blank? ? nil : normalized
|
|
||||||
}
|
|
||||||
|
|
||||||
before_validation :generate_client_credentials, on: :create, if: :oidc?
|
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",
|
|
||||||
username: "X-Remote-Username",
|
|
||||||
groups: "X-Remote-Groups",
|
|
||||||
admin: "X-Remote-Admin"
|
|
||||||
}.freeze
|
|
||||||
|
|
||||||
# Scopes
|
# Scopes
|
||||||
scope :active, -> { where(active: true) }
|
scope :active, -> { where(active: true) }
|
||||||
scope :oidc, -> { where(app_type: "oidc") }
|
scope :oidc, -> { where(app_type: "oidc") }
|
||||||
scope :forward_auth, -> { where(app_type: "forward_auth") }
|
scope :saml, -> { where(app_type: "saml") }
|
||||||
scope :ordered, -> { order(domain_pattern: :asc) }
|
scope :oidc_managed_roles, -> { where(role_mapping_mode: "oidc_managed") }
|
||||||
|
scope :hybrid_roles, -> { where(role_mapping_mode: "hybrid") }
|
||||||
|
|
||||||
# Type checks
|
# Type checks
|
||||||
def oidc?
|
def oidc?
|
||||||
app_type == "oidc"
|
app_type == "oidc"
|
||||||
end
|
end
|
||||||
|
|
||||||
def forward_auth?
|
def saml?
|
||||||
app_type == "forward_auth"
|
app_type == "saml"
|
||||||
end
|
end
|
||||||
|
|
||||||
# Client type checks (for OIDC)
|
# Role mapping checks
|
||||||
def public_client?
|
def role_mapping_enabled?
|
||||||
client_secret_digest.blank?
|
role_mapping_mode.in?(['oidc_managed', 'hybrid'])
|
||||||
end
|
end
|
||||||
|
|
||||||
def confidential_client?
|
def oidc_managed_roles?
|
||||||
!public_client?
|
role_mapping_mode == 'oidc_managed'
|
||||||
end
|
end
|
||||||
|
|
||||||
# PKCE requirement check
|
def hybrid_roles?
|
||||||
# Public clients MUST use PKCE (no client secret to protect auth code)
|
role_mapping_mode == 'hybrid'
|
||||||
# Confidential clients can optionally require PKCE (OAuth 2.1 recommendation)
|
|
||||||
def requires_pkce?
|
|
||||||
return false unless oidc?
|
|
||||||
return true if public_client? # Always require PKCE for public clients
|
|
||||||
require_pkce? # Check the flag for confidential clients
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Access control
|
# Access control
|
||||||
# Default-deny: an empty allowed_groups list means no one gets in.
|
|
||||||
# To make an app accessible to "everyone", attach the seeded auto-assign
|
|
||||||
# group (or any group every user is in).
|
|
||||||
def user_allowed?(user)
|
def user_allowed?(user)
|
||||||
return false unless active?
|
return false unless active?
|
||||||
return false unless user.active?
|
return false unless user.active?
|
||||||
|
|
||||||
|
# If no groups are specified, allow all active users
|
||||||
|
return true if allowed_groups.empty?
|
||||||
|
|
||||||
|
# Otherwise, user must be in at least one of the allowed groups
|
||||||
(user.groups & allowed_groups).any?
|
(user.groups & allowed_groups).any?
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -146,262 +77,67 @@ class Application < ApplicationRecord
|
|||||||
{}
|
{}
|
||||||
end
|
end
|
||||||
|
|
||||||
# ForwardAuth helpers
|
def parsed_managed_permissions
|
||||||
def parsed_headers_config
|
return {} unless managed_permissions.present?
|
||||||
return {} unless headers_config.present?
|
managed_permissions.is_a?(Hash) ? managed_permissions : JSON.parse(managed_permissions)
|
||||||
headers_config.is_a?(Hash) ? headers_config : JSON.parse(headers_config)
|
|
||||||
rescue JSON::ParserError
|
rescue JSON::ParserError
|
||||||
{}
|
{}
|
||||||
end
|
end
|
||||||
|
|
||||||
# Check if a domain matches this application's pattern (for ForwardAuth)
|
# Role management methods
|
||||||
def matches_domain?(domain)
|
def user_roles(user)
|
||||||
return false if domain.blank? || !forward_auth?
|
application_roles.joins(:user_role_assignments)
|
||||||
|
.where(user_role_assignments: { user: user })
|
||||||
pattern = domain_pattern.gsub(".", '\.')
|
.active
|
||||||
pattern = pattern.gsub("*", "[^.]*")
|
|
||||||
|
|
||||||
regex = Regexp.new("^#{pattern}$", Regexp::IGNORECASE)
|
|
||||||
regex.match?(domain.downcase)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Policy determination based on user status (for ForwardAuth)
|
def user_has_role?(user, role_name)
|
||||||
def policy_for_user(user)
|
user_roles(user).exists?(name: role_name)
|
||||||
return "deny" unless active?
|
|
||||||
return "deny" unless user.active?
|
|
||||||
|
|
||||||
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
|
end
|
||||||
|
|
||||||
# Get effective header configuration (for ForwardAuth)
|
def assign_role_to_user!(user, role_name, source: 'manual', metadata: {})
|
||||||
def effective_headers
|
role = application_roles.active.find_by!(name: role_name)
|
||||||
DEFAULT_HEADERS.merge(parsed_headers_config.symbolize_keys)
|
role.assign_to_user!(user, source: source, metadata: metadata)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Generate headers for a specific user (for ForwardAuth)
|
def remove_role_from_user!(user, role_name)
|
||||||
def headers_for_user(user)
|
role = application_roles.find_by!(name: role_name)
|
||||||
headers = {}
|
role.remove_from_user!(user)
|
||||||
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
|
|
||||||
headers[header_name] = user.email_address
|
|
||||||
when :name
|
|
||||||
headers[header_name] = user.name.presence || user.email_address
|
|
||||||
when :username
|
|
||||||
headers[header_name] = user.username if user.username.present?
|
|
||||||
when :groups
|
|
||||||
headers[header_name] = user.groups.map(&:name).join(",") if user.groups.any?
|
|
||||||
when :admin
|
|
||||||
headers[header_name] = user.admin? ? "true" : "false"
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
headers
|
# Enhanced access control with roles
|
||||||
|
def user_allowed_with_roles?(user)
|
||||||
|
return user_allowed?(user) unless role_mapping_enabled?
|
||||||
|
|
||||||
|
# For OIDC managed roles, check if user has any roles assigned
|
||||||
|
if oidc_managed_roles?
|
||||||
|
return user_roles(user).exists?
|
||||||
end
|
end
|
||||||
|
|
||||||
# Check if all headers are disabled (for ForwardAuth)
|
# For hybrid mode, either group-based access or role-based access works
|
||||||
def headers_disabled?
|
if hybrid_roles?
|
||||||
headers_config.present? && effective_headers.values.all?(&:blank?)
|
return user_allowed?(user) || user_roles(user).exists?
|
||||||
|
end
|
||||||
|
|
||||||
|
user_allowed?(user)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Generate and return a new client secret
|
# Generate and return a new client secret
|
||||||
def generate_new_client_secret!
|
def generate_new_client_secret!
|
||||||
secret = SecureRandom.urlsafe_base64(48)
|
secret = SecureRandom.urlsafe_base64(48)
|
||||||
self.client_secret = secret
|
self.client_secret = secret
|
||||||
save!
|
self.save!
|
||||||
secret
|
secret
|
||||||
end
|
end
|
||||||
|
|
||||||
# Token TTL helper methods (for OIDC)
|
|
||||||
def access_token_expiry
|
|
||||||
(access_token_ttl || 3600).seconds.from_now
|
|
||||||
end
|
|
||||||
|
|
||||||
def refresh_token_expiry
|
|
||||||
(refresh_token_ttl || 2592000).seconds.from_now
|
|
||||||
end
|
|
||||||
|
|
||||||
def id_token_expiry_seconds
|
|
||||||
id_token_ttl || 3600
|
|
||||||
end
|
|
||||||
|
|
||||||
# Human-readable TTL for display
|
|
||||||
def access_token_ttl_human
|
|
||||||
duration_to_human(access_token_ttl || 3600)
|
|
||||||
end
|
|
||||||
|
|
||||||
def refresh_token_ttl_human
|
|
||||||
duration_to_human(refresh_token_ttl || 2592000)
|
|
||||||
end
|
|
||||||
|
|
||||||
def id_token_ttl_human
|
|
||||||
duration_to_human(id_token_ttl || 3600)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Get app-specific custom claims for a user
|
|
||||||
def custom_claims_for_user(user)
|
|
||||||
app_claim = application_user_claims.find_by(user: user)
|
|
||||||
app_claim&.parsed_custom_claims || {}
|
|
||||||
end
|
|
||||||
|
|
||||||
# Check if this application supports backchannel logout
|
|
||||||
def supports_backchannel_logout?
|
|
||||||
backchannel_logout_uri.present?
|
|
||||||
end
|
|
||||||
|
|
||||||
# Check if a user has an active session with this application
|
|
||||||
# (i.e., has valid, non-revoked tokens)
|
|
||||||
def user_has_active_session?(user)
|
|
||||||
oidc_access_tokens.where(user: user).valid.exists? ||
|
|
||||||
oidc_refresh_tokens.where(user: user).valid.exists?
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def bust_forward_auth_cache
|
|
||||||
Rails.application.config.forward_auth_cache&.delete("fa_apps")
|
|
||||||
end
|
|
||||||
|
|
||||||
def fix_icon_content_types
|
|
||||||
ICON_ATTACHMENTS.each do |attr|
|
|
||||||
attachment = public_send(attr)
|
|
||||||
next unless attachment.attached?
|
|
||||||
# Fix SVG content type if it was detected incorrectly
|
|
||||||
if attachment.filename.extension == "svg" && attachment.content_type == "application/octet-stream"
|
|
||||||
attachment.blob.update(content_type: "image/svg+xml")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def sanitize_svg_icons
|
|
||||||
# Runs in before_validation. The blob has NOT yet been uploaded to disk at
|
|
||||||
# this point (Active Storage uploads in before_save), so we cannot call
|
|
||||||
# download — we must read from the pending attachable.
|
|
||||||
#
|
|
||||||
# attach below re-sets attachment_changes and would re-fire this callback;
|
|
||||||
# we skip if the pending attachable is the cleaned hash we just installed
|
|
||||||
# (tracked by object identity, per-attribute).
|
|
||||||
@svg_sanitized_attachables ||= {}
|
|
||||||
|
|
||||||
ICON_ATTACHMENTS.each do |attr|
|
|
||||||
change = attachment_changes[attr.to_s]
|
|
||||||
next unless change
|
|
||||||
attachable = change.attachable
|
|
||||||
next if attachable.equal?(@svg_sanitized_attachables[attr])
|
|
||||||
|
|
||||||
raw_svg, filename, content_type = read_pending_icon(attachable)
|
|
||||||
next unless raw_svg
|
|
||||||
next unless content_type == "image/svg+xml" || filename.to_s.downcase.end_with?(".svg")
|
|
||||||
|
|
||||||
doc = Loofah.xml_document(raw_svg)
|
|
||||||
doc.scrub!(SvgScrubber.new)
|
|
||||||
clean_svg = doc.to_xml
|
|
||||||
|
|
||||||
sanitized = {
|
|
||||||
io: StringIO.new(clean_svg),
|
|
||||||
filename: filename,
|
|
||||||
content_type: "image/svg+xml"
|
|
||||||
}
|
|
||||||
@svg_sanitized_attachables[attr] = sanitized
|
|
||||||
public_send(attr).attach(sanitized)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def read_pending_icon(attachable)
|
|
||||||
case attachable
|
|
||||||
when ActionDispatch::Http::UploadedFile, Rack::Test::UploadedFile
|
|
||||||
content = attachable.read
|
|
||||||
attachable.rewind
|
|
||||||
[content, attachable.original_filename, attachable.content_type]
|
|
||||||
when Hash
|
|
||||||
io = attachable[:io] || attachable["io"]
|
|
||||||
return [nil, nil, nil] unless io
|
|
||||||
content = io.read
|
|
||||||
io.rewind if io.respond_to?(:rewind)
|
|
||||||
[content,
|
|
||||||
attachable[:filename] || attachable["filename"],
|
|
||||||
attachable[:content_type] || attachable["content_type"]]
|
|
||||||
else
|
|
||||||
[nil, nil, nil]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def icon_validation
|
|
||||||
allowed_types = ["image/png", "image/jpg", "image/jpeg", "image/gif", "image/svg+xml"]
|
|
||||||
|
|
||||||
ICON_ATTACHMENTS.each do |attr|
|
|
||||||
attachment = public_send(attr)
|
|
||||||
next unless attachment.attached?
|
|
||||||
|
|
||||||
unless allowed_types.include?(attachment.content_type)
|
|
||||||
errors.add(attr, "must be a PNG, JPG, GIF, or SVG image")
|
|
||||||
end
|
|
||||||
|
|
||||||
if attachment.blob.byte_size > 2.megabytes
|
|
||||||
errors.add(attr, "must be less than 2MB")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
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
|
def generate_client_credentials
|
||||||
self.client_id ||= SecureRandom.urlsafe_base64(32)
|
self.client_id ||= SecureRandom.urlsafe_base64(32)
|
||||||
# Generate client secret only for confidential clients
|
# Generate and hash the client secret
|
||||||
# Public clients (is_public_client checked) don't get a secret - they use PKCE only
|
if new_record? && client_secret.blank?
|
||||||
if new_record? && client_secret.blank? && !is_public_client_selected?
|
|
||||||
secret = SecureRandom.urlsafe_base64(48)
|
secret = SecureRandom.urlsafe_base64(48)
|
||||||
self.client_secret = secret
|
self.client_secret = secret
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Check if the user selected public client option
|
|
||||||
def is_public_client_selected?
|
|
||||||
ActiveModel::Type::Boolean.new.cast(is_public_client)
|
|
||||||
end
|
|
||||||
|
|
||||||
def backchannel_logout_uri_must_be_https_in_production
|
|
||||||
return unless Rails.env.production?
|
|
||||||
return unless backchannel_logout_uri.present?
|
|
||||||
|
|
||||||
begin
|
|
||||||
uri = URI.parse(backchannel_logout_uri)
|
|
||||||
unless uri.scheme == "https"
|
|
||||||
errors.add(:backchannel_logout_uri, "must use HTTPS in production")
|
|
||||||
end
|
|
||||||
rescue URI::InvalidURIError
|
|
||||||
# Let the format validator handle invalid URIs
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# SSRF guard: the backchannel logout URI is dialled server-side on every user
|
|
||||||
# logout, so it must not target internal infrastructure (loopback, private
|
|
||||||
# ranges, or the link-local cloud metadata endpoint). This is the fast,
|
|
||||||
# config-time check; BackchannelLogoutJob re-checks with DNS resolution.
|
|
||||||
def backchannel_logout_uri_not_internal
|
|
||||||
uri = URI.parse(backchannel_logout_uri)
|
|
||||||
if uri.host.present? && PrivateAddressCheck.internal_host?(uri.host)
|
|
||||||
errors.add(:backchannel_logout_uri, "must not point to a private, loopback, or link-local address")
|
|
||||||
end
|
|
||||||
rescue URI::InvalidURIError
|
|
||||||
# Let the format validator handle invalid URIs
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -2,13 +2,5 @@ class ApplicationGroup < ApplicationRecord
|
|||||||
belongs_to :application
|
belongs_to :application
|
||||||
belongs_to :group
|
belongs_to :group
|
||||||
|
|
||||||
validates :application_id, uniqueness: {scope: :group_id}
|
validates :application_id, uniqueness: { scope: :group_id }
|
||||||
|
|
||||||
after_commit :bust_forward_auth_cache
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def bust_forward_auth_cache
|
|
||||||
Rails.application.config.forward_auth_cache&.delete("fa_apps")
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
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
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
class ApplicationUserClaim < ApplicationRecord
|
|
||||||
belongs_to :application
|
|
||||||
belongs_to :user
|
|
||||||
|
|
||||||
# Reserved OIDC claim names that should not be overridden
|
|
||||||
RESERVED_CLAIMS = %w[
|
|
||||||
iss sub aud exp iat nbf jti nonce azp
|
|
||||||
email email_verified preferred_username name
|
|
||||||
groups
|
|
||||||
].freeze
|
|
||||||
|
|
||||||
validates :user_id, uniqueness: {scope: :application_id}
|
|
||||||
validate :no_reserved_claim_names
|
|
||||||
|
|
||||||
# Parse custom_claims JSON field
|
|
||||||
def parsed_custom_claims
|
|
||||||
return {} if custom_claims.blank?
|
|
||||||
custom_claims.is_a?(Hash) ? custom_claims : {}
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def no_reserved_claim_names
|
|
||||||
return if custom_claims.blank?
|
|
||||||
|
|
||||||
reserved_used = parsed_custom_claims.keys.map(&:to_s) & RESERVED_CLAIMS
|
|
||||||
if reserved_used.any?
|
|
||||||
errors.add(:custom_claims, "cannot override reserved OIDC claims: #{reserved_used.join(", ")}")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
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
|
||||||
@@ -4,43 +4,6 @@ class Group < ApplicationRecord
|
|||||||
has_many :application_groups, dependent: :destroy
|
has_many :application_groups, dependent: :destroy
|
||||||
has_many :applications, through: :application_groups
|
has_many :applications, through: :application_groups
|
||||||
|
|
||||||
# Reserved OIDC claim names that should not be overridden
|
validates :name, presence: true, uniqueness: { case_sensitive: false }
|
||||||
RESERVED_CLAIMS = %w[
|
|
||||||
iss sub aud exp iat nbf jti nonce azp
|
|
||||||
email email_verified preferred_username name
|
|
||||||
groups
|
|
||||||
].freeze
|
|
||||||
|
|
||||||
validates :name, presence: true, uniqueness: {case_sensitive: false}
|
|
||||||
normalizes :name, with: ->(name) { name.strip.downcase }
|
normalizes :name, with: ->(name) { name.strip.downcase }
|
||||||
validate :no_reserved_claim_names
|
|
||||||
|
|
||||||
scope :auto_assign, -> { where(auto_assign: true) }
|
|
||||||
scope :admin, -> { where(admin: true) }
|
|
||||||
|
|
||||||
before_destroy :ensure_other_admin_group_exists
|
|
||||||
|
|
||||||
# Parse custom_claims JSON field
|
|
||||||
def parsed_custom_claims
|
|
||||||
return {} if custom_claims.blank?
|
|
||||||
custom_claims.is_a?(Hash) ? custom_claims : {}
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def ensure_other_admin_group_exists
|
|
||||||
return unless admin?
|
|
||||||
return if Group.where(admin: true).where.not(id: id).exists?
|
|
||||||
errors.add(:base, "cannot delete the last administrators group")
|
|
||||||
throw :abort
|
|
||||||
end
|
|
||||||
|
|
||||||
def no_reserved_claim_names
|
|
||||||
return if custom_claims.blank?
|
|
||||||
|
|
||||||
reserved_used = parsed_custom_claims.keys.map(&:to_s) & RESERVED_CLAIMS
|
|
||||||
if reserved_used.any?
|
|
||||||
errors.add(:custom_claims, "cannot override reserved OIDC claims: #{reserved_used.join(", ")}")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,62 +1,34 @@
|
|||||||
class OidcAccessToken < ApplicationRecord
|
class OidcAccessToken < ApplicationRecord
|
||||||
belongs_to :application
|
belongs_to :application
|
||||||
belongs_to :user
|
belongs_to :user
|
||||||
belongs_to :oidc_authorization_code, optional: true
|
|
||||||
has_many :oidc_refresh_tokens, dependent: :destroy
|
|
||||||
|
|
||||||
before_validation :generate_token, on: :create
|
before_validation :generate_token, on: :create
|
||||||
before_validation :set_expiry, on: :create
|
before_validation :set_expiry, on: :create
|
||||||
|
|
||||||
validates :token_hmac, presence: true, uniqueness: 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 :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
|
|
||||||
|
|
||||||
# Find access token by plaintext token using HMAC verification
|
|
||||||
def self.find_by_token(plaintext_token)
|
|
||||||
return nil if plaintext_token.blank?
|
|
||||||
|
|
||||||
token_hmac = compute_token_hmac(plaintext_token)
|
|
||||||
find_by(token_hmac: token_hmac)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Compute HMAC for token lookup
|
|
||||||
def self.compute_token_hmac(plaintext_token)
|
|
||||||
OpenSSL::HMAC.hexdigest("SHA256", TokenHmac::KEY, plaintext_token)
|
|
||||||
end
|
|
||||||
|
|
||||||
def expired?
|
def expired?
|
||||||
expires_at <= Time.current
|
expires_at <= Time.current
|
||||||
end
|
end
|
||||||
|
|
||||||
def revoked?
|
|
||||||
revoked_at.present?
|
|
||||||
end
|
|
||||||
|
|
||||||
def active?
|
def active?
|
||||||
!expired? && !revoked?
|
!expired?
|
||||||
end
|
end
|
||||||
|
|
||||||
def revoke!
|
def revoke!
|
||||||
update!(revoked_at: Time.current)
|
update!(expires_at: Time.current)
|
||||||
# Also revoke associated refresh tokens
|
|
||||||
oidc_refresh_tokens.each(&:revoke!)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def generate_token
|
def generate_token
|
||||||
# Generate random plaintext token
|
self.token ||= SecureRandom.urlsafe_base64(48)
|
||||||
self.plaintext_token ||= SecureRandom.urlsafe_base64(48)
|
|
||||||
# Store HMAC in database (not plaintext)
|
|
||||||
self.token_hmac ||= self.class.compute_token_hmac(plaintext_token)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_expiry
|
def set_expiry
|
||||||
self.expires_at ||= application.access_token_expiry
|
self.expires_at ||= 1.hour.from_now
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,35 +1,16 @@
|
|||||||
class OidcAuthorizationCode < ApplicationRecord
|
class OidcAuthorizationCode < ApplicationRecord
|
||||||
belongs_to :application
|
belongs_to :application
|
||||||
belongs_to :user
|
belongs_to :user
|
||||||
has_many :oidc_access_tokens
|
|
||||||
has_many :oidc_refresh_tokens
|
|
||||||
|
|
||||||
attr_accessor :plaintext_code
|
|
||||||
|
|
||||||
before_validation :generate_code, on: :create
|
before_validation :generate_code, on: :create
|
||||||
before_validation :set_expiry, on: :create
|
before_validation :set_expiry, on: :create
|
||||||
|
|
||||||
validates :code_hmac, presence: true, uniqueness: true
|
validates :code, presence: true, uniqueness: true
|
||||||
validates :redirect_uri, presence: true
|
validates :redirect_uri, presence: true
|
||||||
validates :code_challenge_method, inclusion: {in: %w[S256], allow_nil: true}
|
|
||||||
validate :validate_code_challenge_format, if: -> { code_challenge.present? }
|
|
||||||
|
|
||||||
scope :valid, -> { where(used: false).where("expires_at > ?", Time.current) }
|
scope :valid, -> { where(used: false).where("expires_at > ?", Time.current) }
|
||||||
scope :expired, -> { where("expires_at <= ?", Time.current) }
|
scope :expired, -> { where("expires_at <= ?", Time.current) }
|
||||||
|
|
||||||
# Find authorization code by plaintext code using HMAC verification
|
|
||||||
def self.find_by_plaintext(plaintext_code)
|
|
||||||
return nil if plaintext_code.blank?
|
|
||||||
|
|
||||||
code_hmac = compute_code_hmac(plaintext_code)
|
|
||||||
find_by(code_hmac: code_hmac)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Compute HMAC for code lookup
|
|
||||||
def self.compute_code_hmac(plaintext_code)
|
|
||||||
OpenSSL::HMAC.hexdigest("SHA256", TokenHmac::KEY, plaintext_code)
|
|
||||||
end
|
|
||||||
|
|
||||||
def expired?
|
def expired?
|
||||||
expires_at <= Time.current
|
expires_at <= Time.current
|
||||||
end
|
end
|
||||||
@@ -42,33 +23,13 @@ class OidcAuthorizationCode < ApplicationRecord
|
|||||||
update!(used: true)
|
update!(used: true)
|
||||||
end
|
end
|
||||||
|
|
||||||
def uses_pkce?
|
|
||||||
code_challenge.present?
|
|
||||||
end
|
|
||||||
|
|
||||||
# Parse claims_requests JSON field
|
|
||||||
def parsed_claims_requests
|
|
||||||
return {} if claims_requests.blank?
|
|
||||||
claims_requests.is_a?(Hash) ? claims_requests : {}
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def generate_code
|
def generate_code
|
||||||
# Generate random plaintext code
|
self.code ||= SecureRandom.urlsafe_base64(32)
|
||||||
self.plaintext_code ||= SecureRandom.urlsafe_base64(32)
|
|
||||||
# Store HMAC in database (not plaintext)
|
|
||||||
self.code_hmac ||= self.class.compute_code_hmac(plaintext_code)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_expiry
|
def set_expiry
|
||||||
self.expires_at ||= 10.minutes.from_now
|
self.expires_at ||= 10.minutes.from_now
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
@@ -1,88 +0,0 @@
|
|||||||
class OidcRefreshToken < ApplicationRecord
|
|
||||||
belongs_to :application
|
|
||||||
belongs_to :user
|
|
||||||
belongs_to :oidc_access_token
|
|
||||||
belongs_to :oidc_authorization_code, optional: true
|
|
||||||
|
|
||||||
before_validation :generate_token, on: :create
|
|
||||||
before_validation :set_expiry, on: :create
|
|
||||||
before_validation :set_token_family_id, on: :create
|
|
||||||
|
|
||||||
validates :token_hmac, 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
|
|
||||||
|
|
||||||
# Find refresh token by plaintext token using HMAC verification
|
|
||||||
def self.find_by_token(plaintext_token)
|
|
||||||
return nil if plaintext_token.blank?
|
|
||||||
|
|
||||||
token_hmac = compute_token_hmac(plaintext_token)
|
|
||||||
find_by(token_hmac: token_hmac)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Compute HMAC for token lookup
|
|
||||||
def self.compute_token_hmac(plaintext_token)
|
|
||||||
OpenSSL::HMAC.hexdigest("SHA256", TokenHmac::KEY, plaintext_token)
|
|
||||||
end
|
|
||||||
|
|
||||||
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).
|
|
||||||
# Also revoke every access token issued within the family: on a detected reuse
|
|
||||||
# attack the stolen chain's access tokens must not remain usable at /userinfo
|
|
||||||
# until they expire.
|
|
||||||
def revoke_family!
|
|
||||||
return unless token_family_id.present?
|
|
||||||
|
|
||||||
now = Time.current
|
|
||||||
family = OidcRefreshToken.in_family(token_family_id)
|
|
||||||
access_token_ids = family.pluck(:oidc_access_token_id).compact.uniq
|
|
||||||
|
|
||||||
family.update_all(revoked_at: now)
|
|
||||||
if access_token_ids.any?
|
|
||||||
OidcAccessToken.where(id: access_token_ids, revoked_at: nil).update_all(revoked_at: now)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def generate_token
|
|
||||||
# Generate random plaintext token
|
|
||||||
self.token ||= SecureRandom.urlsafe_base64(48)
|
|
||||||
# Store HMAC in database (not plaintext)
|
|
||||||
self.token_hmac ||= self.class.compute_token_hmac(token)
|
|
||||||
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,19 +3,18 @@ class OidcUserConsent < ApplicationRecord
|
|||||||
belongs_to :application
|
belongs_to :application
|
||||||
|
|
||||||
validates :user, :application, :scopes_granted, :granted_at, presence: true
|
validates :user, :application, :scopes_granted, :granted_at, presence: true
|
||||||
validates :user_id, uniqueness: {scope: :application_id}
|
validates :user_id, uniqueness: { scope: :application_id }
|
||||||
|
|
||||||
before_validation :set_granted_at, on: :create
|
before_validation :set_granted_at, on: :create
|
||||||
before_validation :set_sid, on: :create
|
|
||||||
|
|
||||||
# Parse scopes_granted into an array
|
# Parse scopes_granted into an array
|
||||||
def scopes
|
def scopes
|
||||||
scopes_granted.split(" ")
|
scopes_granted.split(' ')
|
||||||
end
|
end
|
||||||
|
|
||||||
# Set scopes from an array
|
# Set scopes from an array
|
||||||
def scopes=(scope_array)
|
def scopes=(scope_array)
|
||||||
self.scopes_granted = Array(scope_array).uniq.join(" ")
|
self.scopes_granted = Array(scope_array).uniq.join(' ')
|
||||||
end
|
end
|
||||||
|
|
||||||
# Check if this consent covers the requested scopes
|
# Check if this consent covers the requested scopes
|
||||||
@@ -31,29 +30,18 @@ class OidcUserConsent < ApplicationRecord
|
|||||||
def formatted_scopes
|
def formatted_scopes
|
||||||
scopes.map do |scope|
|
scopes.map do |scope|
|
||||||
case scope
|
case scope
|
||||||
when "openid"
|
when 'openid'
|
||||||
"Basic authentication"
|
'Basic authentication'
|
||||||
when "profile"
|
when 'profile'
|
||||||
"Profile information"
|
'Profile information'
|
||||||
when "email"
|
when 'email'
|
||||||
"Email address"
|
'Email address'
|
||||||
when "groups"
|
when 'groups'
|
||||||
"Group membership"
|
'Group membership'
|
||||||
else
|
else
|
||||||
scope.humanize
|
scope.humanize
|
||||||
end
|
end
|
||||||
end.join(", ")
|
end.join(', ')
|
||||||
end
|
|
||||||
|
|
||||||
# Find consent by SID
|
|
||||||
def self.find_by_sid(sid)
|
|
||||||
find_by(sid: sid)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Parse claims_requests JSON field
|
|
||||||
def parsed_claims_requests
|
|
||||||
return {} if claims_requests.blank?
|
|
||||||
claims_requests.is_a?(Hash) ? claims_requests : {}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
@@ -61,8 +49,4 @@ class OidcUserConsent < ApplicationRecord
|
|||||||
def set_granted_at
|
def set_granted_at
|
||||||
self.granted_at ||= Time.current
|
self.granted_at ||= Time.current
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_sid
|
|
||||||
self.sid ||= SecureRandom.uuid
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -7,9 +7,6 @@ class Session < ApplicationRecord
|
|||||||
# Scopes
|
# Scopes
|
||||||
scope :active, -> { where("expires_at > ?", Time.current) }
|
scope :active, -> { where("expires_at > ?", Time.current) }
|
||||||
scope :expired, -> { where("expires_at <= ?", Time.current) }
|
scope :expired, -> { where("expires_at <= ?", Time.current) }
|
||||||
# Sessions whose owning user is currently active. Used at request time so a
|
|
||||||
# disabled account cannot continue to authenticate with an existing session.
|
|
||||||
scope :for_active_user, -> { joins(:user).where(users: {status: User.statuses[:active]}) }
|
|
||||||
|
|
||||||
def expired?
|
def expired?
|
||||||
expires_at.present? && expires_at <= Time.current
|
expires_at.present? && expires_at <= Time.current
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
# Loofah scrubber that strips dangerous content from SVG files
|
|
||||||
# while preserving safe SVG elements and attributes for icon display.
|
|
||||||
class SvgScrubber < Loofah::Scrubber
|
|
||||||
ALLOWED_ELEMENTS = %w[
|
|
||||||
svg g defs use symbol
|
|
||||||
circle ellipse line path polygon polyline rect
|
|
||||||
text tspan textPath
|
|
||||||
clipPath mask pattern
|
|
||||||
linearGradient radialGradient stop
|
|
||||||
filter feBlend feColorMatrix feComponentTransfer feComposite
|
|
||||||
feConvolveMatrix feDiffuseLighting feDisplacementMap feFlood
|
|
||||||
feGaussianBlur feImage feMerge feMergeNode feMorphology
|
|
||||||
feOffset feSpecularLighting feTile feTurbulence
|
|
||||||
title desc metadata
|
|
||||||
].freeze
|
|
||||||
|
|
||||||
ALLOWED_ATTRIBUTES = %w[
|
|
||||||
id class style
|
|
||||||
x y x1 y1 x2 y2 cx cy r rx ry
|
|
||||||
width height viewBox preserveAspectRatio
|
|
||||||
d points
|
|
||||||
fill stroke stroke-width stroke-linecap stroke-linejoin stroke-dasharray
|
|
||||||
opacity fill-opacity stroke-opacity
|
|
||||||
transform translate rotate scale
|
|
||||||
font-family font-size font-weight text-anchor
|
|
||||||
clip-path mask filter
|
|
||||||
gradientUnits gradientTransform spreadMethod
|
|
||||||
offset stop-color stop-opacity
|
|
||||||
dx dy textLength lengthAdjust
|
|
||||||
xmlns xmlns:xlink
|
|
||||||
color display visibility overflow
|
|
||||||
fill-rule clip-rule
|
|
||||||
marker-start marker-mid marker-end
|
|
||||||
].freeze
|
|
||||||
|
|
||||||
# Loofah hands attribute names back in their source case (e.g. "viewBox").
|
|
||||||
# Compare against a downcased copy so SVG-spec camelCase attributes aren't
|
|
||||||
# stripped from legitimate icons.
|
|
||||||
ALLOWED_ATTRIBUTES_LOOKUP = ALLOWED_ATTRIBUTES.map(&:downcase).to_set.freeze
|
|
||||||
|
|
||||||
# Event handler attributes that must always be removed
|
|
||||||
EVENT_HANDLER_PATTERN = /\Aon/i
|
|
||||||
|
|
||||||
def initialize
|
|
||||||
@direction = :top_down
|
|
||||||
end
|
|
||||||
|
|
||||||
def scrub(node)
|
|
||||||
return CONTINUE if node.text? || node.cdata?
|
|
||||||
|
|
||||||
if node.element?
|
|
||||||
if ALLOWED_ELEMENTS.include?(node.name)
|
|
||||||
# Remove disallowed and event handler attributes
|
|
||||||
node.attribute_nodes.each do |attr|
|
|
||||||
attr.remove unless safe_attribute?(attr)
|
|
||||||
end
|
|
||||||
return CONTINUE
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
node.remove
|
|
||||||
STOP
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def safe_attribute?(attr)
|
|
||||||
name = attr.name.downcase
|
|
||||||
return false if name.match?(EVENT_HANDLER_PATTERN)
|
|
||||||
return false if attr.value&.match?(/javascript:|data:/i)
|
|
||||||
ALLOWED_ATTRIBUTES_LOOKUP.include?(name)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,15 +1,11 @@
|
|||||||
class User < ApplicationRecord
|
class User < ApplicationRecord
|
||||||
# Encrypt TOTP secrets at rest (key derived from SECRET_KEY_BASE)
|
|
||||||
encrypts :totp_secret
|
|
||||||
|
|
||||||
has_secure_password
|
has_secure_password
|
||||||
has_many :sessions, dependent: :destroy
|
has_many :sessions, dependent: :destroy
|
||||||
has_many :user_groups, dependent: :destroy
|
has_many :user_groups, dependent: :destroy
|
||||||
has_many :groups, through: :user_groups
|
has_many :groups, through: :user_groups
|
||||||
has_many :application_user_claims, dependent: :destroy
|
has_many :user_role_assignments, dependent: :destroy
|
||||||
|
has_many :application_roles, through: :user_role_assignments
|
||||||
has_many :oidc_user_consents, dependent: :destroy
|
has_many :oidc_user_consents, dependent: :destroy
|
||||||
has_many :webauthn_credentials, dependent: :destroy
|
|
||||||
has_many :api_keys, dependent: :destroy
|
|
||||||
|
|
||||||
# Token generation for passwordless flows
|
# Token generation for passwordless flows
|
||||||
generates_token_for :invitation_login, expires_in: 24.hours do
|
generates_token_for :invitation_login, expires_in: 24.hours do
|
||||||
@@ -20,45 +16,21 @@ class User < ApplicationRecord
|
|||||||
updated_at
|
updated_at
|
||||||
end
|
end
|
||||||
|
|
||||||
|
generates_token_for :magic_login, expires_in: 15.minutes do
|
||||||
|
last_sign_in_at
|
||||||
|
end
|
||||||
|
|
||||||
normalizes :email_address, with: ->(e) { e.strip.downcase }
|
normalizes :email_address, with: ->(e) { e.strip.downcase }
|
||||||
normalizes :username, with: ->(u) { u.strip.downcase if u.present? }
|
|
||||||
|
|
||||||
# Reserved OIDC claim names that should not be overridden
|
validates :email_address, presence: true, uniqueness: { case_sensitive: false },
|
||||||
RESERVED_CLAIMS = %w[
|
format: { with: URI::MailTo::EMAIL_REGEXP }
|
||||||
iss sub aud exp iat nbf jti nonce azp
|
validates :password, length: { minimum: 8 }, allow_nil: true
|
||||||
email email_verified preferred_username name
|
|
||||||
groups
|
|
||||||
].freeze
|
|
||||||
|
|
||||||
validates :email_address, presence: true, uniqueness: {case_sensitive: false},
|
|
||||||
format: {with: URI::MailTo::EMAIL_REGEXP}
|
|
||||||
validates :username, uniqueness: {case_sensitive: false}, allow_nil: true,
|
|
||||||
format: {with: /\A[a-zA-Z0-9_-]+\z/, message: "can only contain letters, numbers, underscores, and hyphens"},
|
|
||||||
length: {minimum: 2, maximum: 30}
|
|
||||||
validates :password, length: {minimum: 8}, allow_nil: true
|
|
||||||
validate :no_reserved_claim_names
|
|
||||||
|
|
||||||
# Enum - automatically creates scopes (User.active, User.disabled, etc.)
|
# Enum - automatically creates scopes (User.active, User.disabled, etc.)
|
||||||
enum :status, {active: 0, disabled: 1, pending_invitation: 2}
|
enum :status, { active: 0, disabled: 1, pending_invitation: 2 }
|
||||||
|
|
||||||
# When an account stops being active (e.g. an admin disables it), immediately
|
|
||||||
# terminate its sessions so access is revoked everywhere, not just on expiry.
|
|
||||||
# Defence-in-depth: session lookup also filters by active status at request time.
|
|
||||||
after_update_commit :revoke_sessions_when_deactivated
|
|
||||||
|
|
||||||
# Scopes
|
# Scopes
|
||||||
scope :admins, -> { joins(:groups).where(groups: {admin: true}).distinct }
|
scope :admins, -> { where(admin: true) }
|
||||||
|
|
||||||
# Set true on a user (or on the user_params) to skip the auto-assign callback
|
|
||||||
# for that record. Used by the admin invite form (opt-out checkbox) and by
|
|
||||||
# tests that want a clean slate.
|
|
||||||
attr_accessor :skip_auto_assign
|
|
||||||
|
|
||||||
after_create :add_to_auto_assign_groups, unless: :skip_auto_assign
|
|
||||||
|
|
||||||
def admin?
|
|
||||||
groups.any?(&:admin?)
|
|
||||||
end
|
|
||||||
|
|
||||||
# TOTP methods
|
# TOTP methods
|
||||||
def totp_enabled?
|
def totp_enabled?
|
||||||
@@ -68,17 +40,12 @@ class User < ApplicationRecord
|
|||||||
def enable_totp!
|
def enable_totp!
|
||||||
require "rotp"
|
require "rotp"
|
||||||
self.totp_secret = ROTP::Base32.random
|
self.totp_secret = ROTP::Base32.random
|
||||||
# generate_backup_codes assigns the BCrypt hashes to self.backup_codes and
|
self.backup_codes = generate_backup_codes
|
||||||
# returns the plaintext codes for display. Do NOT reassign backup_codes to the
|
|
||||||
# return value — that would store the plaintext codes and break verification.
|
|
||||||
generate_backup_codes
|
|
||||||
save!
|
save!
|
||||||
end
|
end
|
||||||
|
|
||||||
def disable_totp!
|
def disable_totp!
|
||||||
# Note: This does NOT clear totp_required flag
|
update!(totp_secret: nil, totp_required: false, backup_codes: nil)
|
||||||
# Admins control that flag via admin panel, users cannot remove admin-required 2FA
|
|
||||||
update!(totp_secret: nil, backup_codes: nil)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def totp_provisioning_uri(issuer: "Clinch")
|
def totp_provisioning_uri(issuer: "Clinch")
|
||||||
@@ -94,116 +61,25 @@ class User < ApplicationRecord
|
|||||||
|
|
||||||
require "rotp"
|
require "rotp"
|
||||||
totp = ROTP::TOTP.new(totp_secret)
|
totp = ROTP::TOTP.new(totp_secret)
|
||||||
# Pass `after:` so a code can only be accepted once: ROTP rejects any timestep
|
totp.verify(code, drift_behind: 30, drift_ahead: 30)
|
||||||
# at or before the last accepted one, closing the ~90s drift-window replay.
|
|
||||||
verified_at = totp.verify(code, drift_behind: 30, drift_ahead: 30, after: last_otp_at)
|
|
||||||
return false unless verified_at
|
|
||||||
|
|
||||||
update_column(:last_otp_at, verified_at)
|
|
||||||
true
|
|
||||||
end
|
|
||||||
|
|
||||||
# Console/debug helper: get current TOTP code
|
|
||||||
def console_totp
|
|
||||||
return nil unless totp_enabled?
|
|
||||||
|
|
||||||
require "rotp"
|
|
||||||
ROTP::TOTP.new(totp_secret).now
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def verify_backup_code(code)
|
def verify_backup_code(code)
|
||||||
return false unless backup_codes.present?
|
return false unless backup_codes.present?
|
||||||
|
|
||||||
# Rate limiting: prevent brute force attacks on backup codes
|
codes = JSON.parse(backup_codes)
|
||||||
if rate_limit_backup_code_verification?
|
if codes.include?(code)
|
||||||
Rails.logger.warn "Rate limit exceeded for backup code verification - User ID: #{id}"
|
codes.delete(code)
|
||||||
return false
|
update(backup_codes: codes.to_json)
|
||||||
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&.ip_address}"
|
|
||||||
true
|
true
|
||||||
else
|
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&.ip_address}"
|
|
||||||
false
|
false
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Rate limiting for backup code verification to prevent brute force attacks
|
def parsed_backup_codes
|
||||||
def rate_limit_backup_code_verification?
|
return [] unless backup_codes.present?
|
||||||
# Use Rails.cache to track failed attempts
|
JSON.parse(backup_codes)
|
||||||
cache_key = "backup_code_failed_attempts_#{id}"
|
|
||||||
attempts = Rails.cache.read(cache_key) || 0
|
|
||||||
|
|
||||||
attempts >= 5
|
|
||||||
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
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def has_oidc_consent?(application, requested_scopes)
|
def has_oidc_consent?(application, requested_scopes)
|
||||||
@@ -221,63 +97,9 @@ class User < ApplicationRecord
|
|||||||
oidc_user_consents.destroy_all
|
oidc_user_consents.destroy_all
|
||||||
end
|
end
|
||||||
|
|
||||||
# Parse custom_claims JSON field
|
|
||||||
def parsed_custom_claims
|
|
||||||
return {} if custom_claims.blank?
|
|
||||||
custom_claims.is_a?(Hash) ? custom_claims : {}
|
|
||||||
end
|
|
||||||
|
|
||||||
# Get fully merged claims for a specific application
|
|
||||||
def merged_claims_for_application(application)
|
|
||||||
merged = {}
|
|
||||||
|
|
||||||
# Start with group claims (in order)
|
|
||||||
groups.each do |group|
|
|
||||||
merged.merge!(group.parsed_custom_claims)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Merge user global claims
|
|
||||||
merged.merge!(parsed_custom_claims)
|
|
||||||
|
|
||||||
# Merge app-specific claims (highest priority)
|
|
||||||
merged.merge!(application.custom_claims_for_user(self))
|
|
||||||
|
|
||||||
merged
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def add_to_auto_assign_groups
|
|
||||||
Group.auto_assign.each { |g| groups << g }
|
|
||||||
end
|
|
||||||
|
|
||||||
def revoke_sessions_when_deactivated
|
|
||||||
return unless saved_change_to_status?
|
|
||||||
return if active?
|
|
||||||
|
|
||||||
sessions.destroy_all
|
|
||||||
end
|
|
||||||
|
|
||||||
def no_reserved_claim_names
|
|
||||||
return if custom_claims.blank?
|
|
||||||
|
|
||||||
reserved_used = parsed_custom_claims.keys.map(&:to_s) & RESERVED_CLAIMS
|
|
||||||
if reserved_used.any?
|
|
||||||
errors.add(:custom_claims, "cannot override reserved OIDC claims: #{reserved_used.join(", ")}")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def generate_backup_codes
|
def generate_backup_codes
|
||||||
# Generate plain codes for user to see/save
|
Array.new(10) { SecureRandom.alphanumeric(8).upcase }.to_json
|
||||||
plain_codes = Array.new(10) { SecureRandom.alphanumeric(8).upcase }
|
|
||||||
|
|
||||||
# Store BCrypt hashes of the codes
|
|
||||||
hashed_codes = plain_codes.map { |code| BCrypt::Password.create(code) }
|
|
||||||
|
|
||||||
# Return plain codes for display (will be shown to user once)
|
|
||||||
# Store only hashes in the database (as Array for JSON column)
|
|
||||||
self.backup_codes = hashed_codes
|
|
||||||
|
|
||||||
plain_codes
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -2,5 +2,5 @@ class UserGroup < ApplicationRecord
|
|||||||
belongs_to :user
|
belongs_to :user
|
||||||
belongs_to :group
|
belongs_to :group
|
||||||
|
|
||||||
validates :user_id, uniqueness: {scope: :group_id}
|
validates :user_id, uniqueness: { scope: :group_id }
|
||||||
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,107 +0,0 @@
|
|||||||
class WebauthnCredential < ApplicationRecord
|
|
||||||
belongs_to :user
|
|
||||||
|
|
||||||
# Set default authenticator_type if not provided
|
|
||||||
after_initialize :set_default_authenticator_type, if: :new_record?
|
|
||||||
|
|
||||||
# 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).
|
|
||||||
#
|
|
||||||
# Per WebAuthn §6.1.1, a signature counter of 0 means the authenticator does
|
|
||||||
# not implement a counter (true of most synced passkeys — Apple/Google report
|
|
||||||
# 0 every time), so it cannot be used for clone detection. Only when BOTH the
|
|
||||||
# stored and presented counts are non-zero does a non-increasing value signal
|
|
||||||
# a possible clone.
|
|
||||||
def suspicious_sign_count?(new_sign_count)
|
|
||||||
return false if sign_count.zero? || new_sign_count.zero?
|
|
||||||
|
|
||||||
new_sign_count <= sign_count
|
|
||||||
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 set_default_authenticator_type
|
|
||||||
self.authenticator_type ||= "cross-platform"
|
|
||||||
end
|
|
||||||
|
|
||||||
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
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
module ClaimsMerger
|
|
||||||
extend ActiveSupport::Concern
|
|
||||||
|
|
||||||
# Deep merge claims, combining arrays instead of overwriting them
|
|
||||||
# This ensures that array values (like roles) are combined across group/user/app claims
|
|
||||||
#
|
|
||||||
# Example:
|
|
||||||
# base = { "roles" => ["user"], "level" => 1 }
|
|
||||||
# incoming = { "roles" => ["admin"], "department" => "IT" }
|
|
||||||
# deep_merge_claims(base, incoming)
|
|
||||||
# # => { "roles" => ["user", "admin"], "level" => 1, "department" => "IT" }
|
|
||||||
def deep_merge_claims(base, incoming)
|
|
||||||
result = base.dup
|
|
||||||
|
|
||||||
incoming.each do |key, value|
|
|
||||||
result[key] = if result.key?(key)
|
|
||||||
# If both values are arrays, combine them (union to avoid duplicates)
|
|
||||||
if result[key].is_a?(Array) && value.is_a?(Array)
|
|
||||||
(result[key] + value).uniq
|
|
||||||
# If both values are hashes, recursively merge them
|
|
||||||
elsif result[key].is_a?(Hash) && value.is_a?(Hash)
|
|
||||||
deep_merge_claims(result[key], value)
|
|
||||||
else
|
|
||||||
# Otherwise, incoming value wins (override)
|
|
||||||
value
|
|
||||||
end
|
|
||||||
else
|
|
||||||
# New key, just add it
|
|
||||||
value
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
result
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,133 +1,43 @@
|
|||||||
class OidcJwtService
|
class OidcJwtService
|
||||||
extend ClaimsMerger
|
|
||||||
|
|
||||||
RESERVED_CLAIMS = %i[iss sub aud exp iat nbf jti nonce azp].freeze
|
|
||||||
|
|
||||||
class << self
|
class << self
|
||||||
# Generate an ID token (JWT) for the user
|
# Generate an ID token (JWT) for the user
|
||||||
def generate_id_token(user, application, consent: nil, nonce: nil, access_token: nil, auth_time: nil, acr: nil, scopes: "openid", claims_requests: {})
|
def generate_id_token(user, application, nonce: nil)
|
||||||
now = Time.current.to_i
|
now = Time.current.to_i
|
||||||
# Use application's configured ID token TTL (defaults to 1 hour)
|
|
||||||
ttl = application.id_token_expiry_seconds
|
|
||||||
|
|
||||||
# Use pairwise SID from consent if available, fallback to user ID
|
|
||||||
subject = consent&.sid || user.id.to_s
|
|
||||||
|
|
||||||
# Parse scopes (space-separated string)
|
|
||||||
requested_scopes = scopes.to_s.split
|
|
||||||
|
|
||||||
# Parse claims_requests parameter for id_token context
|
|
||||||
id_token_claims = claims_requests["id_token"] || {}
|
|
||||||
|
|
||||||
# Required claims (always included per OIDC Core spec)
|
|
||||||
payload = {
|
payload = {
|
||||||
iss: issuer_url,
|
iss: issuer_url,
|
||||||
sub: subject,
|
sub: user.id.to_s,
|
||||||
aud: application.client_id,
|
aud: application.client_id,
|
||||||
exp: now + ttl,
|
exp: now + 3600, # 1 hour
|
||||||
iat: now
|
iat: now,
|
||||||
|
email: user.email_address,
|
||||||
|
email_verified: true,
|
||||||
|
preferred_username: user.email_address,
|
||||||
|
name: user.email_address
|
||||||
}
|
}
|
||||||
|
|
||||||
# Email claims (only if 'email' scope requested AND either no claims filter OR email requested)
|
|
||||||
if requested_scopes.include?("email")
|
|
||||||
if should_include_claim?("email", id_token_claims)
|
|
||||||
payload[:email] = user.email_address
|
|
||||||
end
|
|
||||||
if should_include_claim?("email_verified", id_token_claims)
|
|
||||||
payload[:email_verified] = true
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Profile claims (only if 'profile' scope requested)
|
|
||||||
if requested_scopes.include?("profile")
|
|
||||||
if should_include_claim?("preferred_username", id_token_claims)
|
|
||||||
payload[:preferred_username] = user.username.presence || user.email_address
|
|
||||||
end
|
|
||||||
if should_include_claim?("name", id_token_claims)
|
|
||||||
payload[:name] = user.name.presence || user.email_address
|
|
||||||
end
|
|
||||||
if should_include_claim?("updated_at", id_token_claims)
|
|
||||||
payload[:updated_at] = user.updated_at.to_i
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Add nonce if provided (OIDC requires this for implicit flow)
|
# Add nonce if provided (OIDC requires this for implicit flow)
|
||||||
payload[:nonce] = nonce if nonce.present?
|
payload[:nonce] = nonce if nonce.present?
|
||||||
|
|
||||||
# Add auth_time if provided (OIDC Core §2 - required when max_age is used)
|
# Add groups if user has any
|
||||||
payload[:auth_time] = auth_time if auth_time.present?
|
if user.groups.any?
|
||||||
|
|
||||||
# Add acr if provided (OIDC Core §2 - authentication context class reference)
|
|
||||||
payload[:acr] = acr if acr.present?
|
|
||||||
|
|
||||||
# Add azp (authorized party) - the client_id this token was issued to
|
|
||||||
# OIDC Core §2 - required when aud has multiple values, optional but useful for single
|
|
||||||
payload[:azp] = application.client_id
|
|
||||||
|
|
||||||
# Add at_hash if access token is provided (OIDC Core spec §3.1.3.6)
|
|
||||||
# at_hash = left-most 128 bits of SHA-256 hash of access token, base64url encoded
|
|
||||||
if access_token.present?
|
|
||||||
sha256 = Digest::SHA256.digest(access_token)
|
|
||||||
at_hash = Base64.urlsafe_encode64(sha256[0..15], padding: false)
|
|
||||||
payload[:at_hash] = at_hash
|
|
||||||
end
|
|
||||||
|
|
||||||
# Groups claims (only if 'groups' scope requested AND requested in claims parameter)
|
|
||||||
if requested_scopes.include?("groups") && user.groups.any?
|
|
||||||
if should_include_claim?("groups", id_token_claims)
|
|
||||||
payload[:groups] = user.groups.pluck(:name)
|
payload[:groups] = user.groups.pluck(:name)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Add admin claim if user is admin
|
||||||
|
payload[:admin] = true if user.admin?
|
||||||
|
|
||||||
|
# Add role-based claims if role mapping is enabled
|
||||||
|
if application.role_mapping_enabled?
|
||||||
|
add_role_claims!(payload, user, application)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Merge custom claims from groups (arrays are combined, not overwritten)
|
JWT.encode(payload, private_key, "RS256", { kid: key_id, typ: "JWT" })
|
||||||
# Note: Custom claims from groups are always merged (not scope-dependent)
|
|
||||||
# Reserved claims are stripped as defense-in-depth (also validated at model layer)
|
|
||||||
user.groups.each do |group|
|
|
||||||
payload = deep_merge_claims(payload, group.parsed_custom_claims.except(*RESERVED_CLAIMS))
|
|
||||||
end
|
|
||||||
|
|
||||||
# Merge custom claims from user (arrays are combined, other values override)
|
|
||||||
payload = deep_merge_claims(payload, user.parsed_custom_claims.except(*RESERVED_CLAIMS))
|
|
||||||
|
|
||||||
# Merge app-specific custom claims (highest priority, arrays are combined)
|
|
||||||
payload = deep_merge_claims(payload, application.custom_claims_for_user(user).except(*RESERVED_CLAIMS))
|
|
||||||
|
|
||||||
# Filter custom claims based on claims parameter
|
|
||||||
# If claims parameter is present, only include requested custom claims
|
|
||||||
if id_token_claims.any?
|
|
||||||
payload = filter_custom_claims(payload, id_token_claims)
|
|
||||||
end
|
|
||||||
|
|
||||||
JWT.encode(payload, private_key, "RS256", {kid: key_id, typ: "JWT"})
|
|
||||||
end
|
|
||||||
|
|
||||||
# Generate a backchannel logout token (JWT)
|
|
||||||
# Per OIDC Back-Channel Logout spec, this token:
|
|
||||||
# - MUST include iss, aud, iat, jti, events claims
|
|
||||||
# - MUST include sub or sid (or both) - we always include both
|
|
||||||
# - MUST NOT include nonce claim
|
|
||||||
def generate_logout_token(user, application, consent)
|
|
||||||
now = Time.current.to_i
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
iss: issuer_url,
|
|
||||||
sub: consent.sid, # Pairwise subject identifier
|
|
||||||
aud: application.client_id,
|
|
||||||
iat: now,
|
|
||||||
jti: SecureRandom.uuid, # Unique identifier for this logout token
|
|
||||||
sid: consent.sid, # Session ID - always included for granular logout
|
|
||||||
events: {
|
|
||||||
"http://schemas.openid.net/event/backchannel-logout" => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Important: Do NOT include nonce in logout tokens (spec requirement)
|
|
||||||
JWT.encode(payload, private_key, "RS256", {kid: key_id, typ: "JWT"})
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Decode and verify an ID token
|
# Decode and verify an ID token
|
||||||
def decode_id_token(token)
|
def decode_id_token(token)
|
||||||
JWT.decode(token, public_key, true, {algorithm: "RS256"})
|
JWT.decode(token, public_key, true, { algorithm: "RS256" })
|
||||||
end
|
end
|
||||||
|
|
||||||
# Get the public key in JWK format for the JWKS endpoint
|
# Get the public key in JWK format for the JWKS endpoint
|
||||||
@@ -150,14 +60,7 @@ class OidcJwtService
|
|||||||
def issuer_url
|
def issuer_url
|
||||||
# In production, this should come from ENV or config
|
# In production, this should come from ENV or config
|
||||||
# For now, we'll use a placeholder that can be overridden
|
# For now, we'll use a placeholder that can be overridden
|
||||||
host = ENV.fetch("CLINCH_HOST", "localhost:3000")
|
"https://#{ENV.fetch("CLINCH_HOST", "localhost:3000")}"
|
||||||
# Ensure URL has protocol - use https:// in production, http:// in development
|
|
||||||
if host.match?(/^https?:\/\//)
|
|
||||||
host
|
|
||||||
else
|
|
||||||
protocol = Rails.env.production? ? "https" : "http"
|
|
||||||
"#{protocol}://#{host}"
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
@@ -165,37 +68,17 @@ class OidcJwtService
|
|||||||
# Get or generate RSA private key
|
# Get or generate RSA private key
|
||||||
def private_key
|
def private_key
|
||||||
@private_key ||= begin
|
@private_key ||= begin
|
||||||
key_source = nil
|
|
||||||
|
|
||||||
# Try ENV variable first (best for Docker/Kamal)
|
# Try ENV variable first (best for Docker/Kamal)
|
||||||
if ENV["OIDC_PRIVATE_KEY"].present?
|
if ENV["OIDC_PRIVATE_KEY"].present?
|
||||||
key_source = ENV["OIDC_PRIVATE_KEY"]
|
OpenSSL::PKey::RSA.new(ENV["OIDC_PRIVATE_KEY"])
|
||||||
# Then try Rails credentials
|
# Then try Rails credentials
|
||||||
elsif Rails.application.credentials.oidc_private_key.present?
|
elsif Rails.application.credentials.oidc_private_key.present?
|
||||||
key_source = Rails.application.credentials.oidc_private_key
|
OpenSSL::PKey::RSA.new(Rails.application.credentials.oidc_private_key)
|
||||||
end
|
|
||||||
|
|
||||||
if key_source.present?
|
|
||||||
begin
|
|
||||||
# Handle both actual newlines and escaped \n sequences
|
|
||||||
# Some .env loaders may escape newlines, so we need to convert them back
|
|
||||||
key_data = key_source.gsub("\\n", "\n")
|
|
||||||
OpenSSL::PKey::RSA.new(key_data)
|
|
||||||
rescue OpenSSL::PKey::RSAError => e
|
|
||||||
Rails.logger.error "OIDC: Failed to load private key: #{e.message}"
|
|
||||||
Rails.logger.error "OIDC: Key source length: #{key_source.length}, starts with: #{key_source[0..50]}"
|
|
||||||
raise "Invalid OIDC private key format. Please ensure the key is in PEM format with proper newlines."
|
|
||||||
end
|
|
||||||
else
|
else
|
||||||
# In production, we should never generate a key on the fly
|
# Generate a new key for development
|
||||||
# because it would be different across servers/deployments
|
# In production, you MUST set OIDC_PRIVATE_KEY env var or add to credentials
|
||||||
if Rails.env.production?
|
|
||||||
raise "OIDC private key not configured. Set OIDC_PRIVATE_KEY environment variable or add to Rails credentials."
|
|
||||||
end
|
|
||||||
|
|
||||||
# Generate a new key for development/test only
|
|
||||||
Rails.logger.warn "OIDC: No private key found in ENV or credentials, generating new key (development only)"
|
Rails.logger.warn "OIDC: No private key found in ENV or credentials, generating new key (development only)"
|
||||||
Rails.logger.warn "OIDC: Set OIDC_PRIVATE_KEY environment variable for consistency across restarts"
|
Rails.logger.warn "OIDC: Set OIDC_PRIVATE_KEY environment variable in production!"
|
||||||
OpenSSL::PKey::RSA.new(2048)
|
OpenSSL::PKey::RSA.new(2048)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -211,68 +94,49 @@ class OidcJwtService
|
|||||||
@key_id ||= Digest::SHA256.hexdigest(public_key.to_pem)[0..15]
|
@key_id ||= Digest::SHA256.hexdigest(public_key.to_pem)[0..15]
|
||||||
end
|
end
|
||||||
|
|
||||||
# Check if a claim should be included based on claims parameter
|
# Add role-based claims to the JWT payload
|
||||||
# Returns true if:
|
def add_role_claims!(payload, user, application)
|
||||||
# - No claims parameter specified (include all scope-based claims)
|
user_roles = application.user_roles(user)
|
||||||
# - Claim is explicitly requested (even with null spec or essential: true)
|
return if user_roles.empty?
|
||||||
def should_include_claim?(claim_name, id_token_claims)
|
|
||||||
# No claims parameter = include all scope-based claims
|
|
||||||
return true if id_token_claims.empty?
|
|
||||||
|
|
||||||
# Check if claim is requested
|
role_names = user_roles.pluck(:name)
|
||||||
return false unless id_token_claims.key?(claim_name)
|
|
||||||
|
|
||||||
# Claim specification can be:
|
# Filter roles by prefix if configured
|
||||||
# - null (requested)
|
if application.role_prefix.present?
|
||||||
# - true (essential, requested)
|
role_names = role_names.select { |role| role.start_with?(application.role_prefix) }
|
||||||
# - false (not requested)
|
|
||||||
# - Hash with essential/value/values
|
|
||||||
|
|
||||||
claim_spec = id_token_claims[claim_name]
|
|
||||||
return true if claim_spec.nil? || claim_spec == true
|
|
||||||
return false if claim_spec == false
|
|
||||||
|
|
||||||
# If it's a hash, the claim is requested (filtering happens later)
|
|
||||||
true if claim_spec.is_a?(Hash)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Filter custom claims based on claims parameter
|
return if role_names.empty?
|
||||||
# Removes claims not explicitly requested
|
|
||||||
# Applies value/values filtering if specified
|
|
||||||
def filter_custom_claims(payload, id_token_claims)
|
|
||||||
# Get all claim names that are NOT standard OIDC claims
|
|
||||||
standard_claims = %w[iss sub aud exp iat nbf jti nonce azp at_hash auth_time acr email email_verified name preferred_username updated_at groups]
|
|
||||||
custom_claim_names = payload.keys.map(&:to_s) - standard_claims
|
|
||||||
|
|
||||||
filtered = payload.dup
|
# Add roles using the configured claim name
|
||||||
|
claim_name = application.role_claim_name.presence || 'roles'
|
||||||
|
payload[claim_name] = role_names
|
||||||
|
|
||||||
custom_claim_names.each do |claim_name|
|
# Add role permissions if configured
|
||||||
claim_sym = claim_name.to_sym
|
managed_permissions = application.parsed_managed_permissions
|
||||||
|
if managed_permissions['include_permissions'] == true
|
||||||
# If claim is not requested, remove it
|
role_permissions = user_roles.map do |role|
|
||||||
unless id_token_claims.key?(claim_name) || id_token_claims.key?(claim_sym)
|
{
|
||||||
filtered.delete(claim_sym)
|
name: role.name,
|
||||||
next
|
display_name: role.display_name,
|
||||||
|
permissions: role.permissions
|
||||||
|
}
|
||||||
|
end
|
||||||
|
payload['role_permissions'] = role_permissions
|
||||||
end
|
end
|
||||||
|
|
||||||
# Apply value/values filtering if specified
|
# Add role metadata if configured
|
||||||
claim_spec = id_token_claims[claim_name] || id_token_claims[claim_sym]
|
if managed_permissions['include_metadata'] == true
|
||||||
next unless claim_spec.is_a?(Hash)
|
role_metadata = user_roles.map do |role|
|
||||||
|
assignment = role.user_role_assignments.find_by(user: user)
|
||||||
current_value = filtered[claim_sym]
|
{
|
||||||
|
name: role.name,
|
||||||
# Check value constraint
|
source: assignment&.source,
|
||||||
if claim_spec["value"].present?
|
assigned_at: assignment&.created_at
|
||||||
filtered.delete(claim_sym) unless current_value == claim_spec["value"]
|
}
|
||||||
end
|
end
|
||||||
|
payload['role_metadata'] = role_metadata
|
||||||
# Check values constraint (array of allowed values)
|
|
||||||
if claim_spec["values"].is_a?(Array)
|
|
||||||
filtered.delete(claim_sym) unless claim_spec["values"].include?(current_value)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
filtered
|
|
||||||
end
|
|
||||||
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 dark:text-gray-100">Sessions</h1>
|
|
||||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">Manage your active sessions and connected applications.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Connected Applications -->
|
|
||||||
<div class="bg-white dark:bg-gray-800 shadow sm:rounded-lg">
|
|
||||||
<div class="px-4 py-5 sm:p-6">
|
|
||||||
<h3 class="text-lg font-medium leading-6 text-gray-900 dark:text-gray-100">Connected Applications</h3>
|
|
||||||
<div class="mt-2 max-w-xl text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
<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 dark:divide-gray-700">
|
|
||||||
<% @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 dark:text-gray-100">
|
|
||||||
<%= consent.application.name %>
|
|
||||||
</p>
|
|
||||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
Access to: <%= consent.formatted_scopes %>
|
|
||||||
</p>
|
|
||||||
<p class="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
|
||||||
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 dark:bg-gray-700 dark:ring-gray-600 dark:text-gray-200 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 dark:focus:ring-offset-gray-900",
|
|
||||||
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 dark:text-gray-400">No connected applications.</p>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<% if @connected_applications.any? %>
|
|
||||||
<div class="mt-6 pt-6 border-t border-gray-200 dark:border-gray-700">
|
|
||||||
<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 dark:bg-gray-700 dark:ring-gray-600 dark:text-gray-200 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 dark:focus:ring-offset-gray-900 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 dark:bg-gray-800 shadow sm:rounded-lg">
|
|
||||||
<div class="px-4 py-5 sm:p-6">
|
|
||||||
<h3 class="text-lg font-medium leading-6 text-gray-900 dark:text-gray-100">Active Sessions</h3>
|
|
||||||
<div class="mt-2 max-w-xl text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
<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 dark:divide-gray-700">
|
|
||||||
<% @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 dark:text-gray-100">
|
|
||||||
<%= session.device_name || "Unknown Device" %>
|
|
||||||
<% if session.id == Current.session.id %>
|
|
||||||
<span class="ml-2 inline-flex items-center rounded-full bg-green-100 dark:bg-green-900/50 px-2.5 py-0.5 text-xs font-medium text-green-800 dark:text-green-200">
|
|
||||||
This device
|
|
||||||
</span>
|
|
||||||
<% end %>
|
|
||||||
</p>
|
|
||||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
<%= session.ip_address %>
|
|
||||||
</p>
|
|
||||||
<p class="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
|
||||||
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 dark:border-gray-600 bg-white dark:bg-gray-700 dark:text-gray-200 px-3 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 dark:hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900",
|
|
||||||
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 dark:text-gray-400">No other active sessions.</p>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<% if @active_sessions.count > 1 %>
|
|
||||||
<div class="mt-6 pt-6 border-t border-gray-200 dark:border-gray-700">
|
|
||||||
<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 dark:bg-gray-700 dark:ring-gray-600 dark:text-gray-200 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 dark:focus:ring-offset-gray-900 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,77 +0,0 @@
|
|||||||
<div class="mb-6">
|
|
||||||
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">Access check</h1>
|
|
||||||
<p class="mt-2 text-sm text-gray-700 dark:text-gray-300">Pick a user and an application to see whether the user can access it and, if so, which group(s) grant that access.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-white dark:bg-gray-800 shadow sm:rounded-lg">
|
|
||||||
<div class="px-4 py-5 sm:p-6">
|
|
||||||
<%= form_with url: admin_access_path, method: :get, class: "space-y-4" do |form| %>
|
|
||||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<%= form.label :user_id, "User", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
|
|
||||||
<%= form.select :user_id,
|
|
||||||
@users.map { |u| [u.email_address, u.id] },
|
|
||||||
{ include_blank: "Select a user…", selected: @user&.id },
|
|
||||||
class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<%= form.label :application_id, "Application", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
|
|
||||||
<%= form.select :application_id,
|
|
||||||
@applications.map { |a| [a.name, a.id] },
|
|
||||||
{ include_blank: "Select an application…", selected: @application&.id },
|
|
||||||
class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<%= form.submit "Check access", 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 %>
|
|
||||||
|
|
||||||
<% if @user && @application %>
|
|
||||||
<div class="mt-6 rounded-md border <%= @allowed ? "border-green-200 dark:border-green-700 bg-green-50 dark:bg-green-900/30" : "border-red-200 dark:border-red-700 bg-red-50 dark:bg-red-900/30" %> p-4">
|
|
||||||
<div class="flex items-start gap-3">
|
|
||||||
<% if @allowed %>
|
|
||||||
<svg class="h-6 w-6 text-green-600 dark:text-green-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
|
||||||
</svg>
|
|
||||||
<% else %>
|
|
||||||
<svg class="h-6 w-6 text-red-600 dark:text-red-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
|
||||||
</svg>
|
|
||||||
<% end %>
|
|
||||||
<div class="flex-1">
|
|
||||||
<p class="text-sm font-medium <%= @allowed ? "text-green-800 dark:text-green-200" : "text-red-800 dark:text-red-200" %>">
|
|
||||||
<%= @user.email_address %> <%= @allowed ? "can access" : "cannot access" %> <%= @application.name %>.
|
|
||||||
</p>
|
|
||||||
<% if @allowed %>
|
|
||||||
<p class="mt-1 text-xs text-green-700 dark:text-green-300">
|
|
||||||
Granted via:
|
|
||||||
<% @via.each_with_index do |g, i| %>
|
|
||||||
<%= link_to g.name, admin_group_path(g), class: "underline" %><%= "," unless i == @via.size - 1 %>
|
|
||||||
<% end %>
|
|
||||||
</p>
|
|
||||||
<% else %>
|
|
||||||
<p class="mt-1 text-xs text-red-700 dark:text-red-300">
|
|
||||||
<% reasons = [] %>
|
|
||||||
<% reasons << "the application is inactive" unless @application.active? %>
|
|
||||||
<% reasons << "the user is #{@user.status.humanize.downcase}" unless @user.active? %>
|
|
||||||
<% if @application.active? && @user.active? %>
|
|
||||||
<% if @application.allowed_groups.empty? %>
|
|
||||||
<% reasons << "the application has no allowed groups (default deny)" %>
|
|
||||||
<% else %>
|
|
||||||
<% reasons << "the user shares no group with the application's allowed groups" %>
|
|
||||||
<% end %>
|
|
||||||
<% end %>
|
|
||||||
Reason: <%= reasons.join("; ") %>.
|
|
||||||
</p>
|
|
||||||
<% end %>
|
|
||||||
<p class="mt-2 text-xs text-gray-600 dark:text-gray-400">
|
|
||||||
<%= link_to "View user", admin_user_path(@user), class: "underline" %> ·
|
|
||||||
<%= link_to "View application", admin_application_path(@application), class: "underline" %>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,336 +1,161 @@
|
|||||||
<%= form_with(model: [:admin, application], class: "space-y-6", data: { controller: "application-form form-errors" }) do |form| %>
|
<%= form_with(model: [:admin, application], class: "space-y-6") do |form| %>
|
||||||
<%= render "shared/form_errors", form: form %>
|
<% if application.errors.any? %>
|
||||||
|
<div class="rounded-md bg-red-50 p-4">
|
||||||
<div>
|
<div class="flex">
|
||||||
<%= form.label :name, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
|
<div class="ml-3">
|
||||||
<%= form.text_field :name, required: true, class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "My Application" %>
|
<h3 class="text-sm font-medium text-red-800">
|
||||||
</div>
|
<%= pluralize(application.errors.count, "error") %> prohibited this application from being saved:
|
||||||
|
</h3>
|
||||||
<div>
|
<div class="mt-2 text-sm text-red-700">
|
||||||
<%= form.label :slug, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
|
<ul class="list-disc pl-5 space-y-1">
|
||||||
<%= form.text_field :slug, required: true, class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono", placeholder: "my-app" %>
|
<% application.errors.full_messages.each do |message| %>
|
||||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Lowercase letters, numbers, and hyphens only. Used in URLs and API calls.</p>
|
<li><%= message %></li>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<% if application.persisted? %>
|
|
||||||
<span class="block text-sm font-medium text-gray-700 dark:text-gray-300">Application Type</span>
|
|
||||||
<div class="mt-1 flex items-center gap-2">
|
|
||||||
<span class="inline-flex items-center rounded-md bg-blue-50 dark:bg-blue-900/30 px-2 py-1 text-xs font-medium text-blue-700 dark:text-blue-300 ring-1 ring-inset ring-blue-600/20">
|
|
||||||
<%= application.oidc? ? "OpenID Connect (OIDC)" : "Forward Auth (Reverse Proxy)" %>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<%= form.hidden_field :app_type %>
|
|
||||||
<select class="hidden" data-application-form-target="appTypeSelect"><option value="<%= application.app_type %>" selected></option></select>
|
|
||||||
<% else %>
|
|
||||||
<%= form.label :app_type, "Application Type", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
|
|
||||||
<%= 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 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm",
|
|
||||||
data: { action: "change->application-form#updateFieldVisibility", application_form_target: "appTypeSelect" }
|
|
||||||
} %>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<%= form.label :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: "My Application" %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<%= form.label :description, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
|
<%= form.label :slug, class: "block text-sm font-medium text-gray-700" %>
|
||||||
<%= form.text_area :description, rows: 3, class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Optional description of this application" %>
|
<%= form.text_field :slug, 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 font-mono", placeholder: "my-app" %>
|
||||||
</div>
|
<p class="mt-1 text-sm text-gray-500">Lowercase letters, numbers, and hyphens only. Used in URLs and API calls.</p>
|
||||||
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div class="flex items-center justify-between -mb-2">
|
|
||||||
<span class="block text-sm font-medium text-gray-700 dark:text-gray-300">Application Icons</span>
|
|
||||||
<a href="https://dashboardicons.com" target="_blank" rel="noopener noreferrer" class="text-xs text-blue-600 hover:text-blue-800 flex items-center gap-1">
|
|
||||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path>
|
|
||||||
</svg>
|
|
||||||
Browse icons at dashboardicons.com
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<%= render "icon_uploader",
|
|
||||||
form: form,
|
|
||||||
field: :icon,
|
|
||||||
label: "Icon",
|
|
||||||
current_attached: (application.persisted? ? application.icon : nil),
|
|
||||||
current_label: "Current icon" %>
|
|
||||||
|
|
||||||
<%= render "icon_uploader",
|
|
||||||
form: form,
|
|
||||||
field: :icon_dark,
|
|
||||||
label: "Dark mode icon (optional)",
|
|
||||||
help: "Used in place of the main icon when the user's theme is dark. If omitted, the main icon is used in both modes.",
|
|
||||||
current_attached: (application.persisted? ? application.icon_dark : nil),
|
|
||||||
current_label: "Current dark-mode icon",
|
|
||||||
preview_extra_class: "bg-gray-900" %>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<%= form.label :landing_url, "Landing URL", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
|
<%= form.label :description, 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 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "https://app.example.com" %>
|
<%= 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" %>
|
||||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">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"], ["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>
|
</div>
|
||||||
|
|
||||||
<!-- OIDC-specific fields -->
|
<!-- OIDC-specific fields -->
|
||||||
<div id="oidc-fields" class="space-y-6 border-t border-gray-200 dark:border-gray-700 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 dark:text-gray-100">OIDC Configuration</h3>
|
<h3 class="text-base font-semibold text-gray-900">OIDC Configuration</h3>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<%= form.label :redirect_uris, "Redirect URIs", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= form.text_area :redirect_uris, rows: 4, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono", placeholder: "https://example.com/callback\nhttps://app.example.com/auth/callback" %>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">One URI per line. These are the allowed callback URLs for your application.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 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>
|
||||||
|
<%= 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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
<!-- Client Type Selection (only for new applications) -->
|
|
||||||
<% unless application.persisted? %>
|
|
||||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4 bg-gray-50 dark:bg-gray-800">
|
|
||||||
<h4 class="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-3">Client Type</h4>
|
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div class="flex items-start">
|
<label class="block text-sm font-medium text-gray-700">Managed Permissions</label>
|
||||||
<%= form.radio_button :is_public_client, "false", checked: !application.is_public_client, class: "mt-1 h-4 w-4 border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500", data: { action: "change->application-form#updatePkceVisibility" } %>
|
|
||||||
<div class="ml-3">
|
|
||||||
<label for="application_is_public_client_false" class="block text-sm font-medium text-gray-900 dark:text-gray-100">Confidential Client (Recommended)</label>
|
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400">Backend server app that can securely store a client secret. Examples: traditional web apps, server-to-server APIs.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-start">
|
|
||||||
<%= form.radio_button :is_public_client, "true", checked: application.is_public_client, class: "mt-1 h-4 w-4 border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500", data: { action: "change->application-form#updatePkceVisibility" } %>
|
|
||||||
<div class="ml-3">
|
|
||||||
<label for="application_is_public_client_true" class="block text-sm font-medium text-gray-900 dark:text-gray-100">Public Client</label>
|
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400">Frontend-only app that cannot store secrets securely. Examples: SPAs (React/Vue), mobile apps, CLI tools. <strong class="text-amber-600">PKCE is required.</strong></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<% else %>
|
|
||||||
<!-- Show client type for existing applications (read-only) -->
|
|
||||||
<div class="flex items-center gap-2 text-sm">
|
|
||||||
<span class="font-medium text-gray-700 dark:text-gray-300">Client Type:</span>
|
|
||||||
<% if application.public_client? %>
|
|
||||||
<span class="inline-flex items-center rounded-md bg-amber-50 dark:bg-amber-900/30 px-2 py-1 text-xs font-medium text-amber-700 dark:text-amber-300 ring-1 ring-inset ring-amber-600/20">Public Client (PKCE Required)</span>
|
|
||||||
<% else %>
|
|
||||||
<span class="inline-flex items-center rounded-md bg-green-50 dark:bg-green-900/30 px-2 py-1 text-xs font-medium text-green-700 dark:text-green-300 ring-1 ring-inset ring-green-600/20">Confidential Client</span>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<!-- OAuth2/OIDC Flow Information -->
|
|
||||||
<div class="bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-700 rounded-lg p-4 space-y-3">
|
|
||||||
<div>
|
|
||||||
<h4 class="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2">OAuth2 Flow</h4>
|
|
||||||
<p class="text-sm text-gray-700 dark:text-gray-300">
|
|
||||||
Clinch uses the <code class="bg-white dark:bg-gray-800 px-1.5 py-0.5 rounded text-xs font-mono">authorization_code</code> flow with <code class="bg-white dark:bg-gray-800 px-1.5 py-0.5 rounded text-xs font-mono">response_type=code</code> (the modern, secure standard).
|
|
||||||
</p>
|
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
|
||||||
Deprecated flows like Implicit (<code class="bg-white dark:bg-gray-800 px-1 rounded text-xs font-mono">id_token</code>, <code class="bg-white dark:bg-gray-800 px-1 rounded text-xs font-mono">token</code>) are not supported for security reasons.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="border-t border-blue-200 dark:border-blue-700 pt-3">
|
|
||||||
<h4 class="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2">Client Authentication</h4>
|
|
||||||
<p class="text-sm text-gray-700 dark:text-gray-300">
|
|
||||||
Clinch supports both <code class="bg-white dark:bg-gray-800 px-1.5 py-0.5 rounded text-xs font-mono">client_secret_basic</code> (HTTP Basic Auth) and <code class="bg-white dark:bg-gray-800 px-1.5 py-0.5 rounded text-xs font-mono">client_secret_post</code> (POST parameters) authentication methods.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- PKCE Requirement (only for confidential clients) -->
|
|
||||||
<div id="pkce-options" data-application-form-target="pkceOptions" class="<%= 'hidden' if application.persisted? && application.public_client? %>">
|
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<%= form.check_box :require_pkce, class: "h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500" %>
|
<%= 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 :require_pkce, "Require PKCE (Proof Key for Code Exchange)", class: "ml-2 block text-sm font-medium text-gray-900 dark:text-gray-100" %>
|
<%= form.label :managed_permissions_include_permissions, "Include role permissions in tokens", class: "ml-2 block text-sm text-gray-900" %>
|
||||||
</div>
|
|
||||||
<p class="ml-6 text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
Recommended for enhanced security (OAuth 2.1 best practice).
|
|
||||||
<br><span class="text-xs text-gray-400 dark:text-gray-500">Note: Public clients always require PKCE regardless of this setting.</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Skip Consent -->
|
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<%= form.check_box :skip_consent, class: "h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500" %>
|
<%= 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 :skip_consent, "Skip Consent Screen", class: "ml-2 block text-sm font-medium text-gray-900 dark:text-gray-100" %>
|
<%= form.label :managed_permissions_include_metadata, "Include role metadata in tokens", class: "ml-2 block text-sm text-gray-900" %>
|
||||||
</div>
|
|
||||||
<p class="ml-6 text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
Automatically grant consent for all users. Useful for first-party or trusted applications.
|
|
||||||
<br><span class="text-xs text-amber-600">Only enable for applications you fully trust. Consent is still recorded in the database.</span>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<%= form.label :redirect_uris, "Redirect URIs", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
|
|
||||||
<%= form.text_area :redirect_uris, rows: 4, class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono", placeholder: "https://example.com/callback\nhttps://app.example.com/auth/callback" %>
|
|
||||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">One URI per line. These are the allowed callback URLs for your application.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<%= form.label :backchannel_logout_uri, "Backchannel Logout URI (Optional)", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
|
|
||||||
<%= form.url_field :backchannel_logout_uri, class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono", placeholder: "https://app.example.com/oidc/backchannel-logout" %>
|
|
||||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
If the application supports OpenID Connect Backchannel Logout, enter the logout endpoint URL.
|
|
||||||
When users log out, Clinch will send logout notifications to this endpoint for immediate session termination.
|
|
||||||
Leave blank if the application doesn't support backchannel logout.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-4 mt-4">
|
|
||||||
<h4 class="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-3">Token Expiration Settings</h4>
|
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">Configure how long tokens remain valid. Shorter times are more secure but require more frequent refreshes.</p>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
||||||
<div>
|
|
||||||
<%= form.label :access_token_ttl, "Access Token TTL", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
|
|
||||||
<%= form.text_field :access_token_ttl,
|
|
||||||
value: application.access_token_ttl || "1h",
|
|
||||||
placeholder: "e.g., 1h, 30m, 3600",
|
|
||||||
class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono" %>
|
|
||||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
Range: 5m - 24h
|
|
||||||
<br>Default: 1h
|
|
||||||
<% if application.access_token_ttl.present? %>
|
|
||||||
<br>Current: <span class="font-medium"><%= application.access_token_ttl_human %> (<%= application.access_token_ttl %>s)</span>
|
|
||||||
<% end %>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<%= form.label :refresh_token_ttl, "Refresh Token TTL", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
|
|
||||||
<%= form.text_field :refresh_token_ttl,
|
|
||||||
value: application.refresh_token_ttl || "30d",
|
|
||||||
placeholder: "e.g., 30d, 1M, 2592000",
|
|
||||||
class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono" %>
|
|
||||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
Range: 5m - 90d
|
|
||||||
<br>Default: 30d
|
|
||||||
<% if application.refresh_token_ttl.present? %>
|
|
||||||
<br>Current: <span class="font-medium"><%= application.refresh_token_ttl_human %> (<%= application.refresh_token_ttl %>s)</span>
|
|
||||||
<% end %>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<%= form.label :id_token_ttl, "ID Token TTL", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
|
|
||||||
<%= form.text_field :id_token_ttl,
|
|
||||||
value: application.id_token_ttl || "1h",
|
|
||||||
placeholder: "e.g., 1h, 30m, 3600",
|
|
||||||
class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono" %>
|
|
||||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
Range: 5m - 24h
|
|
||||||
<br>Default: 1h
|
|
||||||
<% if application.id_token_ttl.present? %>
|
|
||||||
<br>Current: <span class="font-medium"><%= application.id_token_ttl_human %> (<%= application.id_token_ttl %>s)</span>
|
|
||||||
<% end %>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<details class="mt-3">
|
|
||||||
<summary class="cursor-pointer text-sm text-blue-600 hover:text-blue-800">Understanding Token Types & Session Length</summary>
|
|
||||||
<div class="mt-2 ml-4 space-y-3 text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
<div>
|
|
||||||
<p class="font-medium text-gray-900 dark:text-gray-100 mb-1">Token Types:</p>
|
|
||||||
<p><strong>Access Token:</strong> Used to access protected resources (APIs). Shorter lifetime = more secure. Users won't notice automatic refreshes.</p>
|
|
||||||
<p><strong>Refresh Token:</strong> Used to get new access tokens without re-authentication. Each refresh issues a new refresh token (token rotation).</p>
|
|
||||||
<p><strong>ID Token:</strong> Contains user identity information (JWT). Should match access token lifetime in most cases.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-2">
|
|
||||||
<p class="font-medium text-gray-900 dark:text-gray-100 mb-1">How Session Length Works:</p>
|
|
||||||
<p><strong>Refresh Token TTL = Maximum Inactivity Period</strong></p>
|
|
||||||
<p class="ml-3">Because refresh tokens are automatically rotated (new token = new expiry), active users can stay logged in indefinitely. The TTL controls how long they can be <em>inactive</em> before requiring re-authentication.</p>
|
|
||||||
|
|
||||||
<p class="mt-2"><strong>Example:</strong> Refresh TTL = 30 days</p>
|
|
||||||
<ul class="ml-6 list-disc space-y-1 text-xs">
|
|
||||||
<li>User logs in on Day 0, uses app daily → stays logged in forever (tokens keep rotating)</li>
|
|
||||||
<li>User logs in on Day 0, stops using app → must re-login after 30 days of inactivity</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-2">
|
|
||||||
<p class="font-medium text-gray-900 dark:text-gray-100 mb-1">Forcing Re-Authentication:</p>
|
|
||||||
<p class="ml-3 text-xs">Because of token rotation, there's no way to force periodic re-authentication using TTL settings alone. Active users can stay logged in indefinitely by refreshing tokens before they expire.</p>
|
|
||||||
|
|
||||||
<p class="mt-2 ml-3 text-xs"><strong>To enforce absolute session limits:</strong> Clients can include the <code class="bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-1 rounded">max_age</code> parameter in their authorization requests to require re-authentication after a specific time, regardless of token rotation.</p>
|
|
||||||
|
|
||||||
<p class="mt-2 ml-3 text-xs"><strong>Example:</strong> A banking app might set <code class="bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-1 rounded">max_age=900</code> (15 minutes) in the authorization request to force re-authentication every 15 minutes, even if refresh tokens are still valid.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-2">
|
|
||||||
<p class="font-medium text-gray-900 dark:text-gray-100 mb-1">Common Configurations:</p>
|
|
||||||
<ul class="ml-3 space-y-1 text-xs">
|
|
||||||
<li><strong>Banking/High Security:</strong> Access TTL = <code class="bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-1 rounded">5m</code>, Refresh TTL = <code class="bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-1 rounded">5m</code> → Re-auth every 5 minutes</li>
|
|
||||||
<li><strong>Corporate Tools:</strong> Access TTL = <code class="bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-1 rounded">1h</code>, Refresh TTL = <code class="bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-1 rounded">8h</code> → Re-auth after 8 hours inactive</li>
|
|
||||||
<li><strong>Personal Apps:</strong> Access TTL = <code class="bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-1 rounded">1h</code>, Refresh TTL = <code class="bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-1 rounded">30d</code> → Re-auth after 30 days inactive</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Forward Auth-specific fields -->
|
|
||||||
<div id="forward-auth-fields" class="space-y-6 border-t border-gray-200 dark:border-gray-700 pt-6 <%= 'hidden' unless application.forward_auth? %>" data-application-form-target="forwardAuthFields">
|
|
||||||
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">Forward Auth Configuration</h3>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<%= form.label :domain_pattern, "Domain Pattern", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
|
|
||||||
<%= form.text_field :domain_pattern, class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 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 dark:text-gray-400">Domain pattern to match. Use * for wildcard subdomains (e.g., *.example.com matches app.example.com, api.example.com, etc.)</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 :headers_config, "Custom Headers Configuration (JSON)", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
|
|
||||||
<%= 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 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 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 dark:text-gray-400 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 dark:bg-gray-700 dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-600 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", "username": "Remote-Username", "admin": "Remote-Admin"}' class="text-xs bg-blue-100 dark:bg-blue-900/50 hover:bg-blue-200 dark:hover:bg-blue-900 text-blue-700 dark:text-blue-300 px-2 py-1 rounded">Insert Example</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p><strong>Default headers:</strong> X-Remote-User, X-Remote-Email, X-Remote-Name, X-Remote-Username, 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 dark:bg-gray-700 dark:text-gray-200 px-1 rounded">user</code> - User's email address</p>
|
|
||||||
<p><code class="bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-1 rounded">email</code> - User's email address</p>
|
|
||||||
<p><code class="bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-1 rounded">name</code> - User's display name (falls back to email if not set)</p>
|
|
||||||
<p><code class="bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-1 rounded">username</code> - User's login username (only sent if set)</p>
|
|
||||||
<p><code class="bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-1 rounded">groups</code> - Comma-separated list of group names (e.g., "admin,developers")</p>
|
|
||||||
<p><code class="bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-1 rounded">admin</code> - "true" or "false" indicating admin status</p>
|
|
||||||
<p class="mt-2 italic">Example: <code class="bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-1 rounded">{"user": "Remote-User", "groups": "Remote-Groups", "username": "Remote-Username"}</code></p>
|
|
||||||
<p class="italic">Need custom user fields? Add them to user's custom_claims for OIDC tokens</p>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<%= form.label :group_ids, "Allowed Groups (Optional)", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
|
<%= form.label :group_ids, "Allowed Groups (Optional)", class: "block text-sm font-medium text-gray-700" %>
|
||||||
<div class="mt-2 space-y-2 max-h-48 overflow-y-auto border border-gray-200 dark:border-gray-700 rounded-md p-3">
|
<div class="mt-2 space-y-2 max-h-48 overflow-y-auto border border-gray-200 rounded-md p-3">
|
||||||
<% if @available_groups.any? %>
|
<% if @available_groups.any? %>
|
||||||
<% @available_groups.each do |group| %>
|
<% @available_groups.each do |group| %>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<%= check_box_tag "application[group_ids][]", group.id, application.allowed_groups.include?(group), class: "h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500" %>
|
<%= check_box_tag "application[group_ids][]", group.id, application.allowed_groups.include?(group), class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" %>
|
||||||
<%= label_tag "application_group_ids_#{group.id}", group.name, class: "ml-2 text-sm text-gray-900 dark:text-gray-100" %>
|
<%= label_tag "application_group_ids_#{group.id}", group.name, class: "ml-2 text-sm text-gray-900" %>
|
||||||
<span class="ml-2 text-xs text-gray-500 dark:text-gray-400">(<%= pluralize(group.users.count, "member") %>)</span>
|
<span class="ml-2 text-xs text-gray-500">(<%= pluralize(group.users.count, "member") %>)</span>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% else %>
|
<% else %>
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400">No groups available. Create groups first to restrict access.</p>
|
<p class="text-sm text-gray-500">No groups available. Create groups first to restrict access.</p>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">If no groups are selected, all active users can access this application.</p>
|
<p class="mt-1 text-sm text-gray-500">If no groups are selected, all active users can access this application.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<%= form.check_box :active, class: "h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500" %>
|
<%= 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 dark:text-gray-100" %>
|
<%= form.label :active, "Active", class: "ml-2 block text-sm text-gray-900" %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<%= form.submit application.persisted? ? "Update Application" : "Create Application", 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" %>
|
<%= form.submit application.persisted? ? "Update Application" : "Create Application", 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_applications_path, class: "rounded-md bg-white dark:bg-gray-700 px-3 py-2 text-sm font-semibold text-gray-900 dark:text-gray-200 shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600" %>
|
<%= link_to "Cancel", admin_applications_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" %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% 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>
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
<%# Compact icon uploader. Locals:
|
|
||||||
form - the form builder
|
|
||||||
field - symbol for the file field (:icon or :icon_dark)
|
|
||||||
label - heading text
|
|
||||||
help - small helper paragraph (optional)
|
|
||||||
current_attached - the attachment to show as "current" preview
|
|
||||||
current_label - text for the preview row (e.g. "Current icon")
|
|
||||||
preview_extra_class - extra css for the preview img (e.g. "bg-gray-900")
|
|
||||||
%>
|
|
||||||
<div>
|
|
||||||
<%= form.label field, label, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
|
|
||||||
<% if local_assigns[:help].present? %>
|
|
||||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400"><%= help %></p>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<% if current_attached&.attached? && current_attached.blob&.persisted? && current_attached.blob.key.present? %>
|
|
||||||
<div class="mt-2 mb-3 flex items-center gap-3">
|
|
||||||
<%= image_tag current_attached, class: "h-12 w-12 rounded-md object-cover border border-gray-200 dark:border-gray-700 #{local_assigns[:preview_extra_class]}", alt: current_label %>
|
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
<p class="font-medium"><%= current_label %></p>
|
|
||||||
<p class="text-xs"><%= number_to_human_size(current_attached.blob.byte_size) %></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<div class="mt-2" data-controller="file-drop image-paste">
|
|
||||||
<div class="flex items-center gap-3 px-3 py-2 border border-dashed border-gray-300 dark:border-gray-600 rounded-md hover:border-blue-400 focus-within:border-blue-500 focus-within:ring-1 focus-within:ring-blue-500 transition-colors"
|
|
||||||
data-file-drop-target="dropzone"
|
|
||||||
data-image-paste-target="dropzone"
|
|
||||||
data-action="dragover->file-drop#dragover dragleave->file-drop#dragleave drop->file-drop#drop paste->image-paste#handlePaste"
|
|
||||||
tabindex="0">
|
|
||||||
<svg class="h-5 w-5 text-gray-400 dark:text-gray-500 shrink-0" stroke="currentColor" fill="none" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M12 4v12m0-12l-4 4m4-4l4 4M4 17v2a2 2 0 002 2h12a2 2 0 002-2v-2"/>
|
|
||||||
</svg>
|
|
||||||
<div class="flex-1 text-sm">
|
|
||||||
<label for="<%= form.field_id(field) %>" class="cursor-pointer font-medium text-blue-600 hover:text-blue-500 focus-within:outline-none">
|
|
||||||
<span>Upload</span>
|
|
||||||
<%= form.file_field field,
|
|
||||||
accept: "image/png,image/jpg,image/jpeg,image/gif,image/svg+xml",
|
|
||||||
class: "sr-only",
|
|
||||||
data: {
|
|
||||||
file_drop_target: "input",
|
|
||||||
image_paste_target: "input",
|
|
||||||
action: "change->file-drop#handleFiles"
|
|
||||||
} %>
|
|
||||||
</label>
|
|
||||||
<span class="text-gray-600 dark:text-gray-400"> · drag and drop · or click and paste (⌘V)</span>
|
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400">PNG, JPG, GIF or SVG · max 2MB</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div data-file-drop-target="preview" class="mt-2 hidden">
|
|
||||||
<div class="flex items-center gap-3 p-2 bg-blue-50 dark:bg-blue-900/30 rounded-md border border-blue-200 dark:border-blue-700">
|
|
||||||
<img data-file-drop-target="previewImage" class="h-10 w-10 rounded object-cover" alt="Preview">
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<p class="text-sm font-medium text-gray-900 dark:text-gray-100" data-file-drop-target="filename"></p>
|
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400" data-file-drop-target="filesize"></p>
|
|
||||||
</div>
|
|
||||||
<button type="button" data-action="click->file-drop#clear" class="text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300">
|
|
||||||
<svg class="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<div class="max-w-3xl">
|
<div class="max-w-3xl">
|
||||||
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100 mb-6">Edit Application</h1>
|
<h1 class="text-2xl font-semibold text-gray-900 mb-6">Edit Application</h1>
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-6">Editing: <%= @application.name %></p>
|
<p class="text-sm text-gray-600 mb-6">Editing: <%= @application.name %></p>
|
||||||
<%= render "form", application: @application %>
|
<%= render "form", application: @application %>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,85 +1,58 @@
|
|||||||
<div class="sm:flex sm:items-center">
|
<div class="sm:flex sm:items-center">
|
||||||
<div class="sm:flex-auto">
|
<div class="sm:flex-auto">
|
||||||
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">Applications</h1>
|
<h1 class="text-2xl font-semibold text-gray-900">Applications</h1>
|
||||||
<p class="mt-2 text-sm text-gray-700 dark:text-gray-300">Manage OIDC Clients.</p>
|
<p class="mt-2 text-sm text-gray-700">Manage OIDC Clients.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
|
<div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
|
||||||
<%= link_to "New Application", new_admin_application_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" %>
|
<%= link_to "New Application", new_admin_application_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>
|
</div>
|
||||||
|
|
||||||
<dl class="mt-4 grid grid-cols-3 gap-4">
|
|
||||||
<div class="rounded-lg bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 px-4 py-3">
|
|
||||||
<dt class="text-xs text-gray-500 dark:text-gray-400">Applications</dt>
|
|
||||||
<dd class="mt-1 text-2xl font-semibold text-gray-900 dark:text-gray-100"><%= @applications.size %></dd>
|
|
||||||
</div>
|
|
||||||
<div class="rounded-lg bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 px-4 py-3">
|
|
||||||
<dt class="text-xs text-gray-500 dark:text-gray-400">Users with access</dt>
|
|
||||||
<dd class="mt-1 text-2xl font-semibold text-gray-900 dark:text-gray-100"><%= @total_users_with_access %></dd>
|
|
||||||
</div>
|
|
||||||
<div class="rounded-lg bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 px-4 py-3">
|
|
||||||
<dt class="text-xs text-gray-500 dark:text-gray-400">Groups granting access</dt>
|
|
||||||
<dd class="mt-1 text-2xl font-semibold text-gray-900 dark:text-gray-100"><%= @total_groups_granting_access %></dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
|
|
||||||
<div class="mt-8 flow-root">
|
<div class="mt-8 flow-root">
|
||||||
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
|
<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">
|
<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 dark:divide-gray-600">
|
<table class="min-w-full divide-y divide-gray-300">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col" class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 dark:text-gray-100 sm:pl-0">Application</th>
|
<th scope="col" class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-0">Name</th>
|
||||||
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-100">Slug</th>
|
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Slug</th>
|
||||||
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-100">Type</th>
|
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Type</th>
|
||||||
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-100">Status</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="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-100">Access</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="relative py-3.5 pl-3 pr-4 sm:pr-0">
|
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-0">
|
||||||
<span class="sr-only">Actions</span>
|
<span class="sr-only">Actions</span>
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
<tbody class="divide-y divide-gray-200">
|
||||||
<% @applications.each do |application| %>
|
<% @applications.each do |application| %>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 dark:text-gray-100 sm:pl-0">
|
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-0">
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<% if application.icon.attached? %>
|
|
||||||
<%= app_icon_picture application, class: "h-10 w-10 rounded-lg object-cover border border-gray-200 dark:border-gray-700 flex-shrink-0" %>
|
|
||||||
<% else %>
|
|
||||||
<%= render "shared/app_monogram", name: application.name, class: "h-10 w-10 rounded-lg flex-shrink-0" %>
|
|
||||||
<% end %>
|
|
||||||
<%= link_to application.name, admin_application_path(application), class: "text-blue-600 hover:text-blue-900" %>
|
<%= link_to application.name, admin_application_path(application), class: "text-blue-600 hover:text-blue-900" %>
|
||||||
</div>
|
|
||||||
</td>
|
</td>
|
||||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
|
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||||
<code class="text-xs bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-2 py-1 rounded"><%= application.slug %></code>
|
<code class="text-xs bg-gray-100 px-2 py-1 rounded"><%= application.slug %></code>
|
||||||
</td>
|
</td>
|
||||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
|
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||||
<% case application.app_type %>
|
<% case application.app_type %>
|
||||||
<% when "oidc" %>
|
<% when "oidc" %>
|
||||||
<span class="inline-flex items-center rounded-full bg-purple-100 dark:bg-purple-900/50 px-2 py-1 text-xs font-medium text-purple-700 dark:text-purple-300">OIDC</span>
|
<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 dark:bg-blue-900/50 px-2 py-1 text-xs font-medium text-blue-700 dark:text-blue-300">Forward Auth</span>
|
|
||||||
<% when "saml" %>
|
<% when "saml" %>
|
||||||
<span class="inline-flex items-center rounded-full bg-orange-100 dark:bg-orange-900/50 px-2 py-1 text-xs font-medium text-orange-700 dark:text-orange-300">SAML</span>
|
<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 %>
|
<% end %>
|
||||||
</td>
|
</td>
|
||||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
|
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||||
<% if application.active? %>
|
<% if application.active? %>
|
||||||
<span class="inline-flex items-center rounded-full bg-green-100 dark:bg-green-900/50 px-2 py-1 text-xs font-medium text-green-700 dark:text-green-300">Active</span>
|
<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 %>
|
<% else %>
|
||||||
<span class="inline-flex items-center rounded-full bg-gray-100 dark:bg-gray-700 px-2 py-1 text-xs font-medium text-gray-700 dark:text-gray-300">Inactive</span>
|
<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 %>
|
<% end %>
|
||||||
</td>
|
</td>
|
||||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
|
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||||
<% groups_count = application.allowed_groups.size %>
|
<% if application.allowed_groups.empty? %>
|
||||||
<% users_count = @user_count_by_app[application.id] || 0 %>
|
<span class="text-gray-400">All users</span>
|
||||||
<% if groups_count.zero? %>
|
|
||||||
<span class="inline-flex items-center rounded-full bg-amber-100 dark:bg-amber-900/40 px-2 py-0.5 text-xs font-medium text-amber-700 dark:text-amber-300">No one</span>
|
|
||||||
<% else %>
|
<% else %>
|
||||||
<span class="text-gray-700 dark:text-gray-200"><%= pluralize(users_count, "user") %></span>
|
<%= application.allowed_groups.count %>
|
||||||
<span class="text-gray-400 dark:text-gray-500"> · <%= pluralize(groups_count, "group") %></span>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
</td>
|
</td>
|
||||||
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-0">
|
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-0">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<div class="max-w-3xl">
|
<div class="max-w-3xl">
|
||||||
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100 mb-6">New Application</h1>
|
<h1 class="text-2xl font-semibold text-gray-900 mb-6">New Application</h1>
|
||||||
<%= render "form", application: @application %>
|
<%= render "form", application: @application %>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
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>
|
||||||
@@ -1,65 +1,31 @@
|
|||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<% if flash[:client_id] %>
|
<% if flash[:client_id] && flash[:client_secret] %>
|
||||||
<div class="bg-yellow-50 dark:bg-yellow-900/30 border border-yellow-200 dark:border-yellow-700 rounded-md p-4 mb-6">
|
<div class="bg-yellow-50 border border-yellow-200 rounded-md p-4 mb-6">
|
||||||
<h4 class="text-sm font-medium text-yellow-800 dark:text-yellow-200 mb-2">🔐 OIDC Client Credentials</h4>
|
<h4 class="text-sm font-medium text-yellow-800 mb-2">🔐 OIDC Client Credentials</h4>
|
||||||
<% if flash[:public_client] %>
|
<p class="text-xs text-yellow-700 mb-3">Copy these credentials now. The client secret will not be shown again.</p>
|
||||||
<p class="text-xs text-yellow-700 dark:text-yellow-300 mb-3">This is a public client. Copy the client ID below.</p>
|
|
||||||
<% else %>
|
|
||||||
<p class="text-xs text-yellow-700 dark:text-yellow-300 mb-3">Copy these credentials now. The client secret will not be shown again.</p>
|
|
||||||
<% end %>
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div>
|
<div>
|
||||||
<span class="text-xs font-medium text-yellow-700 dark:text-yellow-300">Client ID:</span>
|
<span class="text-xs font-medium text-yellow-700">Client ID:</span>
|
||||||
</div>
|
</div>
|
||||||
<code class="block bg-yellow-100 dark:bg-yellow-900/50 px-3 py-2 rounded font-mono text-xs break-all"><%= flash[:client_id] %></code>
|
<code class="block bg-yellow-100 px-3 py-2 rounded font-mono text-xs break-all"><%= flash[:client_id] %></code>
|
||||||
<% if flash[:client_secret] %>
|
|
||||||
<div class="mt-3">
|
<div class="mt-3">
|
||||||
<span class="text-xs font-medium text-yellow-700 dark:text-yellow-300">Client Secret:</span>
|
<span class="text-xs font-medium text-yellow-700">Client Secret:</span>
|
||||||
</div>
|
|
||||||
<code class="block bg-yellow-100 dark:bg-yellow-900/50 px-3 py-2 rounded font-mono text-xs break-all"><%= flash[:client_secret] %></code>
|
|
||||||
<% elsif flash[:public_client] %>
|
|
||||||
<div class="mt-3">
|
|
||||||
<span class="text-xs font-medium text-yellow-700 dark:text-yellow-300">Client Secret:</span>
|
|
||||||
</div>
|
|
||||||
<div class="bg-yellow-100 dark:bg-yellow-900/50 px-3 py-2 rounded text-xs text-yellow-600 dark:text-yellow-400">
|
|
||||||
Public clients do not have a client secret. PKCE is required.
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<% env_lines = oidc_env_lines(@application, client_secret: flash[:client_secret]) %>
|
|
||||||
|
|
||||||
<div class="mt-4" data-controller="clipboard">
|
|
||||||
<div class="flex items-center justify-between mb-2">
|
|
||||||
<span class="text-xs font-medium text-yellow-700 dark:text-yellow-300">Environment variables (copy & paste):</span>
|
|
||||||
<button type="button"
|
|
||||||
data-action="clipboard#copy"
|
|
||||||
class="text-xs font-medium text-yellow-700 dark:text-yellow-300 hover:text-yellow-900 dark:hover:text-yellow-100 underline">
|
|
||||||
<span data-clipboard-target="label">Copy</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<textarea data-clipboard-target="source"
|
|
||||||
readonly
|
|
||||||
rows="<%= env_lines.length %>"
|
|
||||||
class="block w-full bg-yellow-100 dark:bg-yellow-900/50 px-3 py-2 rounded font-mono text-xs text-gray-900 dark:text-gray-100 resize-none focus:outline-none focus:ring-1 focus:ring-yellow-500"><%= env_lines.join("\n") %></textarea>
|
|
||||||
</div>
|
</div>
|
||||||
|
<code class="block bg-yellow-100 px-3 py-2 rounded font-mono text-xs break-all"><%= flash[:client_secret] %></code>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<div class="sm:flex sm:items-start sm:justify-between">
|
<div class="sm:flex sm:items-center sm:justify-between">
|
||||||
<div class="flex items-start gap-4">
|
|
||||||
<% if @application.icon.attached? %>
|
|
||||||
<%= app_icon_picture @application, class: "h-16 w-16 rounded-lg object-cover border border-gray-200 dark:border-gray-700 shrink-0" %>
|
|
||||||
<% else %>
|
|
||||||
<%= render "shared/app_monogram", name: @application.name, class: "h-16 w-16 rounded-lg shrink-0" %>
|
|
||||||
<% end %>
|
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100"><%= @application.name %></h1>
|
<h1 class="text-2xl font-semibold text-gray-900"><%= @application.name %></h1>
|
||||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400"><%= @application.description %></p>
|
<p class="mt-1 text-sm text-gray-500"><%= @application.description %></p>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4 sm:mt-0 flex gap-3">
|
<div class="mt-4 sm:mt-0 flex gap-3">
|
||||||
<%= link_to "Edit", edit_admin_application_path(@application), class: "rounded-md bg-white dark:bg-gray-700 px-3 py-2 text-sm font-semibold text-gray-900 dark:text-gray-200 shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600" %>
|
<%= 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" %>
|
<%= 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>
|
||||||
</div>
|
</div>
|
||||||
@@ -67,42 +33,32 @@
|
|||||||
|
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<!-- Basic Information -->
|
<!-- Basic Information -->
|
||||||
<div class="bg-white dark:bg-gray-800 shadow sm:rounded-lg">
|
<div class="bg-white shadow sm:rounded-lg">
|
||||||
<div class="px-4 py-5 sm:p-6">
|
<div class="px-4 py-5 sm:p-6">
|
||||||
<h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-gray-100 mb-4">Basic Information</h3>
|
<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">
|
<dl class="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Slug</dt>
|
<dt class="text-sm font-medium text-gray-500">Slug</dt>
|
||||||
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100"><code class="bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-2 py-1 rounded"><%= @application.slug %></code></dd>
|
<dd class="mt-1 text-sm text-gray-900"><code class="bg-gray-100 px-2 py-1 rounded"><%= @application.slug %></code></dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Type</dt>
|
<dt class="text-sm font-medium text-gray-500">Type</dt>
|
||||||
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
<dd class="mt-1 text-sm text-gray-900">
|
||||||
<% case @application.app_type %>
|
<% case @application.app_type %>
|
||||||
<% when "oidc" %>
|
<% when "oidc" %>
|
||||||
<span class="inline-flex items-center rounded-full bg-purple-100 dark:bg-purple-900/50 px-2 py-1 text-xs font-medium text-purple-700 dark:text-purple-300">OIDC</span>
|
<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" %>
|
<% when "saml" %>
|
||||||
<span class="inline-flex items-center rounded-full bg-blue-100 dark:bg-blue-900/50 px-2 py-1 text-xs font-medium text-blue-700 dark:text-blue-300">Forward Auth</span>
|
<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 %>
|
<% end %>
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Status</dt>
|
<dt class="text-sm font-medium text-gray-500">Status</dt>
|
||||||
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
<dd class="mt-1 text-sm text-gray-900">
|
||||||
<% if @application.active? %>
|
<% if @application.active? %>
|
||||||
<span class="inline-flex items-center rounded-full bg-green-100 dark:bg-green-900/50 px-2 py-1 text-xs font-medium text-green-700 dark:text-green-300">Active</span>
|
<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 %>
|
<% else %>
|
||||||
<span class="inline-flex items-center rounded-full bg-gray-100 dark:bg-gray-700 px-2 py-1 text-xs font-medium text-gray-700 dark:text-gray-300">Inactive</span>
|
<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 class="sm:col-span-2">
|
|
||||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Landing URL</dt>
|
|
||||||
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
|
||||||
<% 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 dark:text-gray-500 italic">Not configured</span>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
@@ -112,148 +68,39 @@
|
|||||||
|
|
||||||
<!-- OIDC Configuration (only for OIDC apps) -->
|
<!-- OIDC Configuration (only for OIDC apps) -->
|
||||||
<% if @application.oidc? %>
|
<% if @application.oidc? %>
|
||||||
<div class="bg-white dark:bg-gray-800 shadow sm:rounded-lg">
|
<div class="bg-white shadow sm:rounded-lg">
|
||||||
<div class="px-4 py-5 sm:p-6">
|
<div class="px-4 py-5 sm:p-6">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-gray-100">OIDC Configuration</h3>
|
<h3 class="text-base font-semibold leading-6 text-gray-900">OIDC Credentials</h3>
|
||||||
<%= button_to "Regenerate Credentials", regenerate_credentials_admin_application_path(@application), method: :post, data: { turbo_confirm: "This will invalidate the current credentials. Continue?" }, class: "text-sm text-red-600 hover:text-red-900" %>
|
<%= button_to "Regenerate Credentials", regenerate_credentials_admin_application_path(@application), method: :post, data: { turbo_confirm: "This will invalidate the current credentials. Continue?" }, class: "text-sm text-red-600 hover:text-red-900" %>
|
||||||
</div>
|
</div>
|
||||||
<dl class="space-y-4">
|
<dl class="space-y-4">
|
||||||
<div class="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Client Type</dt>
|
<dt class="text-sm font-medium text-gray-500">Client ID</dt>
|
||||||
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
<dd class="mt-1 text-sm text-gray-900">
|
||||||
<% if @application.public_client? %>
|
<code class="block bg-gray-100 px-3 py-2 rounded font-mono text-xs break-all"><%= @application.client_id %></code>
|
||||||
<span class="inline-flex items-center rounded-full bg-blue-100 dark:bg-blue-900/50 px-2 py-1 text-xs font-medium text-blue-700 dark:text-blue-300">Public</span>
|
|
||||||
<% else %>
|
|
||||||
<span class="inline-flex items-center rounded-full bg-gray-100 dark:bg-gray-700 px-2 py-1 text-xs font-medium text-gray-700 dark:text-gray-300">Confidential</span>
|
|
||||||
<% end %>
|
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">PKCE</dt>
|
<dt class="text-sm font-medium text-gray-500">Client Secret</dt>
|
||||||
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
<dd class="mt-1 text-sm text-gray-900">
|
||||||
<% if @application.requires_pkce? %>
|
<div class="bg-gray-100 px-3 py-2 rounded text-xs text-gray-500 italic">
|
||||||
<span class="inline-flex items-center rounded-full bg-green-100 dark:bg-green-900/50 px-2 py-1 text-xs font-medium text-green-700 dark:text-green-300">Required</span>
|
|
||||||
<% else %>
|
|
||||||
<span class="inline-flex items-center rounded-full bg-gray-100 dark:bg-gray-700 px-2 py-1 text-xs font-medium text-gray-700 dark:text-gray-300">Optional</span>
|
|
||||||
<% end %>
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<% unless flash[:client_id] %>
|
|
||||||
<div>
|
|
||||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Client ID</dt>
|
|
||||||
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
|
||||||
<code class="block bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-3 py-2 rounded font-mono text-xs break-all"><%= @application.client_id %></code>
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<% if @application.confidential_client? %>
|
|
||||||
<div>
|
|
||||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Client Secret</dt>
|
|
||||||
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
|
||||||
<div class="bg-gray-100 dark:bg-gray-700 px-3 py-2 rounded text-xs text-gray-500 dark:text-gray-400 italic">
|
|
||||||
🔒 Client secret is stored securely and cannot be displayed
|
🔒 Client secret is stored securely and cannot be displayed
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
<p class="mt-2 text-xs text-gray-500">
|
||||||
To get a new client secret, use the "Regenerate Credentials" button above.
|
To get a new client secret, use the "Regenerate Credentials" button above.
|
||||||
</p>
|
</p>
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<% else %>
|
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Client Secret</dt>
|
<dt class="text-sm font-medium text-gray-500">Redirect URIs</dt>
|
||||||
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
<dd class="mt-1 text-sm text-gray-900">
|
||||||
<div class="bg-blue-50 dark:bg-blue-900/30 px-3 py-2 rounded text-xs text-blue-600 dark:text-blue-400">
|
|
||||||
Public clients do not use a client secret. PKCE is required for authorization.
|
|
||||||
</div>
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
<div>
|
|
||||||
<details class="border border-gray-200 dark:border-gray-700 rounded-lg">
|
|
||||||
<summary class="cursor-pointer bg-gray-50 dark:bg-gray-700 px-4 py-3 hover:bg-gray-100 dark:hover:bg-gray-600 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
||||||
Environment variables
|
|
||||||
</summary>
|
|
||||||
<div class="px-4 py-3" data-controller="clipboard">
|
|
||||||
<% env_lines = oidc_env_lines(@application) %>
|
|
||||||
<div class="flex items-center justify-between mb-2">
|
|
||||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
<%= @application.confidential_client? ? "Replace <your-client-secret> with your saved secret." : "Public client — no secret required." %>
|
|
||||||
</span>
|
|
||||||
<button type="button"
|
|
||||||
data-action="clipboard#copy"
|
|
||||||
class="text-xs font-medium text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 underline">
|
|
||||||
<span data-clipboard-target="label">Copy</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<textarea data-clipboard-target="source"
|
|
||||||
readonly
|
|
||||||
rows="<%= env_lines.length %>"
|
|
||||||
class="block w-full bg-gray-100 dark:bg-gray-700 px-3 py-2 rounded font-mono text-xs text-gray-900 dark:text-gray-100 resize-none focus:outline-none focus:ring-1 focus:ring-gray-500"><%= env_lines.join("\n") %></textarea>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
<div>
|
|
||||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Redirect URIs</dt>
|
|
||||||
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
|
||||||
<% if @application.redirect_uris.present? %>
|
<% if @application.redirect_uris.present? %>
|
||||||
<% @application.parsed_redirect_uris.each do |uri| %>
|
<% @application.parsed_redirect_uris.each do |uri| %>
|
||||||
<code class="block bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-3 py-2 rounded font-mono text-xs break-all mb-2"><%= uri %></code>
|
<code class="block bg-gray-100 px-3 py-2 rounded font-mono text-xs break-all mb-2"><%= uri %></code>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% else %>
|
<% else %>
|
||||||
<span class="text-gray-400 dark:text-gray-500">No redirect URIs configured</span>
|
<span class="text-gray-400">No redirect URIs configured</span>
|
||||||
<% end %>
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">
|
|
||||||
Backchannel Logout URI
|
|
||||||
<% if @application.supports_backchannel_logout? %>
|
|
||||||
<span class="ml-2 inline-flex items-center rounded-full bg-green-100 dark:bg-green-900/50 px-2 py-0.5 text-xs font-medium text-green-700 dark:text-green-300">Enabled</span>
|
|
||||||
<% end %>
|
|
||||||
</dt>
|
|
||||||
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
|
||||||
<% if @application.backchannel_logout_uri.present? %>
|
|
||||||
<code class="block bg-gray-100 dark:bg-gray-700 dark:text-gray-200 px-3 py-2 rounded font-mono text-xs break-all"><%= @application.backchannel_logout_uri %></code>
|
|
||||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
When users log out, Clinch will send logout notifications to this endpoint for immediate session termination.
|
|
||||||
</p>
|
|
||||||
<% else %>
|
|
||||||
<span class="text-gray-400 dark:text-gray-500 italic">Not configured</span>
|
|
||||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
Backchannel logout is optional. Configure it if the application supports OpenID Connect Backchannel Logout.
|
|
||||||
</p>
|
|
||||||
<% end %>
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<!-- Forward Auth Configuration (only for Forward Auth apps) -->
|
|
||||||
<% if @application.forward_auth? %>
|
|
||||||
<div class="bg-white dark:bg-gray-800 shadow sm:rounded-lg">
|
|
||||||
<div class="px-4 py-5 sm:p-6">
|
|
||||||
<h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-gray-100 mb-4">Forward Auth Configuration</h3>
|
|
||||||
<dl class="space-y-4">
|
|
||||||
<div>
|
|
||||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Domain Pattern</dt>
|
|
||||||
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
|
||||||
<code class="block bg-gray-100 dark:bg-gray-700 dark:text-gray-200 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 dark:text-gray-400">Headers Configuration</dt>
|
|
||||||
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
|
||||||
<% if @application.headers_config.present? && @application.headers_config.any? %>
|
|
||||||
<code class="block bg-gray-100 dark:bg-gray-700 dark:text-gray-200 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 dark:bg-gray-700 px-3 py-2 rounded text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
Using default headers: X-Remote-User, X-Remote-Email, X-Remote-Name, X-Remote-Username, X-Remote-Groups, X-Remote-Admin
|
|
||||||
</div>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
@@ -263,29 +110,29 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<!-- Group Access Control -->
|
<!-- Group Access Control -->
|
||||||
<div class="bg-white dark:bg-gray-800 shadow sm:rounded-lg">
|
<div class="bg-white shadow sm:rounded-lg">
|
||||||
<div class="px-4 py-5 sm:p-6">
|
<div class="px-4 py-5 sm:p-6">
|
||||||
<h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-gray-100 mb-4">Access Control</h3>
|
<h3 class="text-base font-semibold leading-6 text-gray-900 mb-4">Access Control</h3>
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Allowed Groups</dt>
|
<dt class="text-sm font-medium text-gray-500 mb-2">Allowed Groups</dt>
|
||||||
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
<dd class="mt-1 text-sm text-gray-900">
|
||||||
<% if @allowed_groups.empty? %>
|
<% if @allowed_groups.empty? %>
|
||||||
<div class="rounded-md bg-amber-50 dark:bg-amber-900/30 p-4">
|
<div class="rounded-md bg-blue-50 p-4">
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<div class="ml-3">
|
<div class="ml-3">
|
||||||
<p class="text-sm text-amber-700 dark:text-amber-300">
|
<p class="text-sm text-blue-700">
|
||||||
No groups assigned — no one can access this application. Attach a group to grant access.
|
No groups assigned - all active users can access this application.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% else %>
|
<% else %>
|
||||||
<ul class="divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-md">
|
<ul class="divide-y divide-gray-200 border border-gray-200 rounded-md">
|
||||||
<% @allowed_groups.each do |group| %>
|
<% @allowed_groups.each do |group| %>
|
||||||
<li class="px-4 py-3 flex items-center justify-between">
|
<li class="px-4 py-3 flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-medium text-gray-900 dark:text-gray-100"><%= group.name %></p>
|
<p class="text-sm font-medium text-gray-900"><%= group.name %></p>
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400"><%= pluralize(group.users.count, "member") %></p>
|
<p class="text-xs text-gray-500"><%= pluralize(group.users.count, "member") %></p>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
<% end %>
|
<% end %>
|
||||||
@@ -295,35 +142,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Users with access -->
|
|
||||||
<div class="bg-white dark:bg-gray-800 shadow sm:rounded-lg">
|
|
||||||
<div class="px-4 py-5 sm:p-6">
|
|
||||||
<h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-gray-100 mb-4">
|
|
||||||
Users with access (<%= @users_with_access.count %>)
|
|
||||||
</h3>
|
|
||||||
<% if @users_with_access.any? %>
|
|
||||||
<ul class="divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-md">
|
|
||||||
<% @users_with_access.each do |user| %>
|
|
||||||
<% via = user.groups & @application.allowed_groups %>
|
|
||||||
<li class="px-4 py-3 flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p class="text-sm font-medium text-gray-900 dark:text-gray-100"><%= user.email_address %></p>
|
|
||||||
<div class="flex flex-wrap gap-1 mt-1">
|
|
||||||
<% via.each do |g| %>
|
|
||||||
<span class="inline-flex items-center rounded-full bg-gray-100 dark:bg-gray-700 px-2 py-0.5 text-xs font-medium text-gray-700 dark:text-gray-300">via <%= g.name %></span>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<%= link_to "View", admin_user_path(user), class: "text-blue-600 hover:text-blue-900 text-sm" %>
|
|
||||||
</li>
|
|
||||||
<% end %>
|
|
||||||
</ul>
|
|
||||||
<% else %>
|
|
||||||
<div class="rounded-md bg-gray-50 dark:bg-gray-700 p-4">
|
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400">No users currently have access. Attach a group to grant access.</p>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,28 +1,28 @@
|
|||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-gray-100">Admin Dashboard</h1>
|
<h1 class="text-3xl font-bold text-gray-900">Admin Dashboard</h1>
|
||||||
<p class="mt-2 text-gray-600 dark:text-gray-400">System overview and quick actions</p>
|
<p class="mt-2 text-gray-600">System overview and quick actions</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
<!-- Users Card -->
|
<!-- Users Card -->
|
||||||
<div class="bg-white dark:bg-gray-800 overflow-hidden shadow rounded-lg">
|
<div class="bg-white overflow-hidden shadow rounded-lg">
|
||||||
<div class="p-5">
|
<div class="p-5">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div class="flex-shrink-0">
|
<div class="flex-shrink-0">
|
||||||
<svg class="h-6 w-6 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="h-6 w-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-5 w-0 flex-1">
|
<div class="ml-5 w-0 flex-1">
|
||||||
<dl>
|
<dl>
|
||||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 truncate">
|
<dt class="text-sm font-medium text-gray-500 truncate">
|
||||||
Total Users
|
Total Users
|
||||||
</dt>
|
</dt>
|
||||||
<dd class="flex items-baseline">
|
<dd class="flex items-baseline">
|
||||||
<div class="text-2xl font-semibold text-gray-900 dark:text-gray-100">
|
<div class="text-2xl font-semibold text-gray-900">
|
||||||
<%= @user_count %>
|
<%= @user_count %>
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-2 text-sm text-gray-600 dark:text-gray-400">
|
<div class="ml-2 text-sm text-gray-600">
|
||||||
(<%= @active_user_count %> active)
|
(<%= @active_user_count %> active)
|
||||||
</div>
|
</div>
|
||||||
</dd>
|
</dd>
|
||||||
@@ -30,30 +30,30 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-gray-50 dark:bg-gray-700 px-5 py-3">
|
<div class="bg-gray-50 px-5 py-3">
|
||||||
<%= link_to "Manage users", admin_users_path, class: "text-sm font-medium text-blue-600 hover:text-blue-500" %>
|
<%= link_to "Manage users", admin_users_path, class: "text-sm font-medium text-blue-600 hover:text-blue-500" %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Applications Card -->
|
<!-- Applications Card -->
|
||||||
<div class="bg-white dark:bg-gray-800 overflow-hidden shadow rounded-lg">
|
<div class="bg-white overflow-hidden shadow rounded-lg">
|
||||||
<div class="p-5">
|
<div class="p-5">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div class="flex-shrink-0">
|
<div class="flex-shrink-0">
|
||||||
<svg class="h-6 w-6 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="h-6 w-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 5a1 1 0 011-1h4a1 1 0 010 2H6v10a1 1 0 001 1h10a1 1 0 001-1v-3a1 1 0 112 0v3a3 3 0 01-3 3H7a3 3 0 01-3-3V6a1 1 0 011-1zm9 1a1 1 0 10-2 0v3a1 1 0 102 0V6zm-4 8a1 1 0 100 2h.01a1 1 0 100-2H9zm4 0a1 1 0 100 2h.01a1 1 0 100-2H13z"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 5a1 1 0 011-1h4a1 1 0 010 2H6v10a1 1 0 001 1h10a1 1 0 001-1v-3a1 1 0 112 0v3a3 3 0 01-3 3H7a3 3 0 01-3-3V6a1 1 0 011-1zm9 1a1 1 0 10-2 0v3a1 1 0 102 0V6zm-4 8a1 1 0 100 2h.01a1 1 0 100-2H9zm4 0a1 1 0 100 2h.01a1 1 0 100-2H13z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-5 w-0 flex-1">
|
<div class="ml-5 w-0 flex-1">
|
||||||
<dl>
|
<dl>
|
||||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 truncate">
|
<dt class="text-sm font-medium text-gray-500 truncate">
|
||||||
Applications
|
Applications
|
||||||
</dt>
|
</dt>
|
||||||
<dd class="flex items-baseline">
|
<dd class="flex items-baseline">
|
||||||
<div class="text-2xl font-semibold text-gray-900 dark:text-gray-100">
|
<div class="text-2xl font-semibold text-gray-900">
|
||||||
<%= @application_count %>
|
<%= @application_count %>
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-2 text-sm text-gray-600 dark:text-gray-400">
|
<div class="ml-2 text-sm text-gray-600">
|
||||||
(<%= @active_application_count %> active)
|
(<%= @active_application_count %> active)
|
||||||
</div>
|
</div>
|
||||||
</dd>
|
</dd>
|
||||||
@@ -61,33 +61,33 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-gray-50 dark:bg-gray-700 px-5 py-3">
|
<div class="bg-gray-50 px-5 py-3">
|
||||||
<%= link_to "Manage applications", admin_applications_path, class: "text-sm font-medium text-blue-600 hover:text-blue-500" %>
|
<%= link_to "Manage applications", admin_applications_path, class: "text-sm font-medium text-blue-600 hover:text-blue-500" %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Groups Card -->
|
<!-- Groups Card -->
|
||||||
<div class="bg-white dark:bg-gray-800 overflow-hidden shadow rounded-lg">
|
<div class="bg-white overflow-hidden shadow rounded-lg">
|
||||||
<div class="p-5">
|
<div class="p-5">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div class="flex-shrink-0">
|
<div class="flex-shrink-0">
|
||||||
<svg class="h-6 w-6 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="h-6 w-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-5 w-0 flex-1">
|
<div class="ml-5 w-0 flex-1">
|
||||||
<dl>
|
<dl>
|
||||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 truncate">
|
<dt class="text-sm font-medium text-gray-500 truncate">
|
||||||
Groups
|
Groups
|
||||||
</dt>
|
</dt>
|
||||||
<dd class="text-2xl font-semibold text-gray-900 dark:text-gray-100">
|
<dd class="text-2xl font-semibold text-gray-900">
|
||||||
<%= @group_count %>
|
<%= @group_count %>
|
||||||
</dd>
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-gray-50 dark:bg-gray-700 px-5 py-3">
|
<div class="bg-gray-50 px-5 py-3">
|
||||||
<%= link_to "Manage groups", admin_groups_path, class: "text-sm font-medium text-blue-600 hover:text-blue-500" %>
|
<%= link_to "Manage groups", admin_groups_path, class: "text-sm font-medium text-blue-600 hover:text-blue-500" %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -95,26 +95,26 @@
|
|||||||
|
|
||||||
<!-- Recent Users -->
|
<!-- Recent Users -->
|
||||||
<div class="mt-8">
|
<div class="mt-8">
|
||||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">Recent Users</h2>
|
<h2 class="text-xl font-semibold text-gray-900 mb-4">Recent Users</h2>
|
||||||
<div class="bg-white dark:bg-gray-800 shadow overflow-hidden sm:rounded-lg">
|
<div class="bg-white shadow overflow-hidden sm:rounded-lg">
|
||||||
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
|
<ul class="divide-y divide-gray-200">
|
||||||
<% @recent_users.each do |user| %>
|
<% @recent_users.each do |user| %>
|
||||||
<li class="px-6 py-4">
|
<li class="px-6 py-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-medium text-gray-900 dark:text-gray-100"><%= user.email_address %></p>
|
<p class="text-sm font-medium text-gray-900"><%= user.email_address %></p>
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
<p class="text-xs text-gray-500">
|
||||||
Created <%= time_ago_in_words(user.created_at) %> ago
|
Created <%= time_ago_in_words(user.created_at) %> ago
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<% if user.admin? %>
|
<% if user.admin? %>
|
||||||
<span class="inline-flex items-center rounded-full bg-blue-100 dark:bg-blue-900/50 px-2 py-1 text-xs font-medium text-blue-700 dark:text-blue-300">Admin</span>
|
<span class="inline-flex items-center rounded-full bg-blue-100 px-2 py-1 text-xs font-medium text-blue-700">Admin</span>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% if user.totp_enabled? %>
|
<% if user.totp_enabled? %>
|
||||||
<span class="inline-flex items-center rounded-full bg-green-100 dark:bg-green-900/50 px-2 py-1 text-xs font-medium text-green-700 dark:text-green-300">2FA</span>
|
<span class="inline-flex items-center rounded-full bg-green-100 px-2 py-1 text-xs font-medium text-green-700">2FA</span>
|
||||||
<% end %>
|
<% end %>
|
||||||
<span class="inline-flex items-center rounded-full bg-gray-100 dark:bg-gray-700 px-2 py-1 text-xs font-medium text-gray-700 dark:text-gray-300"><%= user.status.titleize %></span>
|
<span class="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700"><%= user.status.titleize %></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
@@ -125,21 +125,21 @@
|
|||||||
|
|
||||||
<!-- Quick Actions -->
|
<!-- Quick Actions -->
|
||||||
<div class="mt-8">
|
<div class="mt-8">
|
||||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">Quick Actions</h2>
|
<h2 class="text-xl font-semibold text-gray-900 mb-4">Quick Actions</h2>
|
||||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
<%= link_to new_admin_user_path, class: "block p-6 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm hover:bg-gray-50 dark:hover:bg-gray-700 hover:shadow-md transition" do %>
|
<%= link_to new_admin_user_path, class: "block p-6 bg-white rounded-lg border border-gray-200 shadow-sm hover:bg-gray-50 hover:shadow-md transition" do %>
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">Create User</h3>
|
<h3 class="text-lg font-semibold text-gray-900 mb-2">Create User</h3>
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400">Add a new user to the system</p>
|
<p class="text-sm text-gray-600">Add a new user to the system</p>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<%= link_to new_admin_application_path, class: "block p-6 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm hover:bg-gray-50 dark:hover:bg-gray-700 hover:shadow-md transition" do %>
|
<%= link_to new_admin_application_path, class: "block p-6 bg-white rounded-lg border border-gray-200 shadow-sm hover:bg-gray-50 hover:shadow-md transition" do %>
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">Register Application</h3>
|
<h3 class="text-lg font-semibold text-gray-900 mb-2">Register Application</h3>
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400">Add a new OIDC or ForwardAuth app</p>
|
<p class="text-sm text-gray-600">Add a new OIDC or ForwardAuth app</p>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<%= link_to new_admin_group_path, class: "block p-6 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm hover:bg-gray-50 dark:hover:bg-gray-700 hover:shadow-md transition" do %>
|
<%= link_to new_admin_group_path, class: "block p-6 bg-white rounded-lg border border-gray-200 shadow-sm hover:bg-gray-50 hover:shadow-md transition" do %>
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">Create Group</h3>
|
<h3 class="text-lg font-semibold text-gray-900 mb-2">Create Group</h3>
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400">Organize users into a new group</p>
|
<p class="text-sm text-gray-600">Organize users into a new group</p>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
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,99 +1,56 @@
|
|||||||
<%= form_with(model: [:admin, group], class: "space-y-6", data: { controller: "form-errors" }) do |form| %>
|
<%= form_with(model: [:admin, group], class: "space-y-6") do |form| %>
|
||||||
<%= render "shared/form_errors", form: 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>
|
<div>
|
||||||
<%= form.label :name, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
|
<%= form.label :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 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "developers" %>
|
<%= 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: "developers" %>
|
||||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Group names are automatically normalized to lowercase.</p>
|
<p class="mt-1 text-sm text-gray-500">Group names are automatically normalized to lowercase.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<%= form.label :description, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
|
<%= form.label :description, class: "block text-sm font-medium text-gray-700" %>
|
||||||
<%= form.text_area :description, rows: 3, class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Optional description of this group" %>
|
<%= 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 group" %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center">
|
<%= form.label :user_ids, "Group Members", class: "block text-sm font-medium text-gray-700" %>
|
||||||
<%= form.check_box :auto_assign, class: "h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500" %>
|
<div class="mt-2 space-y-2 max-h-64 overflow-y-auto border border-gray-200 rounded-md p-3">
|
||||||
<%= form.label :auto_assign, "Auto Assign", class: "ml-2 text-sm text-gray-900 dark:text-gray-100" %>
|
|
||||||
</div>
|
|
||||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">New users will be automatically added to this group when invited. You can mark multiple groups as auto-assigned.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div class="flex items-center">
|
|
||||||
<%= form.check_box :admin, class: "h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500" %>
|
|
||||||
<%= form.label :admin, "Administrators", class: "ml-2 text-sm text-gray-900 dark:text-gray-100" %>
|
|
||||||
</div>
|
|
||||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Members of this group can access the admin panel. Does not grant automatic access to applications.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<%= form.label :user_ids, "Group Members", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
|
|
||||||
<div class="mt-2 space-y-2 max-h-64 overflow-y-auto border border-gray-200 dark:border-gray-700 rounded-md p-3">
|
|
||||||
<% if @available_users.any? %>
|
<% if @available_users.any? %>
|
||||||
<% @available_users.each do |user| %>
|
<% @available_users.each do |user| %>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<%= check_box_tag "group[user_ids][]", user.id, group.users.include?(user), class: "h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500" %>
|
<%= check_box_tag "group[user_ids][]", user.id, group.users.include?(user), class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" %>
|
||||||
<%= label_tag "group_user_ids_#{user.id}", user.email_address, class: "ml-2 text-sm text-gray-900 dark:text-gray-100" %>
|
<%= label_tag "group_user_ids_#{user.id}", user.email_address, class: "ml-2 text-sm text-gray-900" %>
|
||||||
<% if user.admin? %>
|
<% if user.admin? %>
|
||||||
<span class="ml-2 inline-flex items-center rounded-full bg-blue-100 dark:bg-blue-900/50 px-2 py-0.5 text-xs font-medium text-blue-700 dark:text-blue-300">Admin</span>
|
<span class="ml-2 inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700">Admin</span>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% else %>
|
<% else %>
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400">No users available.</p>
|
<p class="text-sm text-gray-500">No users available.</p>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Select which users should be members of this group.</p>
|
<p class="mt-1 text-sm text-gray-500">Select which users should be members of this group.</p>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<%= form.label :application_ids, "Assigned Applications", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
|
|
||||||
<div class="mt-2 space-y-2 max-h-48 overflow-y-auto border border-gray-200 dark:border-gray-700 rounded-md p-3">
|
|
||||||
<% if @available_applications.any? %>
|
|
||||||
<% @available_applications.each do |application| %>
|
|
||||||
<div class="flex items-center">
|
|
||||||
<%= check_box_tag "group[application_ids][]", application.id, group.applications.include?(application), class: "h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500" %>
|
|
||||||
<%= label_tag "group_application_ids_#{application.id}", application.name, class: "ml-2 text-sm text-gray-900 dark:text-gray-100" %>
|
|
||||||
<% case application.app_type %>
|
|
||||||
<% when "oidc" %>
|
|
||||||
<span class="ml-2 inline-flex items-center rounded-full bg-purple-100 dark:bg-purple-900/50 px-2 py-0.5 text-xs font-medium text-purple-700 dark:text-purple-300">OIDC</span>
|
|
||||||
<% when "trusted_header" %>
|
|
||||||
<span class="ml-2 inline-flex items-center rounded-full bg-indigo-100 dark:bg-indigo-900/50 px-2 py-0.5 text-xs font-medium text-indigo-700 dark:text-indigo-300">ForwardAuth</span>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
<% else %>
|
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400">No applications available.</p>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Select which applications this group grants access to.</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 dark:text-gray-300" %>
|
|
||||||
<%= 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 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 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 dark:text-gray-400 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 dark:bg-gray-700 dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-600 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 dark:bg-blue-900/50 hover:bg-blue-200 dark:hover:bg-blue-900 text-blue-700 dark:text-blue-300 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>
|
||||||
|
|
||||||
<div class="flex gap-3">
|
<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" %>
|
<%= 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 dark:bg-gray-700 px-3 py-2 text-sm font-semibold text-gray-900 dark:text-gray-200 shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-gray-600 hover:bg-gray-50 dark:hover:bg-gray-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" %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<div class="max-w-2xl">
|
<div class="max-w-2xl">
|
||||||
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100 mb-6">Edit Group</h1>
|
<h1 class="text-2xl font-semibold text-gray-900 mb-6">Edit Group</h1>
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-6">Editing: <%= @group.name %></p>
|
<p class="text-sm text-gray-600 mb-6">Editing: <%= @group.name %></p>
|
||||||
<%= render "form", group: @group %>
|
<%= render "form", group: @group %>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<div class="sm:flex sm:items-center">
|
<div class="sm:flex sm:items-center">
|
||||||
<div class="sm:flex-auto">
|
<div class="sm:flex-auto">
|
||||||
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">Groups</h1>
|
<h1 class="text-2xl font-semibold text-gray-900">Groups</h1>
|
||||||
<p class="mt-2 text-sm text-gray-700 dark:text-gray-300">Organize users into groups for application access control.</p>
|
<p class="mt-2 text-sm text-gray-700">Organize users into groups for application access control.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
|
<div class="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
|
||||||
<%= link_to "New Group", new_admin_group_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" %>
|
<%= link_to "New Group", new_admin_group_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" %>
|
||||||
@@ -11,39 +11,37 @@
|
|||||||
<div class="mt-8 flow-root">
|
<div class="mt-8 flow-root">
|
||||||
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
|
<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">
|
<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 dark:divide-gray-600">
|
<table class="min-w-full divide-y divide-gray-300">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col" class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 dark:text-gray-100 sm:pl-0">Name</th>
|
<th scope="col" class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-0">Name</th>
|
||||||
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-100">Description</th>
|
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Description</th>
|
||||||
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-100">Members</th>
|
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Members</th>
|
||||||
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-100">Applications</th>
|
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900">Applications</th>
|
||||||
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-0">
|
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-0">
|
||||||
<span class="sr-only">Actions</span>
|
<span class="sr-only">Actions</span>
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
<tbody class="divide-y divide-gray-200">
|
||||||
<% @groups.each do |group| %>
|
<% @groups.each do |group| %>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 dark:text-gray-100 sm:pl-0">
|
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-0">
|
||||||
<%= link_to group.name, admin_group_path(group), class: "text-blue-600 hover:text-blue-900" %>
|
<%= link_to group.name, admin_group_path(group), class: "text-blue-600 hover:text-blue-900" %>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
|
<td class="px-3 py-4 text-sm text-gray-500">
|
||||||
<%= truncate(group.description, length: 80) || content_tag(:span, "No description", class: "text-gray-400 dark:text-gray-500") %>
|
<%= truncate(group.description, length: 80) || content_tag(:span, "No description", class: "text-gray-400") %>
|
||||||
</td>
|
</td>
|
||||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
|
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||||
<%= pluralize(group.users.count, "member") %>
|
<%= pluralize(group.users.count, "member") %>
|
||||||
</td>
|
</td>
|
||||||
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
|
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||||
<%= pluralize(group.applications.count, "app") %>
|
<%= pluralize(group.applications.count, "app") %>
|
||||||
</td>
|
</td>
|
||||||
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-0">
|
<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 mr-4" %>
|
||||||
<%= 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 mr-4" %>
|
||||||
<%= 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" %>
|
||||||
<%= button_to "Delete", admin_group_path(group), method: :delete, data: { turbo_confirm: "Are you sure you want to delete this group?" }, class: "text-red-600 hover:text-red-900 whitespace-nowrap" %>
|
|
||||||
</div>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<% 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