Compare commits

...

5 Commits

Author SHA1 Message Date
Dan Milne
e882a4d6d1 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
2025-11-18 20:03:03 +11:00
Dan Milne
ab0085e9c9 More complete oidc 2025-11-18 20:02:45 +11:00
Dan Milne
1ee3302319 Improvements derived from rodauth-oauth 2025-11-12 22:17:55 +11:00
Dan Milne
67f28faaca Improve some front end views. More descriptive error condition reporting. Updates to CLINCH_HOST for better WEBAUTHN 2025-11-12 16:24:05 +11:00
Dan Milne
33ad956508 Add test 2025-11-12 15:50:04 +11:00
30 changed files with 1721 additions and 91 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

@@ -10,6 +10,13 @@ module Api
report_data = JSON.parse(request.body.read) report_data = JSON.parse(request.body.read)
csp_report = report_data['csp-report'] csp_report = report_data['csp-report']
# Validate that we have a proper CSP report
unless csp_report.is_a?(Hash) && csp_report.present?
Rails.logger.warn "Received empty or invalid CSP violation report"
head :bad_request
return
end
# Log the violation for security monitoring # Log the violation for security monitoring
Rails.logger.warn "CSP Violation Report:" Rails.logger.warn "CSP Violation Report:"
Rails.logger.warn " Blocked URI: #{csp_report['blocked-uri']}" Rails.logger.warn " Blocked URI: #{csp_report['blocked-uri']}"

View File

@@ -221,7 +221,9 @@ module Api
# Try CLINCH_HOST environment variable first # Try CLINCH_HOST environment variable first
if ENV['CLINCH_HOST'].present? if ENV['CLINCH_HOST'].present?
"https://#{ENV['CLINCH_HOST']}" host = ENV['CLINCH_HOST']
# Ensure URL has https:// protocol
host.match?(/^https?:\/\//) ? host : "https://#{host}"
else else
# Fallback to the request host # Fallback to the request host
request_host = request.host || request.headers['X-Forwarded-Host'] request_host = request.host || request.headers['X-Forwarded-Host']

View File

@@ -134,6 +134,8 @@ module Authentication
original_url = controller_session[:return_to_after_authenticating] original_url = controller_session[:return_to_after_authenticating]
uri = URI.parse(original_url) uri = URI.parse(original_url)
# Skip adding fa_token for OAuth URLs (OAuth flow should not have forward auth tokens)
unless uri.path&.start_with?("/oauth/")
# Add token as query parameter # Add token as query parameter
query_params = URI.decode_www_form(uri.query || "").to_h query_params = URI.decode_www_form(uri.query || "").to_h
query_params['fa_token'] = token query_params['fa_token'] = token
@@ -143,4 +145,5 @@ module Authentication
controller_session[:return_to_after_authenticating] = uri.to_s controller_session[:return_to_after_authenticating] = uri.to_s
end end
end end
end
end end

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"],
@@ -34,7 +36,7 @@ class OidcController < ApplicationController
# GET /oauth/authorize # GET /oauth/authorize
def authorize def authorize
# Get parameters # Get parameters (ignore forward auth tokens and other unknown params)
client_id = params[:client_id] client_id = params[:client_id]
redirect_uri = params[:redirect_uri] redirect_uri = params[:redirect_uri]
state = params[:state] state = params[:state]
@@ -46,20 +48,25 @@ class OidcController < ApplicationController
# Validate required parameters # Validate required parameters
unless client_id.present? && redirect_uri.present? && response_type == "code" unless client_id.present? && redirect_uri.present? && response_type == "code"
render plain: "Invalid request", status: :bad_request error_details = []
error_details << "client_id is required" unless client_id.present?
error_details << "redirect_uri is required" unless redirect_uri.present?
error_details << "response_type must be 'code'" unless response_type == "code"
render plain: "Invalid request: #{error_details.join(', ')}", status: :bad_request
return return
end end
# Validate PKCE parameters if present # Validate PKCE parameters if present
if code_challenge.present? if code_challenge.present?
unless %w[plain S256].include?(code_challenge_method) unless %w[plain S256].include?(code_challenge_method)
render plain: "Invalid request", status: :bad_request render plain: "Invalid code_challenge_method: must be 'plain' or 'S256'", status: :bad_request
return return
end end
# Validate code challenge format (base64url-encoded, 43-128 characters) # Validate code challenge format (base64url-encoded, 43-128 characters)
unless code_challenge.match?(/\A[A-Za-z0-9\-_]{43,128}\z/) unless code_challenge.match?(/\A[A-Za-z0-9\-_]{43,128}\z/)
render plain: "Invalid request", status: :bad_request render plain: "Invalid code_challenge format: must be 43-128 characters of base64url encoding", status: :bad_request
return return
end end
end end
@@ -67,13 +74,33 @@ class OidcController < ApplicationController
# Find the application # Find the application
@application = Application.find_by(client_id: client_id, app_type: "oidc") @application = Application.find_by(client_id: client_id, app_type: "oidc")
unless @application unless @application
render plain: "Invalid request", status: :bad_request # Log all OIDC applications for debugging
all_oidc_apps = Application.where(app_type: "oidc")
Rails.logger.error "OAuth: Invalid request - application not found for client_id: #{client_id}"
Rails.logger.error "OAuth: Available OIDC applications: #{all_oidc_apps.pluck(:id, :client_id, :name)}"
error_msg = if Rails.env.development?
"Invalid request: Application not found for client_id '#{client_id}'. Available OIDC applications: #{all_oidc_apps.pluck(:name, :client_id).map { |name, id| "#{name} (#{id})" }.join(', ')}"
else
"Invalid request: Application not found"
end
render plain: error_msg, status: :bad_request
return return
end end
# Validate redirect URI # Validate redirect URI
unless @application.parsed_redirect_uris.include?(redirect_uri) unless @application.parsed_redirect_uris.include?(redirect_uri)
render plain: "Invalid request", status: :bad_request Rails.logger.error "OAuth: Invalid request - redirect URI mismatch. Expected: #{@application.parsed_redirect_uris}, Got: #{redirect_uri}"
# For development, show detailed error
error_msg = if Rails.env.development?
"Invalid request: Redirect URI mismatch. Application is configured for: #{@application.parsed_redirect_uris.join(', ')}, but received: #{redirect_uri}"
else
"Invalid request: Redirect URI not registered for this application"
end
render plain: error_msg, status: :bad_request
return return
end end
@@ -139,9 +166,29 @@ class OidcController < ApplicationController
code_challenge_method: code_challenge_method code_challenge_method: code_challenge_method
} }
# Render consent page # Render consent page with dynamic CSP for OAuth redirect
@redirect_uri = redirect_uri @redirect_uri = redirect_uri
@scopes = requested_scopes @scopes = requested_scopes
# Add the redirect URI to CSP form-action for this specific request
# This allows the OAuth redirect to work while maintaining security
# CSP must allow the OAuth client's redirect_uri as a form submission target
if redirect_uri.present?
begin
redirect_host = URI.parse(redirect_uri).host
csp = request.content_security_policy
if csp && redirect_host
# Only modify if form_action is available and mutable
if csp.respond_to?(:form_action) && csp.form_action.respond_to?(:<<)
csp.form_action << "https://#{redirect_host}"
end
end
rescue => e
# Log CSP modification errors but don't fail the request
Rails.logger.warn "OAuth: Could not modify CSP for redirect_uri #{redirect_uri}: #{e.message}"
end
end
render :consent render :consent
end end
@@ -208,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
@@ -235,8 +289,7 @@ class OidcController < ApplicationController
auth_code = OidcAuthorizationCode.find_by( auth_code = OidcAuthorizationCode.find_by(
application: application, application: application,
code: code, code: code
used: false
) )
unless auth_code unless auth_code
@@ -244,6 +297,31 @@ class OidcController < ApplicationController
return return
end end
# Use a transaction with pessimistic locking to prevent code reuse
begin
OidcAuthorizationCode.transaction do
# Lock the record to prevent concurrent access
auth_code.lock!
# Check if code has already been used (CRITICAL: check AFTER locking)
if auth_code.used?
# Per OAuth 2.0 spec, if an auth code is reused, revoke all tokens issued from it
Rails.logger.warn "OAuth Security: Authorization code reuse detected for code #{auth_code.id}"
# Revoke all access tokens issued from this authorization code
OidcAccessToken.where(
application: application,
user: auth_code.user,
created_at: auth_code.created_at..Time.current
).update_all(expires_at: Time.current)
render json: {
error: "invalid_grant",
error_description: "Authorization code has already been used"
}, status: :bad_request
return
end
# Check if code is expired # Check if code is expired
if auth_code.expires_at < Time.current if auth_code.expires_at < Time.current
render json: { error: "invalid_grant", error_description: "Authorization code expired" }, status: :bad_request render json: { error: "invalid_grant", error_description: "Authorization code expired" }, status: :bad_request
@@ -266,34 +344,134 @@ class OidcController < ApplicationController
return return
end end
# Mark code as used # Mark code as used BEFORE generating tokens (prevents reuse)
auth_code.update!(used: true) auth_code.update!(used: true)
# 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
rescue ActiveRecord::RecordNotFound
render json: { error: "invalid_grant" }, status: :bad_request
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
@@ -304,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,
@@ -350,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

@@ -0,0 +1,29 @@
class OidcTokenCleanupJob < ApplicationJob
queue_as :default
def perform
# Delete expired access tokens (keep revoked ones for audit trail)
expired_access_tokens = OidcAccessToken.where("expires_at < ?", 7.days.ago)
deleted_count = expired_access_tokens.delete_all
Rails.logger.info "OIDC Token Cleanup: Deleted #{deleted_count} expired access tokens"
# Delete expired refresh tokens (keep revoked ones for audit trail)
expired_refresh_tokens = OidcRefreshToken.where("expires_at < ?", 7.days.ago)
deleted_count = expired_refresh_tokens.delete_all
Rails.logger.info "OIDC Token Cleanup: Deleted #{deleted_count} expired refresh tokens"
# Delete old revoked tokens (after 30 days for audit trail)
old_revoked_access_tokens = OidcAccessToken.where("revoked_at < ?", 30.days.ago)
deleted_count = old_revoked_access_tokens.delete_all
Rails.logger.info "OIDC Token Cleanup: Deleted #{deleted_count} old revoked access tokens"
old_revoked_refresh_tokens = OidcRefreshToken.where("revoked_at < ?", 30.days.ago)
deleted_count = old_revoked_refresh_tokens.delete_all
Rails.logger.info "OIDC Token Cleanup: Deleted #{deleted_count} old revoked refresh tokens"
# Delete old used authorization codes (after 7 days)
old_auth_codes = OidcAuthorizationCode.where("created_at < ?", 7.days.ago)
deleted_count = old_auth_codes.delete_all
Rails.logger.info "OIDC Token Cleanup: Deleted #{deleted_count} old authorization codes"
end
end

View File

@@ -1,4 +1,4 @@
class ApplicationMailer < ActionMailer::Base class ApplicationMailer < ActionMailer::Base
default from: ENV.fetch('CLINCH_EMAIL_FROM', 'clinch@example.com') default from: ENV.fetch('CLINCH_FROM_EMAIL', 'clinch@example.com')
layout "mailer" layout "mailer"
end end

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

@@ -0,0 +1,87 @@
class OidcRefreshToken < ApplicationRecord
belongs_to :application
belongs_to :user
belongs_to :oidc_access_token
has_many :oidc_access_tokens, foreign_key: :oidc_access_token_id, dependent: :nullify
before_validation :generate_token, on: :create
before_validation :set_expiry, on: :create
before_validation :set_token_family_id, on: :create
validates :token_digest, presence: true, uniqueness: true
scope :valid, -> { where("expires_at > ?", Time.current).where(revoked_at: nil) }
scope :expired, -> { where("expires_at <= ?", Time.current) }
scope :revoked, -> { where.not(revoked_at: nil) }
scope :active, -> { valid }
# For token rotation detection (prevents reuse attacks)
scope :in_family, ->(family_id) { where(token_family_id: family_id) }
attr_accessor :token # Store plaintext token temporarily for returning to client
def expired?
expires_at <= Time.current
end
def revoked?
revoked_at.present?
end
def active?
!expired? && !revoked?
end
def revoke!
update!(revoked_at: Time.current)
end
# Revoke all refresh tokens in the same family (token rotation security)
def revoke_family!
return unless token_family_id.present?
OidcRefreshToken.in_family(token_family_id).update_all(revoked_at: Time.current)
end
# Verify a plaintext token against the stored digest
def self.find_by_token(plaintext_token)
return nil if plaintext_token.blank?
# Try to find tokens that could match (we can't search by hash directly)
# This is less efficient but necessary with BCrypt
# In production, you might want to add a token prefix or other optimization
all.find do |refresh_token|
refresh_token.token_matches?(plaintext_token)
end
end
def token_matches?(plaintext_token)
return false if plaintext_token.blank? || token_digest.blank?
BCrypt::Password.new(token_digest) == plaintext_token
rescue BCrypt::Errors::InvalidHash
false
end
private
def generate_token
# Generate a secure random token
plaintext = SecureRandom.urlsafe_base64(48)
self.token = plaintext # Store temporarily for returning to client
# Hash it with BCrypt for storage
self.token_digest = BCrypt::Password.create(plaintext)
end
def set_expiry
# Use application's configured refresh token TTL
self.expires_at ||= application.refresh_token_expiry
end
def set_token_family_id
# Use a random ID to group tokens in the same rotation chain
# This helps detect token reuse attacks
self.token_family_id ||= SecureRandom.random_number(2**31)
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,
@@ -63,7 +65,9 @@ class OidcJwtService
def issuer_url def issuer_url
# In production, this should come from ENV or config # In production, this should come from ENV or config
# For now, we'll use a placeholder that can be overridden # For now, we'll use a placeholder that can be overridden
"https://#{ENV.fetch("CLINCH_HOST", "localhost:3000")}" host = ENV.fetch("CLINCH_HOST", "localhost:3000")
# Ensure URL has https:// protocol
host.match?(/^https?:\/\//) ? host : "https://#{host}"
end end
private private

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

@@ -37,6 +37,8 @@
<% case application.app_type %> <% case application.app_type %>
<% when "oidc" %> <% when "oidc" %>
<span class="inline-flex items-center rounded-full bg-purple-100 px-2 py-1 text-xs font-medium text-purple-700">OIDC</span> <span class="inline-flex items-center rounded-full bg-purple-100 px-2 py-1 text-xs font-medium text-purple-700">OIDC</span>
<% when "forward_auth" %>
<span class="inline-flex items-center rounded-full bg-blue-100 px-2 py-1 text-xs font-medium text-blue-700">Forward Auth</span>
<% when "saml" %> <% when "saml" %>
<span class="inline-flex items-center rounded-full bg-orange-100 px-2 py-1 text-xs font-medium text-orange-700">SAML</span> <span class="inline-flex items-center rounded-full bg-orange-100 px-2 py-1 text-xs font-medium text-orange-700">SAML</span>
<% end %> <% end %>

View File

@@ -39,9 +39,11 @@
<%= pluralize(group.applications.count, "app") %> <%= pluralize(group.applications.count, "app") %>
</td> </td>
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-0"> <td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-0">
<%= link_to "View", admin_group_path(group), class: "text-blue-600 hover:text-blue-900 mr-4" %> <div class="flex justify-end space-x-3">
<%= link_to "Edit", edit_admin_group_path(group), class: "text-blue-600 hover:text-blue-900 mr-4" %> <%= link_to "View", admin_group_path(group), class: "text-blue-600 hover:text-blue-900 whitespace-nowrap" %>
<%= button_to "Delete", admin_group_path(group), method: :delete, data: { turbo_confirm: "Are you sure you want to delete this group?" }, class: "text-red-600 hover:text-red-900" %> <%= link_to "Edit", edit_admin_group_path(group), class: "text-blue-600 hover:text-blue-900 whitespace-nowrap" %>
<%= button_to "Delete", admin_group_path(group), method: :delete, data: { turbo_confirm: "Are you sure you want to delete this group?" }, class: "text-red-600 hover:text-red-900 whitespace-nowrap" %>
</div>
</td> </td>
</tr> </tr>
<% end %> <% end %>

View File

@@ -57,7 +57,7 @@
</div> </div>
</div> </div>
<%= form_with url: oauth_consent_path, method: :post, class: "space-y-3", data: { turbo: false } do |form| %> <%= form_with url: "/oauth/authorize/consent", method: :post, class: "space-y-3", data: { turbo: false }, local: true do |form| %>
<%= form.submit "Authorize", <%= form.submit "Authorize",
class: "w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %> class: "w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %>

View File

@@ -80,14 +80,28 @@ Rails.application.configure do
# Only use :id for inspections in production. # Only use :id for inspections in production.
config.active_record.attributes_for_inspect = [ :id ] config.active_record.attributes_for_inspect = [ :id ]
# Helper method to extract domain from CLINCH_HOST (removes protocol if present)
def self.extract_domain(host)
return host if host.blank?
# Remove protocol (http:// or https://) if present
host.gsub(/^https?:\/\//, '')
end
# Helper method to ensure URL has https:// protocol
def self.ensure_https(url)
return url if url.blank?
# Add https:// if no protocol is present
url.match?(/^https?:\/\//) ? url : "https://#{url}"
end
# Enable DNS rebinding protection and other `Host` header attacks. # Enable DNS rebinding protection and other `Host` header attacks.
# Configure allowed hosts based on deployment scenario # Configure allowed hosts based on deployment scenario
allowed_hosts = [ allowed_hosts = [
ENV.fetch('CLINCH_HOST', 'auth.example.com'), # External domain (auth service itself) extract_domain(ENV.fetch('CLINCH_HOST', 'auth.example.com')), # External domain (auth service itself)
] ]
# Use PublicSuffix to extract registrable domain and allow all subdomains # Use PublicSuffix to extract registrable domain and allow all subdomains
host_domain = ENV.fetch('CLINCH_HOST', 'auth.example.com') host_domain = extract_domain(ENV.fetch('CLINCH_HOST', 'auth.example.com'))
if host_domain.present? if host_domain.present?
begin begin
# Use PublicSuffix to properly extract the domain # Use PublicSuffix to properly extract the domain

View File

@@ -39,6 +39,7 @@ Rails.application.configure do
policy.base_uri :self policy.base_uri :self
# Form actions: Allow self for all form submissions # Form actions: Allow self for all form submissions
# Note: OAuth redirects will be handled dynamically in the consent page
policy.form_action :self policy.form_action :self
# Manifest sources: Allow self for PWA manifest # Manifest sources: Allow self for PWA manifest
@@ -53,9 +54,12 @@ Rails.application.configure do
# Additional security headers for WebAuthn # Additional security headers for WebAuthn
# Required for WebAuthn to work properly # Required for WebAuthn to work properly
policy.require_trusted_types_for :none policy.require_trusted_types_for :none
# CSP reporting using report_uri (supported method)
policy.report_uri "/api/csp-violation-report" policy.report_uri "/api/csp-violation-report"
end end
# Start with CSP in report-only mode for testing # Start with CSP in report-only mode for testing
# Set to false after verifying everything works in production # Set to false after verifying everything works in production
config.content_security_policy_report_only = Rails.env.development? config.content_security_policy_report_only = Rails.env.development?

View File

@@ -23,6 +23,12 @@ Rails.application.config.after_initialize do
def self.emit(event) def self.emit(event)
csp_data = event[:payload] || {} csp_data = event[:payload] || {}
# Skip logging if there's no meaningful violation data
return if csp_data.empty? ||
(csp_data[:violated_directive].nil? &&
csp_data[:blocked_uri].nil? &&
csp_data[:document_uri].nil?)
# Build a structured log message # Build a structured log message
violated_directive = csp_data[:violated_directive] || "unknown" violated_directive = csp_data[:violated_directive] || "unknown"
blocked_uri = csp_data[:blocked_uri] || "unknown" blocked_uri = csp_data[:blocked_uri] || "unknown"

View File

@@ -1,14 +1,31 @@
# WebAuthn configuration for Clinch Identity Provider # WebAuthn configuration for Clinch Identity Provider
WebAuthn.configure do |config| WebAuthn.configure do |config|
# Relying Party name (displayed in authenticator prompts) # Relying Party name (displayed in authenticator prompts)
# For development, use http://localhost to match passkey in Passwords app # CLINCH_HOST should include protocol (https://) for WebAuthn
origin_host = ENV.fetch("CLINCH_HOST", "http://localhost") origin_host = ENV.fetch("CLINCH_HOST", "http://localhost")
config.allowed_origins = [origin_host] config.allowed_origins = [origin_host]
# Relying Party ID (must match origin domain) # Relying Party ID (must match origin domain without protocol)
# Extract domain from origin for RP ID # Extract domain from origin for RP ID if CLINCH_RP_ID not set
if ENV["CLINCH_RP_ID"].present?
config.rp_id = ENV["CLINCH_RP_ID"]
else
# Extract registrable domain from CLINCH_HOST using PublicSuffix
origin_uri = URI.parse(origin_host) origin_uri = URI.parse(origin_host)
config.rp_id = ENV.fetch("CLINCH_RP_ID", "localhost") if origin_uri.host
begin
# Use PublicSuffix to get the registrable domain (e.g., "aapamilne.com" from "auth.aapamilne.com")
domain = PublicSuffix.parse(origin_uri.host)
config.rp_id = domain.domain || origin_uri.host
rescue PublicSuffix::DomainInvalid => e
Rails.logger.warn "WebAuthn: Failed to parse domain '#{origin_uri.host}': #{e.message}, using host as fallback"
config.rp_id = origin_uri.host
end
else
Rails.logger.error "WebAuthn: Could not extract host from CLINCH_HOST '#{origin_host}'"
config.rp_id = "localhost"
end
end
# For development, we also allow localhost with common ports and without port # For development, we also allow localhost with common ports and without port
if Rails.env.development? if Rails.env.development?

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"

View File

@@ -0,0 +1,22 @@
class CreateOidcRefreshTokens < ActiveRecord::Migration[8.1]
def change
create_table :oidc_refresh_tokens do |t|
t.string :token_digest, null: false # BCrypt hashed token
t.references :application, null: false, foreign_key: true
t.references :user, null: false, foreign_key: true
t.references :oidc_access_token, null: false, foreign_key: true
t.string :scope
t.datetime :expires_at, null: false
t.datetime :revoked_at
t.integer :token_family_id # For token rotation detection
t.timestamps
end
add_index :oidc_refresh_tokens, :token_digest, unique: true
add_index :oidc_refresh_tokens, :expires_at
add_index :oidc_refresh_tokens, :revoked_at
add_index :oidc_refresh_tokens, :token_family_id
add_index :oidc_refresh_tokens, [ :application_id, :user_id ]
end
end

View File

@@ -0,0 +1,9 @@
class AddTokenDigestToOidcAccessTokens < ActiveRecord::Migration[8.1]
def change
add_column :oidc_access_tokens, :token_digest, :string
add_column :oidc_access_tokens, :revoked_at, :datetime
add_index :oidc_access_tokens, :token_digest, unique: true
add_index :oidc_access_tokens, :revoked_at
end
end

View File

@@ -0,0 +1,7 @@
class AddTokenExpiryToApplications < ActiveRecord::Migration[8.1]
def change
add_column :applications, :access_token_ttl, :integer, default: 3600 # 1 hour in seconds
add_column :applications, :refresh_token_ttl, :integer, default: 2592000 # 30 days in seconds
add_column :applications, :id_token_ttl, :integer, default: 3600 # 1 hour in seconds
end
end

View File

@@ -0,0 +1,5 @@
class MakeOidcAccessTokenTokenNullable < ActiveRecord::Migration[8.1]
def change
change_column_null :oidc_access_tokens, :token, true
end
end

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"

View File

@@ -0,0 +1,440 @@
require "test_helper"
class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
def setup
@user = User.create!(email_address: "security_test@example.com", password: "password123")
@application = Application.create!(
name: "Security Test App",
slug: "security-test-app",
app_type: "oidc",
redirect_uris: ["http://localhost:4000/callback"].to_json,
active: true
)
# Store the plain text client secret for testing
@client_secret = @application.client_secret_digest
@application.generate_new_client_secret!
@plain_client_secret = @application.client_secret
@application.save!
end
def teardown
OidcAuthorizationCode.where(application: @application).destroy_all
OidcAccessToken.where(application: @application).destroy_all
@user.destroy
@application.destroy
end
# ====================
# CRITICAL SECURITY TESTS
# ====================
test "prevents authorization code reuse - sequential attempts" do
# Create a valid authorization code
auth_code = OidcAuthorizationCode.create!(
application: @application,
user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
expires_at: 10.minutes.from_now
)
token_params = {
grant_type: "authorization_code",
code: auth_code.code,
redirect_uri: "http://localhost:4000/callback"
}
# First request should succeed
post "/oauth/token", params: token_params, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
}
assert_response :success
first_response = JSON.parse(@response.body)
assert first_response.key?("access_token")
assert first_response.key?("id_token")
# Second request with same code should fail
post "/oauth/token", params: token_params, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
}
assert_response :bad_request
error = JSON.parse(@response.body)
assert_equal "invalid_grant", error["error"]
assert_match(/already been used/, error["error_description"])
end
test "revokes existing tokens when authorization code is reused" do
# Create a valid authorization code
auth_code = OidcAuthorizationCode.create!(
application: @application,
user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
expires_at: 10.minutes.from_now
)
token_params = {
grant_type: "authorization_code",
code: auth_code.code,
redirect_uri: "http://localhost:4000/callback"
}
# First request - get access token
post "/oauth/token", params: token_params, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
}
assert_response :success
first_response = JSON.parse(@response.body)
first_access_token = first_response["access_token"]
# Verify the token works
get "/oauth/userinfo", headers: {
"Authorization" => "Bearer #{first_access_token}"
}
assert_response :success
# Second request with same code - should fail AND revoke first token
post "/oauth/token", params: token_params, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
}
assert_response :bad_request
# Verify the first token is now revoked (expired)
get "/oauth/userinfo", headers: {
"Authorization" => "Bearer #{first_access_token}"
}
assert_response :unauthorized, "First access token should be revoked after code reuse"
end
test "rejects already used authorization code" do
# Create and mark code as used
auth_code = OidcAuthorizationCode.create!(
application: @application,
user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
used: true,
expires_at: 10.minutes.from_now
)
token_params = {
grant_type: "authorization_code",
code: auth_code.code,
redirect_uri: "http://localhost:4000/callback"
}
post "/oauth/token", params: token_params, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
}
assert_response :bad_request
error = JSON.parse(@response.body)
assert_equal "invalid_grant", error["error"]
assert_match(/already been used/, error["error_description"])
end
test "rejects expired authorization code" do
# Create expired code
auth_code = OidcAuthorizationCode.create!(
application: @application,
user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
expires_at: 5.minutes.ago
)
token_params = {
grant_type: "authorization_code",
code: auth_code.code,
redirect_uri: "http://localhost:4000/callback"
}
post "/oauth/token", params: token_params, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
}
assert_response :bad_request
error = JSON.parse(@response.body)
assert_equal "invalid_grant", error["error"]
assert_match(/expired/, error["error_description"])
end
test "rejects authorization code with mismatched redirect_uri" do
auth_code = OidcAuthorizationCode.create!(
application: @application,
user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
expires_at: 10.minutes.from_now
)
token_params = {
grant_type: "authorization_code",
code: auth_code.code,
redirect_uri: "http://evil.com/callback" # Wrong redirect URI
}
post "/oauth/token", params: token_params, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
}
assert_response :bad_request
error = JSON.parse(@response.body)
assert_equal "invalid_grant", error["error"]
assert_match(/Redirect URI mismatch/, error["error_description"])
end
test "rejects non-existent authorization code" do
token_params = {
grant_type: "authorization_code",
code: "nonexistent_code_12345",
redirect_uri: "http://localhost:4000/callback"
}
post "/oauth/token", params: token_params, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
}
assert_response :bad_request
error = JSON.parse(@response.body)
assert_equal "invalid_grant", error["error"]
end
test "rejects authorization code for different application" do
# Create another application
other_app = Application.create!(
name: "Other App",
slug: "other-app",
app_type: "oidc",
redirect_uris: ["http://localhost:5000/callback"].to_json,
active: true
)
other_secret = other_app.client_secret
# Create auth code for first application
auth_code = OidcAuthorizationCode.create!(
application: @application,
user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
expires_at: 10.minutes.from_now
)
# Try to use it with different application credentials
token_params = {
grant_type: "authorization_code",
code: auth_code.code,
redirect_uri: "http://localhost:4000/callback"
}
post "/oauth/token", params: token_params, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{other_app.client_id}:#{other_secret}")
}
assert_response :bad_request
error = JSON.parse(@response.body)
assert_equal "invalid_grant", error["error"]
other_app.destroy
end
# ====================
# CLIENT AUTHENTICATION TESTS
# ====================
test "rejects invalid client_id in Basic auth" do
auth_code = OidcAuthorizationCode.create!(
application: @application,
user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
expires_at: 10.minutes.from_now
)
token_params = {
grant_type: "authorization_code",
code: auth_code.code,
redirect_uri: "http://localhost:4000/callback"
}
post "/oauth/token", params: token_params, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("invalid_client_id:#{@plain_client_secret}")
}
assert_response :unauthorized
error = JSON.parse(@response.body)
assert_equal "invalid_client", error["error"]
end
test "rejects invalid client_secret in Basic auth" do
auth_code = OidcAuthorizationCode.create!(
application: @application,
user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
expires_at: 10.minutes.from_now
)
token_params = {
grant_type: "authorization_code",
code: auth_code.code,
redirect_uri: "http://localhost:4000/callback"
}
post "/oauth/token", params: token_params, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:wrong_secret")
}
assert_response :unauthorized
error = JSON.parse(@response.body)
assert_equal "invalid_client", error["error"]
end
test "accepts client credentials in POST body" do
auth_code = OidcAuthorizationCode.create!(
application: @application,
user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
expires_at: 10.minutes.from_now
)
token_params = {
grant_type: "authorization_code",
code: auth_code.code,
redirect_uri: "http://localhost:4000/callback",
client_id: @application.client_id,
client_secret: @plain_client_secret
}
post "/oauth/token", params: token_params
assert_response :success
response_body = JSON.parse(@response.body)
assert response_body.key?("access_token")
assert response_body.key?("id_token")
end
test "rejects request with no client authentication" do
auth_code = OidcAuthorizationCode.create!(
application: @application,
user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
expires_at: 10.minutes.from_now
)
token_params = {
grant_type: "authorization_code",
code: auth_code.code,
redirect_uri: "http://localhost:4000/callback"
}
post "/oauth/token", params: token_params
assert_response :unauthorized
error = JSON.parse(@response.body)
assert_equal "invalid_client", error["error"]
end
# ====================
# GRANT TYPE VALIDATION
# ====================
test "rejects unsupported grant_type" do
post "/oauth/token", params: {
grant_type: "password",
username: "user",
password: "pass"
}, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
}
assert_response :bad_request
error = JSON.parse(@response.body)
assert_equal "unsupported_grant_type", error["error"]
end
test "rejects missing grant_type" do
post "/oauth/token", params: {
code: "some_code",
redirect_uri: "http://localhost:4000/callback"
}, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
}
assert_response :bad_request
error = JSON.parse(@response.body)
assert_equal "unsupported_grant_type", error["error"]
end
# ====================
# TIMING ATTACK PROTECTION
# ====================
test "client authentication uses constant-time comparison" do
auth_code = OidcAuthorizationCode.create!(
application: @application,
user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
expires_at: 10.minutes.from_now
)
token_params = {
grant_type: "authorization_code",
code: auth_code.code,
redirect_uri: "http://localhost:4000/callback"
}
# Test with completely wrong secret
times_wrong = []
5.times do
start_time = Time.now.to_f
post "/oauth/token", params: token_params, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:wrong_secret_xxx")
}
times_wrong << (Time.now.to_f - start_time)
assert_response :unauthorized
end
# Test with almost correct secret (differs by one character)
correct_secret = @plain_client_secret
almost_correct = correct_secret[0..-2] + "X"
times_almost = []
5.times do
start_time = Time.now.to_f
post "/oauth/token", params: token_params, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{almost_correct}")
}
times_almost << (Time.now.to_f - start_time)
assert_response :unauthorized
end
# The timing difference should be minimal (within 50ms) if using constant-time comparison
avg_wrong = times_wrong.sum / times_wrong.size
avg_almost = times_almost.sum / times_almost.size
timing_difference = (avg_wrong - avg_almost).abs
# This is a best-effort check - in practice, constant-time comparison is handled by bcrypt
assert timing_difference < 0.05,
"Timing difference #{timing_difference}s suggests potential timing attack vulnerability"
end
end

View File

@@ -0,0 +1,299 @@
require "test_helper"
class OidcPkceControllerTest < ActionDispatch::IntegrationTest
def setup
@user = User.create!(email_address: "pkce_test@example.com", password: "password123")
@application = Application.create!(
name: "PKCE Test App",
slug: "pkce-test-app",
app_type: "oidc",
redirect_uris: ["http://localhost:4000/callback"].to_json,
active: true
)
# Sign in the user using the test helper
sign_in_as(@user)
end
def teardown
Current.session&.destroy
OidcAuthorizationCode.where(application: @application).destroy_all
OidcAccessToken.where(application: @application).destroy_all
@user.destroy
@application.destroy
end
test "discovery endpoint includes PKCE support" do
get "/.well-known/openid-configuration"
assert_response :success
config = JSON.parse(@response.body)
assert config.key?("code_challenge_methods_supported")
assert_includes config["code_challenge_methods_supported"], "S256"
assert_includes config["code_challenge_methods_supported"], "plain"
end
test "authorization endpoint accepts PKCE parameters (S256)" do
code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
code_challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
auth_params = {
response_type: "code",
client_id: @application.client_id,
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
state: "test_state",
nonce: "test_nonce",
code_challenge: code_challenge,
code_challenge_method: "S256"
}
get "/oauth/authorize", params: auth_params
# Should show consent page (user is already authenticated)
assert_response :success
assert_match /consent/, @response.body.downcase
end
test "authorization endpoint accepts PKCE parameters (plain)" do
code_challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
auth_params = {
response_type: "code",
client_id: @application.client_id,
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
state: "test_state",
nonce: "test_nonce",
code_challenge: code_challenge,
code_challenge_method: "plain"
}
get "/oauth/authorize", params: auth_params
# Should show consent page (user is already authenticated)
assert_response :success
assert_match /consent/, @response.body.downcase
end
test "authorization endpoint rejects invalid code_challenge_method" do
auth_params = {
response_type: "code",
client_id: @application.client_id,
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
code_challenge_method: "invalid_method"
}
get "/oauth/authorize", params: auth_params
assert_response :bad_request
assert_match(/Invalid code_challenge_method/, @response.body)
end
test "authorization endpoint rejects invalid code_challenge format" do
# Contains + character which is not base64url
auth_params = {
response_type: "code",
client_id: @application.client_id,
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
code_challenge: "invalid+challenge",
code_challenge_method: "S256"
}
get "/oauth/authorize", params: auth_params
assert_response :bad_request
assert_match(/Invalid code_challenge format/, @response.body)
end
test "token endpoint requires code_verifier when PKCE was used (S256)" do
# Create authorization code with PKCE S256
auth_code = OidcAuthorizationCode.create!(
application: @application,
user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
code_challenge_method: "S256",
expires_at: 10.minutes.from_now
)
token_params = {
grant_type: "authorization_code",
code: auth_code.code,
redirect_uri: "http://localhost:4000/callback"
}
post "/oauth/token", params: token_params, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@application.client_secret}")
}
assert_response :bad_request
error = JSON.parse(@response.body)
assert_equal "invalid_request", error["error"]
assert_match(/code_verifier is required/, error["error_description"])
end
test "token endpoint requires code_verifier when PKCE was used (plain)" do
# Create authorization code with PKCE plain
auth_code = OidcAuthorizationCode.create!(
application: @application,
user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
code_challenge_method: "plain",
expires_at: 10.minutes.from_now
)
token_params = {
grant_type: "authorization_code",
code: auth_code.code,
redirect_uri: "http://localhost:4000/callback"
}
post "/oauth/token", params: token_params, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@application.client_secret}")
}
assert_response :bad_request
error = JSON.parse(@response.body)
assert_equal "invalid_request", error["error"]
assert_match(/code_verifier is required/, error["error_description"])
end
test "token endpoint rejects invalid code_verifier (S256)" do
# Create authorization code with PKCE S256
auth_code = OidcAuthorizationCode.create!(
application: @application,
user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
code_challenge_method: "S256",
expires_at: 10.minutes.from_now
)
token_params = {
grant_type: "authorization_code",
code: auth_code.code,
redirect_uri: "http://localhost:4000/callback",
# Use a properly formatted but wrong verifier (43+ chars, base64url)
code_verifier: "wrongverifier_with_enough_characters_base64url"
}
post "/oauth/token", params: token_params, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@application.client_secret}")
}
assert_response :bad_request
error = JSON.parse(@response.body)
assert_equal "invalid_grant", error["error"]
assert_match(/Invalid code verifier/, error["error_description"])
end
test "token endpoint accepts valid code_verifier (S256)" do
# Generate valid PKCE pair
code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
code_challenge = Digest::SHA256.base64digest(code_verifier)
.tr("+/", "-_")
.tr("=", "")
# Create authorization code with PKCE S256
auth_code = OidcAuthorizationCode.create!(
application: @application,
user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
code_challenge: code_challenge,
code_challenge_method: "S256",
expires_at: 10.minutes.from_now
)
token_params = {
grant_type: "authorization_code",
code: auth_code.code,
redirect_uri: "http://localhost:4000/callback",
code_verifier: code_verifier
}
post "/oauth/token", params: token_params, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@application.client_secret}")
}
assert_response :success
tokens = JSON.parse(@response.body)
assert tokens.key?("access_token")
assert tokens.key?("id_token")
assert_equal "Bearer", tokens["token_type"]
end
test "token endpoint accepts valid code_verifier (plain)" do
code_verifier = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
# Create authorization code with PKCE plain
auth_code = OidcAuthorizationCode.create!(
application: @application,
user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
code_challenge: code_verifier, # Same as verifier for plain method
code_challenge_method: "plain",
expires_at: 10.minutes.from_now
)
token_params = {
grant_type: "authorization_code",
code: auth_code.code,
redirect_uri: "http://localhost:4000/callback",
code_verifier: code_verifier
}
post "/oauth/token", params: token_params, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@application.client_secret}")
}
assert_response :success
tokens = JSON.parse(@response.body)
assert tokens.key?("access_token")
assert tokens.key?("id_token")
assert_equal "Bearer", tokens["token_type"]
end
test "token endpoint works without PKCE (backward compatibility)" do
# Create authorization code without PKCE
auth_code = OidcAuthorizationCode.create!(
application: @application,
user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
expires_at: 10.minutes.from_now
)
token_params = {
grant_type: "authorization_code",
code: auth_code.code,
redirect_uri: "http://localhost:4000/callback"
}
post "/oauth/token", params: token_params, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@application.client_secret}")
}
assert_response :success
tokens = JSON.parse(@response.body)
assert tokens.key?("access_token")
assert tokens.key?("id_token")
assert_equal "Bearer", tokens["token_type"]
end
end

View File

@@ -0,0 +1,235 @@
require "test_helper"
class OidcRefreshTokenControllerTest < ActionDispatch::IntegrationTest
setup do
@user = users(:alice)
@application = applications(:kavita_app)
# Store a known client secret for testing
@client_secret = SecureRandom.urlsafe_base64(48)
@application.client_secret = @client_secret
@application.save!
end
test "token endpoint returns refresh_token with authorization_code grant" do
# Create an authorization code
auth_code = OidcAuthorizationCode.create!(
application: @application,
user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: @application.parsed_redirect_uris.first,
scope: "openid profile email",
expires_at: 10.minutes.from_now
)
# Exchange authorization code for tokens
post "/oauth/token", params: {
grant_type: "authorization_code",
code: auth_code.code,
redirect_uri: @application.parsed_redirect_uris.first,
client_id: @application.client_id,
client_secret: @client_secret
}
assert_response :success
json = JSON.parse(response.body)
assert json["access_token"].present?
assert json["id_token"].present?
assert json["refresh_token"].present?
assert_equal "Bearer", json["token_type"]
assert_equal 3600, json["expires_in"]
end
test "refresh_token grant exchanges refresh token for new tokens" do
# Create access and refresh tokens
access_token = OidcAccessToken.create!(
application: @application,
user: @user,
scope: "openid profile email"
)
refresh_token = OidcRefreshToken.create!(
application: @application,
user: @user,
oidc_access_token: access_token,
scope: "openid profile email"
)
# Store the plaintext refresh token (available only during creation)
plaintext_refresh_token = refresh_token.token
# Use refresh token to get new tokens
post "/oauth/token", params: {
grant_type: "refresh_token",
refresh_token: plaintext_refresh_token,
client_id: @application.client_id,
client_secret: @client_secret
}
assert_response :success
json = JSON.parse(response.body)
assert json["access_token"].present?
assert json["id_token"].present?
assert json["refresh_token"].present?
assert_equal "Bearer", json["token_type"]
# Old refresh token should be revoked
assert refresh_token.reload.revoked?
end
test "refresh_token grant fails with expired refresh token" do
access_token = OidcAccessToken.create!(
application: @application,
user: @user,
scope: "openid profile email"
)
refresh_token = OidcRefreshToken.create!(
application: @application,
user: @user,
oidc_access_token: access_token,
scope: "openid profile email",
expires_at: 1.hour.ago # Expired
)
plaintext_refresh_token = refresh_token.token
post "/oauth/token", params: {
grant_type: "refresh_token",
refresh_token: plaintext_refresh_token,
client_id: @application.client_id,
client_secret: @client_secret
}
assert_response :bad_request
json = JSON.parse(response.body)
assert_equal "invalid_grant", json["error"]
end
test "refresh_token grant fails with revoked refresh token" do
access_token = OidcAccessToken.create!(
application: @application,
user: @user,
scope: "openid profile email"
)
refresh_token = OidcRefreshToken.create!(
application: @application,
user: @user,
oidc_access_token: access_token,
scope: "openid profile email"
)
plaintext_refresh_token = refresh_token.token
refresh_token.revoke!
post "/oauth/token", params: {
grant_type: "refresh_token",
refresh_token: plaintext_refresh_token,
client_id: @application.client_id,
client_secret: @client_secret
}
assert_response :bad_request
json = JSON.parse(response.body)
assert_equal "invalid_grant", json["error"]
end
test "token revocation endpoint revokes access tokens" do
access_token = OidcAccessToken.create!(
application: @application,
user: @user,
scope: "openid profile email"
)
plaintext_access_token = access_token.plaintext_token
post "/oauth/revoke", params: {
token: plaintext_access_token,
token_type_hint: "access_token",
client_id: @application.client_id,
client_secret: @client_secret
}
assert_response :success
assert access_token.reload.revoked?
end
test "token revocation endpoint revokes refresh tokens" do
access_token = OidcAccessToken.create!(
application: @application,
user: @user,
scope: "openid profile email"
)
refresh_token = OidcRefreshToken.create!(
application: @application,
user: @user,
oidc_access_token: access_token,
scope: "openid profile email"
)
plaintext_refresh_token = refresh_token.token
post "/oauth/revoke", params: {
token: plaintext_refresh_token,
token_type_hint: "refresh_token",
client_id: @application.client_id,
client_secret: @client_secret
}
assert_response :success
assert refresh_token.reload.revoked?
end
test "token rotation: new refresh token has same family id" do
access_token = OidcAccessToken.create!(
application: @application,
user: @user,
scope: "openid profile email"
)
old_refresh_token = OidcRefreshToken.create!(
application: @application,
user: @user,
oidc_access_token: access_token,
scope: "openid profile email"
)
family_id = old_refresh_token.token_family_id
plaintext_refresh_token = old_refresh_token.token
post "/oauth/token", params: {
grant_type: "refresh_token",
refresh_token: plaintext_refresh_token,
client_id: @application.client_id,
client_secret: @client_secret
}
assert_response :success
# Find the new refresh token
new_refresh_token = OidcRefreshToken.active.where(user: @user, application: @application).last
assert_equal family_id, new_refresh_token.token_family_id
end
test "userinfo endpoint works with hashed access token" do
access_token = OidcAccessToken.create!(
application: @application,
user: @user,
scope: "openid profile email"
)
plaintext_token = access_token.plaintext_token
get "/oauth/userinfo", headers: {
"Authorization" => "Bearer #{plaintext_token}"
}
assert_response :success
json = JSON.parse(response.body)
assert_equal @user.id.to_s, json["sub"]
assert_equal @user.email_address, json["email"]
end
end

View File

@@ -0,0 +1,7 @@
require "test_helper"
class OidcTokenCleanupJobTest < ActiveJob::TestCase
# test "the truth" do
# assert true
# end
end