Compare commits
5 Commits
v0.8.8
...
9dbde8ea31
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9dbde8ea31 | ||
|
|
191a7b5fb3 | ||
|
|
7a9348c1f1 | ||
|
|
225d8ae5ca | ||
|
|
65c19fa732 |
@@ -1 +1 @@
|
|||||||
3.4.8
|
4.0.1
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
# For a containerized dev environment, see Dev Containers: https://guides.rubyonrails.org/getting_started_with_devcontainer.html
|
# For a containerized dev environment, see Dev Containers: https://guides.rubyonrails.org/getting_started_with_devcontainer.html
|
||||||
|
|
||||||
# Make sure RUBY_VERSION matches the Ruby version in .ruby-version
|
# Make sure RUBY_VERSION matches the Ruby version in .ruby-version
|
||||||
ARG RUBY_VERSION=3.4.8
|
ARG RUBY_VERSION=4.0.1
|
||||||
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base
|
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base
|
||||||
|
|
||||||
LABEL org.opencontainers.image.source=https://github.com/dkam/clinch
|
LABEL org.opencontainers.image.source=https://github.com/dkam/clinch
|
||||||
|
|||||||
@@ -446,6 +446,7 @@ PLATFORMS
|
|||||||
arm-linux-gnu
|
arm-linux-gnu
|
||||||
arm-linux-musl
|
arm-linux-musl
|
||||||
arm64-darwin-24
|
arm64-darwin-24
|
||||||
|
arm64-darwin-25
|
||||||
x86_64-linux
|
x86_64-linux
|
||||||
x86_64-linux-gnu
|
x86_64-linux-gnu
|
||||||
x86_64-linux-musl
|
x86_64-linux-musl
|
||||||
|
|||||||
42
README.md
42
README.md
@@ -15,14 +15,20 @@ Do you host your own web apps? MeTube, Kavita, Audiobookshelf, Gitea, Grafana, P
|
|||||||
|
|
||||||
Clinch runs as a single Docker container, using SQLite as the database, the job queue (Solid Queue) and the shared cache (Solid Cache). The webserver, Puma, runs the job queue in-process, avoiding the need for another container.
|
Clinch runs as a single Docker container, using SQLite as the database, the job queue (Solid Queue) and the shared cache (Solid Cache). The webserver, Puma, runs the job queue in-process, avoiding the need for another container.
|
||||||
|
|
||||||
Clinch sits in a sweet spot between two excellent open-source identity solutions:
|
Clinch sits in a sweet spot among several excellent open-source identity solutions:
|
||||||
|
|
||||||
**[Authelia](https://www.authelia.com)** is a fantastic choice for those who prefer external user management through LDAP and enjoy comprehensive YAML-based configuration. It's lightweight, secure, and works beautifully with reverse proxies.
|
**[Authelia](https://www.authelia.com)** is a fantastic choice for those who prefer external user management through LDAP and enjoy comprehensive YAML-based configuration. It's lightweight, secure, and works beautifully with reverse proxies.
|
||||||
|
|
||||||
|
**[VoidAuth](https://voidauth.app/)** is an open-source SSO provider with a similar feature set to Clinch — OIDC, ForwardAuth, passkeys, user management, and easy Docker deployment. If you're evaluating self-hosted auth solutions, it's well worth a look.
|
||||||
|
|
||||||
**[Authentik](https://goauthentik.io)** is an enterprise-grade powerhouse offering extensive protocol support (OAuth2, SAML, LDAP, RADIUS), advanced policy engines, and distributed "outpost" architecture for complex deployments.
|
**[Authentik](https://goauthentik.io)** is an enterprise-grade powerhouse offering extensive protocol support (OAuth2, SAML, LDAP, RADIUS), advanced policy engines, and distributed "outpost" architecture for complex deployments.
|
||||||
|
|
||||||
**Clinch** offers a middle ground with built-in user management, a modern web interface, and focused SSO capabilities (OIDC + ForwardAuth). It's perfect for users who want self-hosted simplicity without external dependencies or enterprise complexity.
|
**Clinch** offers a middle ground with built-in user management, a modern web interface, and focused SSO capabilities (OIDC + ForwardAuth). It's perfect for users who want self-hosted simplicity without external dependencies or enterprise complexity.
|
||||||
|
|
||||||
|
- **[Passes the OpenID Connect Conformance Tests](https://www.certification.openid.net/plan-detail.html?plan=FbQNTJuYVzrzs&public=true)** — verified against the official OIDC test suite
|
||||||
|
- **450+ tests, 1800+ assertions** — comprehensive test coverage across integration, model, controller, and security tests
|
||||||
|
- **Single Docker container** — SQLite, job queue, and cache all in one process
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
@@ -76,8 +82,6 @@ Apps that only need "who is it?", or you want available from the internet behind
|
|||||||
|
|
||||||
#### OpenID Connect (OIDC)
|
#### OpenID Connect (OIDC)
|
||||||
|
|
||||||
**[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:
|
Standard OAuth2/OIDC provider with endpoints:
|
||||||
- `/.well-known/openid-configuration` - Discovery endpoint
|
- `/.well-known/openid-configuration` - Discovery endpoint
|
||||||
- `/authorize` - Authorization endpoint with PKCE support
|
- `/authorize` - Authorization endpoint with PKCE support
|
||||||
@@ -131,6 +135,32 @@ Works with reverse proxies (Caddy, Traefik, Nginx):
|
|||||||
|
|
||||||
**Note:** ForwardAuth requires applications to run on the same domain as Clinch (e.g., `app.yourdomain.com` with Clinch at `auth.yourdomain.com`) for secure session cookie sharing. Take a look at Authentik if you need multi domain support.
|
**Note:** ForwardAuth requires applications to run on the same domain as Clinch (e.g., `app.yourdomain.com` with Clinch at `auth.yourdomain.com`) for secure session cookie sharing. Take a look at Authentik if you need multi domain support.
|
||||||
|
|
||||||
|
#### API Keys (Bearer Tokens)
|
||||||
|
|
||||||
|
For server-to-server access to ForwardAuth-protected services (e.g., a video player accessing WebDAV, rclone syncing files), Clinch supports API keys that work as bearer tokens — no browser or cookies needed.
|
||||||
|
|
||||||
|
- **Token format:** `clk_<base64>` prefix for easy identification
|
||||||
|
- **Storage:** HMAC-SHA256 hashed (plaintext shown once at creation, never stored)
|
||||||
|
- **Scope:** Each key is tied to one ForwardAuth application and one user
|
||||||
|
- **Expiration:** Optional — set a date or leave blank for no expiry
|
||||||
|
- **Auth flow:** `Authorization: Bearer clk_...` header checked before cookie auth
|
||||||
|
- **Failure response:** 401 JSON `{"error": "..."}` (no redirect)
|
||||||
|
|
||||||
|
**Creating an API key:**
|
||||||
|
1. Go to **Dashboard → Manage API Keys** (or `/api_keys`)
|
||||||
|
2. Click **New API Key**, select a ForwardAuth application, and give it a name
|
||||||
|
3. Copy the `clk_...` token — it's shown only once
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
```bash
|
||||||
|
curl -H "Authorization: Bearer clk_..." \
|
||||||
|
-H "X-Forwarded-Host: webdav.example.com" \
|
||||||
|
https://auth.example.com/api/verify
|
||||||
|
# Returns 200 with X-Remote-User headers on success
|
||||||
|
```
|
||||||
|
|
||||||
|
API keys respect the same access controls as browser sessions — the user must have access to the application, the application must be active, and the user's account must be active.
|
||||||
|
|
||||||
### SMTP Integration
|
### SMTP Integration
|
||||||
Send emails for:
|
Send emails for:
|
||||||
- Invitation links (one-time token, 7-day expiry)
|
- Invitation links (one-time token, 7-day expiry)
|
||||||
@@ -287,7 +317,7 @@ This is transparent to end users and requires no configuration.
|
|||||||
## Setup & Installation
|
## Setup & Installation
|
||||||
|
|
||||||
### Requirements
|
### Requirements
|
||||||
- Ruby 3.3+
|
- Ruby 4.0+
|
||||||
- SQLite 3.8+
|
- SQLite 3.8+
|
||||||
- SMTP server (for sending emails)
|
- SMTP server (for sending emails)
|
||||||
|
|
||||||
@@ -701,7 +731,7 @@ user.revoke_all_consents!
|
|||||||
|
|
||||||
### Running Tests
|
### Running Tests
|
||||||
|
|
||||||
Clinch has comprehensive test coverage with 341 tests covering integration, models, controllers, services, and system tests.
|
Clinch has comprehensive test coverage with 450 tests covering integration, models, controllers, services, and system tests.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Run all tests
|
# Run all tests
|
||||||
@@ -761,7 +791,7 @@ All security scans run automatically on every pull request and push to main via
|
|||||||
|
|
||||||
**Current Status:**
|
**Current Status:**
|
||||||
- ✅ All security scans passing
|
- ✅ All security scans passing
|
||||||
- ✅ 341 tests, 1349 assertions, 0 failures
|
- ✅ 450 tests, 1818 assertions, 0 failures
|
||||||
- ✅ No known dependency vulnerabilities
|
- ✅ No known dependency vulnerabilities
|
||||||
- ✅ Phases 1-4 security hardening complete (18+ vulnerabilities fixed)
|
- ✅ Phases 1-4 security hardening complete (18+ vulnerabilities fixed)
|
||||||
- 🟡 3 outstanding security issues (all MEDIUM/LOW priority)
|
- 🟡 3 outstanding security issues (all MEDIUM/LOW priority)
|
||||||
|
|||||||
@@ -28,12 +28,10 @@ class ApplicationController < ActionController::Base
|
|||||||
uri = URI.parse(url)
|
uri = URI.parse(url)
|
||||||
return url unless uri.query
|
return url unless uri.query
|
||||||
|
|
||||||
# Parse query string into hash
|
params = Rack::Utils.parse_query(uri.query)
|
||||||
params = CGI.parse(uri.query)
|
|
||||||
params.delete(param_name)
|
params.delete(param_name)
|
||||||
|
|
||||||
# Rebuild query string (empty string if no params left)
|
uri.query = params.any? ? Rack::Utils.build_query(params) : nil
|
||||||
uri.query = params.any? ? URI.encode_www_form(params) : nil
|
|
||||||
uri.to_s
|
uri.to_s
|
||||||
rescue URI::InvalidURIError
|
rescue URI::InvalidURIError
|
||||||
url
|
url
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ class SessionsController < ApplicationController
|
|||||||
begin
|
begin
|
||||||
uri = URI.parse(session[:return_to_after_authenticating])
|
uri = URI.parse(session[:return_to_after_authenticating])
|
||||||
if uri.query.present?
|
if uri.query.present?
|
||||||
query_params = CGI.parse(uri.query)
|
query_params = Rack::Utils.parse_query(uri.query)
|
||||||
@login_hint = query_params["login_hint"]&.first
|
@login_hint = query_params["login_hint"]
|
||||||
end
|
end
|
||||||
rescue URI::InvalidURIError
|
rescue URI::InvalidURIError
|
||||||
# Ignore parsing errors
|
# Ignore parsing errors
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
module Clinch
|
module Clinch
|
||||||
VERSION = "0.8.8"
|
VERSION = "0.9.0"
|
||||||
end
|
end
|
||||||
|
|||||||
22
db/schema.rb
generated
22
db/schema.rb
generated
@@ -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: 2026_01_05_000809) do
|
ActiveRecord::Schema[8.1].define(version: 2026_03_05_000001) do
|
||||||
create_table "active_storage_attachments", force: :cascade do |t|
|
create_table "active_storage_attachments", force: :cascade do |t|
|
||||||
t.bigint "blob_id", null: false
|
t.bigint "blob_id", null: false
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
@@ -39,6 +39,24 @@ ActiveRecord::Schema[8.1].define(version: 2026_01_05_000809) do
|
|||||||
t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true
|
t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
create_table "api_keys", force: :cascade do |t|
|
||||||
|
t.integer "application_id", null: false
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "expires_at"
|
||||||
|
t.datetime "last_used_at"
|
||||||
|
t.string "name", null: false
|
||||||
|
t.datetime "revoked_at"
|
||||||
|
t.string "token_hmac", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.integer "user_id", null: false
|
||||||
|
t.index ["application_id"], name: "index_api_keys_on_application_id"
|
||||||
|
t.index ["expires_at"], name: "index_api_keys_on_expires_at"
|
||||||
|
t.index ["revoked_at"], name: "index_api_keys_on_revoked_at"
|
||||||
|
t.index ["token_hmac"], name: "index_api_keys_on_token_hmac", unique: true
|
||||||
|
t.index ["user_id", "application_id"], name: "index_api_keys_on_user_id_and_application_id"
|
||||||
|
t.index ["user_id"], name: "index_api_keys_on_user_id"
|
||||||
|
end
|
||||||
|
|
||||||
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
|
||||||
@@ -249,6 +267,8 @@ ActiveRecord::Schema[8.1].define(version: 2026_01_05_000809) do
|
|||||||
|
|
||||||
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
|
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
|
||||||
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
|
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
|
||||||
|
add_foreign_key "api_keys", "applications"
|
||||||
|
add_foreign_key "api_keys", "users"
|
||||||
add_foreign_key "application_groups", "applications"
|
add_foreign_key "application_groups", "applications"
|
||||||
add_foreign_key "application_groups", "groups"
|
add_foreign_key "application_groups", "groups"
|
||||||
add_foreign_key "application_user_claims", "applications", on_delete: :cascade
|
add_foreign_key "application_user_claims", "applications", on_delete: :cascade
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ class OidcPromptLoginTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
assert_response :redirect
|
assert_response :redirect
|
||||||
first_redirect_url = response.location
|
first_redirect_url = response.location
|
||||||
first_code = CGI.parse(URI(first_redirect_url).query)["code"].first
|
first_code = Rack::Utils.parse_query(URI(first_redirect_url).query)["code"]
|
||||||
|
|
||||||
# Exchange for tokens and extract auth_time
|
# Exchange for tokens and extract auth_time
|
||||||
post "/oauth/token", params: {
|
post "/oauth/token", params: {
|
||||||
@@ -90,7 +90,7 @@ class OidcPromptLoginTest < ActionDispatch::IntegrationTest
|
|||||||
# Should receive authorization code
|
# Should receive authorization code
|
||||||
assert_response :redirect
|
assert_response :redirect
|
||||||
second_redirect_url = response.location
|
second_redirect_url = response.location
|
||||||
second_code = CGI.parse(URI(second_redirect_url).query)["code"].first
|
second_code = Rack::Utils.parse_query(URI(second_redirect_url).query)["code"]
|
||||||
|
|
||||||
assert second_code.present?, "Should receive authorization code after re-authentication"
|
assert second_code.present?, "Should receive authorization code after re-authentication"
|
||||||
|
|
||||||
@@ -134,11 +134,11 @@ class OidcPromptLoginTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
# Parse the redirect URL
|
# Parse the redirect URL
|
||||||
uri = URI.parse(redirect_url)
|
uri = URI.parse(redirect_url)
|
||||||
query_params = uri.query ? CGI.parse(uri.query) : {}
|
query_params = uri.query ? Rack::Utils.parse_query(uri.query) : {}
|
||||||
|
|
||||||
assert_equal "login_required", query_params["error"]&.first,
|
assert_equal "login_required", query_params["error"],
|
||||||
"Should return login_required error for prompt=none when not authenticated"
|
"Should return login_required error for prompt=none when not authenticated"
|
||||||
assert_equal "test-state", query_params["state"]&.first,
|
assert_equal "test-state", query_params["state"],
|
||||||
"Should return state parameter"
|
"Should return state parameter"
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -165,7 +165,7 @@ class OidcPromptLoginTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
assert_response :redirect
|
assert_response :redirect
|
||||||
first_redirect_url = response.location
|
first_redirect_url = response.location
|
||||||
first_code = CGI.parse(URI(first_redirect_url).query)["code"].first
|
first_code = Rack::Utils.parse_query(URI(first_redirect_url).query)["code"]
|
||||||
|
|
||||||
# Exchange for tokens and extract auth_time from ID token
|
# Exchange for tokens and extract auth_time from ID token
|
||||||
post "/oauth/token", params: {
|
post "/oauth/token", params: {
|
||||||
@@ -209,7 +209,7 @@ class OidcPromptLoginTest < ActionDispatch::IntegrationTest
|
|||||||
# Should receive authorization code redirect
|
# Should receive authorization code redirect
|
||||||
assert_response :redirect
|
assert_response :redirect
|
||||||
second_redirect_url = response.location
|
second_redirect_url = response.location
|
||||||
second_code = CGI.parse(URI(second_redirect_url).query)["code"].first
|
second_code = Rack::Utils.parse_query(URI(second_redirect_url).query)["code"]
|
||||||
|
|
||||||
assert second_code.present?, "Should receive authorization code after re-authentication"
|
assert second_code.present?, "Should receive authorization code after re-authentication"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user