diff --git a/README.md b/README.md index ff7d014..385cd32 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,9 @@ Apps that speak OIDC use the OIDC flow. Apps that only need "who is it?", or you want available from the internet behind authentication (MeTube, Jellyfin) use ForwardAuth. #### OpenID Connect (OIDC) + +**[OpenID Certified](https://www.certification.openid.net/plan-detail.html?plan=FbQNTJuYVzrzs&public=true)** - Clinch passes the official OpenID Connect conformance tests (valid as of [v0.8.6](https://github.com/dkam/clinch/releases/tag/0.8.6)). + Standard OAuth2/OIDC provider with endpoints: - `/.well-known/openid-configuration` - Discovery endpoint - `/authorize` - Authorization endpoint with PKCE support diff --git a/app/controllers/active_sessions_controller.rb b/app/controllers/active_sessions_controller.rb index e3f0620..a47e32e 100644 --- a/app/controllers/active_sessions_controller.rb +++ b/app/controllers/active_sessions_controller.rb @@ -71,7 +71,7 @@ class ActiveSessionsController < ApplicationController 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}." + redirect_to root_path, notice: "Revoked access tokens for #{application.name}. Re-authentication will be required on next use." end def revoke_all_consents diff --git a/app/controllers/admin/applications_controller.rb b/app/controllers/admin/applications_controller.rb index 12a9ad3..2bf5878 100644 --- a/app/controllers/admin/applications_controller.rb +++ b/app/controllers/admin/applications_controller.rb @@ -104,7 +104,7 @@ module Admin 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, :is_public_client, :require_pkce + :icon, :backchannel_logout_uri, :is_public_client, :require_pkce, :skip_consent ) # Handle headers_config - it comes as a JSON string from the text area diff --git a/app/models/application.rb b/app/models/application.rb index beddc91..1cc73f0 100644 --- a/app/models/application.rb +++ b/app/models/application.rb @@ -5,6 +5,23 @@ class Application < ApplicationRecord # When true, no client_secret will be generated (public client) attr_accessor :is_public_client + # Virtual setters for TTL fields - accept human-friendly durations + # e.g., "1h", "30m", "1d", or plain numbers "3600" + def access_token_ttl=(value) + parsed = DurationParser.parse(value) + super(parsed) + end + + def refresh_token_ttl=(value) + parsed = DurationParser.parse(value) + super(parsed) + end + + def id_token_ttl=(value) + parsed = DurationParser.parse(value) + super(parsed) + end + has_one_attached :icon # Fix SVG content type after attachment @@ -39,7 +56,7 @@ class Application < ApplicationRecord # Token TTL validations (for OIDC apps) validates :access_token_ttl, numericality: {greater_than_or_equal_to: 300, less_than_or_equal_to: 86400}, if: :oidc? # 5 min - 24 hours - validates :refresh_token_ttl, numericality: {greater_than_or_equal_to: 86400, less_than_or_equal_to: 7776000}, if: :oidc? # 1 day - 90 days + validates :refresh_token_ttl, numericality: {greater_than_or_equal_to: 300, less_than_or_equal_to: 7776000}, if: :oidc? # 5 min - 90 days validates :id_token_ttl, numericality: {greater_than_or_equal_to: 300, less_than_or_equal_to: 86400}, if: :oidc? # 5 min - 24 hours normalizes :slug, with: ->(slug) { slug.strip.downcase } diff --git a/app/views/admin/applications/_form.html.erb b/app/views/admin/applications/_form.html.erb index 3f55c55..97dc1f2 100644 --- a/app/views/admin/applications/_form.html.erb +++ b/app/views/admin/applications/_form.html.erb @@ -153,6 +153,26 @@ <% end %> + +
+
+

OAuth2 Flow

+

+ Clinch uses the authorization_code flow with response_type=code (the modern, secure standard). +

+

+ Deprecated flows like Implicit (id_token, token) are not supported for security reasons. +

+
+ +
+

Client Authentication

+

+ Clinch supports both client_secret_basic (HTTP Basic Auth) and client_secret_post (POST parameters) authentication methods. +

+
+
+
@@ -165,6 +185,16 @@

+ +
+ <%= form.check_box :skip_consent, class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" %> + <%= form.label :skip_consent, "Skip Consent Screen", class: "ml-2 block text-sm font-medium text-gray-900" %> +
+

+ Automatically grant consent for all users. Useful for first-party or trusted applications. +
Only enable for applications you fully trust. Consent is still recorded in the database. +

+
<%= 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" %> @@ -187,43 +217,90 @@
- <%= form.label :access_token_ttl, "Access Token TTL (seconds)", class: "block text-sm font-medium text-gray-700" %> - <%= form.number_field :access_token_ttl, value: application.access_token_ttl || 3600, min: 300, max: 86400, step: 60, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %> + <%= form.label :access_token_ttl, "Access Token TTL", class: "block text-sm font-medium text-gray-700" %> + <%= form.text_field :access_token_ttl, + value: application.access_token_ttl || "1h", + placeholder: "e.g., 1h, 30m, 3600", + class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono" %>

- Range: 5 min - 24 hours -
Default: 1 hour (3600s) -
Current: <%= application.access_token_ttl_human || "1 hour" %> + Range: 5m - 24h +
Default: 1h + <% if application.access_token_ttl.present? %> +
Current: <%= application.access_token_ttl_human %> (<%= application.access_token_ttl %>s) + <% end %>

- <%= form.label :refresh_token_ttl, "Refresh Token TTL (seconds)", class: "block text-sm font-medium text-gray-700" %> - <%= form.number_field :refresh_token_ttl, value: application.refresh_token_ttl || 2592000, min: 86400, max: 7776000, step: 86400, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %> + <%= form.label :refresh_token_ttl, "Refresh Token TTL", class: "block text-sm font-medium text-gray-700" %> + <%= form.text_field :refresh_token_ttl, + value: application.refresh_token_ttl || "30d", + placeholder: "e.g., 30d, 1M, 2592000", + class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono" %>

- Range: 1 day - 90 days -
Default: 30 days (2592000s) -
Current: <%= application.refresh_token_ttl_human || "30 days" %> + Range: 5m - 90d +
Default: 30d + <% if application.refresh_token_ttl.present? %> +
Current: <%= application.refresh_token_ttl_human %> (<%= application.refresh_token_ttl %>s) + <% end %>

- <%= form.label :id_token_ttl, "ID Token TTL (seconds)", class: "block text-sm font-medium text-gray-700" %> - <%= form.number_field :id_token_ttl, value: application.id_token_ttl || 3600, min: 300, max: 86400, step: 60, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %> + <%= form.label :id_token_ttl, "ID Token TTL", class: "block text-sm font-medium text-gray-700" %> + <%= form.text_field :id_token_ttl, + value: application.id_token_ttl || "1h", + placeholder: "e.g., 1h, 30m, 3600", + class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono" %>

- Range: 5 min - 24 hours -
Default: 1 hour (3600s) -
Current: <%= application.id_token_ttl_human || "1 hour" %> + Range: 5m - 24h +
Default: 1h + <% if application.id_token_ttl.present? %> +
Current: <%= application.id_token_ttl_human %> (<%= application.id_token_ttl %>s) + <% end %>

- Understanding Token Types -
-

Access Token: Used to access protected resources (APIs). Shorter lifetime = more secure. Users won't notice automatic refreshes.

-

Refresh Token: Used to get new access tokens without re-authentication. Longer lifetime = better UX (less re-logins).

-

ID Token: Contains user identity information (JWT). Should match access token lifetime in most cases.

-

💡 Tip: Banking apps use 5-15 min access tokens. Internal tools use 1-4 hours.

+ Understanding Token Types & Session Length +
+
+

Token Types:

+

Access Token: Used to access protected resources (APIs). Shorter lifetime = more secure. Users won't notice automatic refreshes.

+

Refresh Token: Used to get new access tokens without re-authentication. Each refresh issues a new refresh token (token rotation).

+

ID Token: Contains user identity information (JWT). Should match access token lifetime in most cases.

+
+ +
+

How Session Length Works:

+

Refresh Token TTL = Maximum Inactivity Period

+

Because refresh tokens are automatically rotated (new token = new expiry), active users can stay logged in indefinitely. The TTL controls how long they can be inactive before requiring re-authentication.

+ +

Example: Refresh TTL = 30 days

+
    +
  • User logs in on Day 0, uses app daily → stays logged in forever (tokens keep rotating)
  • +
  • User logs in on Day 0, stops using app → must re-login after 30 days of inactivity
  • +
+
+ +
+

Forcing Re-Authentication:

+

Because of token rotation, there's no way to force periodic re-authentication using TTL settings alone. Active users can stay logged in indefinitely by refreshing tokens before they expire.

+ +

To enforce absolute session limits: Clients can include the max_age parameter in their authorization requests to require re-authentication after a specific time, regardless of token rotation.

+ +

Example: A banking app might set max_age=900 (15 minutes) in the authorization request to force re-authentication every 15 minutes, even if refresh tokens are still valid.

+
+ +
+

Common Configurations:

+
    +
  • Banking/High Security: Access TTL = 5m, Refresh TTL = 5m → Re-auth every 5 minutes
  • +
  • Corporate Tools: Access TTL = 1h, Refresh TTL = 8h → Re-auth after 8 hours inactive
  • +
  • Personal Apps: Access TTL = 1h, Refresh TTL = 30d → Re-auth after 30 days inactive
  • +
+
diff --git a/app/views/dashboard/index.html.erb b/app/views/dashboard/index.html.erb index bd4e1ab..090312a 100644 --- a/app/views/dashboard/index.html.erb +++ b/app/views/dashboard/index.html.erb @@ -147,9 +147,9 @@ <% end %> <% if app.user_has_active_session?(@user) %> - <%= button_to "Logout", logout_from_app_active_sessions_path(application_id: app.id), method: :delete, + <%= button_to "Require Re-Auth", 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?" } } %> + form: { data: { turbo_confirm: "This will revoke #{app.name}'s access tokens. The next time #{app.name} needs to authenticate, you'll sign in again (no re-authorization needed). Continue?" } } %> <% end %>
diff --git a/test/controllers/api/forward_auth_controller_test.rb b/test/controllers/api/forward_auth_controller_test.rb index 8f583b8..8280a69 100644 --- a/test/controllers/api/forward_auth_controller_test.rb +++ b/test/controllers/api/forward_auth_controller_test.rb @@ -279,7 +279,7 @@ module Api rd: evil_url # Ensure the rd parameter is preserved in login } - assert_response 302 + assert_response 303 # Should NOT redirect to evil URL after successful authentication refute_match evil_url, response.location, "Should not redirect to evil URL after authentication" # Should redirect to the legitimate URL (not the evil one) diff --git a/test/integration/forward_auth_advanced_test.rb b/test/integration/forward_auth_advanced_test.rb index 1a913a9..68ed0e0 100644 --- a/test/integration/forward_auth_advanced_test.rb +++ b/test/integration/forward_auth_advanced_test.rb @@ -31,7 +31,7 @@ class ForwardAuthAdvancedTest < ActionDispatch::IntegrationTest # Step 3: Sign in post "/signin", params: {email_address: @user.email_address, password: "password"} - assert_response 302 + assert_response 303 redirect_uri = URI.parse(response.location) assert_equal "https", redirect_uri.scheme assert_equal "app.example.com", redirect_uri.host @@ -101,7 +101,7 @@ class ForwardAuthAdvancedTest < ActionDispatch::IntegrationTest # Sign in post "/signin", params: {email_address: @user.email_address, password: "password"} - assert_response 302 + assert_response 303 # Should have access (in allowed group) get "/api/verify", headers: {"X-Forwarded-Host" => "admin.example.com"} @@ -139,7 +139,7 @@ class ForwardAuthAdvancedTest < ActionDispatch::IntegrationTest # Sign in post "/signin", params: {email_address: @user.email_address, password: "password"} - assert_response 302 + assert_response 303 # Should have access (bypass mode) get "/api/verify", headers: {"X-Forwarded-Host" => "public.example.com"} @@ -255,7 +255,7 @@ class ForwardAuthAdvancedTest < ActionDispatch::IntegrationTest # Sign in once post "/signin", params: {email_address: @user.email_address, password: "password"} - assert_response 302 + assert_response 303 # Test access to each application apps.each do |app| diff --git a/test/integration/forward_auth_integration_test.rb b/test/integration/forward_auth_integration_test.rb index a824f5e..ad134e1 100644 --- a/test/integration/forward_auth_integration_test.rb +++ b/test/integration/forward_auth_integration_test.rb @@ -27,7 +27,7 @@ class ForwardAuthIntegrationTest < ActionDispatch::IntegrationTest # Step 2: Sign in post "/signin", params: {email_address: @user.email_address, password: "password"} - assert_response 302 + assert_response 303 # Signin now redirects back with fa_token parameter assert_match(/\?fa_token=/, response.location) assert cookies[:session_id]