5 Commits

Author SHA1 Message Date
Dan Milne
11ec753c68 Bump up the forward auth token ttl, fix leaking of error data
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-11-09 12:27:53 +11:00
Dan Milne
4df2eee4d9 Bug fix for domain names with empty string instead of null. Form errors and some security fixes
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-11-09 12:22:41 +11:00
Dan Milne
d9f11abbbf Fixes for OIDC and HTML 2025-11-09 12:04:26 +11:00
Dan Milne
c92e69fa4a Add PCKE 2025-11-09 11:54:45 +11:00
Dan Milne
038801f34b Add pkce
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-11-09 10:21:29 +11:00
14 changed files with 338 additions and 26 deletions

1
VERSION Normal file
View File

@@ -0,0 +1 @@
2025.02

View File

@@ -5,4 +5,7 @@ class ApplicationController < ActionController::Base
# Changes to the importmap will invalidate the etag for HTML responses # Changes to the importmap will invalidate the etag for HTML responses
stale_when_importmap_changes stale_when_importmap_changes
# CSRF protection
protect_from_forgery with: :exception
end end

View File

@@ -120,11 +120,11 @@ module Authentication
# Generate a secure random token # Generate a secure random token
token = SecureRandom.urlsafe_base64(32) token = SecureRandom.urlsafe_base64(32)
# Store it with an expiry of 30 seconds # Store it with an expiry of 60 seconds
Rails.cache.write( Rails.cache.write(
"forward_auth_token:#{token}", "forward_auth_token:#{token}",
session_obj.id, session_obj.id,
expires_in: 30.seconds expires_in: 60.seconds
) )
# Set the token as a query parameter on the redirect URL # Set the token as a query parameter on the redirect URL

View File

@@ -15,11 +15,13 @@ class OidcController < ApplicationController
jwks_uri: "#{base_url}/.well-known/jwks.json", jwks_uri: "#{base_url}/.well-known/jwks.json",
end_session_endpoint: "#{base_url}/logout", end_session_endpoint: "#{base_url}/logout",
response_types_supported: ["code"], response_types_supported: ["code"],
response_modes_supported: ["query"],
subject_types_supported: ["public"], subject_types_supported: ["public"],
id_token_signing_alg_values_supported: ["RS256"], id_token_signing_alg_values_supported: ["RS256"],
scopes_supported: ["openid", "profile", "email", "groups"], scopes_supported: ["openid", "profile", "email", "groups"],
token_endpoint_auth_methods_supported: ["client_secret_post", "client_secret_basic"], token_endpoint_auth_methods_supported: ["client_secret_post", "client_secret_basic"],
claims_supported: ["sub", "email", "email_verified", "name", "preferred_username", "groups", "admin"] claims_supported: ["sub", "email", "email_verified", "name", "preferred_username", "groups", "admin"],
code_challenge_methods_supported: ["plain", "S256"]
} }
render json: config render json: config
@@ -39,23 +41,39 @@ class OidcController < ApplicationController
nonce = params[:nonce] nonce = params[:nonce]
scope = params[:scope] || "openid" scope = params[:scope] || "openid"
response_type = params[:response_type] response_type = params[:response_type]
code_challenge = params[:code_challenge]
code_challenge_method = params[:code_challenge_method] || "plain"
# Validate required parameters # Validate required parameters
unless client_id.present? && redirect_uri.present? && response_type == "code" unless client_id.present? && redirect_uri.present? && response_type == "code"
render plain: "Invalid request: missing required parameters", status: :bad_request render plain: "Invalid request", status: :bad_request
return return
end end
# Validate PKCE parameters if present
if code_challenge.present?
unless %w[plain S256].include?(code_challenge_method)
render plain: "Invalid request", status: :bad_request
return
end
# Validate code challenge format (base64url-encoded, 43-128 characters)
unless code_challenge.match?(/\A[A-Za-z0-9\-_]{43,128}\z/)
render plain: "Invalid request", status: :bad_request
return
end
end
# Find the application # Find the application
@application = Application.find_by(client_id: client_id, app_type: "oidc") @application = Application.find_by(client_id: client_id, app_type: "oidc")
unless @application unless @application
render plain: "Invalid client_id", status: :bad_request render plain: "Invalid request", status: :bad_request
return return
end end
# Validate redirect URI # Validate redirect URI
unless @application.parsed_redirect_uris.include?(redirect_uri) unless @application.parsed_redirect_uris.include?(redirect_uri)
render plain: "Invalid redirect_uri", status: :bad_request render plain: "Invalid request", status: :bad_request
return return
end end
@@ -67,7 +85,9 @@ class OidcController < ApplicationController
redirect_uri: redirect_uri, redirect_uri: redirect_uri,
state: state, state: state,
nonce: nonce, nonce: nonce,
scope: scope scope: scope,
code_challenge: code_challenge,
code_challenge_method: code_challenge_method
} }
redirect_to signin_path, alert: "Please sign in to continue" redirect_to signin_path, alert: "Please sign in to continue"
return return
@@ -96,6 +116,8 @@ class OidcController < ApplicationController
redirect_uri: redirect_uri, redirect_uri: redirect_uri,
scope: scope, scope: scope,
nonce: nonce, nonce: nonce,
code_challenge: code_challenge,
code_challenge_method: code_challenge_method,
expires_at: 10.minutes.from_now expires_at: 10.minutes.from_now
) )
@@ -112,7 +134,9 @@ class OidcController < ApplicationController
redirect_uri: redirect_uri, redirect_uri: redirect_uri,
state: state, state: state,
nonce: nonce, nonce: nonce,
scope: scope scope: scope,
code_challenge: code_challenge,
code_challenge_method: code_challenge_method
} }
# Render consent page # Render consent page
@@ -165,6 +189,8 @@ class OidcController < ApplicationController
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'],
code_challenge: oauth_params['code_challenge'],
code_challenge_method: oauth_params['code_challenge_method'],
expires_at: 10.minutes.from_now expires_at: 10.minutes.from_now
) )
@@ -205,6 +231,7 @@ class OidcController < ApplicationController
# Get the authorization code # Get the authorization code
code = params[:code] code = params[:code]
redirect_uri = params[:redirect_uri] redirect_uri = params[:redirect_uri]
code_verifier = params[:code_verifier]
auth_code = OidcAuthorizationCode.find_by( auth_code = OidcAuthorizationCode.find_by(
application: application, application: application,
@@ -229,6 +256,16 @@ class OidcController < ApplicationController
return return
end end
# Validate PKCE if code challenge is present
pkce_result = validate_pkce(auth_code, code_verifier)
unless pkce_result[:valid]
render json: {
error: pkce_result[:error],
error_description: pkce_result[:error_description]
}, status: pkce_result[:status]
return
end
# Mark code as used # Mark code as used
auth_code.update!(used: true) auth_code.update!(used: true)
@@ -342,6 +379,58 @@ class OidcController < ApplicationController
private private
def validate_pkce(auth_code, code_verifier)
# Skip PKCE validation if no code challenge was stored (legacy clients)
return { valid: true } unless auth_code.code_challenge.present?
# PKCE is required but no verifier provided
unless code_verifier.present?
return {
valid: false,
error: "invalid_request",
error_description: "code_verifier is required when code_challenge was provided",
status: :bad_request
}
end
# Validate code verifier format (base64url-encoded, 43-128 characters)
unless code_verifier.match?(/\A[A-Za-z0-9\-_]{43,128}\z/)
return {
valid: false,
error: "invalid_request",
error_description: "Invalid code_verifier format. Must be 43-128 characters of base64url encoding",
status: :bad_request
}
end
# Recreate code challenge based on method
expected_challenge = case auth_code.code_challenge_method
when "plain"
code_verifier
when "S256"
Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false)
else
return {
valid: false,
error: "server_error",
error_description: "Unsupported code challenge method",
status: :internal_server_error
}
end
# Validate the code challenge
unless auth_code.code_challenge == expected_challenge
return {
valid: false,
error: "invalid_grant",
error_description: "Invalid code verifier",
status: :bad_request
}
end
{ valid: true }
end
def extract_client_credentials def extract_client_credentials
# Try Authorization header first (Basic auth) # Try Authorization header first (Basic auth)
if request.headers["Authorization"]&.start_with?("Basic ") if request.headers["Authorization"]&.start_with?("Basic ")

View File

@@ -19,4 +19,14 @@ module ApplicationHelper
:smtp :smtp
end end
end end
def border_class_for(type)
case type.to_s
when 'notice' then 'border-green-200'
when 'alert', 'error' then 'border-red-200'
when 'warning' then 'border-yellow-200'
when 'info' then 'border-blue-200'
else 'border-gray-200'
end
end
end end

View File

@@ -18,7 +18,10 @@ class Application < ApplicationRecord
validates :landing_url, format: { with: URI::regexp(%w[http https]), allow_nil: true, message: "must be a valid URL" } validates :landing_url, format: { with: URI::regexp(%w[http https]), allow_nil: true, message: "must be a valid URL" }
normalizes :slug, with: ->(slug) { slug.strip.downcase } normalizes :slug, with: ->(slug) { slug.strip.downcase }
normalizes :domain_pattern, with: ->(pattern) { pattern&.strip&.downcase } normalizes :domain_pattern, with: ->(pattern) {
normalized = pattern&.strip&.downcase
normalized.blank? ? nil : normalized
}
before_validation :generate_client_credentials, on: :create, if: :oidc? before_validation :generate_client_credentials, on: :create, if: :oidc?

View File

@@ -7,6 +7,8 @@ class OidcAuthorizationCode < ApplicationRecord
validates :code, presence: true, uniqueness: true validates :code, presence: true, uniqueness: true
validates :redirect_uri, presence: true validates :redirect_uri, presence: true
validates :code_challenge_method, inclusion: { in: %w[plain S256], allow_nil: true }
validate :validate_code_challenge_format, if: -> { code_challenge.present? }
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) }
@@ -23,6 +25,10 @@ class OidcAuthorizationCode < ApplicationRecord
update!(used: true) update!(used: true)
end end
def uses_pkce?
code_challenge.present?
end
private private
def generate_code def generate_code
@@ -32,4 +38,11 @@ class OidcAuthorizationCode < ApplicationRecord
def set_expiry def set_expiry
self.expires_at ||= 10.minutes.from_now self.expires_at ||= 10.minutes.from_now
end end
def validate_code_challenge_format
# PKCE code challenge should be base64url-encoded, 43-128 characters
unless code_challenge.match?(/\A[A-Za-z0-9\-_]{43,128}\z/)
errors.add(:code_challenge, "must be 43-128 characters of base64url encoding")
end
end
end end

View File

@@ -71,16 +71,3 @@
</div> </div>
</div> </div>
<% end %> <% end %>
<%# Helper method for border colors %>
<%
def border_class_for(type)
case type.to_s
when 'notice' then 'border-green-200'
when 'alert', 'error' then 'border-red-200'
when 'warning' then 'border-yellow-200'
when 'info' then 'border-blue-200'
else 'border-gray-200'
end
end
%>

View File

@@ -1,5 +1,5 @@
<%# Usage: <%= render "shared/form_errors", object: @user %> %> <%# Usage: render "shared/form_errors", object: @user %>
<%# Usage: <%= render "shared/form_errors", form: form %> %> <%# Usage: render "shared/form_errors", form: form %>
<% form_object = form.respond_to?(:object) ? form.object : (object || form) %> <% form_object = form.respond_to?(:object) ? form.object : (object || form) %>
<% if form_object&.errors&.any? %> <% if form_object&.errors&.any? %>

View File

@@ -53,5 +53,5 @@ Rails.application.configure do
# Disable Sentry in test environment to avoid interference with tests # Disable Sentry in test environment to avoid interference with tests
# Sentry can be explicitly enabled for integration testing if needed # Sentry can be explicitly enabled for integration testing if needed
config.sentry.enabled = false ENV["SENTRY_ENABLED_IN_DEVELOPMENT"] = "false"
end end

View File

@@ -0,0 +1,9 @@
class AddPkceSupportToOidcAuthorizationCodes < ActiveRecord::Migration[8.1]
def change
add_column :oidc_authorization_codes, :code_challenge, :string
add_column :oidc_authorization_codes, :code_challenge_method, :string
# Add index for code_challenge to improve query performance
add_index :oidc_authorization_codes, :code_challenge
end
end

View File

@@ -0,0 +1,17 @@
class FixEmptyDomainPatterns < ActiveRecord::Migration[8.1]
def up
# Convert empty string domain_patterns to NULL
# This fixes a unique constraint issue where multiple OIDC apps
# had empty string domain_patterns, causing uniqueness violations
execute <<-SQL
UPDATE applications
SET domain_pattern = NULL
WHERE domain_pattern = ''
SQL
end
def down
# No need to reverse this - empty strings and NULL are functionally equivalent
# for OIDC applications where domain_pattern is not used
end
end

5
db/schema.rb generated
View File

@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.1].define(version: 2025_11_04_064114) do ActiveRecord::Schema[8.1].define(version: 2025_11_09_011443) do
create_table "application_groups", force: :cascade do |t| create_table "application_groups", force: :cascade do |t|
t.integer "application_id", null: false t.integer "application_id", null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
@@ -69,6 +69,8 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_04_064114) do
create_table "oidc_authorization_codes", force: :cascade do |t| create_table "oidc_authorization_codes", force: :cascade do |t|
t.integer "application_id", null: false t.integer "application_id", null: false
t.string "code", null: false t.string "code", null: false
t.string "code_challenge"
t.string "code_challenge_method"
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "expires_at", null: false t.datetime "expires_at", null: false
t.string "nonce" t.string "nonce"
@@ -80,6 +82,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_04_064114) do
t.index ["application_id", "user_id"], name: "index_oidc_authorization_codes_on_application_id_and_user_id" t.index ["application_id", "user_id"], name: "index_oidc_authorization_codes_on_application_id_and_user_id"
t.index ["application_id"], name: "index_oidc_authorization_codes_on_application_id" t.index ["application_id"], name: "index_oidc_authorization_codes_on_application_id"
t.index ["code"], name: "index_oidc_authorization_codes_on_code", unique: true t.index ["code"], name: "index_oidc_authorization_codes_on_code", unique: true
t.index ["code_challenge"], name: "index_oidc_authorization_codes_on_code_challenge"
t.index ["expires_at"], name: "index_oidc_authorization_codes_on_expires_at" t.index ["expires_at"], name: "index_oidc_authorization_codes_on_expires_at"
t.index ["user_id"], name: "index_oidc_authorization_codes_on_user_id" t.index ["user_id"], name: "index_oidc_authorization_codes_on_user_id"
end end

View File

@@ -0,0 +1,177 @@
require "test_helper"
class PkceAuthorizationCodeTest < ActiveSupport::TestCase
def setup
@user = User.create!(email_address: "pkce_test@example.com", password: "password123")
@application = Application.create!(
name: "PKCE Test App",
slug: "pkce-test-app",
app_type: "oidc",
redirect_uris: ["http://localhost:4000/callback"].to_json,
active: true
)
end
def teardown
# Clean up any authorization codes first to avoid foreign key constraints
OidcAuthorizationCode.where(application: @application).destroy_all
@user.destroy
@application.destroy
end
test "authorization code can store PKCE challenge with S256 method" do
code_challenge = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
code_challenge_method = "S256"
auth_code = OidcAuthorizationCode.create!(
application: @application,
user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
code_challenge: code_challenge,
code_challenge_method: code_challenge_method,
expires_at: 10.minutes.from_now
)
assert_equal code_challenge, auth_code.code_challenge
assert_equal code_challenge_method, auth_code.code_challenge_method
assert auth_code.uses_pkce?
end
test "authorization code can store PKCE challenge with plain method" do
code_challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
code_challenge_method = "plain"
auth_code = OidcAuthorizationCode.create!(
application: @application,
user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
code_challenge: code_challenge,
code_challenge_method: code_challenge_method,
expires_at: 10.minutes.from_now
)
assert_equal code_challenge, auth_code.code_challenge
assert_equal code_challenge_method, auth_code.code_challenge_method
assert auth_code.uses_pkce?
end
test "authorization code works without PKCE (backward compatibility)" do
auth_code = OidcAuthorizationCode.create!(
application: @application,
user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
expires_at: 10.minutes.from_now
)
assert_nil auth_code.code_challenge
assert_nil auth_code.code_challenge_method
assert_not auth_code.uses_pkce?
end
test "code_challenge_method validation accepts valid methods" do
auth_code = OidcAuthorizationCode.new(
application: @application,
user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
code_challenge: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk",
code_challenge_method: "S256",
expires_at: 10.minutes.from_now
)
assert auth_code.valid?
end
test "code_challenge_method validation rejects invalid methods" do
auth_code = OidcAuthorizationCode.new(
application: @application,
user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
code_challenge: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk",
code_challenge_method: "invalid_method",
expires_at: 10.minutes.from_now
)
assert_not auth_code.valid?
assert_includes auth_code.errors[:code_challenge_method], "is not included in the list"
end
test "code_challenge format validation accepts valid base64url" do
# Valid base64url encoded string (43 characters, valid characters)
valid_challenge = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
auth_code = OidcAuthorizationCode.new(
application: @application,
user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
code_challenge: valid_challenge,
code_challenge_method: "S256",
expires_at: 10.minutes.from_now
)
assert auth_code.valid?
end
test "code_challenge format validation rejects invalid format" do
# Invalid: contains + character (not base64url)
invalid_challenge = "dBjftJeZ4CVP+mB92K27uhbUJU1p1r/wW1gFWFOEjXk"
auth_code = OidcAuthorizationCode.new(
application: @application,
user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
code_challenge: invalid_challenge,
code_challenge_method: "S256",
expires_at: 10.minutes.from_now
)
assert_not auth_code.valid?
assert_includes auth_code.errors[:code_challenge], "must be 43-128 characters of base64url encoding"
end
test "code_challenge format validation rejects wrong length" do
# Invalid: too short (42 characters)
short_challenge = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjX"
auth_code = OidcAuthorizationCode.new(
application: @application,
user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
code_challenge: short_challenge,
code_challenge_method: "S256",
expires_at: 10.minutes.from_now
)
assert_not auth_code.valid?
assert_includes auth_code.errors[:code_challenge], "must be 43-128 characters of base64url encoding"
end
test "code_challenge validation is skipped when no challenge present" do
auth_code = OidcAuthorizationCode.new(
application: @application,
user: @user,
code: SecureRandom.urlsafe_base64(32),
redirect_uri: "http://localhost:4000/callback",
scope: "openid profile",
expires_at: 10.minutes.from_now
)
# Should be valid even without code_challenge
assert auth_code.valid?
end
end