<%= form_with(model: [:admin, application], class: "space-y-6", data: { controller: "application-form form-errors" }) do |form| %> <%= render "shared/form_errors", form: form %>
<%= form.label :name, class: "block text-sm font-medium text-gray-700" %> <%= form.text_field :name, required: true, 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: "My Application" %>
<%= form.label :slug, class: "block text-sm font-medium text-gray-700" %> <%= form.text_field :slug, required: true, 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: "my-app" %>

Lowercase letters, numbers, and hyphens only. Used in URLs and API calls.

<%= form.label :description, class: "block text-sm font-medium text-gray-700" %> <%= 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.label :icon, "Application Icon", class: "block text-sm font-medium text-gray-700" %> Browse icons at dashboardicons.com
<% if application.icon.attached? && application.persisted? %> <% begin %> <%# Only show icon if we can successfully get its URL (blob is persisted) %> <% if application.icon.blob&.persisted? && application.icon.blob.key.present? %>
<%= image_tag application.icon, class: "h-16 w-16 rounded-lg object-cover border border-gray-200", alt: "Current icon" %>

Current icon

<%= number_to_human_size(application.icon.blob.byte_size) %>

<% end %> <% rescue ArgumentError => e %> <%# Handle case where icon attachment exists but can't generate signed_id %> <% if e.message.include?("Cannot get a signed_id for a new record") %>

Icon uploaded

File will be processed shortly

<% else %> <%# Re-raise if it's a different error %> <% raise e %> <% end %> <% end %> <% end %>

or drag and drop

PNG, JPG, GIF, or SVG up to 2MB

💡 Tip: Click here and press Ctrl+V (or Cmd+V) to paste an image from your clipboard

<%= 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" %>

The main URL users will visit to access this application. This will be shown as a link on their dashboard.

<%= form.label :app_type, "Application Type", class: "block text-sm font-medium text-gray-700" %> <%= form.select :app_type, [["OpenID Connect (OIDC)", "oidc"], ["Forward Auth (Reverse Proxy)", "forward_auth"]], {}, { class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", disabled: application.persisted?, data: { action: "change->application-form#updateFieldVisibility", application_form_target: "appTypeSelect" } } %> <% if application.persisted? %>

Application type cannot be changed after creation.

<% end %>

OIDC Configuration

<% unless application.persisted? %>

Client Type

<%= 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" } %>

Backend server app that can securely store a client secret. Examples: traditional web apps, server-to-server APIs.

<%= 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" } %>

Frontend-only app that cannot store secrets securely. Examples: SPAs (React/Vue), mobile apps, CLI tools. PKCE is required.

<% else %>
Client Type: <% if application.public_client? %> Public Client (PKCE Required) <% else %> Confidential Client <% end %>
<% 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.

<%= 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" %>

Recommended for enhanced security (OAuth 2.1 best practice).
Note: Public clients always require PKCE regardless of this setting.

<%= 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" %>

One URI per line. These are the allowed callback URLs for your application.

<%= 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" %>

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.

Token Expiration Settings

Configure how long tokens remain valid. Shorter times are more secure but require more frequent refreshes.

<%= 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: 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", 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: 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", 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: 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 & 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

Forward Auth Configuration

<%= form.label :domain_pattern, "Domain Pattern", class: "block text-sm font-medium text-gray-700" %> <%= form.text_field :domain_pattern, 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: "*.example.com or app.example.com" %>

Domain pattern to match. Use * for wildcard subdomains (e.g., *.example.com matches app.example.com, api.example.com, etc.)

<%= form.label :headers_config, "Custom Headers Configuration (JSON)", class: "block text-sm font-medium text-gray-700" %> <%= form.text_area :headers_config, value: (application.headers_config.present? && application.headers_config.any? ? JSON.pretty_generate(application.headers_config) : ""), rows: 10, 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: '{"user": "Remote-User", "groups": "Remote-Groups"}', data: { action: "input->json-validator#validate blur->json-validator#format", json_validator_target: "textarea" } %>

Optional: Customize header names sent to your application.

Default headers: X-Remote-User, X-Remote-Email, X-Remote-Name, X-Remote-Username, X-Remote-Groups, X-Remote-Admin

Show available header keys and what data they send

user - User's email address

email - User's email address

name - User's display name (falls back to email if not set)

username - User's login username (only sent if set)

groups - Comma-separated list of group names (e.g., "admin,developers")

admin - "true" or "false" indicating admin status

Example: {"user": "Remote-User", "groups": "Remote-Groups", "username": "Remote-Username"}

Need custom user fields? Add them to user's custom_claims for OIDC tokens

<%= form.label :group_ids, "Allowed Groups (Optional)", class: "block text-sm font-medium text-gray-700" %>
<% if @available_groups.any? %> <% @available_groups.each do |group| %>
<%= check_box_tag "application[group_ids][]", group.id, application.allowed_groups.include?(group), class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" %> <%= label_tag "application_group_ids_#{group.id}", group.name, class: "ml-2 text-sm text-gray-900" %> (<%= pluralize(group.users.count, "member") %>)
<% end %> <% else %>

No groups available. Create groups first to restrict access.

<% end %>

If no groups are selected, all active users can access this application.

<%= form.check_box :active, class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" %> <%= form.label :active, "Active", class: "ml-2 block text-sm text-gray-900" %>
<%= form.submit application.persisted? ? "Update Application" : "Create Application", class: "rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" %> <%= link_to "Cancel", admin_applications_path, 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" %>
<% end %>