diff --git a/app/controllers/active_sessions_controller.rb b/app/controllers/active_sessions_controller.rb index 7fa365b..bad85d3 100644 --- a/app/controllers/active_sessions_controller.rb +++ b/app/controllers/active_sessions_controller.rb @@ -16,16 +16,82 @@ class ActiveSessionsController < ApplicationController 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: "Successfully logged out of #{application.name}." + end + def revoke_all_consents @user = Current.session.user - count = @user.oidc_user_consents.count + 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 diff --git a/app/controllers/admin/applications_controller.rb b/app/controllers/admin/applications_controller.rb index fa837c3..be50a90 100644 --- a/app/controllers/admin/applications_controller.rb +++ b/app/controllers/admin/applications_controller.rb @@ -100,6 +100,7 @@ module Admin 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) diff --git a/app/controllers/oidc_controller.rb b/app/controllers/oidc_controller.rb index 605cad9..8da8d4f 100644 --- a/app/controllers/oidc_controller.rb +++ b/app/controllers/oidc_controller.rb @@ -23,7 +23,9 @@ class OidcController < ApplicationController scopes_supported: ["openid", "profile", "email", "groups"], token_endpoint_auth_methods_supported: ["client_secret_post", "client_secret_basic"], claims_supported: ["sub", "email", "email_verified", "name", "preferred_username", "groups", "admin"], - code_challenge_methods_supported: ["plain", "S256"] + code_challenge_methods_supported: ["plain", "S256"], + backchannel_logout_supported: true, + backchannel_logout_session_supported: true } render json: config @@ -627,6 +629,11 @@ class OidcController < ApplicationController # If user is authenticated, log them out if authenticated? + user = Current.session.user + + # Send backchannel logout notifications to all connected applications + send_backchannel_logout_notifications(user) + # Invalidate the current session Current.session&.destroy reset_session @@ -766,4 +773,26 @@ class OidcController < ApplicationController false 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 diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 29bb35e..3ce4b5c 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -134,6 +134,12 @@ class SessionsController < ApplicationController end def destroy + # Send backchannel logout notifications before terminating session + if authenticated? + user = Current.session.user + send_backchannel_logout_notifications(user) + end + terminate_session redirect_to signin_path, status: :see_other, notice: "Signed out successfully." end @@ -311,4 +317,26 @@ class SessionsController < ApplicationController 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 diff --git a/app/models/application.rb b/app/models/application.rb index 21a54a0..c7b3853 100644 --- a/app/models/application.rb +++ b/app/models/application.rb @@ -1,6 +1,11 @@ class Application < ApplicationRecord 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 :allowed_groups, through: :application_groups, source: :group has_many :application_user_claims, dependent: :destroy @@ -18,6 +23,15 @@ class Application < ApplicationRecord validates :client_secret, presence: true, on: :create, if: -> { oidc? } validates :domain_pattern, presence: true, uniqueness: { case_sensitive: false }, if: :forward_auth? validates :landing_url, format: { with: URI::regexp(%w[http https]), allow_nil: true, message: "must be a valid URL" } + 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) 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 || {} 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 + 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) if seconds < 3600 "#{seconds / 60} minutes" @@ -213,4 +263,18 @@ class Application < ApplicationRecord self.client_secret = secret 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 diff --git a/app/services/oidc_jwt_service.rb b/app/services/oidc_jwt_service.rb index 57ea186..991da42 100644 --- a/app/services/oidc_jwt_service.rb +++ b/app/services/oidc_jwt_service.rb @@ -45,6 +45,30 @@ class OidcJwtService 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 + # Decode and verify an ID token def decode_id_token(token) JWT.decode(token, public_key, true, { algorithm: "RS256" }) diff --git a/app/views/admin/applications/_form.html.erb b/app/views/admin/applications/_form.html.erb index 9b46c7d..9681d02 100644 --- a/app/views/admin/applications/_form.html.erb +++ b/app/views/admin/applications/_form.html.erb @@ -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" %> +
Current icon
+<%= number_to_human_size(application.icon.blob.byte_size) %>
+or drag and drop
+PNG, JPG, GIF, or SVG up to 2MB
+One URI per line. These are the allowed callback URLs for your application.
+ 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. +
+Configure how long tokens remain valid. Shorter times are more secure but require more frequent refreshes.
diff --git a/app/views/admin/applications/index.html.erb b/app/views/admin/applications/index.html.erb index 58e0ed4..8d7b650 100644 --- a/app/views/admin/applications/index.html.erb +++ b/app/views/admin/applications/index.html.erb @@ -14,7 +14,7 @@| Name | +Application | Slug | Type | Status | @@ -28,7 +28,18 @@ <% @applications.each do |application| %>
|---|---|---|---|---|
|
- <%= link_to application.name, admin_application_path(application), class: "text-blue-600 hover:text-blue-900" %>
+
+ <% 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 %>
+
+
+
+ <% end %>
+ <%= link_to application.name, admin_application_path(application), class: "text-blue-600 hover:text-blue-900" %>
+ |
<%= application.slug %>
diff --git a/app/views/admin/applications/show.html.erb b/app/views/admin/applications/show.html.erb
index 6df7e50..baafd29 100644
--- a/app/views/admin/applications/show.html.erb
+++ b/app/views/admin/applications/show.html.erb
@@ -16,10 +16,21 @@
<% end %>
-
-
- <%= @application.name %>-<%= @application.description %> +
+
+ <% 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 %>
+
+
+
+ <% end %>
+
+
<%= @application.name %>+<%= @application.description %> +
<%= 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" %>
@@ -78,27 +89,29 @@
-
OIDC Credentials+OIDC Configuration<%= 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" %>
-
-
-
+ <% unless flash[:client_id] && flash[:client_secret] %>
+
+
+
+
+ <% end %>
+
-
<% end %>
diff --git a/app/views/oidc/consent.html.erb b/app/views/oidc/consent.html.erb
index ed05e87..1cc0c15 100644
--- a/app/views/oidc/consent.html.erb
+++ b/app/views/oidc/consent.html.erb
@@ -1,6 +1,15 @@
-
- <%= app.name %> -- - <%= app.app_type.humanize %> - +
+ <% 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 %>
+
-
+
+
+ <% end %>
+
+
+
+ <% if app.description.present? %>
+ + <%= app.name %> ++ + <%= app.app_type.humanize %> + ++ <%= app.description %> + + <% end %> +- <% if app.oidc? %> - OIDC Application +
+ <% if app.landing_url.present? %>
+ <%= link_to "Open Application", app.landing_url,
+ target: "_blank",
+ rel: "noopener noreferrer",
+ class: "w-full flex justify-center items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition" %>
<% else %>
- ForwardAuth Protected Application
+
+ No landing URL configured
+
<% end %>
-
- <% if app.landing_url.present? %>
- <%= link_to "Open Application", app.landing_url,
- target: "_blank",
- rel: "noopener noreferrer",
- class: "w-full flex justify-center items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition" %>
- <% else %>
-
- No landing URL configured
-
- <% 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 %>
+
-
+
+ <% 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 %>
+
+
+
+ <% end %>
Authorize Application<%= @application.name %> is requesting access to your account. diff --git a/config/initializers/version.rb b/config/initializers/version.rb new file mode 100644 index 0000000..44088ef --- /dev/null +++ b/config/initializers/version.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +module Clinch + VERSION = "0.6.0" +end diff --git a/config/routes.rb b/config/routes.rb index c5377c9..f6d6bf2 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -49,6 +49,7 @@ Rails.application.routes.draw do end resource :active_sessions, only: [:show] do member do + delete :logout_from_app delete :revoke_consent delete :revoke_all_consents end diff --git a/db/schema.rb b/db/schema.rb index de8269e..42ff1aa 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,35 @@ # # 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| t.integer "application_id", 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.boolean "active", default: true, null: false t.string "app_type", null: false + t.string "backchannel_logout_uri" t.string "client_id" t.string "client_secret_digest" 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" 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", "groups" add_foreign_key "application_user_claims", "applications", on_delete: :cascade |