Add backchannel logout, per application logout.
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled

This commit is contained in:
Dan Milne
2025-11-27 16:38:27 +11:00
parent eb2d7379bf
commit 6be23c2c37
14 changed files with 436 additions and 55 deletions

View File

@@ -16,16 +16,82 @@ class ActiveSessionsController < ApplicationController
return return
end 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 # Revoke the consent
consent.destroy consent.destroy
redirect_to active_sessions_path, notice: "Successfully revoked access to #{application.name}." redirect_to active_sessions_path, notice: "Successfully revoked access to #{application.name}."
end 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: "Successfully logged out of #{application.name}."
end
def revoke_all_consents def revoke_all_consents
@user = Current.session.user @user = Current.session.user
count = @user.oidc_user_consents.count consents = @user.oidc_user_consents.includes(:application)
count = consents.count
if count > 0 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 @user.oidc_user_consents.destroy_all
redirect_to active_sessions_path, notice: "Successfully revoked access to #{count} applications." redirect_to active_sessions_path, notice: "Successfully revoked access to #{count} applications."
else else

View File

@@ -100,6 +100,7 @@ module Admin
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, :domain_pattern, :landing_url, :access_token_ttl, :refresh_token_ttl, :id_token_ttl,
:icon, :backchannel_logout_uri,
headers_config: {} headers_config: {}
).tap do |whitelisted| ).tap do |whitelisted|
# Remove client_secret from params if present (shouldn't be updated via form) # Remove client_secret from params if present (shouldn't be updated via form)

View File

@@ -23,7 +23,9 @@ class OidcController < ApplicationController
scopes_supported: ["openid", "profile", "email", "groups"], scopes_supported: ["openid", "profile", "email", "groups"],
token_endpoint_auth_methods_supported: ["client_secret_post", "client_secret_basic"], token_endpoint_auth_methods_supported: ["client_secret_post", "client_secret_basic"],
claims_supported: ["sub", "email", "email_verified", "name", "preferred_username", "groups", "admin"], claims_supported: ["sub", "email", "email_verified", "name", "preferred_username", "groups", "admin"],
code_challenge_methods_supported: ["plain", "S256"] code_challenge_methods_supported: ["plain", "S256"],
backchannel_logout_supported: true,
backchannel_logout_session_supported: true
} }
render json: config render json: config
@@ -627,6 +629,11 @@ class OidcController < ApplicationController
# If user is authenticated, log them out # If user is authenticated, log them out
if authenticated? if authenticated?
user = Current.session.user
# Send backchannel logout notifications to all connected applications
send_backchannel_logout_notifications(user)
# Invalidate the current session # Invalidate the current session
Current.session&.destroy Current.session&.destroy
reset_session reset_session
@@ -766,4 +773,26 @@ class OidcController < ApplicationController
false false
end end
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 "OidcController: Enqueued #{consents.count} backchannel logout notifications for user #{user.id}"
rescue => e
# Log error but don't block logout
Rails.logger.error "OidcController: Failed to enqueue backchannel logout: #{e.class} - #{e.message}"
end
end end

View File

@@ -134,6 +134,12 @@ class SessionsController < ApplicationController
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
@@ -311,4 +317,26 @@ class SessionsController < ApplicationController
nil nil
end end
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

View File

@@ -1,6 +1,11 @@
class Application < ApplicationRecord class Application < ApplicationRecord
has_secure_password :client_secret, validations: false has_secure_password :client_secret, validations: false
has_one_attached :icon
# Fix SVG content type after attachment
after_save :fix_icon_content_type, if: -> { icon.attached? && saved_change_to_attribute?(:id) == false }
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 :application_user_claims, dependent: :destroy
@@ -18,6 +23,15 @@ class Application < ApplicationRecord
validates :client_secret, presence: true, on: :create, if: -> { oidc? } validates :client_secret, presence: true, on: :create, if: -> { oidc? }
validates :domain_pattern, presence: true, uniqueness: { case_sensitive: false }, if: :forward_auth? 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 :landing_url, format: { with: URI::regexp(%w[http https]), allow_nil: true, message: "must be a valid URL" }
validates :backchannel_logout_uri, format: {
with: URI::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? }
# Icon validation using ActiveStorage validators
validate :icon_validation, if: -> { icon.attached? }
# Token TTL validations (for OIDC apps) # 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 :access_token_ttl, numericality: { greater_than_or_equal_to: 300, less_than_or_equal_to: 86400 }, if: :oidc? # 5 min - 24 hours
@@ -193,8 +207,44 @@ class Application < ApplicationRecord
app_claim&.parsed_custom_claims || {} app_claim&.parsed_custom_claims || {}
end 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 fix_icon_content_type
return unless icon.attached?
# Fix SVG content type if it was detected incorrectly
if icon.filename.extension == "svg" && icon.content_type == "application/octet-stream"
icon.blob.update(content_type: "image/svg+xml")
end
end
def icon_validation
return unless icon.attached?
# Check content type
allowed_types = ['image/png', 'image/jpg', 'image/jpeg', 'image/gif', 'image/svg+xml']
unless allowed_types.include?(icon.content_type)
errors.add(:icon, 'must be a PNG, JPG, GIF, or SVG image')
end
# Check file size (2MB limit)
if icon.blob.byte_size > 2.megabytes
errors.add(:icon, 'must be less than 2MB')
end
end
def duration_to_human(seconds) def duration_to_human(seconds)
if seconds < 3600 if seconds < 3600
"#{seconds / 60} minutes" "#{seconds / 60} minutes"
@@ -213,4 +263,18 @@ class Application < ApplicationRecord
self.client_secret = secret self.client_secret = secret
end end
end 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
end end

View File

@@ -45,6 +45,30 @@ class OidcJwtService
JWT.encode(payload, private_key, "RS256", { kid: key_id, typ: "JWT" }) JWT.encode(payload, private_key, "RS256", { kid: key_id, typ: "JWT" })
end 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
# 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" })

View File

@@ -17,6 +17,56 @@
<%= 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" %> <%= form.text_area :description, rows: 3, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Optional description of this application" %>
</div> </div>
<div>
<%= form.label :icon, "Application Icon", class: "block text-sm font-medium text-gray-700" %>
<% if application.icon.attached? %>
<div class="mt-2 mb-3 flex items-center gap-4">
<%= image_tag application.icon, class: "h-16 w-16 rounded-lg object-cover border border-gray-200", alt: "Current icon" %>
<div class="text-sm text-gray-600">
<p class="font-medium">Current icon</p>
<p class="text-xs"><%= number_to_human_size(application.icon.blob.byte_size) %></p>
</div>
</div>
<% end %>
<div class="mt-2" data-controller="file-drop">
<div class="flex justify-center px-6 pt-5 pb-6 border-2 border-gray-300 border-dashed rounded-md hover:border-blue-400 transition-colors"
data-file-drop-target="dropzone"
data-action="dragover->file-drop#dragover dragleave->file-drop#dragleave drop->file-drop#drop">
<div class="space-y-1 text-center">
<svg class="mx-auto h-12 w-12 text-gray-400" stroke="currentColor" fill="none" viewBox="0 0 48 48">
<path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<div class="flex text-sm text-gray-600">
<label for="<%= form.field_id(:icon) %>" class="relative cursor-pointer bg-white rounded-md font-medium text-blue-600 hover:text-blue-500 focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2 focus-within:ring-blue-500">
<span>Upload a file</span>
<%= form.file_field :icon,
accept: "image/png,image/jpg,image/jpeg,image/gif,image/svg+xml",
class: "sr-only",
data: { file_drop_target: "input", action: "change->file-drop#handleFiles" } %>
</label>
<p class="pl-1">or drag and drop</p>
</div>
<p class="text-xs text-gray-500">PNG, JPG, GIF, or SVG up to 2MB</p>
</div>
</div>
<div data-file-drop-target="preview" class="mt-3 hidden">
<div class="flex items-center gap-3 p-3 bg-blue-50 rounded-md border border-blue-200">
<img data-file-drop-target="previewImage" class="h-12 w-12 rounded object-cover" alt="Preview">
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900" data-file-drop-target="filename"></p>
<p class="text-xs text-gray-500" data-file-drop-target="filesize"></p>
</div>
<button type="button" data-action="click->file-drop#clear" class="text-gray-400 hover:text-gray-600">
<svg class="h-5 w-5" 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>
<div> <div>
<%= form.label :landing_url, "Landing URL", class: "block text-sm font-medium text-gray-700" %> <%= form.label :landing_url, "Landing URL", class: "block text-sm font-medium text-gray-700" %>
<%= form.url_field :landing_url, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "https://app.example.com" %> <%= form.url_field :landing_url, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "https://app.example.com" %>
@@ -45,6 +95,16 @@
<p class="mt-1 text-sm text-gray-500">One URI per line. These are the allowed callback URLs for your application.</p> <p class="mt-1 text-sm text-gray-500">One URI per line. These are the allowed callback URLs for your application.</p>
</div> </div>
<div>
<%= form.label :backchannel_logout_uri, "Backchannel Logout URI (Optional)", class: "block text-sm font-medium text-gray-700" %>
<%= form.url_field :backchannel_logout_uri, 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://app.example.com/oidc/backchannel-logout" %>
<p class="mt-1 text-sm text-gray-500">
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 pt-4 mt-4"> <div class="border-t border-gray-200 pt-4 mt-4">
<h4 class="text-sm font-semibold text-gray-900 mb-3">Token Expiration Settings</h4> <h4 class="text-sm font-semibold text-gray-900 mb-3">Token Expiration Settings</h4>
<p class="text-sm text-gray-500 mb-4">Configure how long tokens remain valid. Shorter times are more secure but require more frequent refreshes.</p> <p class="text-sm text-gray-500 mb-4">Configure how long tokens remain valid. Shorter times are more secure but require more frequent refreshes.</p>

View File

@@ -14,7 +14,7 @@
<table class="min-w-full divide-y divide-gray-300"> <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 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">Application</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">Slug</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">Type</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">Status</th>
@@ -28,7 +28,18 @@
<% @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 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? %>
<%= image_tag application.icon, class: "h-10 w-10 rounded-lg object-cover border border-gray-200 flex-shrink-0", alt: "#{application.name} icon" %>
<% else %>
<div class="h-10 w-10 rounded-lg bg-gray-100 border border-gray-200 flex items-center justify-center flex-shrink-0">
<svg class="h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
<% 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"> <td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
<code class="text-xs bg-gray-100 px-2 py-1 rounded"><%= application.slug %></code> <code class="text-xs bg-gray-100 px-2 py-1 rounded"><%= application.slug %></code>

View File

@@ -16,11 +16,22 @@
</div> </div>
<% end %> <% end %>
<div class="sm:flex sm:items-center sm:justify-between"> <div class="sm:flex sm:items-start sm:justify-between">
<div class="flex items-start gap-4">
<% if @application.icon.attached? %>
<%= image_tag @application.icon, class: "h-16 w-16 rounded-lg object-cover border border-gray-200 shrink-0", alt: "#{@application.name} icon" %>
<% else %>
<div class="h-16 w-16 rounded-lg bg-gray-100 border border-gray-200 flex items-center justify-center shrink-0">
<svg class="h-8 w-8 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
<% end %>
<div> <div>
<h1 class="text-2xl font-semibold text-gray-900"><%= @application.name %></h1> <h1 class="text-2xl font-semibold text-gray-900"><%= @application.name %></h1>
<p class="mt-1 text-sm text-gray-500"><%= @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 px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50" %> <%= 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" %>
<%= 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" %>
@@ -78,10 +89,11 @@
<div class="bg-white 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">OIDC Credentials</h3> <h3 class="text-base font-semibold leading-6 text-gray-900">OIDC Configuration</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">
<% unless flash[:client_id] && flash[:client_secret] %>
<div> <div>
<dt class="text-sm font-medium text-gray-500">Client ID</dt> <dt class="text-sm font-medium text-gray-500">Client ID</dt>
<dd class="mt-1 text-sm text-gray-900"> <dd class="mt-1 text-sm text-gray-900">
@@ -99,6 +111,7 @@
</p> </p>
</dd> </dd>
</div> </div>
<% end %>
<div> <div>
<dt class="text-sm font-medium text-gray-500">Redirect URIs</dt> <dt class="text-sm font-medium text-gray-500">Redirect URIs</dt>
<dd class="mt-1 text-sm text-gray-900"> <dd class="mt-1 text-sm text-gray-900">
@@ -111,6 +124,27 @@
<% end %> <% end %>
</dd> </dd>
</div> </div>
<div>
<dt class="text-sm font-medium text-gray-500">
Backchannel Logout URI
<% if @application.supports_backchannel_logout? %>
<span class="ml-2 inline-flex items-center rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700">Enabled</span>
<% end %>
</dt>
<dd class="mt-1 text-sm text-gray-900">
<% if @application.backchannel_logout_uri.present? %>
<code class="block bg-gray-100 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">
When users log out, Clinch will send logout notifications to this endpoint for immediate session termination.
</p>
<% else %>
<span class="text-gray-400 italic">Not configured</span>
<p class="mt-1 text-xs text-gray-500">
Backchannel logout is optional. Configure it if the application supports OpenID Connect Backchannel Logout.
</p>
<% end %>
</dd>
</div>
</dl> </dl>
</div> </div>
</div> </div>

View File

@@ -102,11 +102,22 @@
<% @applications.each do |app| %> <% @applications.each do |app| %>
<div class="bg-white rounded-lg border border-gray-200 shadow-sm hover:shadow-md transition"> <div class="bg-white rounded-lg border border-gray-200 shadow-sm hover:shadow-md transition">
<div class="p-6"> <div class="p-6">
<div class="flex items-center justify-between mb-3"> <div class="flex items-start gap-3 mb-4">
<% if app.icon.attached? %>
<%= image_tag app.icon, class: "h-12 w-12 rounded-lg object-cover border border-gray-200 shrink-0", alt: "#{app.name} icon" %>
<% else %>
<div class="h-12 w-12 rounded-lg bg-gray-100 border border-gray-200 flex items-center justify-center shrink-0">
<svg class="h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
<% end %>
<div class="flex-1 min-w-0">
<div class="flex items-start justify-between">
<h3 class="text-lg font-semibold text-gray-900 truncate"> <h3 class="text-lg font-semibold text-gray-900 truncate">
<%= app.name %> <%= app.name %>
</h3> </h3>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium <span class="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium shrink-0
<% if app.oidc? %> <% if app.oidc? %>
bg-blue-100 text-blue-800 bg-blue-100 text-blue-800
<% else %> <% else %>
@@ -115,15 +126,15 @@
<%= app.app_type.humanize %> <%= app.app_type.humanize %>
</span> </span>
</div> </div>
<% if app.description.present? %>
<p class="text-sm text-gray-600 mb-4"> <p class="text-sm text-gray-600 mt-1 line-clamp-2">
<% if app.oidc? %> <%= app.description %>
OIDC Application
<% else %>
ForwardAuth Protected Application
<% end %>
</p> </p>
<% end %>
</div>
</div>
<div class="space-y-2">
<% if app.landing_url.present? %> <% if app.landing_url.present? %>
<%= link_to "Open Application", app.landing_url, <%= link_to "Open Application", app.landing_url,
target: "_blank", target: "_blank",
@@ -134,6 +145,13 @@
No landing URL configured No landing URL configured
</div> </div>
<% end %> <% end %>
<% if app.user_has_active_session?(@user) %>
<%= button_to "Logout", logout_from_app_active_sessions_path(application_id: app.id), method: :delete,
class: "w-full flex justify-center items-center px-4 py-2 border border-orange-300 text-sm font-medium rounded-md text-orange-700 bg-white hover:bg-orange-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-orange-500 transition",
form: { data: { turbo_confirm: "This will log you out of #{app.name}. You can sign back in without re-authorizing. Continue?" } } %>
<% end %>
</div>
</div> </div>
</div> </div>
<% end %> <% end %>

View File

@@ -1,6 +1,15 @@
<div class="mx-auto max-w-md"> <div class="mx-auto max-w-md">
<div class="bg-white py-8 px-6 shadow rounded-lg sm:px-10"> <div class="bg-white py-8 px-6 shadow rounded-lg sm:px-10">
<div class="mb-8"> <div class="mb-8 text-center">
<% if @application.icon.attached? %>
<%= image_tag @application.icon, class: "mx-auto h-20 w-20 rounded-xl object-cover border-2 border-gray-200 shadow-sm mb-4", alt: "#{@application.name} icon" %>
<% else %>
<div class="mx-auto h-20 w-20 rounded-xl bg-gray-100 border-2 border-gray-200 flex items-center justify-center mb-4">
<svg class="h-10 w-10 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
<% end %>
<h2 class="text-2xl font-bold text-gray-900">Authorize Application</h2> <h2 class="text-2xl font-bold text-gray-900">Authorize Application</h2>
<p class="mt-2 text-sm text-gray-600"> <p class="mt-2 text-sm text-gray-600">
<strong><%= @application.name %></strong> is requesting access to your account. <strong><%= @application.name %></strong> is requesting access to your account.

View File

@@ -0,0 +1,5 @@
# frozen_string_literal: true
module Clinch
VERSION = "0.6.0"
end

View File

@@ -49,6 +49,7 @@ Rails.application.routes.draw do
end end
resource :active_sessions, only: [:show] do resource :active_sessions, only: [:show] do
member do member do
delete :logout_from_app
delete :revoke_consent delete :revoke_consent
delete :revoke_all_consents delete :revoke_all_consents
end end

33
db/schema.rb generated
View File

@@ -10,7 +10,35 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.1].define(version: 2025_11_25_012446) do ActiveRecord::Schema[8.1].define(version: 2025_11_25_081147) do
create_table "active_storage_attachments", force: :cascade do |t|
t.bigint "blob_id", null: false
t.datetime "created_at", null: false
t.string "name", null: false
t.bigint "record_id", null: false
t.string "record_type", null: false
t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id"
t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true
end
create_table "active_storage_blobs", force: :cascade do |t|
t.bigint "byte_size", null: false
t.string "checksum"
t.string "content_type"
t.datetime "created_at", null: false
t.string "filename", null: false
t.string "key", null: false
t.text "metadata"
t.string "service_name", null: false
t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true
end
create_table "active_storage_variant_records", force: :cascade do |t|
t.bigint "blob_id", null: false
t.string "variation_digest", null: false
t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true
end
create_table "application_groups", force: :cascade do |t| create_table "application_groups", force: :cascade do |t|
t.integer "application_id", null: false t.integer "application_id", null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
@@ -36,6 +64,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_25_012446) do
t.integer "access_token_ttl", default: 3600 t.integer "access_token_ttl", default: 3600
t.boolean "active", default: true, null: false t.boolean "active", default: true, null: false
t.string "app_type", null: false t.string "app_type", null: false
t.string "backchannel_logout_uri"
t.string "client_id" t.string "client_id"
t.string "client_secret_digest" t.string "client_secret_digest"
t.datetime "created_at", null: false t.datetime "created_at", null: false
@@ -211,6 +240,8 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_25_012446) do
t.index ["user_id"], name: "index_webauthn_credentials_on_user_id" t.index ["user_id"], name: "index_webauthn_credentials_on_user_id"
end end
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "application_groups", "applications" add_foreign_key "application_groups", "applications"
add_foreign_key "application_groups", "groups" add_foreign_key "application_groups", "groups"
add_foreign_key "application_user_claims", "applications", on_delete: :cascade add_foreign_key "application_user_claims", "applications", on_delete: :cascade