Compare commits
3 Commits
4f31fadc6c
...
ed7ceedef5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed7ceedef5 | ||
|
|
40815d3576 | ||
|
|
a17c08c890 |
58
README.md
58
README.md
@@ -1,30 +1,11 @@
|
|||||||
# Clinch
|
# Clinch
|
||||||
|
|
||||||
> [!NOTE]
|
> [!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**
|
**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.
|
Clinch gives you one place to manage users and lets any web app authenticate against it without managing its 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.
|
|
||||||
|
|
||||||
## Why Clinch?
|
## Why Clinch?
|
||||||
|
|
||||||
@@ -87,7 +68,7 @@ Clinch sits in a sweet spot between two excellent open-source identity solutions
|
|||||||
### SSO Protocols
|
### SSO Protocols
|
||||||
|
|
||||||
Apps that speak OIDC use the OIDC flow.
|
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)
|
#### OpenID Connect (OIDC)
|
||||||
Standard OAuth2/OIDC provider with endpoints:
|
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
|
## 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.
|
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
|
### Starting the Console
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Docker
|
# Docker / Docker Compose
|
||||||
docker exec -it clinch bin/rails console
|
docker exec -it clinch bin/rails console
|
||||||
|
# or
|
||||||
|
docker compose exec -it clinch bin/rails console
|
||||||
|
|
||||||
# Local development
|
# Local development
|
||||||
bin/rails console
|
bin/rails console
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ class OidcController < ApplicationController
|
|||||||
response_types_supported: ["code"],
|
response_types_supported: ["code"],
|
||||||
response_modes_supported: ["query"],
|
response_modes_supported: ["query"],
|
||||||
grant_types_supported: ["authorization_code", "refresh_token"],
|
grant_types_supported: ["authorization_code", "refresh_token"],
|
||||||
subject_types_supported: ["public"],
|
subject_types_supported: ["pairwise"],
|
||||||
id_token_signing_alg_values_supported: ["RS256"],
|
id_token_signing_alg_values_supported: ["RS256"],
|
||||||
scopes_supported: ["openid", "profile", "email", "groups", "offline_access"],
|
scopes_supported: ["openid", "profile", "email", "groups", "offline_access"],
|
||||||
token_endpoint_auth_methods_supported: ["client_secret_post", "client_secret_basic"],
|
token_endpoint_auth_methods_supported: ["client_secret_post", "client_secret_basic"],
|
||||||
@@ -422,8 +422,14 @@ class OidcController < ApplicationController
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
# Generate ID token (JWT) with pairwise SID
|
# Generate ID token (JWT) with pairwise SID and at_hash
|
||||||
id_token = OidcJwtService.generate_id_token(user, application, consent: consent, nonce: auth_code.nonce)
|
id_token = OidcJwtService.generate_id_token(
|
||||||
|
user,
|
||||||
|
application,
|
||||||
|
consent: consent,
|
||||||
|
nonce: auth_code.nonce,
|
||||||
|
access_token: access_token_record.plaintext_token
|
||||||
|
)
|
||||||
|
|
||||||
# Return tokens
|
# Return tokens
|
||||||
render json: {
|
render json: {
|
||||||
@@ -482,14 +488,11 @@ class OidcController < ApplicationController
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
# Find the refresh token record
|
# Find the refresh token record using indexed token prefix lookup
|
||||||
# Note: This is inefficient with BCrypt hashing, but necessary for security
|
refresh_token_record = OidcRefreshToken.find_by_token(refresh_token)
|
||||||
# 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
|
# 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
|
render json: { error: "invalid_grant", error_description: "Invalid refresh token" }, status: :bad_request
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -542,8 +545,13 @@ class OidcController < ApplicationController
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
# Generate new ID token (JWT with pairwise SID, no nonce for refresh grants)
|
# 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)
|
id_token = OidcJwtService.generate_id_token(
|
||||||
|
user,
|
||||||
|
application,
|
||||||
|
consent: consent,
|
||||||
|
access_token: new_access_token.plaintext_token
|
||||||
|
)
|
||||||
|
|
||||||
# Return new tokens
|
# Return new tokens
|
||||||
render json: {
|
render json: {
|
||||||
@@ -668,9 +676,7 @@ class OidcController < ApplicationController
|
|||||||
|
|
||||||
if token_type_hint == "refresh_token" || token_type_hint.nil?
|
if token_type_hint == "refresh_token" || token_type_hint.nil?
|
||||||
# Try to find as refresh token
|
# Try to find as refresh token
|
||||||
refresh_token_record = OidcRefreshToken.where(application: application).find do |rt|
|
refresh_token_record = OidcRefreshToken.find_by_token(token)
|
||||||
rt.token_matches?(token)
|
|
||||||
end
|
|
||||||
|
|
||||||
if refresh_token_record
|
if refresh_token_record
|
||||||
refresh_token_record.revoke!
|
refresh_token_record.revoke!
|
||||||
@@ -681,9 +687,7 @@ class OidcController < ApplicationController
|
|||||||
|
|
||||||
if !revoked && (token_type_hint == "access_token" || token_type_hint.nil?)
|
if !revoked && (token_type_hint == "access_token" || token_type_hint.nil?)
|
||||||
# Try to find as access token
|
# Try to find as access token
|
||||||
access_token_record = OidcAccessToken.where(application: application).find do |at|
|
access_token_record = OidcAccessToken.find_by_token(token)
|
||||||
at.token_matches?(token)
|
|
||||||
end
|
|
||||||
|
|
||||||
if access_token_record
|
if access_token_record
|
||||||
access_token_record.revoke!
|
access_token_record.revoke!
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ class OidcRefreshToken < ApplicationRecord
|
|||||||
before_validation :set_token_family_id, on: :create
|
before_validation :set_token_family_id, on: :create
|
||||||
|
|
||||||
validates :token_digest, presence: true, uniqueness: true
|
validates :token_digest, presence: true, uniqueness: true
|
||||||
|
validates :token_prefix, presence: true
|
||||||
|
|
||||||
scope :valid, -> { where("expires_at > ?", Time.current).where(revoked_at: nil) }
|
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) }
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ class OidcJwtService
|
|||||||
|
|
||||||
class << self
|
class << self
|
||||||
# Generate an ID token (JWT) for the user
|
# 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
|
now = Time.current.to_i
|
||||||
# Use application's configured ID token TTL (defaults to 1 hour)
|
# Use application's configured ID token TTL (defaults to 1 hour)
|
||||||
ttl = application.id_token_expiry_seconds
|
ttl = application.id_token_expiry_seconds
|
||||||
@@ -26,6 +26,14 @@ class OidcJwtService
|
|||||||
# Add nonce if provided (OIDC requires this for implicit flow)
|
# Add nonce if provided (OIDC requires this for implicit flow)
|
||||||
payload[:nonce] = nonce if nonce.present?
|
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
|
# Add groups if user has any
|
||||||
if user.groups.any?
|
if user.groups.any?
|
||||||
payload[:groups] = user.groups.pluck(:name)
|
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.
|
# Replace the default in-process memory cache store with a durable alternative.
|
||||||
config.cache_store = :solid_cache_store
|
config.cache_store = :solid_cache_store
|
||||||
|
|
||||||
# Use async processor for background jobs (modify as needed for production)
|
# Use Solid Queue for background jobs
|
||||||
config.active_job.queue_adapter = :async
|
config.active_job.queue_adapter = :solid_queue
|
||||||
|
|
||||||
# Ignore bad email addresses and do not raise email delivery errors.
|
# 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.
|
# 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"], "moderator"
|
||||||
assert_includes decoded["roles"], "app_admin"
|
assert_includes decoded["roles"], "app_admin"
|
||||||
end
|
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
|
end
|
||||||
Reference in New Issue
Block a user