4 Commits

Author SHA1 Message Date
Dan Milne
fd8785a43d Add API keys / bearer tokens for forward auth
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / scan_container (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
Enables server-to-server authentication for forward auth applications
(e.g., video players accessing WebDAV) where browser cookies aren't
available. API keys use clk_ prefixed tokens stored as HMAC hashes.

Bearer token auth is checked before cookie auth in /api/verify.
Invalid tokens return 401 JSON (no redirect). Requests without
bearer tokens fall through to existing cookie flow unchanged.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 21:45:40 +11:00
Dan Milne
444ae6291c Add missing files, fix formatting
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / scan_container (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2026-01-05 23:34:11 +11:00
Dan Milne
233fb723d5 More accurate language around passing the OpenID Conformance tests
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / scan_container (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2026-01-05 23:32:34 +11:00
Dan Milne
cc6d4fcc65 Add test files, update checklist
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / scan_container (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2026-01-05 23:28:55 +11:00
24 changed files with 1586 additions and 12 deletions

View File

@@ -76,7 +76,7 @@ Apps that only need "who is it?", or you want available from the internet behind
#### OpenID Connect (OIDC)
**[OpenID Certified](https://www.certification.openid.net/plan-detail.html?plan=FbQNTJuYVzrzs&public=true)** - Clinch passes the official OpenID Connect conformance tests (valid as of [v0.8.6](https://github.com/dkam/clinch/releases/tag/0.8.6)).
**[OpenID Connect Conformance](https://www.certification.openid.net/plan-detail.html?plan=FbQNTJuYVzrzs&public=true)** - Clinch passes the official OpenID Connect conformance tests (valid as of [v0.8.6](https://github.com/dkam/clinch/releases/tag/0.8.6)).
Standard OAuth2/OIDC provider with endpoints:
- `/.well-known/openid-configuration` - Discovery endpoint

View File

@@ -11,6 +11,10 @@ module Api
def verify
# Note: app_slug parameter is no longer used - we match domains directly with Application (forward_auth type)
# Check for bearer token first (API keys for server-to-server auth)
bearer_result = authenticate_bearer_token
return bearer_result if bearer_result
# Check for one-time forward auth token first (to handle race condition)
session_id = check_forward_auth_token
@@ -113,6 +117,43 @@ module Api
private
def authenticate_bearer_token
auth_header = request.headers["Authorization"]
return nil unless auth_header&.start_with?("Bearer ")
token = auth_header.delete_prefix("Bearer ").strip
return render_bearer_error("Missing token") if token.blank?
api_key = ApiKey.find_by_token(token)
return render_bearer_error("Invalid or expired API key") unless api_key&.active?
user = api_key.user
return render_bearer_error("User account is not active") unless user.active?
forwarded_host = request.headers["X-Forwarded-Host"] || request.headers["Host"]
app = api_key.application
if forwarded_host.present? && !app.matches_domain?(forwarded_host)
return render_bearer_error("API key not valid for this domain")
end
unless app.active?
return render_bearer_error("Application is inactive")
end
api_key.touch_last_used!
headers = app.headers_for_user(user)
headers.each { |key, value| response.headers[key] = value }
Rails.logger.info "ForwardAuth: API key '#{api_key.name}' authenticated user #{user.email_address} for #{forwarded_host}"
head :ok
end
def render_bearer_error(message)
render json: { error: message }, status: :unauthorized
end
def check_forward_auth_token
# Check for one-time token in query parameters (for race condition handling)
token = params[:fa_token]

View File

@@ -0,0 +1,51 @@
class ApiKeysController < ApplicationController
before_action :set_api_key, only: :destroy
def index
@api_keys = Current.session.user.api_keys.includes(:application).order(created_at: :desc)
end
def new
@api_key = ApiKey.new
@applications = forward_auth_apps_for_user
end
def create
@api_key = Current.session.user.api_keys.build(api_key_params)
if @api_key.save
flash[:api_key_token] = @api_key.plaintext_token
redirect_to api_key_path(@api_key)
else
@applications = forward_auth_apps_for_user
render :new, status: :unprocessable_entity
end
end
def show
@api_key = Current.session.user.api_keys.find(params[:id])
@plaintext_token = flash[:api_key_token]
redirect_to api_keys_path unless @plaintext_token
end
def destroy
@api_key.revoke!
redirect_to api_keys_path, notice: "API key revoked."
end
private
def set_api_key
@api_key = Current.session.user.api_keys.find(params[:id])
end
def api_key_params
params.require(:api_key).permit(:name, :application_id, :expires_at)
end
def forward_auth_apps_for_user
user = Current.session.user
Application.forward_auth.active.select { |app| app.user_allowed?(user) }
end
end

View File

@@ -422,7 +422,11 @@ class OidcController < ApplicationController
# Record user consent
requested_scopes = oauth_params["scope"].split(" ")
parsed_claims = JSON.parse(oauth_params["claims_requests"]) rescue {}
parsed_claims = begin
JSON.parse(oauth_params["claims_requests"])
rescue
{}
end
consent = OidcUserConsent.find_or_initialize_by(user: user, application: application)
consent.scopes_granted = requested_scopes.join(" ")
@@ -780,10 +784,10 @@ class OidcController < ApplicationController
# Extract access token from Authorization header or POST body
# RFC 6750: Bearer token can be in Authorization header, request body, or query string
token = if request.headers["Authorization"]&.start_with?("Bearer ")
request.headers["Authorization"].sub("Bearer ", "")
elsif request.params["access_token"].present?
request.params["access_token"]
end
request.headers["Authorization"].sub("Bearer ", "")
elsif request.params["access_token"].present?
request.params["access_token"]
end
unless token
head :unauthorized
@@ -1026,7 +1030,7 @@ class OidcController < ApplicationController
end
# Validate code verifier format (per RFC 7636: [A-Za-z0-9\-._~], 43-128 characters)
unless code_verifier.match?(/\A[A-Za-z0-9\.\-_~]{43,128}\z/)
unless code_verifier.match?(/\A[A-Za-z0-9.\-_~]{43,128}\z/)
return {
valid: false,
error: "invalid_request",

View File

@@ -0,0 +1,15 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["source", "label"]
async copy() {
try {
await navigator.clipboard.writeText(this.sourceTarget.value)
this.labelTarget.textContent = "Copied!"
setTimeout(() => { this.labelTarget.textContent = "Copy" }, 2000)
} catch {
this.sourceTarget.select()
}
}
}

View File

@@ -0,0 +1,45 @@
class DurationParser
UNITS = {
"s" => 1, # seconds
"m" => 60, # minutes
"h" => 3600, # hours
"d" => 86400, # days
"w" => 604800, # weeks
"M" => 2592000, # months (30 days)
"y" => 31536000 # years (365 days)
}
# Parse a duration string into seconds
# Accepts formats: "1h", "30m", "1d", "1M" (month), "3600" (plain number)
# Returns integer seconds or nil if invalid
# Case-sensitive: 1s, 1m, 1h, 1d, 1w, 1M (month), 1y
def self.parse(input)
# Handle integers directly
return input if input.is_a?(Integer)
# Convert to string and strip whitespace
str = input.to_s.strip
# Return nil for blank input
return nil if str.blank?
# Try to parse as plain number (already in seconds)
if str.match?(/^\d+$/)
return str.to_i
end
# Try to parse with unit (e.g., "1h", "30m", "1M")
# Allow optional space between number and unit
# Case-sensitive to avoid confusion (1m = minute, 1M = month)
match = str.match(/^(\d+)\s*([smhdwMy])$/)
return nil unless match
number = match[1].to_i
unit = match[2]
multiplier = UNITS[unit]
return nil unless multiplier
number * multiplier
end
end

66
app/models/api_key.rb Normal file
View File

@@ -0,0 +1,66 @@
class ApiKey < ApplicationRecord
belongs_to :user
belongs_to :application
before_validation :generate_token, on: :create
validates :name, presence: true
validates :token_hmac, presence: true, uniqueness: true
validate :application_must_be_forward_auth
validate :user_must_have_access
scope :active, -> { where(revoked_at: nil).where("expires_at IS NULL OR expires_at > ?", Time.current) }
scope :revoked, -> { where.not(revoked_at: nil) }
attr_accessor :plaintext_token
def self.find_by_token(plaintext_token)
return nil if plaintext_token.blank?
token_hmac = compute_token_hmac(plaintext_token)
find_by(token_hmac: token_hmac)
end
def self.compute_token_hmac(plaintext_token)
OpenSSL::HMAC.hexdigest("SHA256", TokenHmac::KEY, plaintext_token)
end
def expired?
expires_at.present? && expires_at <= Time.current
end
def revoked?
revoked_at.present?
end
def active?
!expired? && !revoked?
end
def revoke!
update!(revoked_at: Time.current)
end
def touch_last_used!
update_column(:last_used_at, Time.current)
end
private
def generate_token
self.plaintext_token ||= "clk_#{SecureRandom.urlsafe_base64(48)}"
self.token_hmac ||= self.class.compute_token_hmac(plaintext_token)
end
def application_must_be_forward_auth
if application && !application.forward_auth?
errors.add(:application, "must be a forward auth application")
end
end
def user_must_have_access
if user && application && !application.user_allowed?(user)
errors.add(:user, "does not have access to this application")
end
end
end

View File

@@ -34,6 +34,7 @@ class Application < ApplicationRecord
has_many :oidc_access_tokens, dependent: :destroy
has_many :oidc_refresh_tokens, dependent: :destroy
has_many :oidc_user_consents, dependent: :destroy
has_many :api_keys, dependent: :destroy
validates :name, presence: true
validates :slug, presence: true, uniqueness: {case_sensitive: false},

View File

@@ -9,6 +9,7 @@ class User < ApplicationRecord
has_many :application_user_claims, dependent: :destroy
has_many :oidc_user_consents, dependent: :destroy
has_many :webauthn_credentials, dependent: :destroy
has_many :api_keys, dependent: :destroy
# Token generation for passwordless flows
generates_token_for :invitation_login, expires_in: 24.hours do

View File

@@ -0,0 +1,71 @@
<div class="max-w-4xl mx-auto">
<div class="mb-8 flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-gray-900">API Keys</h1>
<p class="mt-2 text-sm text-gray-600">
Bearer tokens for server-to-server access to forward auth applications.
</p>
</div>
<%= link_to "New API Key", new_api_key_path,
class: "inline-flex items-center rounded-md border border-transparent bg-blue-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2" %>
</div>
<% if @api_keys.any? %>
<div class="bg-white shadow overflow-hidden sm:rounded-lg">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Application</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Created</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Last Used</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Expires</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider"></th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<% @api_keys.each do |key| %>
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900"><%= key.name %></td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500"><%= key.application.name %></td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500"><%= key.created_at.strftime("%b %d, %Y") %></td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500"><%= key.last_used_at ? time_ago_in_words(key.last_used_at) + " ago" : "Never" %></td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500"><%= key.expires_at ? key.expires_at.strftime("%b %d, %Y") : "Never" %></td>
<td class="px-6 py-4 whitespace-nowrap">
<% if key.revoked? %>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">Revoked</span>
<% elsif key.expired? %>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">Expired</span>
<% else %>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">Active</span>
<% end %>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<% if key.active? %>
<%= button_to "Revoke", api_key_path(key), method: :delete,
class: "text-red-600 hover:text-red-900",
form: { data: { turbo_confirm: "Revoke this API key? This cannot be undone." } } %>
<% end %>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% else %>
<div class="bg-gray-50 rounded-lg border border-gray-200 p-8 text-center">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"></path>
</svg>
<h3 class="mt-4 text-lg font-medium text-gray-900">No API keys</h3>
<p class="mt-2 text-sm text-gray-500">
Create an API key to authenticate server-to-server requests.
</p>
<div class="mt-6">
<%= link_to "Create API Key", new_api_key_path,
class: "inline-flex items-center rounded-md border border-transparent bg-blue-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-blue-700" %>
</div>
</div>
<% end %>
</div>

View File

@@ -0,0 +1,55 @@
<div class="max-w-lg mx-auto">
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900">New API Key</h1>
<p class="mt-2 text-sm text-gray-600">
Create a bearer token for server-to-server access to a forward auth application.
</p>
</div>
<div class="bg-white shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
<%= form_with(model: @api_key, class: "space-y-6") do |f| %>
<% if @api_key.errors.any? %>
<div class="rounded-md bg-red-50 p-4">
<div class="text-sm text-red-700">
<ul class="list-disc pl-5 space-y-1">
<% @api_key.errors.full_messages.each do |msg| %>
<li><%= msg %></li>
<% end %>
</ul>
</div>
</div>
<% end %>
<div>
<%= f.label :name, class: "block text-sm font-medium text-gray-700" %>
<%= f.text_field :name, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm",
placeholder: "e.g., Video Player WebDAV" %>
</div>
<div>
<%= f.label :application_id, "Application", class: "block text-sm font-medium text-gray-700" %>
<% if @applications.any? %>
<%= f.collection_select :application_id, @applications, :id, :name,
{ prompt: "Select an application" },
{ class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" } %>
<% else %>
<p class="mt-1 text-sm text-gray-500">No forward auth applications available.</p>
<% end %>
</div>
<div>
<%= f.label :expires_at, "Expiration (optional)", class: "block text-sm font-medium text-gray-700" %>
<%= f.datetime_local_field :expires_at, 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">Leave blank for no expiration.</p>
</div>
<div class="flex items-center justify-end gap-3">
<%= link_to "Cancel", api_keys_path, class: "text-sm font-medium text-gray-700 hover:text-gray-500" %>
<%= f.submit "Create API Key",
class: "inline-flex justify-center rounded-md border border-transparent bg-blue-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2" %>
</div>
<% end %>
</div>
</div>
</div>

View File

@@ -0,0 +1,59 @@
<div class="max-w-2xl mx-auto" data-controller="clipboard">
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900">API Key Created</h1>
<p class="mt-2 text-sm text-gray-600">
Copy your API key now. You won't be able to see it again.
</p>
</div>
<div class="bg-white shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
<div class="rounded-md bg-yellow-50 p-4 mb-6">
<div class="flex">
<svg class="h-5 w-5 text-yellow-400 mr-3 flex-shrink-0" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd" />
</svg>
<div class="text-sm text-yellow-800">
<p class="font-medium">Save this key now!</p>
<p class="mt-1">This is the only time you'll see the full API key. Store it securely.</p>
</div>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">API Key</label>
<div class="flex items-center gap-2">
<input type="text" readonly value="<%= @plaintext_token %>"
data-clipboard-target="source"
class="flex-1 rounded-md border-gray-300 bg-gray-50 font-mono text-sm shadow-sm focus:border-blue-500 focus:ring-blue-500" />
<button data-action="click->clipboard#copy"
data-clipboard-target="button"
class="inline-flex items-center rounded-md border border-gray-300 bg-white py-2 px-3 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
<svg class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" />
</svg>
<span data-clipboard-target="label">Copy</span>
</button>
</div>
</div>
<div class="mt-6 space-y-2 text-sm text-gray-600">
<p><strong>Name:</strong> <%= @api_key.name %></p>
<p><strong>Application:</strong> <%= @api_key.application.name %></p>
<p><strong>Expires:</strong> <%= @api_key.expires_at ? @api_key.expires_at.strftime("%b %d, %Y %H:%M") : "Never" %></p>
</div>
<div class="mt-6 rounded-md bg-gray-50 p-4">
<p class="text-sm font-medium text-gray-700 mb-2">Usage example:</p>
<pre class="text-xs text-gray-600 overflow-x-auto">curl -H "Authorization: Bearer <%= @plaintext_token %>" \
-H "X-Forwarded-Host: your-app.example.com" \
<%= request.base_url %>/api/verify</pre>
</div>
<div class="mt-8">
<%= link_to "Done", api_keys_path,
class: "inline-flex justify-center rounded-md border border-transparent bg-blue-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2" %>
</div>
</div>
</div>
</div>

View File

@@ -91,6 +91,32 @@
</div>
</div>
<% end %>
<!-- API Keys Card -->
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg class="h-6 w-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"></path>
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">
API Keys
</dt>
<dd class="text-lg font-semibold text-gray-900">
<%= @user.api_keys.active.count %>
</dd>
</dl>
</div>
</div>
</div>
<div class="bg-gray-50 px-5 py-3">
<%= link_to "Manage API Keys", api_keys_path, class: "text-sm font-medium text-blue-600 hover:text-blue-500" %>
</div>
</div>
</div>
<!-- Your Applications Section -->

View File

@@ -59,7 +59,7 @@ Rails.application.configure do
# Use Solid Queue for background jobs
config.active_job.queue_adapter = :solid_queue
config.solid_queue.connects_to = { database: { writing: :queue } }
config.solid_queue.connects_to = {database: {writing: :queue}}
# Ignore bad email addresses and do not raise email delivery errors.
# Set this to true and configure the email server for immediate delivery to raise delivery errors.

View File

@@ -1,5 +1,5 @@
# frozen_string_literal: true
module Clinch
VERSION = "0.8.7"
VERSION = "0.8.8"
end

View File

@@ -40,6 +40,8 @@ Rails.application.routes.draw do
end
# Authenticated routes
resources :api_keys, only: [:index, :new, :create, :show, :destroy]
root "dashboard#index"
resource :profile, only: [:show, :update] do
member do

View File

@@ -0,0 +1,20 @@
class CreateApiKeys < ActiveRecord::Migration[8.1]
def change
create_table :api_keys do |t|
t.references :user, null: false, foreign_key: true
t.references :application, null: false, foreign_key: true
t.string :name, null: false
t.string :token_hmac, null: false
t.datetime :expires_at
t.datetime :last_used_at
t.datetime :revoked_at
t.timestamps
end
add_index :api_keys, :token_hmac, unique: true
add_index :api_keys, [:user_id, :application_id]
add_index :api_keys, :expires_at
add_index :api_keys, :revoked_at
end
end

View File

@@ -56,7 +56,8 @@ This checklist ensures Clinch meets security, quality, and documentation standar
- [x] Authorization code flow with PKCE support
- [x] Refresh token rotation
- [x] Token family tracking (detects replay attacks)
- [x] All tokens HMAC-SHA256 hashed in database
- [x] All tokens and authorization codes HMAC-SHA256 hashed in database
- [x] TOTP secrets AES-256-GCM encrypted at rest (Rails credentials)
- [x] Configurable token expiry (access, refresh, ID)
- [x] One-time use authorization codes
- [x] Pairwise subject identifiers (privacy)
@@ -130,8 +131,7 @@ This checklist ensures Clinch meets security, quality, and documentation standar
## Code Quality
- [x] **RuboCop** - Code style and linting
- Configuration: Rails Omakase
- [x] **StandardRB** - Code style and linting
- CI: Runs on every PR and push to main
- [x] **Documentation** - Comprehensive README

View File

@@ -0,0 +1,148 @@
require "test_helper"
module Api
class ForwardAuthBearerTest < ActionDispatch::IntegrationTest
setup do
@user = users(:bob)
@app = Application.create!(
name: "WebDAV App",
slug: "webdav-app",
app_type: "forward_auth",
domain_pattern: "webdav.example.com",
active: true
)
@api_key = @user.api_keys.create!(name: "Test Key", application: @app)
@token = @api_key.plaintext_token
end
test "valid bearer token returns 200 with user headers" do
get "/api/verify", headers: {
"Authorization" => "Bearer #{@token}",
"X-Forwarded-Host" => "webdav.example.com"
}
assert_response :ok
assert_equal @user.email_address, response.headers["x-remote-user"]
assert_equal @user.email_address, response.headers["x-remote-email"]
end
test "valid bearer token updates last_used_at" do
assert_nil @api_key.last_used_at
get "/api/verify", headers: {
"Authorization" => "Bearer #{@token}",
"X-Forwarded-Host" => "webdav.example.com"
}
assert_response :ok
assert @api_key.reload.last_used_at.present?
end
test "expired bearer token returns 401 JSON" do
@api_key.update_column(:expires_at, 1.hour.ago)
get "/api/verify", headers: {
"Authorization" => "Bearer #{@token}",
"X-Forwarded-Host" => "webdav.example.com"
}
assert_response :unauthorized
json = JSON.parse(response.body)
assert_equal "Invalid or expired API key", json["error"]
end
test "revoked bearer token returns 401 JSON" do
@api_key.revoke!
get "/api/verify", headers: {
"Authorization" => "Bearer #{@token}",
"X-Forwarded-Host" => "webdav.example.com"
}
assert_response :unauthorized
json = JSON.parse(response.body)
assert_equal "Invalid or expired API key", json["error"]
end
test "invalid bearer token returns 401 JSON" do
get "/api/verify", headers: {
"Authorization" => "Bearer clk_totally_bogus_token",
"X-Forwarded-Host" => "webdav.example.com"
}
assert_response :unauthorized
json = JSON.parse(response.body)
assert_equal "Invalid or expired API key", json["error"]
end
test "bearer token for wrong domain returns 401 JSON" do
get "/api/verify", headers: {
"Authorization" => "Bearer #{@token}",
"X-Forwarded-Host" => "other.example.com"
}
assert_response :unauthorized
json = JSON.parse(response.body)
assert_equal "API key not valid for this domain", json["error"]
end
test "bearer token for inactive user returns 401 JSON" do
@user.update!(status: :disabled)
get "/api/verify", headers: {
"Authorization" => "Bearer #{@token}",
"X-Forwarded-Host" => "webdav.example.com"
}
assert_response :unauthorized
json = JSON.parse(response.body)
assert_equal "User account is not active", json["error"]
end
test "bearer token for inactive application returns 401 JSON" do
@app.update!(active: false)
get "/api/verify", headers: {
"Authorization" => "Bearer #{@token}",
"X-Forwarded-Host" => "webdav.example.com"
}
assert_response :unauthorized
json = JSON.parse(response.body)
assert_equal "Application is inactive", json["error"]
end
test "no bearer token falls through to cookie auth" do
# No auth header, no session -> should redirect (cookie flow)
get "/api/verify", headers: {
"X-Forwarded-Host" => "webdav.example.com"
}
assert_response :redirect
assert_match %r{/signin}, response.location
end
test "bearer token does not redirect on failure" do
get "/api/verify", headers: {
"Authorization" => "Bearer clk_bad",
"X-Forwarded-Host" => "webdav.example.com"
}
assert_response :unauthorized
assert_equal "application/json", response.media_type
# Should NOT be a redirect
assert_nil response.headers["Location"]
end
test "cookie auth still works when no bearer token present" do
sign_in_as(@user)
get "/api/verify", headers: {
"X-Forwarded-Host" => "webdav.example.com"
}
assert_response :ok
assert_equal @user.email_address, response.headers["x-remote-user"]
end
end
end

View File

@@ -0,0 +1,394 @@
require "test_helper"
class OidcClaimsSecurityTest < ActionDispatch::IntegrationTest
setup do
@user = User.create!(email_address: "claims_security_test@example.com", password: "password123")
@application = Application.create!(
name: "Claims Security Test App",
slug: "claims-security-test-app",
app_type: "oidc",
redirect_uris: ["http://localhost:4000/callback"].to_json,
active: true,
require_pkce: false
)
# Store the plain text client secret for testing
@application.generate_new_client_secret!
@plain_client_secret = @application.client_secret
@application.save!
end
def teardown
# Delete in correct order to avoid foreign key constraints
OidcRefreshToken.where(application: @application).delete_all
OidcAccessToken.where(application: @application).delete_all
OidcAuthorizationCode.where(application: @application).delete_all
OidcUserConsent.where(application: @application).delete_all
@user.destroy
@application.destroy
end
# ====================
# CLAIMS PARAMETER ESCALATION ATTACKS
# ====================
test "rejects claims parameter during authorization code exchange" do
# Create consent with minimal scopes (no profile, email, or admin access)
OidcUserConsent.create!(
user: @user,
application: @application,
scopes_granted: "openid",
granted_at: Time.current,
sid: "test-sid-123"
)
auth_code = OidcAuthorizationCode.create!(
application: @application,
user: @user,
redirect_uri: "http://localhost:4000/callback",
scope: "openid",
expires_at: 10.minutes.from_now
)
# ATTEMPT: Inject claims parameter during token exchange (ATTACK!)
# The client is trying to request 'admin' claim that they never got consent for
post "/oauth/token", params: {
grant_type: "authorization_code",
code: auth_code.plaintext_code,
redirect_uri: "http://localhost:4000/callback",
claims: '{"id_token":{"admin":{"essential":true}}}' # ← ATTACK!
}, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
}
# SHOULD: Reject the claims parameter - it's only allowed in authorization requests
assert_response :bad_request
error = JSON.parse(response.body)
assert_equal "invalid_request", error["error"], "Should reject claims parameter at token endpoint"
assert_match(/claims.*not allowed|unsupported parameter/i, error["error_description"], "Error should mention claims parameter not allowed")
end
test "rejects claims parameter during authorization code exchange with profile escalation" do
# Create consent with ONLY openid scope (no profile scope)
OidcUserConsent.create!(
user: @user,
application: @application,
scopes_granted: "openid",
granted_at: Time.current,
sid: "test-sid-123"
)
auth_code = OidcAuthorizationCode.create!(
application: @application,
user: @user,
redirect_uri: "http://localhost:4000/callback",
scope: "openid",
expires_at: 10.minutes.from_now
)
# ATTEMPT: Try to get profile claims via claims parameter without profile scope
post "/oauth/token", params: {
grant_type: "authorization_code",
code: auth_code.plaintext_code,
redirect_uri: "http://localhost:4000/callback",
claims: '{"id_token":{"name":null,"email":{"essential":true}}}'
}, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
}
# SHOULD: Reject the claims parameter
assert_response :bad_request
error = JSON.parse(response.body)
assert_equal "invalid_request", error["error"]
end
test "rejects claims parameter during refresh token grant" do
access_token = OidcAccessToken.create!(
application: @application,
user: @user,
scope: "openid"
)
refresh_token = OidcRefreshToken.create!(
application: @application,
user: @user,
oidc_access_token: access_token,
scope: "openid"
)
plaintext_refresh_token = refresh_token.token
# ATTEMPT: Inject claims parameter during refresh (ATTACK!)
# Trying to escalate to admin claims during refresh
post "/oauth/token", params: {
grant_type: "refresh_token",
refresh_token: plaintext_refresh_token,
claims: '{"id_token":{"admin":true,"role":{"essential":true}}}' # ← ATTACK!
}, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
}
# SHOULD: Reject the claims parameter
assert_response :bad_request
error = JSON.parse(response.body)
assert_equal "invalid_request", error["error"], "Should reject claims parameter at refresh token endpoint"
assert_match(/claims.*not allowed|unsupported parameter/i, error["error_description"])
end
test "rejects claims parameter during refresh token grant with custom claims escalation" do
# Setup: User has a custom claim at user level
@user.update!(custom_claims: {"role" => "user"})
access_token = OidcAccessToken.create!(
application: @application,
user: @user,
scope: "openid"
)
refresh_token = OidcRefreshToken.create!(
application: @application,
user: @user,
oidc_access_token: access_token,
scope: "openid"
)
plaintext_refresh_token = refresh_token.token
# ATTEMPT: Try to escalate role to admin via claims parameter
post "/oauth/token", params: {
grant_type: "refresh_token",
refresh_token: plaintext_refresh_token,
claims: '{"id_token":{"role":{"value":"admin"}}}' # ← ATTACK! Trying to override role value
}, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
}
# SHOULD: Reject the claims parameter
assert_response :bad_request
error = JSON.parse(response.body)
assert_equal "invalid_request", error["error"]
end
test "allows token exchange without claims parameter" do
# Create consent
OidcUserConsent.create!(
user: @user,
application: @application,
scopes_granted: "openid profile",
granted_at: Time.current,
sid: "test-sid-123"
)
auth_code = OidcAuthorizationCode.create!(
application: @application,
user: @user,
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
expires_at: 10.minutes.from_now
)
# Normal token exchange WITHOUT claims parameter should work fine
post "/oauth/token", params: {
grant_type: "authorization_code",
code: auth_code.plaintext_code,
redirect_uri: "http://localhost:4000/callback"
}, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
}
assert_response :success
response_body = JSON.parse(response.body)
assert response_body.key?("access_token")
assert response_body.key?("id_token")
end
test "allows refresh without claims parameter" do
# Create consent for this application
OidcUserConsent.create!(
user: @user,
application: @application,
scopes_granted: "openid profile",
granted_at: Time.current,
sid: "test-sid-refresh-456"
)
access_token = OidcAccessToken.create!(
application: @application,
user: @user,
scope: "openid profile"
)
refresh_token = OidcRefreshToken.create!(
application: @application,
user: @user,
oidc_access_token: access_token,
scope: "openid profile"
)
plaintext_refresh_token = refresh_token.token
# Normal refresh WITHOUT claims parameter should work fine
post "/oauth/token", params: {
grant_type: "refresh_token",
refresh_token: plaintext_refresh_token
}, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
}
assert_response :success
response_body = JSON.parse(response.body)
assert response_body.key?("access_token")
assert response_body.key?("id_token")
end
# ====================
# CLAIMS PARAMETER IS AUTHORIZATION-ONLY
# ====================
test "claims parameter is only valid in authorization request per OIDC spec" do
# Per OIDC Core spec section 18.2.1, claims parameter usage location is "Authorization Request"
# This test verifies that claims parameter cannot be used at token endpoint
OidcUserConsent.create!(
user: @user,
application: @application,
scopes_granted: "openid",
granted_at: Time.current,
sid: "test-sid-123"
)
auth_code = OidcAuthorizationCode.create!(
application: @application,
user: @user,
redirect_uri: "http://localhost:4000/callback",
scope: "openid",
expires_at: 10.minutes.from_now
)
# Test various attempts to inject claims parameter
malicious_claims = [
'{"id_token":{"admin":true}}',
'{"id_token":{"email":{"essential":true}}}',
'{"userinfo":{"groups":{"values":["admin"]}}}',
'{"id_token":{"custom_claim":"custom_value"}}',
"invalid-json"
]
malicious_claims.each do |claims_value|
post "/oauth/token", params: {
grant_type: "authorization_code",
code: auth_code.plaintext_code,
redirect_uri: "http://localhost:4000/callback",
claims: claims_value
}, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
}
# All should be rejected
assert_response :bad_request, "Claims parameter '#{claims_value}' should be rejected"
error = JSON.parse(response.body)
assert_equal "invalid_request", error["error"]
end
end
# ====================
# VERIFY CONSENT-BASED ACCESS IS ENFORCED
# ====================
test "token endpoint respects scopes granted during authorization" do
# Create consent with ONLY openid scope (no email, profile, etc.)
OidcUserConsent.create!(
user: @user,
application: @application,
scopes_granted: "openid",
granted_at: Time.current,
sid: "test-sid-123"
)
auth_code = OidcAuthorizationCode.create!(
application: @application,
user: @user,
redirect_uri: "http://localhost:4000/callback",
scope: "openid",
expires_at: 10.minutes.from_now
)
# Exchange code for tokens
post "/oauth/token", params: {
grant_type: "authorization_code",
code: auth_code.plaintext_code,
redirect_uri: "http://localhost:4000/callback"
}, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
}
assert_response :success
response_body = JSON.parse(response.body)
id_token = response_body["id_token"]
# Decode ID token to check claims
decoded = JWT.decode(id_token, nil, false).first
# Should only have required claims, not email/profile
assert_includes decoded.keys, "iss"
assert_includes decoded.keys, "sub"
assert_includes decoded.keys, "aud"
assert_includes decoded.keys, "exp"
assert_includes decoded.keys, "iat"
# Should NOT have claims that weren't consented to
refute_includes decoded.keys, "email", "Should not include email without email scope"
refute_includes decoded.keys, "email_verified", "Should not include email_verified without email scope"
refute_includes decoded.keys, "name", "Should not include name without profile scope"
refute_includes decoded.keys, "preferred_username", "Should not include preferred_username without profile scope"
end
test "refresh token preserves original scopes granted during authorization" do
# Create consent with specific scopes
OidcUserConsent.create!(
user: @user,
application: @application,
scopes_granted: "openid email",
granted_at: Time.current,
sid: "test-sid-refresh-123"
)
access_token = OidcAccessToken.create!(
application: @application,
user: @user,
scope: "openid email"
)
refresh_token = OidcRefreshToken.create!(
application: @application,
user: @user,
oidc_access_token: access_token,
scope: "openid email"
)
plaintext_refresh_token = refresh_token.token
# Refresh the token
post "/oauth/token", params: {
grant_type: "refresh_token",
refresh_token: plaintext_refresh_token
}, headers: {
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
}
assert_response :success
response_body = JSON.parse(response.body)
id_token = response_body["id_token"]
# Decode ID token to verify scopes are preserved
decoded = JWT.decode(id_token, nil, false).first
# Should have email claims (from original consent)
assert_includes decoded.keys, "email", "Should preserve email scope from original consent"
assert_includes decoded.keys, "email_verified", "Should preserve email_verified scope from original consent"
# Should NOT have profile claims (not in original consent)
refute_includes decoded.keys, "name", "Should not add profile claims that weren't consented to"
refute_includes decoded.keys, "preferred_username", "Should not add preferred_username that wasn't consented to"
end
end

View File

@@ -0,0 +1,236 @@
require "test_helper"
class OidcPromptLoginTest < ActionDispatch::IntegrationTest
setup do
@user = users(:alice)
@application = applications(:kavita_app)
@client_secret = SecureRandom.urlsafe_base64(48)
@application.client_secret = @client_secret
@application.save!
# Pre-authorize the application so we skip consent screen
consent = OidcUserConsent.find_or_initialize_by(
user: @user,
application: @application
)
consent.scopes_granted ||= "openid profile email"
consent.save!
end
teardown do
# Clean up
OidcAccessToken.where(user: @user, application: @application).destroy_all
OidcAuthorizationCode.where(user: @user, application: @application).destroy_all
end
test "max_age requires re-authentication when session is too old" do
# Sign in to create a session
post "/signin", params: {
email_address: @user.email_address,
password: "password"
}
assert_response :redirect
follow_redirect!
assert_response :success
# Get first auth_time
get "/oauth/authorize", params: {
client_id: @application.client_id,
redirect_uri: @application.parsed_redirect_uris.first,
response_type: "code",
scope: "openid",
state: "first-state",
nonce: "first-nonce"
}
assert_response :redirect
first_redirect_url = response.location
first_code = CGI.parse(URI(first_redirect_url).query)["code"].first
# Exchange for tokens and extract auth_time
post "/oauth/token", params: {
grant_type: "authorization_code",
code: first_code,
redirect_uri: @application.parsed_redirect_uris.first,
client_id: @application.client_id,
client_secret: @client_secret
}
assert_response :success
first_tokens = JSON.parse(response.body)
first_id_token = OidcJwtService.decode_id_token(first_tokens["id_token"])
first_auth_time = first_id_token[0]["auth_time"]
# Wait a bit (simulate time passing - in real scenario this would be actual seconds)
# Then request with max_age=0 (means session must be brand new)
get "/oauth/authorize", params: {
client_id: @application.client_id,
redirect_uri: @application.parsed_redirect_uris.first,
response_type: "code",
scope: "openid",
state: "second-state",
nonce: "second-nonce",
max_age: "0" # Requires session to be 0 seconds old (i.e., brand new)
}
# Should redirect to sign in because session is too old
assert_response :redirect
assert_redirected_to(/signin/)
# Sign in again
post "/signin", params: {
email_address: @user.email_address,
password: "password"
}
assert_response :redirect
follow_redirect!
# Should receive authorization code
assert_response :redirect
second_redirect_url = response.location
second_code = CGI.parse(URI(second_redirect_url).query)["code"].first
assert second_code.present?, "Should receive authorization code after re-authentication"
# Exchange second authorization code for tokens
post "/oauth/token", params: {
grant_type: "authorization_code",
code: second_code,
redirect_uri: @application.parsed_redirect_uris.first,
client_id: @application.client_id,
client_secret: @client_secret
}
assert_response :success
second_tokens = JSON.parse(response.body)
second_id_token = OidcJwtService.decode_id_token(second_tokens["id_token"])
second_auth_time = second_id_token[0]["auth_time"]
# The second auth_time should be >= the first (re-authentication occurred)
# Note: May be equal if both occur in the same second (test timing edge case)
assert second_auth_time >= first_auth_time,
"max_age=0 should result in a re-authentication. " \
"First: #{first_auth_time}, Second: #{second_auth_time}"
end
test "prompt=none returns login_required error when not authenticated" do
# Don't sign in - user is not authenticated
# Request authorization with prompt=none
get "/oauth/authorize", params: {
client_id: @application.client_id,
redirect_uri: @application.parsed_redirect_uris.first,
response_type: "code",
scope: "openid",
state: "test-state",
prompt: "none"
}
# Should redirect with error=login_required (NOT to sign-in page)
assert_response :redirect
redirect_url = response.location
# Parse the redirect URL
uri = URI.parse(redirect_url)
query_params = uri.query ? CGI.parse(uri.query) : {}
assert_equal "login_required", query_params["error"]&.first,
"Should return login_required error for prompt=none when not authenticated"
assert_equal "test-state", query_params["state"]&.first,
"Should return state parameter"
end
test "prompt=login forces re-authentication with new auth_time" do
# First authentication
post "/signin", params: {
email_address: @user.email_address,
password: "password"
}
assert_response :redirect
follow_redirect!
assert_response :success
# Get first authorization code
get "/oauth/authorize", params: {
client_id: @application.client_id,
redirect_uri: @application.parsed_redirect_uris.first,
response_type: "code",
scope: "openid",
state: "first-state",
nonce: "first-nonce"
}
assert_response :redirect
first_redirect_url = response.location
first_code = CGI.parse(URI(first_redirect_url).query)["code"].first
# Exchange for tokens and extract auth_time from ID token
post "/oauth/token", params: {
grant_type: "authorization_code",
code: first_code,
redirect_uri: @application.parsed_redirect_uris.first,
client_id: @application.client_id,
client_secret: @client_secret
}
assert_response :success
first_tokens = JSON.parse(response.body)
first_id_token = OidcJwtService.decode_id_token(first_tokens["id_token"])
first_auth_time = first_id_token[0]["auth_time"]
# Now request authorization again with prompt=login
get "/oauth/authorize", params: {
client_id: @application.client_id,
redirect_uri: @application.parsed_redirect_uris.first,
response_type: "code",
scope: "openid",
state: "second-state",
nonce: "second-nonce",
prompt: "login"
}
# Should redirect to sign in
assert_response :redirect
assert_redirected_to(/signin/)
# Sign in again (simulating user re-authentication)
post "/signin", params: {
email_address: @user.email_address,
password: "password"
}
assert_response :redirect
# Follow redirect to after_authentication_url (which is /oauth/authorize without prompt=login)
follow_redirect!
# Should receive authorization code redirect
assert_response :redirect
second_redirect_url = response.location
second_code = CGI.parse(URI(second_redirect_url).query)["code"].first
assert second_code.present?, "Should receive authorization code after re-authentication"
# Exchange second authorization code for tokens
post "/oauth/token", params: {
grant_type: "authorization_code",
code: second_code,
redirect_uri: @application.parsed_redirect_uris.first,
client_id: @application.client_id,
client_secret: @client_secret
}
assert_response :success
second_tokens = JSON.parse(response.body)
second_id_token = OidcJwtService.decode_id_token(second_tokens["id_token"])
second_auth_time = second_id_token[0]["auth_time"]
# The second auth_time should be >= the first (re-authentication occurred)
# Note: May be equal if both occur in the same second (test timing edge case)
assert second_auth_time >= first_auth_time,
"prompt=login should result in a later auth_time. " \
"First: #{first_auth_time}, Second: #{second_auth_time}"
end
end

View File

@@ -0,0 +1,136 @@
require "test_helper"
class DurationParserTest < ActiveSupport::TestCase
# Valid formats
test "parses seconds" do
assert_equal 1, DurationParser.parse("1s")
assert_equal 30, DurationParser.parse("30s")
assert_equal 3600, DurationParser.parse("3600s")
end
test "parses minutes" do
assert_equal 60, DurationParser.parse("1m")
assert_equal 300, DurationParser.parse("5m")
assert_equal 1800, DurationParser.parse("30m")
end
test "parses hours" do
assert_equal 3600, DurationParser.parse("1h")
assert_equal 7200, DurationParser.parse("2h")
assert_equal 86400, DurationParser.parse("24h")
end
test "parses days" do
assert_equal 86400, DurationParser.parse("1d")
assert_equal 172800, DurationParser.parse("2d")
assert_equal 2592000, DurationParser.parse("30d")
end
test "parses weeks" do
assert_equal 604800, DurationParser.parse("1w")
assert_equal 1209600, DurationParser.parse("2w")
end
test "parses months (30 days)" do
assert_equal 2592000, DurationParser.parse("1M")
assert_equal 5184000, DurationParser.parse("2M")
end
test "parses years (365 days)" do
assert_equal 31536000, DurationParser.parse("1y")
assert_equal 63072000, DurationParser.parse("2y")
end
# Plain numbers
test "parses plain integer as seconds" do
assert_equal 3600, DurationParser.parse(3600)
assert_equal 300, DurationParser.parse(300)
assert_equal 0, DurationParser.parse(0)
end
test "parses plain numeric string as seconds" do
assert_equal 3600, DurationParser.parse("3600")
assert_equal 300, DurationParser.parse("300")
assert_equal 0, DurationParser.parse("0")
end
# Whitespace handling
test "handles leading and trailing whitespace" do
assert_equal 3600, DurationParser.parse(" 1h ")
assert_equal 300, DurationParser.parse(" 5m ")
assert_equal 86400, DurationParser.parse("\t1d\n")
end
test "handles space between number and unit" do
assert_equal 3600, DurationParser.parse("1 h")
assert_equal 300, DurationParser.parse("5 m")
assert_equal 86400, DurationParser.parse("1 d")
end
# Case sensitivity - only lowercase units work (except M for months)
test "lowercase units work" do
assert_equal 1, DurationParser.parse("1s")
assert_equal 60, DurationParser.parse("1m") # minute (lowercase)
assert_equal 3600, DurationParser.parse("1h")
assert_equal 86400, DurationParser.parse("1d")
assert_equal 604800, DurationParser.parse("1w")
assert_equal 31536000, DurationParser.parse("1y")
end
test "uppercase M for months works" do
assert_equal 2592000, DurationParser.parse("1M") # month (uppercase)
end
test "returns nil for wrong case" do
assert_nil DurationParser.parse("1S") # Should be 1s
assert_nil DurationParser.parse("1H") # Should be 1h
assert_nil DurationParser.parse("1D") # Should be 1d
assert_nil DurationParser.parse("1W") # Should be 1w
assert_nil DurationParser.parse("1Y") # Should be 1y
end
# Edge cases
test "handles zero duration" do
assert_equal 0, DurationParser.parse("0s")
assert_equal 0, DurationParser.parse("0m")
assert_equal 0, DurationParser.parse("0h")
end
test "handles large numbers" do
assert_equal 86400000, DurationParser.parse("1000d")
assert_equal 360000, DurationParser.parse("100h")
end
# Invalid formats - should return nil (not raise)
test "returns nil for invalid format" do
assert_nil DurationParser.parse("invalid")
assert_nil DurationParser.parse("1x")
assert_nil DurationParser.parse("abc")
assert_nil DurationParser.parse("1.5h") # No decimals
assert_nil DurationParser.parse("-1h") # No negatives
assert_nil DurationParser.parse("h1") # Wrong order
end
test "returns nil for blank input" do
assert_nil DurationParser.parse("")
assert_nil DurationParser.parse(nil)
assert_nil DurationParser.parse(" ")
end
test "returns nil for multiple units" do
assert_nil DurationParser.parse("1h30m") # Keep it simple, don't support this
assert_nil DurationParser.parse("1d2h")
end
# String coercion
test "handles string input" do
assert_equal 3600, DurationParser.parse("1h")
assert_equal 3600, DurationParser.parse(:"1h") # Symbol
end
# Boundary validation (not parser's job, but good to know)
test "parses values outside typical TTL ranges without error" do
assert_equal 1, DurationParser.parse("1s") # Below min access_token_ttl
assert_equal 315360000, DurationParser.parse("10y") # Above max refresh_token_ttl
end
end

View File

@@ -0,0 +1,94 @@
require "test_helper"
class ApiKeyTest < ActiveSupport::TestCase
setup do
@user = users(:bob)
@app = Application.create!(
name: "WebDAV",
slug: "webdav",
app_type: "forward_auth",
domain_pattern: "webdav.example.com",
active: true
)
end
test "generates clk_ prefixed token on create" do
key = @user.api_keys.create!(name: "Test Key", application: @app)
assert key.plaintext_token.start_with?("clk_")
assert key.token_hmac.present?
end
test "find_by_token looks up via HMAC" do
key = @user.api_keys.create!(name: "Test Key", application: @app)
found = ApiKey.find_by_token(key.plaintext_token)
assert_equal key.id, found.id
end
test "find_by_token returns nil for invalid token" do
assert_nil ApiKey.find_by_token("clk_bogus")
assert_nil ApiKey.find_by_token("")
assert_nil ApiKey.find_by_token(nil)
end
test "active scope excludes revoked and expired keys" do
active_key = @user.api_keys.create!(name: "Active", application: @app)
revoked_key = @user.api_keys.create!(name: "Revoked", application: @app)
revoked_key.revoke!
expired_key = @user.api_keys.create!(name: "Expired", application: @app, expires_at: 1.day.ago)
active_keys = @user.api_keys.active
assert_includes active_keys, active_key
assert_not_includes active_keys, revoked_key
assert_not_includes active_keys, expired_key
end
test "active? expired? revoked? methods" do
key = @user.api_keys.create!(name: "Test", application: @app)
assert key.active?
assert_not key.expired?
assert_not key.revoked?
key.revoke!
assert_not key.active?
assert key.revoked?
key2 = @user.api_keys.create!(name: "Expiring", application: @app, expires_at: 1.hour.ago)
assert_not key2.active?
assert key2.expired?
end
test "nil expires_at means never expires" do
key = @user.api_keys.create!(name: "No Expiry", application: @app, expires_at: nil)
assert_not key.expired?
assert key.active?
end
test "touch_last_used! updates timestamp" do
key = @user.api_keys.create!(name: "Test", application: @app)
assert_nil key.last_used_at
key.touch_last_used!
assert key.reload.last_used_at.present?
end
test "validates application must be forward_auth" do
oidc_app = applications(:kavita_app)
key = @user.api_keys.build(name: "Bad", application: oidc_app)
assert_not key.valid?
assert_includes key.errors[:application], "must be a forward auth application"
end
test "validates user must have access to application" do
group = groups(:admin_group)
@app.allowed_groups << group
# @user (bob) is not in admin_group
key = @user.api_keys.build(name: "No Access", application: @app)
assert_not key.valid?
assert_includes key.errors[:user], "does not have access to this application"
end
test "validates name presence" do
key = @user.api_keys.build(name: "", application: @app)
assert_not key.valid?
assert_includes key.errors[:name], "can't be blank"
end
end

View File

@@ -0,0 +1,109 @@
require "test_helper"
class ApplicationDurationParserTest < ActiveSupport::TestCase
test "access_token_ttl accepts human-friendly durations" do
app = Application.new(access_token_ttl: "1h")
assert_equal 3600, app.access_token_ttl
app.access_token_ttl = "30m"
assert_equal 1800, app.access_token_ttl
app.access_token_ttl = "5m"
assert_equal 300, app.access_token_ttl
end
test "refresh_token_ttl accepts human-friendly durations" do
app = Application.new(refresh_token_ttl: "30d")
assert_equal 2592000, app.refresh_token_ttl
app.refresh_token_ttl = "1M"
assert_equal 2592000, app.refresh_token_ttl
app.refresh_token_ttl = "7d"
assert_equal 604800, app.refresh_token_ttl
end
test "id_token_ttl accepts human-friendly durations" do
app = Application.new(id_token_ttl: "1h")
assert_equal 3600, app.id_token_ttl
app.id_token_ttl = "2h"
assert_equal 7200, app.id_token_ttl
end
test "TTL fields still accept plain numbers" do
app = Application.new(
access_token_ttl: 3600,
refresh_token_ttl: 2592000,
id_token_ttl: 3600
)
assert_equal 3600, app.access_token_ttl
assert_equal 2592000, app.refresh_token_ttl
assert_equal 3600, app.id_token_ttl
end
test "TTL fields accept plain number strings" do
app = Application.new(
access_token_ttl: "3600",
refresh_token_ttl: "2592000",
id_token_ttl: "3600"
)
assert_equal 3600, app.access_token_ttl
assert_equal 2592000, app.refresh_token_ttl
assert_equal 3600, app.id_token_ttl
end
test "invalid TTL values are set to nil" do
app = Application.new(
access_token_ttl: "invalid",
refresh_token_ttl: "bad",
id_token_ttl: "nope"
)
assert_nil app.access_token_ttl
assert_nil app.refresh_token_ttl
assert_nil app.id_token_ttl
end
test "validation still works with parsed values" do
app = Application.new(
name: "Test",
slug: "test",
app_type: "oidc",
redirect_uris: "https://example.com/callback"
)
# Too short (below 5 minutes)
app.access_token_ttl = "1m"
assert_not app.valid?
assert_includes app.errors[:access_token_ttl], "must be greater than or equal to 300"
# Too long (above 24 hours for access token)
app.access_token_ttl = "2d"
assert_not app.valid?
assert_includes app.errors[:access_token_ttl], "must be less than or equal to 86400"
# Just right
app.access_token_ttl = "1h"
app.valid? # Revalidate
assert app.errors[:access_token_ttl].blank?
end
test "can create OIDC app with human-friendly TTL values" do
app = Application.create!(
name: "Test App",
slug: "test-app",
app_type: "oidc",
redirect_uris: "https://example.com/callback",
access_token_ttl: "1h",
refresh_token_ttl: "30d",
id_token_ttl: "2h"
)
assert_equal 3600, app.access_token_ttl
assert_equal 2592000, app.refresh_token_ttl
assert_equal 7200, app.id_token_ttl
end
end