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/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 44df291..20eadc3 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -86,8 +86,17 @@ class SessionsController < ApplicationController end # Sign in successful (password only) + # Preserve the return_to_after_authenticating value across session boundary + # (e.g., when max_age flow destroys the session and creates a temporary one) + preserved_return_url = session[:return_to_after_authenticating] + start_new_session_for user, acr: "1" + # Restore the return URL if it was lost during session recreation + if preserved_return_url.present? && session[:return_to_after_authenticating].blank? + session[:return_to_after_authenticating] = preserved_return_url + end + # Use status: :see_other to ensure browser makes a GET request # This prevents Turbo from converting it to a TURBO_STREAM request redirect_to after_authentication_url, notice: "Signed in successfully.", allow_other_host: true, status: :see_other @@ -125,7 +134,12 @@ class SessionsController < ApplicationController if session[:totp_redirect_url].present? session[:return_to_after_authenticating] = session.delete(:totp_redirect_url) end + # Preserve return URL across session boundary for max_age flow + preserved_return_url = session[:return_to_after_authenticating] start_new_session_for user, acr: "2" + if preserved_return_url.present? && session[:return_to_after_authenticating].blank? + session[:return_to_after_authenticating] = preserved_return_url + end redirect_to after_authentication_url, notice: "Signed in successfully.", allow_other_host: true return end @@ -137,7 +151,12 @@ class SessionsController < ApplicationController if session[:totp_redirect_url].present? session[:return_to_after_authenticating] = session.delete(:totp_redirect_url) end + # Preserve return URL across session boundary for max_age flow + preserved_return_url = session[:return_to_after_authenticating] start_new_session_for user, acr: "2" + if preserved_return_url.present? && session[:return_to_after_authenticating].blank? + session[:return_to_after_authenticating] = preserved_return_url + end redirect_to after_authentication_url, notice: "Signed in successfully using backup code.", allow_other_host: true return end 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 %> + +
+ 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.
+
+ Clinch supports both client_secret_basic (HTTP Basic Auth) and client_secret_post (POST parameters) authentication methods.
+
+ 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.
+
- 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 %>
- 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 %>
- 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 %>
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.
+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
+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:
+5m, Refresh TTL = 5m → Re-auth every 5 minutes1h, Refresh TTL = 8h → Re-auth after 8 hours inactive1h, Refresh TTL = 30d → Re-auth after 30 days inactive