From 038801f34b9a2ad9bb150668c39ef618ab24ceb9 Mon Sep 17 00:00:00 2001 From: Dan Milne Date: Sun, 9 Nov 2025 10:21:29 +1100 Subject: [PATCH] Add pkce --- ...kce_support_to_oidc_authorization_codes.rb | 9 + test/models/pkce_authorization_code_test.rb | 177 ++++++++++++++++++ 2 files changed, 186 insertions(+) create mode 100644 db/migrate/20251108090123_add_pkce_support_to_oidc_authorization_codes.rb create mode 100644 test/models/pkce_authorization_code_test.rb diff --git a/db/migrate/20251108090123_add_pkce_support_to_oidc_authorization_codes.rb b/db/migrate/20251108090123_add_pkce_support_to_oidc_authorization_codes.rb new file mode 100644 index 0000000..fb9ea87 --- /dev/null +++ b/db/migrate/20251108090123_add_pkce_support_to_oidc_authorization_codes.rb @@ -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 diff --git a/test/models/pkce_authorization_code_test.rb b/test/models/pkce_authorization_code_test.rb new file mode 100644 index 0000000..0a92f15 --- /dev/null +++ b/test/models/pkce_authorization_code_test.rb @@ -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 \ No newline at end of file