3 Commits

Author SHA1 Message Date
Dan Milne
ed7ceedef5 Include the hash of the access token in the JWT / ID Token under the key at_hash as per the requirements. Update the discovery endpoint to describe subject_type as 'pairwise', rather than 'public', since we do pairwise subject ids.
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2025-12-31 14:45:38 +11:00
Dan Milne
40815d3576 Use SolidQueue in production. Use the find_by_token method, rather than iterating over refresh tokens, as we already fixed for tokens 2025-12-31 14:32:34 +11:00
Dan Milne
a17c08c890 Improve the README 2025-12-31 14:31:53 +11:00
6 changed files with 59 additions and 73 deletions

View File

@@ -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

View File

@@ -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!

View File

@@ -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) }

View File

@@ -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)

View File

@@ -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.

View File

@@ -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