Harden OIDC, add SVG sanitization, improve form UX and security defaults
Remove PKCE plain method support (S256 only), enforce openid scope requirement, filter to supported scopes, strip reserved claims from custom claims as defense-in-depth, sanitize SVG icons with Loofah, add global input padding, switch session cookies to SameSite=Lax, use Session.active scope, and remove unsafe-eval from CSP. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,12 @@
|
|||||||
@custom-variant dark (&:where(.dark, .dark *));
|
@custom-variant dark (&:where(.dark, .dark *));
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
|
input:where([type="text"], [type="email"], [type="password"], [type="number"], [type="url"], [type="tel"], [type="search"]),
|
||||||
|
textarea,
|
||||||
|
select {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
.dark input:where([type="text"], [type="email"], [type="password"], [type="number"], [type="url"], [type="tel"], [type="search"]),
|
.dark input:where([type="text"], [type="email"], [type="password"], [type="number"], [type="url"], [type="tel"], [type="search"]),
|
||||||
.dark textarea,
|
.dark textarea,
|
||||||
.dark select {
|
.dark select {
|
||||||
|
|||||||
@@ -122,15 +122,14 @@ module Admin
|
|||||||
end
|
end
|
||||||
|
|
||||||
def user_params
|
def user_params
|
||||||
# Base attributes that all admins can modify
|
permitted = [:email_address, :username, :name, :password, :status, :totp_required, :custom_claims]
|
||||||
base_params = params.require(:user).permit(:email_address, :username, :name, :password, :status, :totp_required, :custom_claims)
|
|
||||||
|
|
||||||
# Only allow modifying admin status when editing other users (prevent self-demotion)
|
# Only allow modifying admin status when editing other users (prevent self-demotion)
|
||||||
if params[:id] != Current.session.user.id.to_s
|
if params[:id] != Current.session.user.id.to_s
|
||||||
base_params[:admin] = params[:user][:admin] if params[:user][:admin].present?
|
permitted << :admin
|
||||||
end
|
end
|
||||||
|
|
||||||
base_params
|
params.require(:user).permit(*permitted)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ module Authentication
|
|||||||
end
|
end
|
||||||
|
|
||||||
def find_session_by_cookie
|
def find_session_by_cookie
|
||||||
Session.find_by(id: cookies.signed[:session_id]) if cookies.signed[:session_id]
|
Session.active.find_by(id: cookies.signed[:session_id]) if cookies.signed[:session_id]
|
||||||
end
|
end
|
||||||
|
|
||||||
def request_authentication
|
def request_authentication
|
||||||
@@ -58,8 +58,8 @@ module Authentication
|
|||||||
{
|
{
|
||||||
value: session.id,
|
value: session.id,
|
||||||
httponly: true,
|
httponly: true,
|
||||||
same_site: :none, # Allow cross-site cookies for OIDC testing
|
same_site: :lax,
|
||||||
secure: true # Required for SameSite=None
|
secure: true
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
class OidcController < ApplicationController
|
class OidcController < ApplicationController
|
||||||
|
SUPPORTED_SCOPES = %w[openid profile email groups offline_access].freeze
|
||||||
|
|
||||||
# Discovery and JWKS endpoints are public
|
# Discovery and JWKS endpoints are public
|
||||||
# authorize is also unauthenticated to handle prompt=none and prompt=login specially
|
# authorize is also unauthenticated to handle prompt=none and prompt=login specially
|
||||||
allow_unauthenticated_access only: [:discovery, :jwks, :token, :revoke, :userinfo, :logout, :authorize]
|
allow_unauthenticated_access only: [:discovery, :jwks, :token, :revoke, :userinfo, :logout, :authorize]
|
||||||
@@ -29,7 +31,7 @@ class OidcController < ApplicationController
|
|||||||
grant_types_supported: ["authorization_code", "refresh_token"],
|
grant_types_supported: ["authorization_code", "refresh_token"],
|
||||||
subject_types_supported: ["pairwise"],
|
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: SUPPORTED_SCOPES,
|
||||||
token_endpoint_auth_methods_supported: ["client_secret_post", "client_secret_basic"],
|
token_endpoint_auth_methods_supported: ["client_secret_post", "client_secret_basic"],
|
||||||
claims_supported: [
|
claims_supported: [
|
||||||
"sub", # Always included
|
"sub", # Always included
|
||||||
@@ -42,7 +44,7 @@ class OidcController < ApplicationController
|
|||||||
# Note: Custom claims are also supported but not listed here
|
# Note: Custom claims are also supported but not listed here
|
||||||
# ID-token-only claims (auth_time, acr, azp, at_hash, nonce) are not listed
|
# ID-token-only claims (auth_time, acr, azp, at_hash, nonce) are not listed
|
||||||
],
|
],
|
||||||
code_challenge_methods_supported: ["plain", "S256"],
|
code_challenge_methods_supported: ["S256"],
|
||||||
backchannel_logout_supported: true,
|
backchannel_logout_supported: true,
|
||||||
backchannel_logout_session_supported: true,
|
backchannel_logout_session_supported: true,
|
||||||
request_parameter_supported: false,
|
request_parameter_supported: false,
|
||||||
@@ -67,7 +69,7 @@ class OidcController < ApplicationController
|
|||||||
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 = params[:code_challenge]
|
||||||
code_challenge_method = params[:code_challenge_method] || "plain"
|
code_challenge_method = params[:code_challenge_method] || "S256"
|
||||||
|
|
||||||
# Validate client_id first (required before we can look up the application)
|
# Validate client_id first (required before we can look up the application)
|
||||||
# OAuth2 RFC 6749 Section 4.1.2.1: If client_id is missing/invalid, show error page (don't redirect)
|
# OAuth2 RFC 6749 Section 4.1.2.1: If client_id is missing/invalid, show error page (don't redirect)
|
||||||
@@ -146,10 +148,10 @@ class OidcController < ApplicationController
|
|||||||
|
|
||||||
# Validate PKCE parameters if present (now we can safely redirect with error)
|
# Validate PKCE parameters if present (now we can safely redirect with error)
|
||||||
if code_challenge.present?
|
if code_challenge.present?
|
||||||
unless %w[plain S256].include?(code_challenge_method)
|
unless code_challenge_method == "S256"
|
||||||
Rails.logger.error "OAuth: Invalid code_challenge_method: #{code_challenge_method}"
|
Rails.logger.error "OAuth: Invalid code_challenge_method: #{code_challenge_method}"
|
||||||
error_uri = "#{redirect_uri}?error=invalid_request"
|
error_uri = "#{redirect_uri}?error=invalid_request"
|
||||||
error_uri += "&error_description=#{CGI.escape("Invalid code_challenge_method: must be 'plain' or 'S256'")}"
|
error_uri += "&error_description=#{CGI.escape("Invalid code_challenge_method: only 'S256' is supported")}"
|
||||||
error_uri += "&state=#{CGI.escape(state)}" if state.present?
|
error_uri += "&state=#{CGI.escape(state)}" if state.present?
|
||||||
redirect_to error_uri, allow_other_host: true
|
redirect_to error_uri, allow_other_host: true
|
||||||
return
|
return
|
||||||
@@ -289,7 +291,15 @@ class OidcController < ApplicationController
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
requested_scopes = scope.split(" ")
|
requested_scopes = scope.split(" ") & SUPPORTED_SCOPES
|
||||||
|
scope = requested_scopes.join(" ")
|
||||||
|
|
||||||
|
unless requested_scopes.include?("openid")
|
||||||
|
error_uri = "#{redirect_uri}?error=invalid_scope&error_description=#{CGI.escape("The 'openid' scope is required")}"
|
||||||
|
error_uri += "&state=#{CGI.escape(state)}" if state.present?
|
||||||
|
redirect_to error_uri, allow_other_host: true
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
# Check if application is configured to skip consent
|
# Check if application is configured to skip consent
|
||||||
# If so, automatically create consent and proceed without showing consent screen
|
# If so, automatically create consent and proceed without showing consent screen
|
||||||
@@ -420,8 +430,7 @@ class OidcController < ApplicationController
|
|||||||
|
|
||||||
user = Current.session.user
|
user = Current.session.user
|
||||||
|
|
||||||
# Record user consent
|
requested_scopes = oauth_params["scope"].split(" ") & SUPPORTED_SCOPES
|
||||||
requested_scopes = oauth_params["scope"].split(" ")
|
|
||||||
parsed_claims = begin
|
parsed_claims = begin
|
||||||
JSON.parse(oauth_params["claims_requests"])
|
JSON.parse(oauth_params["claims_requests"])
|
||||||
rescue
|
rescue
|
||||||
@@ -1041,16 +1050,14 @@ class OidcController < ApplicationController
|
|||||||
|
|
||||||
# Recreate code challenge based on method
|
# Recreate code challenge based on method
|
||||||
expected_challenge = case auth_code.code_challenge_method
|
expected_challenge = case auth_code.code_challenge_method
|
||||||
when "plain"
|
|
||||||
code_verifier
|
|
||||||
when "S256"
|
when "S256"
|
||||||
Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false)
|
Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false)
|
||||||
else
|
else
|
||||||
return {
|
return {
|
||||||
valid: false,
|
valid: false,
|
||||||
error: "server_error",
|
error: "invalid_request",
|
||||||
error_description: "Unsupported code challenge method",
|
error_description: "Unsupported code challenge method: only 'S256' is supported",
|
||||||
status: :internal_server_error
|
status: :bad_request
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -1156,6 +1163,7 @@ class OidcController < ApplicationController
|
|||||||
# id_token and/or userinfo keys, each mapping to claim requests
|
# id_token and/or userinfo keys, each mapping to claim requests
|
||||||
def parse_claims_parameter(claims_string)
|
def parse_claims_parameter(claims_string)
|
||||||
return {} if claims_string.blank?
|
return {} if claims_string.blank?
|
||||||
|
return nil if claims_string.length > 4096
|
||||||
|
|
||||||
parsed = JSON.parse(claims_string)
|
parsed = JSON.parse(claims_string)
|
||||||
return nil unless parsed.is_a?(Hash)
|
return nil unless parsed.is_a?(Hash)
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ class Application < ApplicationRecord
|
|||||||
|
|
||||||
has_one_attached :icon
|
has_one_attached :icon
|
||||||
|
|
||||||
# Fix SVG content type after attachment
|
before_validation :sanitize_svg_icon, if: -> { attachment_changes["icon"].present? }
|
||||||
after_save :fix_icon_content_type, if: -> { icon.attached? && saved_change_to_attribute?(:id) == false }
|
after_save :fix_icon_content_type, if: -> { icon.attached? && saved_change_to_attribute?(:id) == false }
|
||||||
|
|
||||||
has_many :application_groups, dependent: :destroy
|
has_many :application_groups, dependent: :destroy
|
||||||
@@ -283,6 +283,21 @@ class Application < ApplicationRecord
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def sanitize_svg_icon
|
||||||
|
return unless icon.content_type == "image/svg+xml"
|
||||||
|
|
||||||
|
raw_svg = icon.download
|
||||||
|
doc = Loofah.xml_document(raw_svg)
|
||||||
|
doc.scrub!(SvgScrubber.new)
|
||||||
|
clean_svg = doc.to_xml
|
||||||
|
|
||||||
|
icon.attach(
|
||||||
|
io: StringIO.new(clean_svg),
|
||||||
|
filename: icon.filename.to_s,
|
||||||
|
content_type: "image/svg+xml"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
def icon_validation
|
def icon_validation
|
||||||
return unless icon.attached?
|
return unless icon.attached?
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ class OidcAuthorizationCode < ApplicationRecord
|
|||||||
|
|
||||||
validates :code_hmac, presence: true, uniqueness: true
|
validates :code_hmac, 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}
|
validates :code_challenge_method, inclusion: {in: %w[S256], allow_nil: true}
|
||||||
validate :validate_code_challenge_format, if: -> { code_challenge.present? }
|
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) }
|
||||||
|
|||||||
@@ -107,12 +107,12 @@ class User < ApplicationRecord
|
|||||||
save! # Save the updated array
|
save! # Save the updated array
|
||||||
|
|
||||||
# Log successful backup code usage for security monitoring
|
# Log successful backup code usage for security monitoring
|
||||||
Rails.logger.info "Backup code used successfully - User ID: #{id}, IP: #{Current.session&.client_ip}"
|
Rails.logger.info "Backup code used successfully - User ID: #{id}, IP: #{Current.session&.ip_address}"
|
||||||
true
|
true
|
||||||
else
|
else
|
||||||
# Increment failed attempt counter and log for security monitoring
|
# Increment failed attempt counter and log for security monitoring
|
||||||
increment_backup_code_failed_attempts
|
increment_backup_code_failed_attempts
|
||||||
Rails.logger.warn "Failed backup code attempt - User ID: #{id}, IP: #{Current.session&.client_ip}"
|
Rails.logger.warn "Failed backup code attempt - User ID: #{id}, IP: #{Current.session&.ip_address}"
|
||||||
false
|
false
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
class OidcJwtService
|
class OidcJwtService
|
||||||
extend ClaimsMerger
|
extend ClaimsMerger
|
||||||
|
|
||||||
|
RESERVED_CLAIMS = %i[iss sub aud exp iat nbf jti nonce azp].freeze
|
||||||
|
|
||||||
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, access_token: nil, auth_time: nil, acr: nil, scopes: "openid", claims_requests: {})
|
def generate_id_token(user, application, consent: nil, nonce: nil, access_token: nil, auth_time: nil, acr: nil, scopes: "openid", claims_requests: {})
|
||||||
@@ -79,15 +81,16 @@ class OidcJwtService
|
|||||||
|
|
||||||
# Merge custom claims from groups (arrays are combined, not overwritten)
|
# Merge custom claims from groups (arrays are combined, not overwritten)
|
||||||
# Note: Custom claims from groups are always merged (not scope-dependent)
|
# Note: Custom claims from groups are always merged (not scope-dependent)
|
||||||
|
# Reserved claims are stripped as defense-in-depth (also validated at model layer)
|
||||||
user.groups.each do |group|
|
user.groups.each do |group|
|
||||||
payload = deep_merge_claims(payload, group.parsed_custom_claims)
|
payload = deep_merge_claims(payload, group.parsed_custom_claims.except(*RESERVED_CLAIMS))
|
||||||
end
|
end
|
||||||
|
|
||||||
# Merge custom claims from user (arrays are combined, other values override)
|
# Merge custom claims from user (arrays are combined, other values override)
|
||||||
payload = deep_merge_claims(payload, user.parsed_custom_claims)
|
payload = deep_merge_claims(payload, user.parsed_custom_claims.except(*RESERVED_CLAIMS))
|
||||||
|
|
||||||
# Merge app-specific custom claims (highest priority, arrays are combined)
|
# Merge app-specific custom claims (highest priority, arrays are combined)
|
||||||
payload = deep_merge_claims(payload, application.custom_claims_for_user(user))
|
payload = deep_merge_claims(payload, application.custom_claims_for_user(user).except(*RESERVED_CLAIMS))
|
||||||
|
|
||||||
# Filter custom claims based on claims parameter
|
# Filter custom claims based on claims parameter
|
||||||
# If claims parameter is present, only include requested custom claims
|
# If claims parameter is present, only include requested custom claims
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ Rails.application.configure do
|
|||||||
|
|
||||||
# Scripts: Allow self, importmaps, unsafe-inline for Turbo/StimulusJS, and blob: for downloads
|
# Scripts: Allow self, importmaps, unsafe-inline for Turbo/StimulusJS, and blob: for downloads
|
||||||
# Note: unsafe_inline is needed for Stimulus controllers and Turbo navigation
|
# Note: unsafe_inline is needed for Stimulus controllers and Turbo navigation
|
||||||
policy.script_src :self, :unsafe_inline, :unsafe_eval, "blob:"
|
policy.script_src :self, :unsafe_inline, "blob:"
|
||||||
|
|
||||||
# Styles: Allow self and unsafe_inline for TailwindCSS dynamic classes
|
# Styles: Allow self and unsafe_inline for TailwindCSS dynamic classes
|
||||||
# and Stimulus controller style manipulations
|
# and Stimulus controller style manipulations
|
||||||
|
|||||||
@@ -33,8 +33,7 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
|
|||||||
config = JSON.parse(@response.body)
|
config = JSON.parse(@response.body)
|
||||||
|
|
||||||
assert config.key?("code_challenge_methods_supported")
|
assert config.key?("code_challenge_methods_supported")
|
||||||
assert_includes config["code_challenge_methods_supported"], "S256"
|
assert_equal ["S256"], config["code_challenge_methods_supported"]
|
||||||
assert_includes config["code_challenge_methods_supported"], "plain"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "authorization endpoint accepts PKCE parameters (S256)" do
|
test "authorization endpoint accepts PKCE parameters (S256)" do
|
||||||
@@ -58,7 +57,7 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
|
|||||||
assert_match(/consent/, @response.body.downcase)
|
assert_match(/consent/, @response.body.downcase)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "authorization endpoint accepts PKCE parameters (plain)" do
|
test "authorization endpoint rejects PKCE plain method" do
|
||||||
code_challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
|
code_challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
|
||||||
|
|
||||||
auth_params = {
|
auth_params = {
|
||||||
@@ -74,9 +73,9 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
get "/oauth/authorize", params: auth_params
|
get "/oauth/authorize", params: auth_params
|
||||||
|
|
||||||
# Should show consent page (user is already authenticated)
|
assert_response :redirect
|
||||||
assert_response :success
|
assert_match(/error=invalid_request/, @response.location)
|
||||||
assert_match(/consent/, @response.body.downcase)
|
assert_match(/S256/, @response.location)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "authorization endpoint rejects invalid code_challenge_method" do
|
test "authorization endpoint rejects invalid code_challenge_method" do
|
||||||
@@ -153,7 +152,7 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
|
|||||||
assert_match(/code_verifier is required/, error["error_description"])
|
assert_match(/code_verifier is required/, error["error_description"])
|
||||||
end
|
end
|
||||||
|
|
||||||
test "token endpoint requires code_verifier when PKCE was used (plain)" do
|
test "token endpoint requires code_verifier when PKCE was used" do
|
||||||
# Create consent for token endpoint
|
# Create consent for token endpoint
|
||||||
OidcUserConsent.create!(
|
OidcUserConsent.create!(
|
||||||
user: @user,
|
user: @user,
|
||||||
@@ -163,14 +162,16 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
|
|||||||
sid: "test-sid-123"
|
sid: "test-sid-123"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create authorization code with PKCE plain
|
code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
|
||||||
|
code_challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false)
|
||||||
|
|
||||||
auth_code = OidcAuthorizationCode.create!(
|
auth_code = OidcAuthorizationCode.create!(
|
||||||
application: @application,
|
application: @application,
|
||||||
user: @user,
|
user: @user,
|
||||||
redirect_uri: "http://localhost:4000/callback",
|
redirect_uri: "http://localhost:4000/callback",
|
||||||
scope: "openid profile",
|
scope: "openid profile",
|
||||||
code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
|
code_challenge: code_challenge,
|
||||||
code_challenge_method: "plain",
|
code_challenge_method: "S256",
|
||||||
expires_at: 10.minutes.from_now
|
expires_at: 10.minutes.from_now
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -274,28 +275,24 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
|
|||||||
assert_equal "Bearer", tokens["token_type"]
|
assert_equal "Bearer", tokens["token_type"]
|
||||||
end
|
end
|
||||||
|
|
||||||
test "token endpoint accepts valid code_verifier (plain)" do
|
test "token endpoint rejects code_verifier with plain challenge method" do
|
||||||
# Create consent for token endpoint
|
|
||||||
OidcUserConsent.create!(
|
|
||||||
user: @user,
|
|
||||||
application: @application,
|
|
||||||
scopes_granted: "openid profile",
|
|
||||||
granted_at: Time.current,
|
|
||||||
sid: "test-sid-123"
|
|
||||||
)
|
|
||||||
|
|
||||||
code_verifier = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
|
code_verifier = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
|
||||||
|
|
||||||
# Create authorization code with PKCE plain
|
# Directly insert a plain auth code to simulate legacy data
|
||||||
auth_code = OidcAuthorizationCode.create!(
|
# Generate code HMAC manually since save!(validate: false) skips before_validation
|
||||||
|
plaintext_code = SecureRandom.urlsafe_base64(32)
|
||||||
|
auth_code = OidcAuthorizationCode.new(
|
||||||
application: @application,
|
application: @application,
|
||||||
user: @user,
|
user: @user,
|
||||||
redirect_uri: "http://localhost:4000/callback",
|
redirect_uri: "http://localhost:4000/callback",
|
||||||
scope: "openid profile",
|
scope: "openid profile",
|
||||||
code_challenge: code_verifier, # Same as verifier for plain method
|
code_challenge: code_verifier,
|
||||||
code_challenge_method: "plain",
|
code_challenge_method: "plain",
|
||||||
|
code_hmac: OidcAuthorizationCode.compute_code_hmac(plaintext_code),
|
||||||
expires_at: 10.minutes.from_now
|
expires_at: 10.minutes.from_now
|
||||||
)
|
)
|
||||||
|
auth_code.plaintext_code = plaintext_code
|
||||||
|
auth_code.save!(validate: false)
|
||||||
|
|
||||||
token_params = {
|
token_params = {
|
||||||
grant_type: "authorization_code",
|
grant_type: "authorization_code",
|
||||||
@@ -308,11 +305,9 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
|
|||||||
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@application.client_secret}")
|
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@application.client_secret}")
|
||||||
}
|
}
|
||||||
|
|
||||||
assert_response :success
|
assert_response :bad_request
|
||||||
tokens = JSON.parse(@response.body)
|
body = JSON.parse(@response.body)
|
||||||
assert tokens.key?("access_token")
|
assert_equal "invalid_request", body["error"]
|
||||||
assert tokens.key?("id_token")
|
|
||||||
assert_equal "Bearer", tokens["token_type"]
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "token endpoint works without PKCE (backward compatibility)" do
|
test "token endpoint works without PKCE (backward compatibility)" do
|
||||||
|
|||||||
@@ -38,23 +38,18 @@ class PkceAuthorizationCodeTest < ActiveSupport::TestCase
|
|||||||
assert auth_code.uses_pkce?
|
assert auth_code.uses_pkce?
|
||||||
end
|
end
|
||||||
|
|
||||||
test "authorization code can store PKCE challenge with plain method" do
|
test "authorization code rejects plain PKCE method" do
|
||||||
code_challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
|
assert_raises(ActiveRecord::RecordInvalid) do
|
||||||
code_challenge_method = "plain"
|
OidcAuthorizationCode.create!(
|
||||||
|
application: @application,
|
||||||
auth_code = OidcAuthorizationCode.create!(
|
user: @user,
|
||||||
application: @application,
|
redirect_uri: "http://localhost:4000/callback",
|
||||||
user: @user,
|
scope: "openid profile",
|
||||||
redirect_uri: "http://localhost:4000/callback",
|
code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
|
||||||
scope: "openid profile",
|
code_challenge_method: "plain",
|
||||||
code_challenge: code_challenge,
|
expires_at: 10.minutes.from_now
|
||||||
code_challenge_method: code_challenge_method,
|
)
|
||||||
expires_at: 10.minutes.from_now
|
end
|
||||||
)
|
|
||||||
|
|
||||||
assert_equal code_challenge, auth_code.code_challenge
|
|
||||||
assert_equal code_challenge_method, auth_code.code_challenge_method
|
|
||||||
assert auth_code.uses_pkce?
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "authorization code works without PKCE (backward compatibility)" do
|
test "authorization code works without PKCE (backward compatibility)" do
|
||||||
|
|||||||
Reference in New Issue
Block a user