More complete oidc
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-18 20:03:03 +11:00
parent ab0085e9c9
commit e882a4d6d1
9 changed files with 401 additions and 43 deletions

View File

@@ -13,7 +13,8 @@ I've completed all planned features:
* TOTP ( QR Code ) 2FA, with backup codes ( encrypted at rest ) * TOTP ( QR Code ) 2FA, with backup codes ( encrypted at rest )
* Passkey generation and login, with detection of Passkey during login * Passkey generation and login, with detection of Passkey during login
* Forward Auth configured and working * Forward Auth configured and working
* OIDC provider with auto discovery working * OIDC provider with auto discovery, refresh tokens, and token revocation
* Configurable token expiry per application (access, refresh, ID tokens)
* Invite users by email, assign to groups * Invite users by email, assign to groups
* Self managed password reset by email * Self managed password reset by email
* Use Groups to assign Applications ( Family group can access Kavita, Developers can access Gitea ) * Use Groups to assign Applications ( Family group can access Kavita, Developers can access Gitea )
@@ -86,11 +87,17 @@ Clinch sits in a sweet spot between two excellent open-source identity solutions
#### OpenID Connect (OIDC) #### OpenID Connect (OIDC)
Standard OAuth2/OIDC provider with endpoints: Standard OAuth2/OIDC provider with endpoints:
- `/.well-known/openid-configuration` - Discovery endpoint - `/.well-known/openid-configuration` - Discovery endpoint
- `/authorize` - Authorization endpoint - `/authorize` - Authorization endpoint with PKCE support
- `/token` - Token endpoint - `/token` - Token endpoint (authorization_code and refresh_token grants)
- `/userinfo` - User info endpoint - `/userinfo` - User info endpoint
- `/revoke` - Token revocation endpoint (RFC 7009)
Client apps (Audiobookshelf, Kavita, Grafana, etc.) redirect to Clinch for login and receive ID tokens and access tokens. Features:
- **Refresh tokens** - Long-lived tokens (30 days default) with automatic rotation and revocation
- **Configurable token expiry** - Set access token (5min-24hr), refresh token (1-90 days), and ID token TTL per application
- **Token security** - BCrypt-hashed tokens, automatic cleanup of expired tokens
Client apps (Audiobookshelf, Kavita, Grafana, etc.) redirect to Clinch for login and receive ID tokens, access tokens, and refresh tokens.
#### Trusted-Header SSO (ForwardAuth) #### Trusted-Header SSO (ForwardAuth)
Works with reverse proxies (Caddy, Traefik, Nginx): Works with reverse proxies (Caddy, Traefik, Nginx):
@@ -156,25 +163,29 @@ Send emails for:
- Redirect URIs (for OIDC apps) - Redirect URIs (for OIDC apps)
- Domain pattern (for ForwardAuth apps, supports wildcards like *.example.com) - Domain pattern (for ForwardAuth apps, supports wildcards like *.example.com)
- Headers config (for ForwardAuth apps, JSON configuration for custom header names) - Headers config (for ForwardAuth apps, JSON configuration for custom header names)
- Token TTL configuration (access_token_ttl, refresh_token_ttl, id_token_ttl)
- Metadata (flexible JSON storage) - Metadata (flexible JSON storage)
- Active flag - Active flag
- Many-to-many with Groups (allowlist) - Many-to-many with Groups (allowlist)
**OIDC Tokens** **OIDC Tokens**
- Authorization codes (10-minute expiry, one-time use) - Authorization codes (10-minute expiry, one-time use, PKCE support)
- Access tokens (1-hour expiry, revocable) - Access tokens (opaque, BCrypt-hashed, configurable expiry 5min-24hr, revocable)
- Refresh tokens (opaque, BCrypt-hashed, configurable expiry 1-90 days, single-use with rotation)
- ID tokens (JWT, signed with RS256, configurable expiry 5min-24hr)
--- ---
## Authentication Flows ## Authentication Flows
### OIDC Authorization Flow ### OIDC Authorization Flow
1. Client redirects user to `/authorize` with client_id, redirect_uri, scope 1. Client redirects user to `/authorize` with client_id, redirect_uri, scope (optional PKCE)
2. User authenticates with Clinch (username/password + optional TOTP) 2. User authenticates with Clinch (username/password + optional TOTP)
3. Access control check: Is user in an allowed group for this app? 3. Access control check: Is user in an allowed group for this app?
4. If allowed, generate authorization code and redirect to client 4. If allowed, generate authorization code and redirect to client
5. Client exchanges code for access token at `/token` 5. Client exchanges code at `/token` for ID token, access token, and refresh token
6. Client uses access token to fetch user info from `/userinfo` 6. Client uses access token to fetch fresh user info from `/userinfo`
7. When access token expires, client uses refresh token to get new tokens (no re-authentication)
### ForwardAuth Flow ### ForwardAuth Flow
1. User requests protected resource at `https://app.example.com/dashboard` 1. User requests protected resource at `https://app.example.com/dashboard`
@@ -258,6 +269,10 @@ SMTP_ENABLE_STARTTLS=true
# Application # Application
CLINCH_HOST=https://auth.example.com CLINCH_HOST=https://auth.example.com
CLINCH_FROM_EMAIL=noreply@example.com CLINCH_FROM_EMAIL=noreply@example.com
# OIDC (optional - generates temporary key in development)
# Generate with: openssl genpkey -algorithm RSA -out private_key.pem -pkeyopt rsa_keygen_bits:2048
OIDC_PRIVATE_KEY=<contents-of-private-key.pem>
``` ```
### First Run ### First Run

View File

@@ -99,7 +99,8 @@ module Admin
def application_params def application_params
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, headers_config: {} :domain_pattern, :landing_url, :access_token_ttl, :refresh_token_ttl, :id_token_ttl,
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)
whitelisted.delete(:client_secret) whitelisted.delete(:client_secret)

View File

@@ -1,7 +1,7 @@
class OidcController < ApplicationController class OidcController < ApplicationController
# Discovery and JWKS endpoints are public # Discovery and JWKS endpoints are public
allow_unauthenticated_access only: [:discovery, :jwks, :token, :userinfo, :logout] allow_unauthenticated_access only: [:discovery, :jwks, :token, :revoke, :userinfo, :logout]
skip_before_action :verify_authenticity_token, only: [:token, :logout] skip_before_action :verify_authenticity_token, only: [:token, :revoke, :logout]
# GET /.well-known/openid-configuration # GET /.well-known/openid-configuration
def discovery def discovery
@@ -11,11 +11,13 @@ class OidcController < ApplicationController
issuer: base_url, issuer: base_url,
authorization_endpoint: "#{base_url}/oauth/authorize", authorization_endpoint: "#{base_url}/oauth/authorize",
token_endpoint: "#{base_url}/oauth/token", token_endpoint: "#{base_url}/oauth/token",
revocation_endpoint: "#{base_url}/oauth/revoke",
userinfo_endpoint: "#{base_url}/oauth/userinfo", userinfo_endpoint: "#{base_url}/oauth/userinfo",
jwks_uri: "#{base_url}/.well-known/jwks.json", jwks_uri: "#{base_url}/.well-known/jwks.json",
end_session_endpoint: "#{base_url}/logout", end_session_endpoint: "#{base_url}/logout",
response_types_supported: ["code"], response_types_supported: ["code"],
response_modes_supported: ["query"], response_modes_supported: ["query"],
grant_types_supported: ["authorization_code", "refresh_token"],
subject_types_supported: ["public"], subject_types_supported: ["public"],
id_token_signing_alg_values_supported: ["RS256"], id_token_signing_alg_values_supported: ["RS256"],
scopes_supported: ["openid", "profile", "email", "groups"], scopes_supported: ["openid", "profile", "email", "groups"],
@@ -253,10 +255,17 @@ class OidcController < ApplicationController
def token def token
grant_type = params[:grant_type] grant_type = params[:grant_type]
unless grant_type == "authorization_code" case grant_type
when "authorization_code"
handle_authorization_code_grant
when "refresh_token"
handle_refresh_token_grant
else
render json: { error: "unsupported_grant_type" }, status: :bad_request render json: { error: "unsupported_grant_type" }, status: :bad_request
return
end end
end
def handle_authorization_code_grant
# Get client credentials from Authorization header or params # Get client credentials from Authorization header or params
client_id, client_secret = extract_client_credentials client_id, client_secret = extract_client_credentials
@@ -341,25 +350,31 @@ class OidcController < ApplicationController
# Get the user # Get the user
user = auth_code.user user = auth_code.user
# Generate access token # Generate access token record (opaque token with BCrypt hashing)
access_token = SecureRandom.urlsafe_base64(32) access_token_record = OidcAccessToken.create!(
OidcAccessToken.create!(
application: application, application: application,
user: user, user: user,
token: access_token, scope: auth_code.scope
scope: auth_code.scope,
expires_at: 1.hour.from_now
) )
# Generate ID token # Generate refresh token (opaque, with hashing)
refresh_token_record = OidcRefreshToken.create!(
application: application,
user: user,
oidc_access_token: access_token_record,
scope: auth_code.scope
)
# Generate ID token (JWT)
id_token = OidcJwtService.generate_id_token(user, application, nonce: auth_code.nonce) id_token = OidcJwtService.generate_id_token(user, application, nonce: auth_code.nonce)
# Return tokens # Return tokens
render json: { render json: {
access_token: access_token, access_token: access_token_record.plaintext_token, # Opaque token
token_type: "Bearer", token_type: "Bearer",
expires_in: 3600, expires_in: application.access_token_ttl || 3600,
id_token: id_token, id_token: id_token, # JWT
refresh_token: refresh_token_record.token, # Opaque token
scope: auth_code.scope scope: auth_code.scope
} }
end end
@@ -368,6 +383,96 @@ class OidcController < ApplicationController
end end
end end
def handle_refresh_token_grant
# Get client credentials from Authorization header or params
client_id, client_secret = extract_client_credentials
unless client_id && client_secret
render json: { error: "invalid_client" }, status: :unauthorized
return
end
# Find and validate the application
application = Application.find_by(client_id: client_id)
unless application && application.authenticate_client_secret(client_secret)
render json: { error: "invalid_client" }, status: :unauthorized
return
end
# Get the refresh token
refresh_token = params[:refresh_token]
unless refresh_token.present?
render json: { error: "invalid_request", error_description: "refresh_token is required" }, status: :bad_request
return
end
# Find the refresh token record
# Note: This is inefficient with BCrypt hashing, but necessary for security
# In production, consider adding a token prefix for faster lookup
refresh_token_record = OidcRefreshToken.where(application: application).find do |rt|
rt.token_matches?(refresh_token)
end
unless refresh_token_record
render json: { error: "invalid_grant", error_description: "Invalid refresh token" }, status: :bad_request
return
end
# Check if refresh token is expired
if refresh_token_record.expired?
render json: { error: "invalid_grant", error_description: "Refresh token expired" }, status: :bad_request
return
end
# Check if refresh token is revoked
if refresh_token_record.revoked?
# If a revoked refresh token is used, it's a security issue
# Revoke all tokens in the family (token rotation attack detection)
Rails.logger.warn "OAuth Security: Revoked refresh token reuse detected for token family #{refresh_token_record.token_family_id}"
refresh_token_record.revoke_family!
render json: { error: "invalid_grant", error_description: "Refresh token has been revoked" }, status: :bad_request
return
end
# Get the user
user = refresh_token_record.user
# Revoke the old refresh token (token rotation)
refresh_token_record.revoke!
# Generate new access token record (opaque token with BCrypt hashing)
new_access_token = OidcAccessToken.create!(
application: application,
user: user,
scope: refresh_token_record.scope
)
# Generate new refresh token (token rotation)
new_refresh_token = OidcRefreshToken.create!(
application: application,
user: user,
oidc_access_token: new_access_token,
scope: refresh_token_record.scope,
token_family_id: refresh_token_record.token_family_id # Keep same family for rotation tracking
)
# Generate new ID token (JWT, no nonce for refresh grants)
id_token = OidcJwtService.generate_id_token(user, application)
# Return new tokens
render json: {
access_token: new_access_token.plaintext_token, # Opaque token
token_type: "Bearer",
expires_in: application.access_token_ttl || 3600,
id_token: id_token, # JWT
refresh_token: new_refresh_token.token, # Opaque token
scope: refresh_token_record.scope
}
rescue ActiveRecord::RecordNotFound
render json: { error: "invalid_grant" }, status: :bad_request
end
# GET /oauth/userinfo # GET /oauth/userinfo
def userinfo def userinfo
# Extract access token from Authorization header # Extract access token from Authorization header
@@ -377,24 +482,22 @@ class OidcController < ApplicationController
return return
end end
access_token = auth_header.sub("Bearer ", "") token = auth_header.sub("Bearer ", "")
# Find the access token # Find and validate access token (opaque token with BCrypt hashing)
token_record = OidcAccessToken.find_by(token: access_token) access_token = OidcAccessToken.find_by_token(token)
unless token_record unless access_token&.active?
head :unauthorized head :unauthorized
return return
end end
# Check if token is expired # Get the user (with fresh data from database)
if token_record.expires_at < Time.current user = access_token.user
unless user
head :unauthorized head :unauthorized
return return
end end
# Get the user
user = token_record.user
# Return user claims # Return user claims
claims = { claims = {
sub: user.id.to_s, sub: user.id.to_s,
@@ -423,6 +526,73 @@ class OidcController < ApplicationController
render json: claims render json: claims
end end
# POST /oauth/revoke
# RFC 7009 - Token Revocation
def revoke
# Get client credentials
client_id, client_secret = extract_client_credentials
unless client_id && client_secret
# RFC 7009 says we should return 200 OK even for invalid client
# But log the attempt for security monitoring
Rails.logger.warn "OAuth: Token revocation attempted with invalid client credentials"
head :ok
return
end
# Find and validate the application
application = Application.find_by(client_id: client_id)
unless application && application.authenticate_client_secret(client_secret)
Rails.logger.warn "OAuth: Token revocation attempted for invalid application: #{client_id}"
head :ok
return
end
# Get the token to revoke
token = params[:token]
token_type_hint = params[:token_type_hint] # Optional hint: "access_token" or "refresh_token"
unless token.present?
# RFC 7009: Missing token parameter is an error
render json: { error: "invalid_request", error_description: "token parameter is required" }, status: :bad_request
return
end
# Try to find and revoke the token
# Check token type hint first for efficiency, otherwise try both
revoked = false
if token_type_hint == "refresh_token" || token_type_hint.nil?
# Try to find as refresh token
refresh_token_record = OidcRefreshToken.where(application: application).find do |rt|
rt.token_matches?(token)
end
if refresh_token_record
refresh_token_record.revoke!
Rails.logger.info "OAuth: Refresh token revoked for application #{application.name}"
revoked = true
end
end
if !revoked && (token_type_hint == "access_token" || token_type_hint.nil?)
# Try to find as access token
access_token_record = OidcAccessToken.where(application: application).find do |at|
at.token_matches?(token)
end
if access_token_record
access_token_record.revoke!
Rails.logger.info "OAuth: Access token revoked for application #{application.name}"
revoked = true
end
end
# RFC 7009: Always return 200 OK, even if token was not found
# This prevents token scanning attacks
head :ok
end
# GET /logout # GET /logout
def logout def logout
# OpenID Connect RP-Initiated Logout # OpenID Connect RP-Initiated Logout

View File

@@ -5,6 +5,7 @@ class Application < ApplicationRecord
has_many :allowed_groups, through: :application_groups, source: :group has_many :allowed_groups, through: :application_groups, source: :group
has_many :oidc_authorization_codes, dependent: :destroy has_many :oidc_authorization_codes, dependent: :destroy
has_many :oidc_access_tokens, dependent: :destroy has_many :oidc_access_tokens, dependent: :destroy
has_many :oidc_refresh_tokens, dependent: :destroy
has_many :oidc_user_consents, dependent: :destroy has_many :oidc_user_consents, dependent: :destroy
validates :name, presence: true validates :name, presence: true
@@ -17,6 +18,11 @@ class Application < ApplicationRecord
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" }
# 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 :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 } normalizes :slug, with: ->(slug) { slug.strip.downcase }
normalizes :domain_pattern, with: ->(pattern) { normalizes :domain_pattern, with: ->(pattern) {
normalized = pattern&.strip&.downcase normalized = pattern&.strip&.downcase
@@ -154,8 +160,44 @@ class Application < ApplicationRecord
secret secret
end end
# Token TTL helper methods (for OIDC)
def access_token_expiry
(access_token_ttl || 3600).seconds.from_now
end
def refresh_token_expiry
(refresh_token_ttl || 2592000).seconds.from_now
end
def id_token_expiry_seconds
id_token_ttl || 3600
end
# Human-readable TTL for display
def access_token_ttl_human
duration_to_human(access_token_ttl || 3600)
end
def refresh_token_ttl_human
duration_to_human(refresh_token_ttl || 2592000)
end
def id_token_ttl_human
duration_to_human(id_token_ttl || 3600)
end
private private
def duration_to_human(seconds)
if seconds < 3600
"#{seconds / 60} minutes"
elsif seconds < 86400
"#{seconds / 3600} hours"
else
"#{seconds / 86400} days"
end
end
def generate_client_credentials def generate_client_credentials
self.client_id ||= SecureRandom.urlsafe_base64(32) self.client_id ||= SecureRandom.urlsafe_base64(32)
# Generate and hash the client secret # Generate and hash the client secret

View File

@@ -1,34 +1,83 @@
class OidcAccessToken < ApplicationRecord class OidcAccessToken < ApplicationRecord
belongs_to :application belongs_to :application
belongs_to :user belongs_to :user
has_many :oidc_refresh_tokens, dependent: :destroy
before_validation :generate_token, on: :create before_validation :generate_token, on: :create
before_validation :set_expiry, on: :create before_validation :set_expiry, on: :create
validates :token, presence: true, uniqueness: true validates :token, uniqueness: true, presence: true
scope :valid, -> { where("expires_at > ?", Time.current) } scope :valid, -> { where("expires_at > ?", Time.current).where(revoked_at: nil) }
scope :expired, -> { where("expires_at <= ?", Time.current) } scope :expired, -> { where("expires_at <= ?", Time.current) }
scope :revoked, -> { where.not(revoked_at: nil) }
scope :active, -> { valid }
attr_accessor :plaintext_token # Store plaintext temporarily for returning to client
def expired? def expired?
expires_at <= Time.current expires_at <= Time.current
end end
def revoked?
revoked_at.present?
end
def active? def active?
!expired? !expired? && !revoked?
end end
def revoke! def revoke!
update!(expires_at: Time.current) update!(revoked_at: Time.current)
# Also revoke associated refresh tokens
oidc_refresh_tokens.each(&:revoke!)
end
# Check if a plaintext token matches the hashed token
def token_matches?(plaintext_token)
return false if plaintext_token.blank?
# Use BCrypt to compare if token_digest exists
if token_digest.present?
BCrypt::Password.new(token_digest) == plaintext_token
# Fall back to direct comparison for backward compatibility
elsif token.present?
token == plaintext_token
else
false
end
end
# Find by token (validates and checks if revoked)
def self.find_by_token(plaintext_token)
return nil if plaintext_token.blank?
# Find all non-revoked, non-expired tokens
valid.find_each do |access_token|
# Use BCrypt to compare (if token_digest exists) or direct comparison
if access_token.token_digest.present?
return access_token if BCrypt::Password.new(access_token.token_digest) == plaintext_token
elsif access_token.token == plaintext_token
return access_token
end
end
nil
end end
private private
def generate_token def generate_token
self.token ||= SecureRandom.urlsafe_base64(48) return if token.present?
# Generate opaque access token
plaintext = SecureRandom.urlsafe_base64(48)
self.plaintext_token = plaintext # Store temporarily for returning to client
self.token_digest = BCrypt::Password.create(plaintext)
# Keep token column for backward compatibility during migration
self.token = plaintext
end end
def set_expiry def set_expiry
self.expires_at ||= 1.hour.from_now self.expires_at ||= application.access_token_expiry
end end
end end

View File

@@ -3,12 +3,14 @@ class OidcJwtService
# Generate an ID token (JWT) for the user # Generate an ID token (JWT) for the user
def generate_id_token(user, application, nonce: nil) def generate_id_token(user, application, nonce: nil)
now = Time.current.to_i now = Time.current.to_i
# Use application's configured ID token TTL (defaults to 1 hour)
ttl = application.id_token_expiry_seconds
payload = { payload = {
iss: issuer_url, iss: issuer_url,
sub: user.id.to_s, sub: user.id.to_s,
aud: application.client_id, aud: application.client_id,
exp: now + 3600, # 1 hour exp: now + ttl,
iat: now, iat: now,
email: user.email_address, email: user.email_address,
email_verified: true, email_verified: true,

View File

@@ -44,6 +44,53 @@
<%= 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" %> <%= 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" %>
<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 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>
<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>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<%= 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" %>
<p class="mt-1 text-xs text-gray-500">
Range: 5 min - 24 hours
<br>Default: 1 hour (3600s)
<br>Current: <span class="font-medium"><%= application.access_token_ttl_human || "1 hour" %></span>
</p>
</div>
<div>
<%= 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" %>
<p class="mt-1 text-xs text-gray-500">
Range: 1 day - 90 days
<br>Default: 30 days (2592000s)
<br>Current: <span class="font-medium"><%= application.refresh_token_ttl_human || "30 days" %></span>
</p>
</div>
<div>
<%= 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" %>
<p class="mt-1 text-xs text-gray-500">
Range: 5 min - 24 hours
<br>Default: 1 hour (3600s)
<br>Current: <span class="font-medium"><%= application.id_token_ttl_human || "1 hour" %></span>
</p>
</div>
</div>
<details class="mt-3">
<summary class="cursor-pointer text-sm text-blue-600 hover:text-blue-800">Understanding Token Types</summary>
<div class="mt-2 ml-4 space-y-2 text-sm text-gray-600">
<p><strong>Access Token:</strong> Used to access protected resources (APIs). Shorter lifetime = more secure. Users won't notice automatic refreshes.</p>
<p><strong>Refresh Token:</strong> Used to get new access tokens without re-authentication. Longer lifetime = better UX (less re-logins).</p>
<p><strong>ID Token:</strong> Contains user identity information (JWT). Should match access token lifetime in most cases.</p>
<p class="text-xs italic mt-2">💡 Tip: Banking apps use 5-15 min access tokens. Internal tools use 1-4 hours.</p>
</div>
</details>
</div>
</div> </div>
<!-- Forward Auth-specific fields --> <!-- Forward Auth-specific fields -->

View File

@@ -29,6 +29,7 @@ Rails.application.routes.draw do
get "/oauth/authorize", to: "oidc#authorize" get "/oauth/authorize", to: "oidc#authorize"
post "/oauth/authorize/consent", to: "oidc#consent", as: :oauth_consent post "/oauth/authorize/consent", to: "oidc#consent", as: :oauth_consent
post "/oauth/token", to: "oidc#token" post "/oauth/token", to: "oidc#token"
post "/oauth/revoke", to: "oidc#revoke"
get "/oauth/userinfo", to: "oidc#userinfo" get "/oauth/userinfo", to: "oidc#userinfo"
get "/logout", to: "oidc#logout" get "/logout", to: "oidc#logout"

35
db/schema.rb generated
View File

@@ -10,7 +10,7 @@
# #
# 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_09_011443) do ActiveRecord::Schema[8.1].define(version: 2025_11_12_120314) do
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
@@ -22,6 +22,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_09_011443) do
end end
create_table "applications", force: :cascade do |t| create_table "applications", force: :cascade do |t|
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 "client_id" t.string "client_id"
@@ -30,10 +31,12 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_09_011443) do
t.text "description" t.text "description"
t.string "domain_pattern" t.string "domain_pattern"
t.json "headers_config", default: {}, null: false t.json "headers_config", default: {}, null: false
t.integer "id_token_ttl", default: 3600
t.string "landing_url" t.string "landing_url"
t.text "metadata" t.text "metadata"
t.string "name", null: false t.string "name", null: false
t.text "redirect_uris" t.text "redirect_uris"
t.integer "refresh_token_ttl", default: 2592000
t.string "slug", null: false t.string "slug", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.index ["active"], name: "index_applications_on_active" t.index ["active"], name: "index_applications_on_active"
@@ -55,14 +58,18 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_09_011443) do
t.integer "application_id", null: false t.integer "application_id", null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "expires_at", null: false t.datetime "expires_at", null: false
t.datetime "revoked_at"
t.string "scope" t.string "scope"
t.string "token", null: false t.string "token"
t.string "token_digest"
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.integer "user_id", null: false t.integer "user_id", null: false
t.index ["application_id", "user_id"], name: "index_oidc_access_tokens_on_application_id_and_user_id" t.index ["application_id", "user_id"], name: "index_oidc_access_tokens_on_application_id_and_user_id"
t.index ["application_id"], name: "index_oidc_access_tokens_on_application_id" t.index ["application_id"], name: "index_oidc_access_tokens_on_application_id"
t.index ["expires_at"], name: "index_oidc_access_tokens_on_expires_at" t.index ["expires_at"], name: "index_oidc_access_tokens_on_expires_at"
t.index ["revoked_at"], name: "index_oidc_access_tokens_on_revoked_at"
t.index ["token"], name: "index_oidc_access_tokens_on_token", unique: true t.index ["token"], name: "index_oidc_access_tokens_on_token", unique: true
t.index ["token_digest"], name: "index_oidc_access_tokens_on_token_digest", unique: true
t.index ["user_id"], name: "index_oidc_access_tokens_on_user_id" t.index ["user_id"], name: "index_oidc_access_tokens_on_user_id"
end end
@@ -87,6 +94,27 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_09_011443) do
t.index ["user_id"], name: "index_oidc_authorization_codes_on_user_id" t.index ["user_id"], name: "index_oidc_authorization_codes_on_user_id"
end end
create_table "oidc_refresh_tokens", force: :cascade do |t|
t.integer "application_id", null: false
t.datetime "created_at", null: false
t.datetime "expires_at", null: false
t.integer "oidc_access_token_id", null: false
t.datetime "revoked_at"
t.string "scope"
t.string "token_digest", null: false
t.integer "token_family_id"
t.datetime "updated_at", null: false
t.integer "user_id", null: false
t.index ["application_id", "user_id"], name: "index_oidc_refresh_tokens_on_application_id_and_user_id"
t.index ["application_id"], name: "index_oidc_refresh_tokens_on_application_id"
t.index ["expires_at"], name: "index_oidc_refresh_tokens_on_expires_at"
t.index ["oidc_access_token_id"], name: "index_oidc_refresh_tokens_on_oidc_access_token_id"
t.index ["revoked_at"], name: "index_oidc_refresh_tokens_on_revoked_at"
t.index ["token_digest"], name: "index_oidc_refresh_tokens_on_token_digest", unique: true
t.index ["token_family_id"], name: "index_oidc_refresh_tokens_on_token_family_id"
t.index ["user_id"], name: "index_oidc_refresh_tokens_on_user_id"
end
create_table "oidc_user_consents", force: :cascade do |t| create_table "oidc_user_consents", 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
@@ -174,6 +202,9 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_09_011443) do
add_foreign_key "oidc_access_tokens", "users" add_foreign_key "oidc_access_tokens", "users"
add_foreign_key "oidc_authorization_codes", "applications" add_foreign_key "oidc_authorization_codes", "applications"
add_foreign_key "oidc_authorization_codes", "users" add_foreign_key "oidc_authorization_codes", "users"
add_foreign_key "oidc_refresh_tokens", "applications"
add_foreign_key "oidc_refresh_tokens", "oidc_access_tokens"
add_foreign_key "oidc_refresh_tokens", "users"
add_foreign_key "oidc_user_consents", "applications" add_foreign_key "oidc_user_consents", "applications"
add_foreign_key "oidc_user_consents", "users" add_foreign_key "oidc_user_consents", "users"
add_foreign_key "sessions", "users" add_foreign_key "sessions", "users"