Improvements derived from rodauth-oauth
This commit is contained in:
@@ -58,13 +58,13 @@ class OidcController < ApplicationController
|
|||||||
# Validate PKCE parameters if present
|
# Validate PKCE parameters if present
|
||||||
if code_challenge.present?
|
if code_challenge.present?
|
||||||
unless %w[plain S256].include?(code_challenge_method)
|
unless %w[plain S256].include?(code_challenge_method)
|
||||||
render plain: "Invalid request", status: :bad_request
|
render plain: "Invalid code_challenge_method: must be 'plain' or 'S256'", status: :bad_request
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
# Validate code challenge format (base64url-encoded, 43-128 characters)
|
# Validate code challenge format (base64url-encoded, 43-128 characters)
|
||||||
unless code_challenge.match?(/\A[A-Za-z0-9\-_]{43,128}\z/)
|
unless code_challenge.match?(/\A[A-Za-z0-9\-_]{43,128}\z/)
|
||||||
render plain: "Invalid request", status: :bad_request
|
render plain: "Invalid code_challenge format: must be 43-128 characters of base64url encoding", status: :bad_request
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -170,9 +170,21 @@ class OidcController < ApplicationController
|
|||||||
|
|
||||||
# Add the redirect URI to CSP form-action for this specific request
|
# Add the redirect URI to CSP form-action for this specific request
|
||||||
# This allows the OAuth redirect to work while maintaining security
|
# This allows the OAuth redirect to work while maintaining security
|
||||||
|
# CSP must allow the OAuth client's redirect_uri as a form submission target
|
||||||
if redirect_uri.present?
|
if redirect_uri.present?
|
||||||
redirect_host = URI.parse(redirect_uri).host
|
begin
|
||||||
request.content_security_policy.form_action << "https://#{redirect_host}" if redirect_host
|
redirect_host = URI.parse(redirect_uri).host
|
||||||
|
csp = request.content_security_policy
|
||||||
|
if csp && redirect_host
|
||||||
|
# Only modify if form_action is available and mutable
|
||||||
|
if csp.respond_to?(:form_action) && csp.form_action.respond_to?(:<<)
|
||||||
|
csp.form_action << "https://#{redirect_host}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
rescue => e
|
||||||
|
# Log CSP modification errors but don't fail the request
|
||||||
|
Rails.logger.warn "OAuth: Could not modify CSP for redirect_uri #{redirect_uri}: #{e.message}"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
render :consent
|
render :consent
|
||||||
@@ -268,8 +280,7 @@ class OidcController < ApplicationController
|
|||||||
|
|
||||||
auth_code = OidcAuthorizationCode.find_by(
|
auth_code = OidcAuthorizationCode.find_by(
|
||||||
application: application,
|
application: application,
|
||||||
code: code,
|
code: code
|
||||||
used: false
|
|
||||||
)
|
)
|
||||||
|
|
||||||
unless auth_code
|
unless auth_code
|
||||||
@@ -277,55 +288,84 @@ class OidcController < ApplicationController
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
# Check if code is expired
|
# Use a transaction with pessimistic locking to prevent code reuse
|
||||||
if auth_code.expires_at < Time.current
|
begin
|
||||||
render json: { error: "invalid_grant", error_description: "Authorization code expired" }, status: :bad_request
|
OidcAuthorizationCode.transaction do
|
||||||
return
|
# Lock the record to prevent concurrent access
|
||||||
|
auth_code.lock!
|
||||||
|
|
||||||
|
# Check if code has already been used (CRITICAL: check AFTER locking)
|
||||||
|
if auth_code.used?
|
||||||
|
# Per OAuth 2.0 spec, if an auth code is reused, revoke all tokens issued from it
|
||||||
|
Rails.logger.warn "OAuth Security: Authorization code reuse detected for code #{auth_code.id}"
|
||||||
|
|
||||||
|
# Revoke all access tokens issued from this authorization code
|
||||||
|
OidcAccessToken.where(
|
||||||
|
application: application,
|
||||||
|
user: auth_code.user,
|
||||||
|
created_at: auth_code.created_at..Time.current
|
||||||
|
).update_all(expires_at: Time.current)
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
error: "invalid_grant",
|
||||||
|
error_description: "Authorization code has already been used"
|
||||||
|
}, status: :bad_request
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if code is expired
|
||||||
|
if auth_code.expires_at < Time.current
|
||||||
|
render json: { error: "invalid_grant", error_description: "Authorization code expired" }, status: :bad_request
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Validate redirect URI matches
|
||||||
|
unless auth_code.redirect_uri == redirect_uri
|
||||||
|
render json: { error: "invalid_grant", error_description: "Redirect URI mismatch" }, status: :bad_request
|
||||||
|
return
|
||||||
|
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 BEFORE generating tokens (prevents reuse)
|
||||||
|
auth_code.update!(used: true)
|
||||||
|
|
||||||
|
# Get the user
|
||||||
|
user = auth_code.user
|
||||||
|
|
||||||
|
# Generate access token
|
||||||
|
access_token = SecureRandom.urlsafe_base64(32)
|
||||||
|
OidcAccessToken.create!(
|
||||||
|
application: application,
|
||||||
|
user: user,
|
||||||
|
token: access_token,
|
||||||
|
scope: auth_code.scope,
|
||||||
|
expires_at: 1.hour.from_now
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate ID token
|
||||||
|
id_token = OidcJwtService.generate_id_token(user, application, nonce: auth_code.nonce)
|
||||||
|
|
||||||
|
# Return tokens
|
||||||
|
render json: {
|
||||||
|
access_token: access_token,
|
||||||
|
token_type: "Bearer",
|
||||||
|
expires_in: 3600,
|
||||||
|
id_token: id_token,
|
||||||
|
scope: auth_code.scope
|
||||||
|
}
|
||||||
|
end
|
||||||
|
rescue ActiveRecord::RecordNotFound
|
||||||
|
render json: { error: "invalid_grant" }, status: :bad_request
|
||||||
end
|
end
|
||||||
|
|
||||||
# Validate redirect URI matches
|
|
||||||
unless auth_code.redirect_uri == redirect_uri
|
|
||||||
render json: { error: "invalid_grant", error_description: "Redirect URI mismatch" }, status: :bad_request
|
|
||||||
return
|
|
||||||
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
|
|
||||||
auth_code.update!(used: true)
|
|
||||||
|
|
||||||
# Get the user
|
|
||||||
user = auth_code.user
|
|
||||||
|
|
||||||
# Generate access token
|
|
||||||
access_token = SecureRandom.urlsafe_base64(32)
|
|
||||||
OidcAccessToken.create!(
|
|
||||||
application: application,
|
|
||||||
user: user,
|
|
||||||
token: access_token,
|
|
||||||
scope: auth_code.scope,
|
|
||||||
expires_at: 1.hour.from_now
|
|
||||||
)
|
|
||||||
|
|
||||||
# Generate ID token
|
|
||||||
id_token = OidcJwtService.generate_id_token(user, application, nonce: auth_code.nonce)
|
|
||||||
|
|
||||||
# Return tokens
|
|
||||||
render json: {
|
|
||||||
access_token: access_token,
|
|
||||||
token_type: "Bearer",
|
|
||||||
expires_in: 3600,
|
|
||||||
id_token: id_token,
|
|
||||||
scope: auth_code.scope
|
|
||||||
}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# GET /oauth/userinfo
|
# GET /oauth/userinfo
|
||||||
|
|||||||
@@ -63,7 +63,9 @@ class OidcJwtService
|
|||||||
def issuer_url
|
def issuer_url
|
||||||
# In production, this should come from ENV or config
|
# In production, this should come from ENV or config
|
||||||
# For now, we'll use a placeholder that can be overridden
|
# For now, we'll use a placeholder that can be overridden
|
||||||
"https://#{ENV.fetch("CLINCH_HOST", "localhost:3000")}"
|
host = ENV.fetch("CLINCH_HOST", "localhost:3000")
|
||||||
|
# Ensure URL has https:// protocol
|
||||||
|
host.match?(/^https?:\/\//) ? host : "https://#{host}"
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|||||||
@@ -11,13 +11,14 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
|
|||||||
active: true
|
active: true
|
||||||
)
|
)
|
||||||
|
|
||||||
# Sign in the user by creating a session directly
|
# Sign in the user using the test helper
|
||||||
@session = Session.create!(user: @user)
|
sign_in_as(@user)
|
||||||
cookies[:session_id] = @session.id
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def teardown
|
def teardown
|
||||||
|
Current.session&.destroy
|
||||||
OidcAuthorizationCode.where(application: @application).destroy_all
|
OidcAuthorizationCode.where(application: @application).destroy_all
|
||||||
|
OidcAccessToken.where(application: @application).destroy_all
|
||||||
@user.destroy
|
@user.destroy
|
||||||
@application.destroy
|
@application.destroy
|
||||||
end
|
end
|
||||||
@@ -50,9 +51,9 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
get "/oauth/authorize", params: auth_params
|
get "/oauth/authorize", params: auth_params
|
||||||
|
|
||||||
# Should redirect to consent page (user is already authenticated)
|
# Should show consent page (user is already authenticated)
|
||||||
assert_response :success
|
assert_response :success
|
||||||
assert_template "consent"
|
assert_match /consent/, @response.body.downcase
|
||||||
end
|
end
|
||||||
|
|
||||||
test "authorization endpoint accepts PKCE parameters (plain)" do
|
test "authorization endpoint accepts PKCE parameters (plain)" do
|
||||||
@@ -71,9 +72,9 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
get "/oauth/authorize", params: auth_params
|
get "/oauth/authorize", params: auth_params
|
||||||
|
|
||||||
# Should redirect to consent page (user is already authenticated)
|
# Should show consent page (user is already authenticated)
|
||||||
assert_response :success
|
assert_response :success
|
||||||
assert_template "consent"
|
assert_match /consent/, @response.body.downcase
|
||||||
end
|
end
|
||||||
|
|
||||||
test "authorization endpoint rejects invalid code_challenge_method" do
|
test "authorization endpoint rejects invalid code_challenge_method" do
|
||||||
@@ -184,7 +185,8 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
|
|||||||
grant_type: "authorization_code",
|
grant_type: "authorization_code",
|
||||||
code: auth_code.code,
|
code: auth_code.code,
|
||||||
redirect_uri: "http://localhost:4000/callback",
|
redirect_uri: "http://localhost:4000/callback",
|
||||||
code_verifier: "invalid_verifier"
|
# Use a properly formatted but wrong verifier (43+ chars, base64url)
|
||||||
|
code_verifier: "wrongverifier_with_enough_characters_base64url"
|
||||||
}
|
}
|
||||||
|
|
||||||
post "/oauth/token", params: token_params, headers: {
|
post "/oauth/token", params: token_params, headers: {
|
||||||
|
|||||||
Reference in New Issue
Block a user