Compare commits
3 Commits
4f31fadc6c
...
ed7ceedef5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed7ceedef5 | ||
|
|
40815d3576 | ||
|
|
a17c08c890 |
58
README.md
58
README.md
@@ -1,30 +1,11 @@
|
||||
# Clinch
|
||||
|
||||
> [!NOTE]
|
||||
> This software is experiemental. If you'd like to try it out, find bugs, security flaws and improvements, please do.
|
||||
> This software is experimental. If you'd like to try it out, find bugs, security flaws and improvements, please do.
|
||||
|
||||
**A lightweight, self-hosted identity & SSO / IpD portal**
|
||||
|
||||
Clinch gives you one place to manage users and lets any web app authenticate against it without managing it's own users.
|
||||
|
||||
All planned features are complete:
|
||||
|
||||
* Create Admin user on first login
|
||||
* TOTP ( QR Code ) 2FA, with backup codes ( encrypted at rest )
|
||||
* Passkey generation and login, with detection of Passkey during login
|
||||
* Forward Auth configured and working
|
||||
* OIDC provider with auto discovery, refresh tokens, and token revocation
|
||||
* Configurable token expiry per application (access, refresh, ID tokens)
|
||||
* Backchannel Logout
|
||||
* Per-application logout / revoke
|
||||
* Invite users by email, assign to groups
|
||||
* Self managed password reset by email
|
||||
* Use Groups to assign Applications ( Family group can access Kavita, Developers can access Gitea )
|
||||
* Configurable Group, User & App+User custom claims for OIDC token
|
||||
* Display all Applications available to the user on their Dashboard
|
||||
* Display all logged in sessions and OIDC logged in sessions
|
||||
|
||||
What remains now is ensure test coverage, and validating correct implementation.
|
||||
Clinch gives you one place to manage users and lets any web app authenticate against it without managing its own users.
|
||||
|
||||
## Why Clinch?
|
||||
|
||||
@@ -87,7 +68,7 @@ Clinch sits in a sweet spot between two excellent open-source identity solutions
|
||||
### SSO Protocols
|
||||
|
||||
Apps that speak OIDC use the OIDC flow.
|
||||
Apps that only need "who is it?", or you want available from the interenet behind authentication ( MeTube, Jellyfin ) use ForwardAuth.
|
||||
Apps that only need "who is it?", or you want available from the internet behind authentication (MeTube, Jellyfin) use ForwardAuth.
|
||||
|
||||
#### OpenID Connect (OIDC)
|
||||
Standard OAuth2/OIDC provider with endpoints:
|
||||
@@ -335,44 +316,17 @@ OIDC_PRIVATE_KEY=<contents-of-private-key.pem>
|
||||
|
||||
---
|
||||
|
||||
## Roadmap
|
||||
|
||||
### In Progress
|
||||
- OIDC provider implementation
|
||||
- ForwardAuth endpoint
|
||||
- Admin UI for user/group/app management
|
||||
- First-run wizard
|
||||
|
||||
### Planned Features
|
||||
- **Audit logging** - Track all authentication events
|
||||
- **WebAuthn/Passkeys** - Hardware key support
|
||||
|
||||
#### Maybe
|
||||
- **SAML support** - SAML 2.0 identity provider
|
||||
- **Policy engine** - Rule-based access control
|
||||
- Example: `IF user.email =~ "*@gmail.com" AND app.slug == "kavita" THEN DENY`
|
||||
- Stored as JSON, evaluated after auth but before consent
|
||||
- **LDAP sync** - Import users from LDAP/Active Directory
|
||||
|
||||
---
|
||||
|
||||
## Rails Console
|
||||
|
||||
One advantage of being a Rails application is direct access to the Rails console for administrative tasks. This is particularly useful for debugging, emergency access, or bulk operations.
|
||||
|
||||
You can start the console with:
|
||||
|
||||
`bin/rails console`
|
||||
|
||||
or in Docker compose with:
|
||||
|
||||
`docker compose exec -it clinch bin/rails console`
|
||||
|
||||
### Starting the Console
|
||||
|
||||
```bash
|
||||
# Docker
|
||||
# Docker / Docker Compose
|
||||
docker exec -it clinch bin/rails console
|
||||
# or
|
||||
docker compose exec -it clinch bin/rails console
|
||||
|
||||
# Local development
|
||||
bin/rails console
|
||||
|
||||
@@ -26,7 +26,7 @@ class OidcController < ApplicationController
|
||||
response_types_supported: ["code"],
|
||||
response_modes_supported: ["query"],
|
||||
grant_types_supported: ["authorization_code", "refresh_token"],
|
||||
subject_types_supported: ["public"],
|
||||
subject_types_supported: ["pairwise"],
|
||||
id_token_signing_alg_values_supported: ["RS256"],
|
||||
scopes_supported: ["openid", "profile", "email", "groups", "offline_access"],
|
||||
token_endpoint_auth_methods_supported: ["client_secret_post", "client_secret_basic"],
|
||||
@@ -422,8 +422,14 @@ class OidcController < ApplicationController
|
||||
return
|
||||
end
|
||||
|
||||
# Generate ID token (JWT) with pairwise SID
|
||||
id_token = OidcJwtService.generate_id_token(user, application, consent: consent, nonce: auth_code.nonce)
|
||||
# Generate ID token (JWT) with pairwise SID and at_hash
|
||||
id_token = OidcJwtService.generate_id_token(
|
||||
user,
|
||||
application,
|
||||
consent: consent,
|
||||
nonce: auth_code.nonce,
|
||||
access_token: access_token_record.plaintext_token
|
||||
)
|
||||
|
||||
# Return tokens
|
||||
render json: {
|
||||
@@ -482,14 +488,11 @@ class OidcController < ApplicationController
|
||||
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
|
||||
# Find the refresh token record using indexed token prefix lookup
|
||||
refresh_token_record = OidcRefreshToken.find_by_token(refresh_token)
|
||||
|
||||
unless refresh_token_record
|
||||
# Verify the token belongs to the correct application
|
||||
unless refresh_token_record && refresh_token_record.application == application
|
||||
render json: { error: "invalid_grant", error_description: "Invalid refresh token" }, status: :bad_request
|
||||
return
|
||||
end
|
||||
@@ -542,8 +545,13 @@ class OidcController < ApplicationController
|
||||
return
|
||||
end
|
||||
|
||||
# Generate new ID token (JWT with pairwise SID, no nonce for refresh grants)
|
||||
id_token = OidcJwtService.generate_id_token(user, application, consent: consent)
|
||||
# Generate new ID token (JWT with pairwise SID and at_hash, no nonce for refresh grants)
|
||||
id_token = OidcJwtService.generate_id_token(
|
||||
user,
|
||||
application,
|
||||
consent: consent,
|
||||
access_token: new_access_token.plaintext_token
|
||||
)
|
||||
|
||||
# Return new tokens
|
||||
render json: {
|
||||
@@ -668,9 +676,7 @@ class OidcController < ApplicationController
|
||||
|
||||
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
|
||||
refresh_token_record = OidcRefreshToken.find_by_token(token)
|
||||
|
||||
if refresh_token_record
|
||||
refresh_token_record.revoke!
|
||||
@@ -681,9 +687,7 @@ class OidcController < ApplicationController
|
||||
|
||||
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
|
||||
access_token_record = OidcAccessToken.find_by_token(token)
|
||||
|
||||
if access_token_record
|
||||
access_token_record.revoke!
|
||||
|
||||
@@ -10,6 +10,7 @@ class OidcRefreshToken < ApplicationRecord
|
||||
before_validation :set_token_family_id, on: :create
|
||||
|
||||
validates :token_digest, presence: true, uniqueness: true
|
||||
validates :token_prefix, presence: true
|
||||
|
||||
scope :valid, -> { where("expires_at > ?", Time.current).where(revoked_at: nil) }
|
||||
scope :expired, -> { where("expires_at <= ?", Time.current) }
|
||||
|
||||
@@ -3,7 +3,7 @@ class OidcJwtService
|
||||
|
||||
class << self
|
||||
# Generate an ID token (JWT) for the user
|
||||
def generate_id_token(user, application, consent: nil, nonce: nil)
|
||||
def generate_id_token(user, application, consent: nil, nonce: nil, access_token: nil)
|
||||
now = Time.current.to_i
|
||||
# Use application's configured ID token TTL (defaults to 1 hour)
|
||||
ttl = application.id_token_expiry_seconds
|
||||
@@ -26,6 +26,14 @@ class OidcJwtService
|
||||
# Add nonce if provided (OIDC requires this for implicit flow)
|
||||
payload[:nonce] = nonce if nonce.present?
|
||||
|
||||
# Add at_hash if access token is provided (OIDC Core spec §3.1.3.6)
|
||||
# at_hash = left-most 128 bits of SHA-256 hash of access token, base64url encoded
|
||||
if access_token.present?
|
||||
sha256 = Digest::SHA256.digest(access_token)
|
||||
at_hash = Base64.urlsafe_encode64(sha256[0..15], padding: false)
|
||||
payload[:at_hash] = at_hash
|
||||
end
|
||||
|
||||
# Add groups if user has any
|
||||
if user.groups.any?
|
||||
payload[:groups] = user.groups.pluck(:name)
|
||||
|
||||
@@ -57,8 +57,8 @@ Rails.application.configure do
|
||||
# Replace the default in-process memory cache store with a durable alternative.
|
||||
config.cache_store = :solid_cache_store
|
||||
|
||||
# Use async processor for background jobs (modify as needed for production)
|
||||
config.active_job.queue_adapter = :async
|
||||
# Use Solid Queue for background jobs
|
||||
config.active_job.queue_adapter = :solid_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.
|
||||
|
||||
@@ -476,4 +476,23 @@ class OidcJwtServiceTest < ActiveSupport::TestCase
|
||||
assert_includes decoded["roles"], "moderator"
|
||||
assert_includes decoded["roles"], "app_admin"
|
||||
end
|
||||
|
||||
test "should include at_hash when access token is provided" do
|
||||
access_token = "test-access-token-abc123xyz"
|
||||
token = @service.generate_id_token(@user, @application, access_token: access_token)
|
||||
|
||||
decoded = JWT.decode(token, nil, false).first
|
||||
assert_includes decoded.keys, "at_hash", "Should include at_hash claim"
|
||||
|
||||
# Verify at_hash is correctly computed: base64url(sha256(access_token)[0:16])
|
||||
expected_hash = Base64.urlsafe_encode64(Digest::SHA256.digest(access_token)[0..15], padding: false)
|
||||
assert_equal expected_hash, decoded["at_hash"], "at_hash should match SHA-256 hash of access token"
|
||||
end
|
||||
|
||||
test "should not include at_hash when access token is not provided" do
|
||||
token = @service.generate_id_token(@user, @application)
|
||||
|
||||
decoded = JWT.decode(token, nil, false).first
|
||||
refute_includes decoded.keys, "at_hash", "Should not include at_hash when no access token"
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user