diff --git a/README.md b/README.md index 089d920..0629a64 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ Features: - **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 - **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 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) **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) - Refresh tokens (opaque, BCrypt-hashed, configurable expiry 1-90 days, single-use with rotation) - ID tokens (JWT, signed with RS256, configurable expiry 5min-24hr) diff --git a/app/controllers/oidc_controller.rb b/app/controllers/oidc_controller.rb index a202b5b..6a44993 100644 --- a/app/controllers/oidc_controller.rb +++ b/app/controllers/oidc_controller.rb @@ -154,11 +154,9 @@ class OidcController < ApplicationController existing_consent = user.has_oidc_consent?(@application, requested_scopes) if existing_consent # User has already consented, generate authorization code directly - code = SecureRandom.urlsafe_base64(32) auth_code = OidcAuthorizationCode.create!( application: @application, user: user, - code: code, redirect_uri: redirect_uri, scope: scope, nonce: nonce, @@ -167,8 +165,8 @@ class OidcController < ApplicationController expires_at: 10.minutes.from_now ) - # Redirect back to client with authorization code - redirect_uri = "#{redirect_uri}?code=#{code}" + # Redirect back to client with authorization code (plaintext) + redirect_uri = "#{redirect_uri}?code=#{auth_code.plaintext_code}" redirect_uri += "&state=#{CGI.escape(state)}" if state.present? redirect_to redirect_uri, allow_other_host: true return @@ -258,11 +256,9 @@ class OidcController < ApplicationController ) # Generate authorization code - code = SecureRandom.urlsafe_base64(32) auth_code = OidcAuthorizationCode.create!( application: application, user: user, - code: code, redirect_uri: oauth_params['redirect_uri'], scope: oauth_params['scope'], nonce: oauth_params['nonce'], @@ -274,8 +270,8 @@ class OidcController < ApplicationController # Clear OAuth params from session session.delete(:oauth_params) - # Redirect back to client with authorization code - redirect_uri = "#{oauth_params['redirect_uri']}?code=#{code}" + # Redirect back to client with authorization code (plaintext) + redirect_uri = "#{oauth_params['redirect_uri']}?code=#{auth_code.plaintext_code}" redirect_uri += "&state=#{CGI.escape(oauth_params['state'])}" if oauth_params['state'] redirect_to redirect_uri, allow_other_host: true @@ -335,12 +331,10 @@ class OidcController < ApplicationController redirect_uri = params[:redirect_uri] code_verifier = params[:code_verifier] - auth_code = OidcAuthorizationCode.find_by( - application: application, - code: code - ) + # Find authorization code using HMAC verification + auth_code = OidcAuthorizationCode.find_by_plaintext(code) - unless auth_code + unless auth_code && auth_code.application == application render json: { error: "invalid_grant" }, status: :bad_request return end diff --git a/app/models/oidc_authorization_code.rb b/app/models/oidc_authorization_code.rb index bca0784..788137e 100644 --- a/app/models/oidc_authorization_code.rb +++ b/app/models/oidc_authorization_code.rb @@ -2,6 +2,8 @@ class OidcAuthorizationCode < ApplicationRecord belongs_to :application belongs_to :user + attr_accessor :plaintext_code + before_validation :generate_code, 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 :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? expires_at <= Time.current end @@ -32,7 +47,10 @@ class OidcAuthorizationCode < ApplicationRecord private 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 def set_expiry