Compare commits
4 Commits
32235f9647
...
9530c8284f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9530c8284f | ||
|
|
bb5aa2e6d6 | ||
|
|
cc7beba9de | ||
|
|
00eca6d8b2 |
18
.env.example
18
.env.example
@@ -1,5 +1,21 @@
|
||||
# Rails Configuration
|
||||
SECRET_KEY_BASE=generate-with-bin-rails-secret
|
||||
# SECRET_KEY_BASE is used for:
|
||||
# - 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
|
||||
|
||||
# Database
|
||||
|
||||
@@ -26,16 +26,17 @@ module Admin
|
||||
@application.allowed_groups = Group.where(id: group_ids)
|
||||
end
|
||||
|
||||
# Get the plain text client secret to show one time
|
||||
# Get the plain text client secret to show one time (confidential clients only)
|
||||
client_secret = nil
|
||||
if @application.oidc?
|
||||
if @application.oidc? && @application.confidential_client?
|
||||
client_secret = @application.generate_new_client_secret!
|
||||
end
|
||||
|
||||
if @application.oidc? && client_secret
|
||||
if @application.oidc?
|
||||
flash[:notice] = "Application created successfully."
|
||||
flash[:client_id] = @application.client_id
|
||||
flash[:client_secret] = client_secret
|
||||
flash[:client_secret] = client_secret if client_secret
|
||||
flash[:public_client] = true if @application.public_client?
|
||||
else
|
||||
flash[:notice] = "Application created successfully."
|
||||
end
|
||||
@@ -74,15 +75,20 @@ module Admin
|
||||
|
||||
def regenerate_credentials
|
||||
if @application.oidc?
|
||||
# Generate new client ID and secret
|
||||
# Generate new client ID (always)
|
||||
new_client_id = SecureRandom.urlsafe_base64(32)
|
||||
client_secret = @application.generate_new_client_secret!
|
||||
|
||||
@application.update!(client_id: new_client_id)
|
||||
|
||||
flash[:notice] = "Credentials regenerated successfully."
|
||||
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
|
||||
else
|
||||
flash[:public_client] = true
|
||||
end
|
||||
|
||||
redirect_to admin_application_path(@application)
|
||||
else
|
||||
@@ -97,15 +103,24 @@ module Admin
|
||||
end
|
||||
|
||||
def application_params
|
||||
params.require(:application).permit(
|
||||
permitted = params.require(:application).permit(
|
||||
:name, :slug, :app_type, :active, :redirect_uris, :description, :metadata,
|
||||
:domain_pattern, :landing_url, :access_token_ttl, :refresh_token_ttl, :id_token_ttl,
|
||||
:icon, :backchannel_logout_uri,
|
||||
headers_config: {}
|
||||
).tap do |whitelisted|
|
||||
# Remove client_secret from params if present (shouldn't be updated via form)
|
||||
whitelisted.delete(:client_secret)
|
||||
:icon, :backchannel_logout_uri, :is_public_client, :require_pkce
|
||||
)
|
||||
|
||||
# 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
|
||||
|
||||
# Remove client_secret from params if present (shouldn't be updated via form)
|
||||
permitted.delete(:client_secret)
|
||||
permitted
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -71,8 +71,9 @@ module Api
|
||||
|
||||
Rails.logger.info "ForwardAuth: User #{user.email_address} granted access to #{forwarded_host} by app #{app.domain_pattern} (policy: #{app.policy_for_user(user)})"
|
||||
else
|
||||
# No application found - allow access with default headers (original behavior)
|
||||
Rails.logger.info "ForwardAuth: No application found for domain: #{forwarded_host}, allowing with default headers"
|
||||
# No application found - DENY by default (fail-closed security)
|
||||
Rails.logger.info "ForwardAuth: Access denied to #{forwarded_host} - no authentication rule configured"
|
||||
return render_forbidden("No authentication rule configured for this domain")
|
||||
end
|
||||
else
|
||||
Rails.logger.info "ForwardAuth: User #{user.email_address} authenticated (no domain specified)"
|
||||
|
||||
@@ -169,7 +169,7 @@ class OidcController < ApplicationController
|
||||
|
||||
# Redirect back to client with authorization code
|
||||
redirect_uri = "#{redirect_uri}?code=#{code}"
|
||||
redirect_uri += "&state=#{state}" if state.present?
|
||||
redirect_uri += "&state=#{CGI.escape(state)}" if state.present?
|
||||
redirect_to redirect_uri, allow_other_host: true
|
||||
return
|
||||
end
|
||||
@@ -224,7 +224,7 @@ class OidcController < ApplicationController
|
||||
if params[:deny].present?
|
||||
session.delete(:oauth_params)
|
||||
error_uri = "#{oauth_params['redirect_uri']}?error=access_denied"
|
||||
error_uri += "&state=#{oauth_params['state']}" if oauth_params['state']
|
||||
error_uri += "&state=#{CGI.escape(oauth_params['state'])}" if oauth_params['state']
|
||||
redirect_to error_uri, allow_other_host: true
|
||||
return
|
||||
end
|
||||
@@ -276,7 +276,7 @@ class OidcController < ApplicationController
|
||||
|
||||
# Redirect back to client with authorization code
|
||||
redirect_uri = "#{oauth_params['redirect_uri']}?code=#{code}"
|
||||
redirect_uri += "&state=#{oauth_params['state']}" if oauth_params['state']
|
||||
redirect_uri += "&state=#{CGI.escape(oauth_params['state'])}" if oauth_params['state']
|
||||
|
||||
redirect_to redirect_uri, allow_other_host: true
|
||||
end
|
||||
@@ -296,22 +296,33 @@ class OidcController < ApplicationController
|
||||
end
|
||||
|
||||
def handle_authorization_code_grant
|
||||
|
||||
# Get client credentials from Authorization header or params
|
||||
client_id, client_secret = extract_client_credentials
|
||||
|
||||
unless client_id && client_secret
|
||||
render json: { error: "invalid_client" }, status: :unauthorized
|
||||
unless client_id
|
||||
render json: { error: "invalid_client", error_description: "client_id is required" }, status: :unauthorized
|
||||
return
|
||||
end
|
||||
|
||||
# Find and validate the application
|
||||
# Find the application
|
||||
application = Application.find_by(client_id: client_id)
|
||||
unless application && application.authenticate_client_secret(client_secret)
|
||||
render json: { error: "invalid_client" }, status: :unauthorized
|
||||
unless application
|
||||
render json: { error: "invalid_client", error_description: "Unknown client" }, status: :unauthorized
|
||||
return
|
||||
end
|
||||
|
||||
# Validate client credentials based on client type
|
||||
if application.public_client?
|
||||
# Public clients don't have a secret - they MUST use PKCE (checked later)
|
||||
Rails.logger.info "OAuth: Public client authentication for #{application.name}"
|
||||
else
|
||||
# Confidential clients MUST provide valid client_secret
|
||||
unless client_secret.present? && application.authenticate_client_secret(client_secret)
|
||||
render json: { error: "invalid_client", error_description: "Invalid client credentials" }, status: :unauthorized
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
# Check if application is active
|
||||
unless application.active?
|
||||
Rails.logger.error "OAuth: Token request for inactive application: #{application.name}"
|
||||
@@ -371,8 +382,8 @@ class OidcController < ApplicationController
|
||||
return
|
||||
end
|
||||
|
||||
# Validate PKCE if code challenge is present
|
||||
pkce_result = validate_pkce(auth_code, code_verifier)
|
||||
# Validate PKCE - required for public clients and optionally for confidential clients
|
||||
pkce_result = validate_pkce(application, auth_code, code_verifier)
|
||||
unless pkce_result[:valid]
|
||||
render json: {
|
||||
error: pkce_result[:error],
|
||||
@@ -433,18 +444,30 @@ class OidcController < ApplicationController
|
||||
# Get client credentials from Authorization header or params
|
||||
client_id, client_secret = extract_client_credentials
|
||||
|
||||
unless client_id && client_secret
|
||||
render json: { error: "invalid_client" }, status: :unauthorized
|
||||
unless client_id
|
||||
render json: { error: "invalid_client", error_description: "client_id is required" }, status: :unauthorized
|
||||
return
|
||||
end
|
||||
|
||||
# Find and validate the application
|
||||
# Find the application
|
||||
application = Application.find_by(client_id: client_id)
|
||||
unless application && application.authenticate_client_secret(client_secret)
|
||||
render json: { error: "invalid_client" }, status: :unauthorized
|
||||
unless application
|
||||
render json: { error: "invalid_client", error_description: "Unknown client" }, status: :unauthorized
|
||||
return
|
||||
end
|
||||
|
||||
# Validate client credentials based on client type
|
||||
if application.public_client?
|
||||
# Public clients don't have a secret
|
||||
Rails.logger.info "OAuth: Public client refresh token request for #{application.name}"
|
||||
else
|
||||
# Confidential clients MUST provide valid client_secret
|
||||
unless client_secret.present? && application.authenticate_client_secret(client_secret)
|
||||
render json: { error: "invalid_client", error_description: "Invalid client credentials" }, status: :unauthorized
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
# Check if application is active
|
||||
unless application.active?
|
||||
Rails.logger.error "OAuth: Refresh token request for inactive application: #{application.name}"
|
||||
@@ -701,7 +724,7 @@ class OidcController < ApplicationController
|
||||
|
||||
if validated_uri
|
||||
redirect_uri = validated_uri
|
||||
redirect_uri += "?state=#{state}" if state.present?
|
||||
redirect_uri += "?state=#{CGI.escape(state)}" if state.present?
|
||||
redirect_to redirect_uri, allow_other_host: true
|
||||
else
|
||||
# Invalid redirect URI - log warning and go to default
|
||||
@@ -716,11 +739,26 @@ class OidcController < ApplicationController
|
||||
|
||||
private
|
||||
|
||||
def validate_pkce(auth_code, code_verifier)
|
||||
# Skip PKCE validation if no code challenge was stored (legacy clients)
|
||||
return { valid: true } unless auth_code.code_challenge.present?
|
||||
def validate_pkce(application, auth_code, code_verifier)
|
||||
# Check if PKCE is required for this application
|
||||
pkce_required = application.requires_pkce?
|
||||
pkce_provided = auth_code.code_challenge.present?
|
||||
|
||||
# PKCE is required but no verifier provided
|
||||
# If PKCE is required but wasn't provided during authorization
|
||||
if pkce_required && !pkce_provided
|
||||
client_type = application.public_client? ? "public clients" : "this application"
|
||||
return {
|
||||
valid: false,
|
||||
error: "invalid_request",
|
||||
error_description: "PKCE is required for #{client_type}. code_challenge must be provided during authorization.",
|
||||
status: :bad_request
|
||||
}
|
||||
end
|
||||
|
||||
# Skip validation if no code challenge was stored (legacy clients without PKCE requirement)
|
||||
return { valid: true } unless pkce_provided
|
||||
|
||||
# PKCE was provided during authorization but no verifier sent with token request
|
||||
unless code_verifier.present?
|
||||
return {
|
||||
valid: false,
|
||||
|
||||
@@ -19,13 +19,21 @@ class ProfilesController < ApplicationController
|
||||
else
|
||||
render :show, status: :unprocessable_entity
|
||||
end
|
||||
else
|
||||
# Updating email
|
||||
elsif params[:user][:email_address].present?
|
||||
# Updating email - requires current password (security: prevents account takeover)
|
||||
unless @user.authenticate(params[:user][:current_password])
|
||||
@user.errors.add(:current_password, "is required to change email")
|
||||
render :show, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
|
||||
if @user.update(email_params)
|
||||
redirect_to profile_path, notice: "Email updated successfully."
|
||||
else
|
||||
render :show, status: :unprocessable_entity
|
||||
end
|
||||
else
|
||||
render :show, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -2,6 +2,11 @@ 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
|
||||
@@ -131,25 +136,27 @@ class WebauthnController < ApplicationController
|
||||
|
||||
# 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, error: "Email is required" }
|
||||
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, message: "User not found" }
|
||||
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?,
|
||||
user_id: user.id,
|
||||
preferred_method: user.preferred_authentication_method,
|
||||
requires_webauthn: user.require_webauthn?
|
||||
}
|
||||
end
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["appTypeSelect", "oidcFields", "forwardAuthFields"]
|
||||
static targets = ["appTypeSelect", "oidcFields", "forwardAuthFields", "pkceOptions"]
|
||||
|
||||
connect() {
|
||||
this.updateFieldVisibility()
|
||||
@@ -21,4 +21,17 @@ export default class extends Controller {
|
||||
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,6 +1,10 @@
|
||||
class Application < ApplicationRecord
|
||||
has_secure_password :client_secret, validations: false
|
||||
|
||||
# Virtual attribute to control client type during creation
|
||||
# When true, no client_secret will be generated (public client)
|
||||
attr_accessor :is_public_client
|
||||
|
||||
has_one_attached :icon
|
||||
|
||||
# Fix SVG content type after attachment
|
||||
@@ -20,7 +24,7 @@ class Application < ApplicationRecord
|
||||
validates :app_type, presence: true,
|
||||
inclusion: { in: %w[oidc forward_auth] }
|
||||
validates :client_id, uniqueness: { allow_nil: true }
|
||||
validates :client_secret, presence: true, on: :create, if: -> { oidc? }
|
||||
validates :client_secret, presence: true, on: :create, if: -> { oidc? && confidential_client? }
|
||||
validates :domain_pattern, presence: true, uniqueness: { case_sensitive: false }, if: :forward_auth?
|
||||
validates :landing_url, format: { with: URI::regexp(%w[http https]), allow_nil: true, message: "must be a valid URL" }
|
||||
validates :backchannel_logout_uri, format: {
|
||||
@@ -74,6 +78,24 @@ class Application < ApplicationRecord
|
||||
app_type == "forward_auth"
|
||||
end
|
||||
|
||||
# Client type checks (for OIDC)
|
||||
def public_client?
|
||||
client_secret_digest.blank?
|
||||
end
|
||||
|
||||
def confidential_client?
|
||||
!public_client?
|
||||
end
|
||||
|
||||
# PKCE requirement check
|
||||
# Public clients MUST use PKCE (no client secret to protect auth code)
|
||||
# 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
|
||||
|
||||
# Access control
|
||||
def user_allowed?(user)
|
||||
return false unless active?
|
||||
@@ -261,13 +283,19 @@ class Application < ApplicationRecord
|
||||
|
||||
def generate_client_credentials
|
||||
self.client_id ||= SecureRandom.urlsafe_base64(32)
|
||||
# Generate and hash the client secret
|
||||
if new_record? && client_secret.blank?
|
||||
# Generate client secret only for confidential clients
|
||||
# Public clients (is_public_client checked) don't get a secret - they use PKCE only
|
||||
if new_record? && client_secret.blank? && !is_public_client_selected?
|
||||
secret = SecureRandom.urlsafe_base64(48)
|
||||
self.client_secret = secret
|
||||
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?
|
||||
|
||||
@@ -4,7 +4,6 @@ class OidcRefreshToken < ApplicationRecord
|
||||
belongs_to :application
|
||||
belongs_to :user
|
||||
belongs_to :oidc_access_token
|
||||
has_many :oidc_access_tokens, foreign_key: :oidc_access_token_id, dependent: :nullify
|
||||
|
||||
before_validation :generate_token_with_prefix, on: :create
|
||||
before_validation :set_expiry, on: :create
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
class User < ApplicationRecord
|
||||
# Encrypt TOTP secrets at rest (key derived from SECRET_KEY_BASE)
|
||||
encrypts :totp_secret
|
||||
|
||||
has_secure_password
|
||||
has_many :sessions, dependent: :destroy
|
||||
has_many :user_groups, dependent: :destroy
|
||||
@@ -74,6 +77,14 @@ class User < ApplicationRecord
|
||||
totp.verify(code, drift_behind: 30, drift_ahead: 30)
|
||||
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
|
||||
|
||||
def verify_backup_code(code)
|
||||
return false unless backup_codes.present?
|
||||
|
||||
|
||||
@@ -120,6 +120,51 @@
|
||||
<div id="oidc-fields" class="space-y-6 border-t border-gray-200 pt-6 <%= 'hidden' unless application.oidc? || !application.persisted? %>" data-application-form-target="oidcFields">
|
||||
<h3 class="text-base font-semibold text-gray-900">OIDC Configuration</h3>
|
||||
|
||||
<!-- Client Type Selection (only for new applications) -->
|
||||
<% unless application.persisted? %>
|
||||
<div class="border border-gray-200 rounded-lg p-4 bg-gray-50">
|
||||
<h4 class="text-sm font-semibold text-gray-900 mb-3">Client Type</h4>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-start">
|
||||
<%= form.radio_button :is_public_client, "false", checked: !application.is_public_client, class: "mt-1 h-4 w-4 border-gray-300 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">Confidential Client (Recommended)</label>
|
||||
<p class="text-sm text-gray-500">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 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">Public Client</label>
|
||||
<p class="text-sm text-gray-500">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">Client Type:</span>
|
||||
<% if application.public_client? %>
|
||||
<span class="inline-flex items-center rounded-md bg-amber-50 px-2 py-1 text-xs font-medium text-amber-700 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 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-600/20">Confidential Client</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- 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">
|
||||
<%= form.check_box :require_pkce, class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" %>
|
||||
<%= form.label :require_pkce, "Require PKCE (Proof Key for Code Exchange)", class: "ml-2 block text-sm font-medium text-gray-900" %>
|
||||
</div>
|
||||
<p class="ml-6 text-sm text-gray-500">
|
||||
Recommended for enhanced security (OAuth 2.1 best practice).
|
||||
<br><span class="text-xs text-gray-400">Note: Public clients always require PKCE regardless of this setting.</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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" %>
|
||||
|
||||
@@ -1,17 +1,30 @@
|
||||
<div class="mb-6">
|
||||
<% if flash[:client_id] && flash[:client_secret] %>
|
||||
<% if flash[:client_id] %>
|
||||
<div class="bg-yellow-50 border border-yellow-200 rounded-md p-4 mb-6">
|
||||
<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">This is a public client. Copy the client ID below.</p>
|
||||
<% else %>
|
||||
<p class="text-xs text-yellow-700 mb-3">Copy these credentials now. The client secret will not be shown again.</p>
|
||||
<% end %>
|
||||
<div class="space-y-2">
|
||||
<div>
|
||||
<span class="text-xs font-medium text-yellow-700">Client ID:</span>
|
||||
</div>
|
||||
<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">
|
||||
<span class="text-xs font-medium text-yellow-700">Client Secret:</span>
|
||||
</div>
|
||||
<code class="block bg-yellow-100 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">Client Secret:</span>
|
||||
</div>
|
||||
<div class="bg-yellow-100 px-3 py-2 rounded text-xs text-yellow-600">
|
||||
Public clients do not have a client secret. PKCE is required.
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -93,13 +106,36 @@
|
||||
<%= 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>
|
||||
<dl class="space-y-4">
|
||||
<% unless flash[:client_id] && flash[:client_secret] %>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Client Type</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900">
|
||||
<% if @application.public_client? %>
|
||||
<span class="inline-flex items-center rounded-full bg-blue-100 px-2 py-1 text-xs font-medium text-blue-700">Public</span>
|
||||
<% else %>
|
||||
<span class="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700">Confidential</span>
|
||||
<% end %>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">PKCE</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900">
|
||||
<% if @application.requires_pkce? %>
|
||||
<span class="inline-flex items-center rounded-full bg-green-100 px-2 py-1 text-xs font-medium text-green-700">Required</span>
|
||||
<% else %>
|
||||
<span class="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700">Optional</span>
|
||||
<% end %>
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
<% unless flash[:client_id] %>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Client ID</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"><%= @application.client_id %></code>
|
||||
</dd>
|
||||
</div>
|
||||
<% if @application.confidential_client? %>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Client Secret</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900">
|
||||
@@ -111,6 +147,16 @@
|
||||
</p>
|
||||
</dd>
|
||||
</div>
|
||||
<% else %>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Client Secret</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900">
|
||||
<div class="bg-blue-50 px-3 py-2 rounded text-xs text-blue-600">
|
||||
Public clients do not use a client secret. PKCE is required for authorization.
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Redirect URIs</dt>
|
||||
|
||||
@@ -31,6 +31,15 @@
|
||||
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>
|
||||
<%= form.label :current_password, "Current Password", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.password_field :current_password,
|
||||
autocomplete: "current-password",
|
||||
placeholder: "Required to change email",
|
||||
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">Enter your current password to confirm this change</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= form.submit "Update Email", class: "inline-flex justify-center rounded-md border border-transparent bg-blue-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2" %>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<%# Enhanced Flash Messages with Support for Multiple Types and Auto-Dismiss %>
|
||||
<% flash.each do |type, message| %>
|
||||
<% next if message.blank? %>
|
||||
<%# Skip credential-related flash messages - they're displayed in a special credentials box %>
|
||||
<% next if %w[client_id client_secret public_client].include?(type.to_s) %>
|
||||
|
||||
<%
|
||||
# Map flash types to styling
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Token HMAC key derivation
|
||||
# This key is used to compute HMAC-based token prefixes for fast lookup
|
||||
# Derived from SECRET_KEY_BASE - no storage needed, deterministic output
|
||||
# Optional: Set OIDC_TOKEN_PREFIX_HMAC env var to override with explicit key
|
||||
module TokenHmac
|
||||
KEY = Rails.application.key_generator.generate_key('oidc_token_prefix', 32)
|
||||
KEY = ENV['OIDC_TOKEN_PREFIX_HMAC'] || Rails.application.key_generator.generate_key('oidc_token_prefix', 32)
|
||||
end
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Clinch
|
||||
VERSION = "0.7.1"
|
||||
VERSION = "0.8.0"
|
||||
end
|
||||
|
||||
3
db/schema.rb
generated
3
db/schema.rb
generated
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.1].define(version: 2025_12_30_005248) do
|
||||
ActiveRecord::Schema[8.1].define(version: 2025_12_30_073656) do
|
||||
create_table "active_storage_attachments", force: :cascade do |t|
|
||||
t.bigint "blob_id", null: false
|
||||
t.datetime "created_at", null: false
|
||||
@@ -77,6 +77,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_12_30_005248) do
|
||||
t.string "name", null: false
|
||||
t.text "redirect_uris"
|
||||
t.integer "refresh_token_ttl", default: 2592000
|
||||
t.boolean "require_pkce", default: true, null: false
|
||||
t.string "slug", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["active"], name: "index_applications_on_active"
|
||||
|
||||
@@ -46,14 +46,13 @@ module Api
|
||||
assert_response 200
|
||||
end
|
||||
|
||||
test "should return 200 with default headers when no rule matches" do
|
||||
test "should return 403 when no rule matches (fail-closed security)" do
|
||||
sign_in_as(@user)
|
||||
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "unknown.example.com" }
|
||||
|
||||
assert_response 200
|
||||
assert_equal @user.email_address, response.headers["x-remote-user"]
|
||||
assert_equal @user.email_address, response.headers["x-remote-email"]
|
||||
assert_response 403
|
||||
assert_equal "No authentication rule configured for this domain", response.headers["x-auth-reason"]
|
||||
end
|
||||
|
||||
test "should return 403 when rule exists but is inactive" do
|
||||
@@ -97,7 +96,8 @@ module Api
|
||||
assert_response 200
|
||||
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "other.com" }
|
||||
assert_response 200 # Falls back to default behavior
|
||||
assert_response 403 # No rule configured - fail-closed
|
||||
assert_equal "No authentication rule configured for this domain", response.headers["x-auth-reason"]
|
||||
end
|
||||
|
||||
test "should match exact domains correctly" do
|
||||
@@ -108,7 +108,8 @@ module Api
|
||||
assert_response 200
|
||||
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "app.api.example.com" }
|
||||
assert_response 200 # Falls back to default behavior
|
||||
assert_response 403 # No rule configured - fail-closed
|
||||
assert_equal "No authentication rule configured for this domain", response.headers["x-auth-reason"]
|
||||
end
|
||||
|
||||
# Header Configuration Tests
|
||||
@@ -228,8 +229,9 @@ module Api
|
||||
|
||||
get "/api/verify"
|
||||
|
||||
assert_response 200
|
||||
# User is authenticated even without host headers
|
||||
# User is authenticated but no domain rule matches (default test host)
|
||||
assert_response 403
|
||||
assert_equal "No authentication rule configured for this domain", response.headers["x-auth-reason"]
|
||||
end
|
||||
|
||||
# Security Tests
|
||||
@@ -239,7 +241,8 @@ module Api
|
||||
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => long_domain }
|
||||
|
||||
assert_response 200 # Should fall back to default behavior
|
||||
assert_response 403 # No rule configured - fail-closed
|
||||
assert_equal "No authentication rule configured for this domain", response.headers["x-auth-reason"]
|
||||
end
|
||||
|
||||
test "should handle case insensitive domain matching" do
|
||||
@@ -425,7 +428,8 @@ module Api
|
||||
"X-Forwarded-Host" => "测试.example.com"
|
||||
}
|
||||
|
||||
assert_response 200
|
||||
assert_response 403 # No rule configured - fail-closed
|
||||
assert_equal "No authentication rule configured for this domain", response.headers["x-auth-reason"]
|
||||
end
|
||||
|
||||
# Protocol and Scheme Tests
|
||||
@@ -478,16 +482,15 @@ module Api
|
||||
5.times do |i|
|
||||
threads << Thread.new do
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "app#{i}.example.com" }
|
||||
results << { status: response.status, user: response.headers["x-remote-user"] }
|
||||
results << { status: response.status }
|
||||
end
|
||||
end
|
||||
|
||||
threads.each(&:join)
|
||||
|
||||
# All requests should succeed
|
||||
# All requests should be denied (no rules configured for these domains)
|
||||
results.each do |result|
|
||||
assert_equal 200, result[:status]
|
||||
assert_equal @user.email_address, result[:user]
|
||||
assert_equal 403, result[:status]
|
||||
end
|
||||
end
|
||||
|
||||
@@ -508,8 +511,9 @@ module Api
|
||||
"X-Forwarded-Host" => "test.example.com\0.evil.com"
|
||||
}
|
||||
|
||||
# Should handle null bytes safely
|
||||
assert_response 200
|
||||
# Should handle null bytes safely - domain doesn't match any rule
|
||||
assert_response 403
|
||||
assert_equal "No authentication rule configured for this domain", response.headers["x-auth-reason"]
|
||||
end
|
||||
|
||||
# Performance and Load Tests
|
||||
@@ -521,7 +525,7 @@ module Api
|
||||
|
||||
request_count.times do |i|
|
||||
get "/api/verify", headers: { "X-Forwarded-Host" => "app#{i}.example.com" }
|
||||
assert_response 200
|
||||
assert_response 403 # No rules configured for these domains
|
||||
end
|
||||
|
||||
total_time = Time.current - start_time
|
||||
|
||||
@@ -8,7 +8,8 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
|
||||
slug: "security-test-app",
|
||||
app_type: "oidc",
|
||||
redirect_uris: ["http://localhost:4000/callback"].to_json,
|
||||
active: true
|
||||
active: true,
|
||||
require_pkce: false
|
||||
)
|
||||
|
||||
# Store the plain text client secret for testing
|
||||
@@ -274,7 +275,8 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
|
||||
slug: "other-app",
|
||||
app_type: "oidc",
|
||||
redirect_uris: ["http://localhost:5000/callback"].to_json,
|
||||
active: true
|
||||
active: true,
|
||||
require_pkce: false
|
||||
)
|
||||
other_secret = other_app.client_secret
|
||||
|
||||
|
||||
@@ -318,16 +318,199 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
|
||||
end
|
||||
|
||||
test "token endpoint works without PKCE (backward compatibility)" do
|
||||
# Create an application with PKCE not required (legacy behavior)
|
||||
legacy_app = Application.create!(
|
||||
name: "Legacy App",
|
||||
slug: "legacy-app",
|
||||
app_type: "oidc",
|
||||
redirect_uris: ["http://localhost:5000/callback"].to_json,
|
||||
active: true,
|
||||
require_pkce: false
|
||||
)
|
||||
legacy_app.generate_new_client_secret!
|
||||
|
||||
# Create consent for token endpoint
|
||||
OidcUserConsent.create!(
|
||||
user: @user,
|
||||
application: @application,
|
||||
application: legacy_app,
|
||||
scopes_granted: "openid profile",
|
||||
granted_at: Time.current,
|
||||
sid: "test-sid-123"
|
||||
)
|
||||
|
||||
# Create authorization code without PKCE
|
||||
auth_code = OidcAuthorizationCode.create!(
|
||||
application: legacy_app,
|
||||
user: @user,
|
||||
code: SecureRandom.urlsafe_base64(32),
|
||||
redirect_uri: "http://localhost:5000/callback",
|
||||
scope: "openid profile",
|
||||
expires_at: 10.minutes.from_now
|
||||
)
|
||||
|
||||
token_params = {
|
||||
grant_type: "authorization_code",
|
||||
code: auth_code.code,
|
||||
redirect_uri: "http://localhost:5000/callback"
|
||||
}
|
||||
|
||||
post "/oauth/token", params: token_params, headers: {
|
||||
"Authorization" => "Basic " + Base64.strict_encode64("#{legacy_app.client_id}:#{legacy_app.client_secret}")
|
||||
}
|
||||
|
||||
assert_response :success
|
||||
tokens = JSON.parse(@response.body)
|
||||
assert tokens.key?("access_token")
|
||||
assert tokens.key?("id_token")
|
||||
assert_equal "Bearer", tokens["token_type"]
|
||||
|
||||
# Cleanup
|
||||
OidcRefreshToken.where(application: legacy_app).delete_all
|
||||
OidcAccessToken.where(application: legacy_app).delete_all
|
||||
OidcAuthorizationCode.where(application: legacy_app).delete_all
|
||||
OidcUserConsent.where(application: legacy_app).delete_all
|
||||
legacy_app.destroy
|
||||
end
|
||||
|
||||
# ====================
|
||||
# PUBLIC CLIENT TESTS
|
||||
# ====================
|
||||
|
||||
test "public client can authenticate with PKCE" do
|
||||
# Create a public client (no client_secret)
|
||||
public_app = Application.create!(
|
||||
name: "Public App",
|
||||
slug: "public-app",
|
||||
app_type: "oidc",
|
||||
redirect_uris: ["http://localhost:6000/callback"].to_json,
|
||||
active: true,
|
||||
is_public_client: true
|
||||
)
|
||||
|
||||
assert public_app.public_client?
|
||||
assert public_app.requires_pkce?
|
||||
assert_nil public_app.client_secret_digest
|
||||
|
||||
# Create consent
|
||||
OidcUserConsent.create!(
|
||||
user: @user,
|
||||
application: public_app,
|
||||
scopes_granted: "openid profile",
|
||||
granted_at: Time.current,
|
||||
sid: "test-sid-123"
|
||||
)
|
||||
|
||||
# PKCE parameters
|
||||
code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
|
||||
code_challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
|
||||
|
||||
# Create authorization code with PKCE
|
||||
auth_code = OidcAuthorizationCode.create!(
|
||||
application: public_app,
|
||||
user: @user,
|
||||
code: SecureRandom.urlsafe_base64(32),
|
||||
redirect_uri: "http://localhost:6000/callback",
|
||||
scope: "openid profile",
|
||||
expires_at: 10.minutes.from_now,
|
||||
code_challenge: code_challenge,
|
||||
code_challenge_method: "S256"
|
||||
)
|
||||
|
||||
# Token request with PKCE but no client_secret
|
||||
token_params = {
|
||||
grant_type: "authorization_code",
|
||||
code: auth_code.code,
|
||||
redirect_uri: "http://localhost:6000/callback",
|
||||
client_id: public_app.client_id,
|
||||
code_verifier: code_verifier
|
||||
}
|
||||
|
||||
post "/oauth/token", params: token_params
|
||||
|
||||
assert_response :success
|
||||
tokens = JSON.parse(@response.body)
|
||||
assert tokens.key?("access_token")
|
||||
assert tokens.key?("id_token")
|
||||
|
||||
# Cleanup
|
||||
OidcRefreshToken.where(application: public_app).delete_all
|
||||
OidcAccessToken.where(application: public_app).delete_all
|
||||
OidcAuthorizationCode.where(application: public_app).delete_all
|
||||
OidcUserConsent.where(application: public_app).delete_all
|
||||
public_app.destroy
|
||||
end
|
||||
|
||||
test "public client fails without PKCE" do
|
||||
# Create a public client (no client_secret)
|
||||
public_app = Application.create!(
|
||||
name: "Public App No PKCE",
|
||||
slug: "public-app-no-pkce",
|
||||
app_type: "oidc",
|
||||
redirect_uris: ["http://localhost:7000/callback"].to_json,
|
||||
active: true,
|
||||
is_public_client: true
|
||||
)
|
||||
|
||||
assert public_app.public_client?
|
||||
assert public_app.requires_pkce?
|
||||
|
||||
# Create consent
|
||||
OidcUserConsent.create!(
|
||||
user: @user,
|
||||
application: public_app,
|
||||
scopes_granted: "openid profile",
|
||||
granted_at: Time.current,
|
||||
sid: "test-sid-123"
|
||||
)
|
||||
|
||||
# Create authorization code WITHOUT PKCE
|
||||
auth_code = OidcAuthorizationCode.create!(
|
||||
application: public_app,
|
||||
user: @user,
|
||||
code: SecureRandom.urlsafe_base64(32),
|
||||
redirect_uri: "http://localhost:7000/callback",
|
||||
scope: "openid profile",
|
||||
expires_at: 10.minutes.from_now
|
||||
)
|
||||
|
||||
# Token request without PKCE should fail
|
||||
token_params = {
|
||||
grant_type: "authorization_code",
|
||||
code: auth_code.code,
|
||||
redirect_uri: "http://localhost:7000/callback",
|
||||
client_id: public_app.client_id
|
||||
}
|
||||
|
||||
post "/oauth/token", params: token_params
|
||||
|
||||
assert_response :bad_request
|
||||
error = JSON.parse(@response.body)
|
||||
assert_equal "invalid_request", error["error"]
|
||||
assert_match /PKCE is required for public clients/, error["error_description"]
|
||||
|
||||
# Cleanup
|
||||
OidcRefreshToken.where(application: public_app).delete_all
|
||||
OidcAccessToken.where(application: public_app).delete_all
|
||||
OidcAuthorizationCode.where(application: public_app).delete_all
|
||||
OidcUserConsent.where(application: public_app).delete_all
|
||||
public_app.destroy
|
||||
end
|
||||
|
||||
test "confidential client with require_pkce fails without PKCE" do
|
||||
# The default @application has require_pkce: true (default)
|
||||
assert @application.confidential_client?
|
||||
assert @application.requires_pkce?
|
||||
|
||||
# Create consent
|
||||
OidcUserConsent.create!(
|
||||
user: @user,
|
||||
application: @application,
|
||||
scopes_granted: "openid profile",
|
||||
granted_at: Time.current,
|
||||
sid: "test-sid-pkce-required"
|
||||
)
|
||||
|
||||
# Create authorization code WITHOUT PKCE
|
||||
auth_code = OidcAuthorizationCode.create!(
|
||||
application: @application,
|
||||
user: @user,
|
||||
@@ -337,6 +520,7 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
|
||||
expires_at: 10.minutes.from_now
|
||||
)
|
||||
|
||||
# Token request without PKCE should fail
|
||||
token_params = {
|
||||
grant_type: "authorization_code",
|
||||
code: auth_code.code,
|
||||
@@ -347,10 +531,9 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
|
||||
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@application.client_secret}")
|
||||
}
|
||||
|
||||
assert_response :success
|
||||
tokens = JSON.parse(@response.body)
|
||||
assert tokens.key?("access_token")
|
||||
assert tokens.key?("id_token")
|
||||
assert_equal "Bearer", tokens["token_type"]
|
||||
assert_response :bad_request
|
||||
error = JSON.parse(@response.body)
|
||||
assert_equal "invalid_request", error["error"]
|
||||
assert_match /PKCE is required/, error["error_description"]
|
||||
end
|
||||
end
|
||||
3
test/fixtures/applications.yml
vendored
3
test/fixtures/applications.yml
vendored
@@ -13,6 +13,7 @@ kavita_app:
|
||||
https://kavita.example.com/signout-callback-oidc
|
||||
metadata: "{}"
|
||||
active: true
|
||||
require_pkce: false
|
||||
|
||||
another_app:
|
||||
name: Another App
|
||||
@@ -24,6 +25,7 @@ another_app:
|
||||
https://app.example.com/auth/callback
|
||||
metadata: "{}"
|
||||
active: true
|
||||
require_pkce: false
|
||||
|
||||
audiobookshelf_app:
|
||||
name: Audiobookshelf
|
||||
@@ -35,3 +37,4 @@ audiobookshelf_app:
|
||||
https://abs.example.com/auth/openid/callback
|
||||
metadata: "{}"
|
||||
active: true
|
||||
require_pkce: false
|
||||
|
||||
@@ -6,6 +6,15 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest
|
||||
@admin_user = users(:two)
|
||||
@group = groups(:one)
|
||||
@group2 = groups(:two)
|
||||
|
||||
# Create a forward_auth application for test.example.com
|
||||
@test_app = Application.create!(
|
||||
name: "Test App",
|
||||
slug: "test-app",
|
||||
app_type: "forward_auth",
|
||||
domain_pattern: "test.example.com",
|
||||
active: true
|
||||
)
|
||||
end
|
||||
|
||||
# Basic Authentication Flow Tests
|
||||
|
||||
Reference in New Issue
Block a user