Store only HMAC'd Auth codes, rather than plain text auth codes.
This commit is contained in:
@@ -82,7 +82,7 @@ Features:
|
|||||||
- **Refresh tokens** - Long-lived tokens (30 days default) with automatic rotation and revocation
|
- **Refresh tokens** - Long-lived tokens (30 days default) with automatic rotation and revocation
|
||||||
- **Token family tracking** - Advanced security detects token replay attacks and revokes compromised token families
|
- **Token family tracking** - Advanced security detects token replay attacks and revokes compromised token families
|
||||||
- **Configurable token expiry** - Set access token (5min-24hr), refresh token (1-90 days), and ID token TTL per application
|
- **Configurable token expiry** - Set access token (5min-24hr), refresh token (1-90 days), and ID token TTL per application
|
||||||
- **Token security** - BCrypt-hashed tokens, automatic cleanup of expired tokens
|
- **Token security** - HMAC-SHA256 hashed authorization codes, BCrypt-hashed access/refresh tokens, automatic cleanup of expired tokens
|
||||||
- **Pairwise subject identifiers** - Each user gets a unique, stable `sub` claim per application for enhanced privacy
|
- **Pairwise subject identifiers** - Each user gets a unique, stable `sub` claim per application for enhanced privacy
|
||||||
|
|
||||||
Client apps (Audiobookshelf, Kavita, Proxmox, Grafana, etc.) redirect to Clinch for login and receive ID tokens, access tokens, and refresh tokens.
|
Client apps (Audiobookshelf, Kavita, Proxmox, Grafana, etc.) redirect to Clinch for login and receive ID tokens, access tokens, and refresh tokens.
|
||||||
@@ -199,7 +199,7 @@ Configure different claims for different applications on a per-user basis:
|
|||||||
- Many-to-many with Groups (allowlist)
|
- Many-to-many with Groups (allowlist)
|
||||||
|
|
||||||
**OIDC Tokens**
|
**OIDC Tokens**
|
||||||
- Authorization codes (10-minute expiry, one-time use, PKCE support)
|
- Authorization codes (opaque, HMAC-SHA256 hashed, 10-minute expiry, one-time use, PKCE support)
|
||||||
- Access tokens (opaque, BCrypt-hashed, configurable expiry 5min-24hr, revocable)
|
- Access tokens (opaque, BCrypt-hashed, configurable expiry 5min-24hr, revocable)
|
||||||
- Refresh tokens (opaque, BCrypt-hashed, configurable expiry 1-90 days, single-use with rotation)
|
- Refresh tokens (opaque, BCrypt-hashed, configurable expiry 1-90 days, single-use with rotation)
|
||||||
- ID tokens (JWT, signed with RS256, configurable expiry 5min-24hr)
|
- ID tokens (JWT, signed with RS256, configurable expiry 5min-24hr)
|
||||||
|
|||||||
@@ -154,11 +154,9 @@ class OidcController < ApplicationController
|
|||||||
existing_consent = user.has_oidc_consent?(@application, requested_scopes)
|
existing_consent = user.has_oidc_consent?(@application, requested_scopes)
|
||||||
if existing_consent
|
if existing_consent
|
||||||
# User has already consented, generate authorization code directly
|
# User has already consented, generate authorization code directly
|
||||||
code = SecureRandom.urlsafe_base64(32)
|
|
||||||
auth_code = OidcAuthorizationCode.create!(
|
auth_code = OidcAuthorizationCode.create!(
|
||||||
application: @application,
|
application: @application,
|
||||||
user: user,
|
user: user,
|
||||||
code: code,
|
|
||||||
redirect_uri: redirect_uri,
|
redirect_uri: redirect_uri,
|
||||||
scope: scope,
|
scope: scope,
|
||||||
nonce: nonce,
|
nonce: nonce,
|
||||||
@@ -167,8 +165,8 @@ class OidcController < ApplicationController
|
|||||||
expires_at: 10.minutes.from_now
|
expires_at: 10.minutes.from_now
|
||||||
)
|
)
|
||||||
|
|
||||||
# Redirect back to client with authorization code
|
# Redirect back to client with authorization code (plaintext)
|
||||||
redirect_uri = "#{redirect_uri}?code=#{code}"
|
redirect_uri = "#{redirect_uri}?code=#{auth_code.plaintext_code}"
|
||||||
redirect_uri += "&state=#{CGI.escape(state)}" if state.present?
|
redirect_uri += "&state=#{CGI.escape(state)}" if state.present?
|
||||||
redirect_to redirect_uri, allow_other_host: true
|
redirect_to redirect_uri, allow_other_host: true
|
||||||
return
|
return
|
||||||
@@ -258,11 +256,9 @@ class OidcController < ApplicationController
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Generate authorization code
|
# Generate authorization code
|
||||||
code = SecureRandom.urlsafe_base64(32)
|
|
||||||
auth_code = OidcAuthorizationCode.create!(
|
auth_code = OidcAuthorizationCode.create!(
|
||||||
application: application,
|
application: application,
|
||||||
user: user,
|
user: user,
|
||||||
code: code,
|
|
||||||
redirect_uri: oauth_params['redirect_uri'],
|
redirect_uri: oauth_params['redirect_uri'],
|
||||||
scope: oauth_params['scope'],
|
scope: oauth_params['scope'],
|
||||||
nonce: oauth_params['nonce'],
|
nonce: oauth_params['nonce'],
|
||||||
@@ -274,8 +270,8 @@ class OidcController < ApplicationController
|
|||||||
# Clear OAuth params from session
|
# Clear OAuth params from session
|
||||||
session.delete(:oauth_params)
|
session.delete(:oauth_params)
|
||||||
|
|
||||||
# Redirect back to client with authorization code
|
# Redirect back to client with authorization code (plaintext)
|
||||||
redirect_uri = "#{oauth_params['redirect_uri']}?code=#{code}"
|
redirect_uri = "#{oauth_params['redirect_uri']}?code=#{auth_code.plaintext_code}"
|
||||||
redirect_uri += "&state=#{CGI.escape(oauth_params['state'])}" if oauth_params['state']
|
redirect_uri += "&state=#{CGI.escape(oauth_params['state'])}" if oauth_params['state']
|
||||||
|
|
||||||
redirect_to redirect_uri, allow_other_host: true
|
redirect_to redirect_uri, allow_other_host: true
|
||||||
@@ -335,12 +331,10 @@ class OidcController < ApplicationController
|
|||||||
redirect_uri = params[:redirect_uri]
|
redirect_uri = params[:redirect_uri]
|
||||||
code_verifier = params[:code_verifier]
|
code_verifier = params[:code_verifier]
|
||||||
|
|
||||||
auth_code = OidcAuthorizationCode.find_by(
|
# Find authorization code using HMAC verification
|
||||||
application: application,
|
auth_code = OidcAuthorizationCode.find_by_plaintext(code)
|
||||||
code: code
|
|
||||||
)
|
|
||||||
|
|
||||||
unless auth_code
|
unless auth_code && auth_code.application == application
|
||||||
render json: { error: "invalid_grant" }, status: :bad_request
|
render json: { error: "invalid_grant" }, status: :bad_request
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ class OidcAuthorizationCode < ApplicationRecord
|
|||||||
belongs_to :application
|
belongs_to :application
|
||||||
belongs_to :user
|
belongs_to :user
|
||||||
|
|
||||||
|
attr_accessor :plaintext_code
|
||||||
|
|
||||||
before_validation :generate_code, on: :create
|
before_validation :generate_code, on: :create
|
||||||
before_validation :set_expiry, on: :create
|
before_validation :set_expiry, on: :create
|
||||||
|
|
||||||
@@ -13,6 +15,19 @@ class OidcAuthorizationCode < ApplicationRecord
|
|||||||
scope :valid, -> { where(used: false).where("expires_at > ?", Time.current) }
|
scope :valid, -> { where(used: false).where("expires_at > ?", Time.current) }
|
||||||
scope :expired, -> { where("expires_at <= ?", Time.current) }
|
scope :expired, -> { where("expires_at <= ?", Time.current) }
|
||||||
|
|
||||||
|
# Find authorization code by plaintext code using HMAC verification
|
||||||
|
def self.find_by_plaintext(plaintext_code)
|
||||||
|
return nil if plaintext_code.blank?
|
||||||
|
|
||||||
|
code_hmac = compute_code_hmac(plaintext_code)
|
||||||
|
find_by(code: code_hmac)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Compute HMAC for code lookup
|
||||||
|
def self.compute_code_hmac(plaintext_code)
|
||||||
|
OpenSSL::HMAC.hexdigest('SHA256', TokenHmac::KEY, plaintext_code)
|
||||||
|
end
|
||||||
|
|
||||||
def expired?
|
def expired?
|
||||||
expires_at <= Time.current
|
expires_at <= Time.current
|
||||||
end
|
end
|
||||||
@@ -32,7 +47,10 @@ class OidcAuthorizationCode < ApplicationRecord
|
|||||||
private
|
private
|
||||||
|
|
||||||
def generate_code
|
def generate_code
|
||||||
self.code ||= SecureRandom.urlsafe_base64(32)
|
# Generate random plaintext code
|
||||||
|
self.plaintext_code ||= SecureRandom.urlsafe_base64(32)
|
||||||
|
# Store HMAC in database (not plaintext)
|
||||||
|
self.code ||= self.class.compute_code_hmac(plaintext_code)
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_expiry
|
def set_expiry
|
||||||
|
|||||||
Reference in New Issue
Block a user