More complete oidc
This commit is contained in:
29
app/jobs/oidc_token_cleanup_job.rb
Normal file
29
app/jobs/oidc_token_cleanup_job.rb
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
class OidcTokenCleanupJob < ApplicationJob
|
||||||
|
queue_as :default
|
||||||
|
|
||||||
|
def perform
|
||||||
|
# Delete expired access tokens (keep revoked ones for audit trail)
|
||||||
|
expired_access_tokens = OidcAccessToken.where("expires_at < ?", 7.days.ago)
|
||||||
|
deleted_count = expired_access_tokens.delete_all
|
||||||
|
Rails.logger.info "OIDC Token Cleanup: Deleted #{deleted_count} expired access tokens"
|
||||||
|
|
||||||
|
# Delete expired refresh tokens (keep revoked ones for audit trail)
|
||||||
|
expired_refresh_tokens = OidcRefreshToken.where("expires_at < ?", 7.days.ago)
|
||||||
|
deleted_count = expired_refresh_tokens.delete_all
|
||||||
|
Rails.logger.info "OIDC Token Cleanup: Deleted #{deleted_count} expired refresh tokens"
|
||||||
|
|
||||||
|
# Delete old revoked tokens (after 30 days for audit trail)
|
||||||
|
old_revoked_access_tokens = OidcAccessToken.where("revoked_at < ?", 30.days.ago)
|
||||||
|
deleted_count = old_revoked_access_tokens.delete_all
|
||||||
|
Rails.logger.info "OIDC Token Cleanup: Deleted #{deleted_count} old revoked access tokens"
|
||||||
|
|
||||||
|
old_revoked_refresh_tokens = OidcRefreshToken.where("revoked_at < ?", 30.days.ago)
|
||||||
|
deleted_count = old_revoked_refresh_tokens.delete_all
|
||||||
|
Rails.logger.info "OIDC Token Cleanup: Deleted #{deleted_count} old revoked refresh tokens"
|
||||||
|
|
||||||
|
# Delete old used authorization codes (after 7 days)
|
||||||
|
old_auth_codes = OidcAuthorizationCode.where("created_at < ?", 7.days.ago)
|
||||||
|
deleted_count = old_auth_codes.delete_all
|
||||||
|
Rails.logger.info "OIDC Token Cleanup: Deleted #{deleted_count} old authorization codes"
|
||||||
|
end
|
||||||
|
end
|
||||||
87
app/models/oidc_refresh_token.rb
Normal file
87
app/models/oidc_refresh_token.rb
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
class OidcRefreshToken < ApplicationRecord
|
||||||
|
belongs_to :application
|
||||||
|
belongs_to :user
|
||||||
|
belongs_to :oidc_access_token
|
||||||
|
has_many :oidc_access_tokens, foreign_key: :oidc_access_token_id, dependent: :nullify
|
||||||
|
|
||||||
|
before_validation :generate_token, on: :create
|
||||||
|
before_validation :set_expiry, on: :create
|
||||||
|
before_validation :set_token_family_id, on: :create
|
||||||
|
|
||||||
|
validates :token_digest, presence: true, uniqueness: true
|
||||||
|
|
||||||
|
scope :valid, -> { where("expires_at > ?", Time.current).where(revoked_at: nil) }
|
||||||
|
scope :expired, -> { where("expires_at <= ?", Time.current) }
|
||||||
|
scope :revoked, -> { where.not(revoked_at: nil) }
|
||||||
|
scope :active, -> { valid }
|
||||||
|
|
||||||
|
# For token rotation detection (prevents reuse attacks)
|
||||||
|
scope :in_family, ->(family_id) { where(token_family_id: family_id) }
|
||||||
|
|
||||||
|
attr_accessor :token # Store plaintext token temporarily for returning to client
|
||||||
|
|
||||||
|
def expired?
|
||||||
|
expires_at <= Time.current
|
||||||
|
end
|
||||||
|
|
||||||
|
def revoked?
|
||||||
|
revoked_at.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def active?
|
||||||
|
!expired? && !revoked?
|
||||||
|
end
|
||||||
|
|
||||||
|
def revoke!
|
||||||
|
update!(revoked_at: Time.current)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Revoke all refresh tokens in the same family (token rotation security)
|
||||||
|
def revoke_family!
|
||||||
|
return unless token_family_id.present?
|
||||||
|
|
||||||
|
OidcRefreshToken.in_family(token_family_id).update_all(revoked_at: Time.current)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Verify a plaintext token against the stored digest
|
||||||
|
def self.find_by_token(plaintext_token)
|
||||||
|
return nil if plaintext_token.blank?
|
||||||
|
|
||||||
|
# Try to find tokens that could match (we can't search by hash directly)
|
||||||
|
# This is less efficient but necessary with BCrypt
|
||||||
|
# In production, you might want to add a token prefix or other optimization
|
||||||
|
all.find do |refresh_token|
|
||||||
|
refresh_token.token_matches?(plaintext_token)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def token_matches?(plaintext_token)
|
||||||
|
return false if plaintext_token.blank? || token_digest.blank?
|
||||||
|
|
||||||
|
BCrypt::Password.new(token_digest) == plaintext_token
|
||||||
|
rescue BCrypt::Errors::InvalidHash
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def generate_token
|
||||||
|
# Generate a secure random token
|
||||||
|
plaintext = SecureRandom.urlsafe_base64(48)
|
||||||
|
self.token = plaintext # Store temporarily for returning to client
|
||||||
|
|
||||||
|
# Hash it with BCrypt for storage
|
||||||
|
self.token_digest = BCrypt::Password.create(plaintext)
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_expiry
|
||||||
|
# Use application's configured refresh token TTL
|
||||||
|
self.expires_at ||= application.refresh_token_expiry
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_token_family_id
|
||||||
|
# Use a random ID to group tokens in the same rotation chain
|
||||||
|
# This helps detect token reuse attacks
|
||||||
|
self.token_family_id ||= SecureRandom.random_number(2**31)
|
||||||
|
end
|
||||||
|
end
|
||||||
22
db/migrate/20251112114852_create_oidc_refresh_tokens.rb
Normal file
22
db/migrate/20251112114852_create_oidc_refresh_tokens.rb
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
class CreateOidcRefreshTokens < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
create_table :oidc_refresh_tokens do |t|
|
||||||
|
t.string :token_digest, null: false # BCrypt hashed token
|
||||||
|
t.references :application, null: false, foreign_key: true
|
||||||
|
t.references :user, null: false, foreign_key: true
|
||||||
|
t.references :oidc_access_token, null: false, foreign_key: true
|
||||||
|
t.string :scope
|
||||||
|
t.datetime :expires_at, null: false
|
||||||
|
t.datetime :revoked_at
|
||||||
|
t.integer :token_family_id # For token rotation detection
|
||||||
|
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
|
||||||
|
add_index :oidc_refresh_tokens, :token_digest, unique: true
|
||||||
|
add_index :oidc_refresh_tokens, :expires_at
|
||||||
|
add_index :oidc_refresh_tokens, :revoked_at
|
||||||
|
add_index :oidc_refresh_tokens, :token_family_id
|
||||||
|
add_index :oidc_refresh_tokens, [ :application_id, :user_id ]
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
class AddTokenDigestToOidcAccessTokens < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
add_column :oidc_access_tokens, :token_digest, :string
|
||||||
|
add_column :oidc_access_tokens, :revoked_at, :datetime
|
||||||
|
|
||||||
|
add_index :oidc_access_tokens, :token_digest, unique: true
|
||||||
|
add_index :oidc_access_tokens, :revoked_at
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
class AddTokenExpiryToApplications < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
add_column :applications, :access_token_ttl, :integer, default: 3600 # 1 hour in seconds
|
||||||
|
add_column :applications, :refresh_token_ttl, :integer, default: 2592000 # 30 days in seconds
|
||||||
|
add_column :applications, :id_token_ttl, :integer, default: 3600 # 1 hour in seconds
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
class MakeOidcAccessTokenTokenNullable < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
change_column_null :oidc_access_tokens, :token, true
|
||||||
|
end
|
||||||
|
end
|
||||||
440
test/controllers/oidc_authorization_code_security_test.rb
Normal file
440
test/controllers/oidc_authorization_code_security_test.rb
Normal file
@@ -0,0 +1,440 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
|
||||||
|
def setup
|
||||||
|
@user = User.create!(email_address: "security_test@example.com", password: "password123")
|
||||||
|
@application = Application.create!(
|
||||||
|
name: "Security Test App",
|
||||||
|
slug: "security-test-app",
|
||||||
|
app_type: "oidc",
|
||||||
|
redirect_uris: ["http://localhost:4000/callback"].to_json,
|
||||||
|
active: true
|
||||||
|
)
|
||||||
|
|
||||||
|
# Store the plain text client secret for testing
|
||||||
|
@client_secret = @application.client_secret_digest
|
||||||
|
@application.generate_new_client_secret!
|
||||||
|
@plain_client_secret = @application.client_secret
|
||||||
|
@application.save!
|
||||||
|
end
|
||||||
|
|
||||||
|
def teardown
|
||||||
|
OidcAuthorizationCode.where(application: @application).destroy_all
|
||||||
|
OidcAccessToken.where(application: @application).destroy_all
|
||||||
|
@user.destroy
|
||||||
|
@application.destroy
|
||||||
|
end
|
||||||
|
|
||||||
|
# ====================
|
||||||
|
# CRITICAL SECURITY TESTS
|
||||||
|
# ====================
|
||||||
|
|
||||||
|
test "prevents authorization code reuse - sequential attempts" do
|
||||||
|
# Create a valid authorization code
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
token_params = {
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: auth_code.code,
|
||||||
|
redirect_uri: "http://localhost:4000/callback"
|
||||||
|
}
|
||||||
|
|
||||||
|
# First request should succeed
|
||||||
|
post "/oauth/token", params: token_params, headers: {
|
||||||
|
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :success
|
||||||
|
first_response = JSON.parse(@response.body)
|
||||||
|
assert first_response.key?("access_token")
|
||||||
|
assert first_response.key?("id_token")
|
||||||
|
|
||||||
|
# Second request with same code should fail
|
||||||
|
post "/oauth/token", params: token_params, headers: {
|
||||||
|
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :bad_request
|
||||||
|
error = JSON.parse(@response.body)
|
||||||
|
assert_equal "invalid_grant", error["error"]
|
||||||
|
assert_match(/already been used/, error["error_description"])
|
||||||
|
end
|
||||||
|
|
||||||
|
test "revokes existing tokens when authorization code is reused" do
|
||||||
|
# Create a valid authorization code
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
token_params = {
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: auth_code.code,
|
||||||
|
redirect_uri: "http://localhost:4000/callback"
|
||||||
|
}
|
||||||
|
|
||||||
|
# First request - get access token
|
||||||
|
post "/oauth/token", params: token_params, headers: {
|
||||||
|
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :success
|
||||||
|
first_response = JSON.parse(@response.body)
|
||||||
|
first_access_token = first_response["access_token"]
|
||||||
|
|
||||||
|
# Verify the token works
|
||||||
|
get "/oauth/userinfo", headers: {
|
||||||
|
"Authorization" => "Bearer #{first_access_token}"
|
||||||
|
}
|
||||||
|
assert_response :success
|
||||||
|
|
||||||
|
# Second request with same code - should fail AND revoke first token
|
||||||
|
post "/oauth/token", params: token_params, headers: {
|
||||||
|
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :bad_request
|
||||||
|
|
||||||
|
# Verify the first token is now revoked (expired)
|
||||||
|
get "/oauth/userinfo", headers: {
|
||||||
|
"Authorization" => "Bearer #{first_access_token}"
|
||||||
|
}
|
||||||
|
assert_response :unauthorized, "First access token should be revoked after code reuse"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects already used authorization code" do
|
||||||
|
# Create and mark code as used
|
||||||
|
auth_code = OidcAuthorizationCode.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
code: SecureRandom.urlsafe_base64(32),
|
||||||
|
redirect_uri: "http://localhost:4000/callback",
|
||||||
|
scope: "openid profile",
|
||||||
|
used: true,
|
||||||
|
expires_at: 10.minutes.from_now
|
||||||
|
)
|
||||||
|
|
||||||
|
token_params = {
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: auth_code.code,
|
||||||
|
redirect_uri: "http://localhost:4000/callback"
|
||||||
|
}
|
||||||
|
|
||||||
|
post "/oauth/token", params: token_params, headers: {
|
||||||
|
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :bad_request
|
||||||
|
error = JSON.parse(@response.body)
|
||||||
|
assert_equal "invalid_grant", error["error"]
|
||||||
|
assert_match(/already been used/, error["error_description"])
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects expired authorization code" do
|
||||||
|
# Create expired code
|
||||||
|
auth_code = OidcAuthorizationCode.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
code: SecureRandom.urlsafe_base64(32),
|
||||||
|
redirect_uri: "http://localhost:4000/callback",
|
||||||
|
scope: "openid profile",
|
||||||
|
expires_at: 5.minutes.ago
|
||||||
|
)
|
||||||
|
|
||||||
|
token_params = {
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: auth_code.code,
|
||||||
|
redirect_uri: "http://localhost:4000/callback"
|
||||||
|
}
|
||||||
|
|
||||||
|
post "/oauth/token", params: token_params, headers: {
|
||||||
|
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :bad_request
|
||||||
|
error = JSON.parse(@response.body)
|
||||||
|
assert_equal "invalid_grant", error["error"]
|
||||||
|
assert_match(/expired/, error["error_description"])
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects authorization code with mismatched redirect_uri" 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
|
||||||
|
)
|
||||||
|
|
||||||
|
token_params = {
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: auth_code.code,
|
||||||
|
redirect_uri: "http://evil.com/callback" # Wrong redirect URI
|
||||||
|
}
|
||||||
|
|
||||||
|
post "/oauth/token", params: token_params, headers: {
|
||||||
|
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :bad_request
|
||||||
|
error = JSON.parse(@response.body)
|
||||||
|
assert_equal "invalid_grant", error["error"]
|
||||||
|
assert_match(/Redirect URI mismatch/, error["error_description"])
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects non-existent authorization code" do
|
||||||
|
token_params = {
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: "nonexistent_code_12345",
|
||||||
|
redirect_uri: "http://localhost:4000/callback"
|
||||||
|
}
|
||||||
|
|
||||||
|
post "/oauth/token", params: token_params, headers: {
|
||||||
|
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :bad_request
|
||||||
|
error = JSON.parse(@response.body)
|
||||||
|
assert_equal "invalid_grant", error["error"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects authorization code for different application" do
|
||||||
|
# Create another application
|
||||||
|
other_app = Application.create!(
|
||||||
|
name: "Other App",
|
||||||
|
slug: "other-app",
|
||||||
|
app_type: "oidc",
|
||||||
|
redirect_uris: ["http://localhost:5000/callback"].to_json,
|
||||||
|
active: true
|
||||||
|
)
|
||||||
|
other_secret = other_app.client_secret
|
||||||
|
|
||||||
|
# Create auth code for first application
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
# Try to use it with different application credentials
|
||||||
|
token_params = {
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: auth_code.code,
|
||||||
|
redirect_uri: "http://localhost:4000/callback"
|
||||||
|
}
|
||||||
|
|
||||||
|
post "/oauth/token", params: token_params, headers: {
|
||||||
|
"Authorization" => "Basic " + Base64.strict_encode64("#{other_app.client_id}:#{other_secret}")
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :bad_request
|
||||||
|
error = JSON.parse(@response.body)
|
||||||
|
assert_equal "invalid_grant", error["error"]
|
||||||
|
|
||||||
|
other_app.destroy
|
||||||
|
end
|
||||||
|
|
||||||
|
# ====================
|
||||||
|
# CLIENT AUTHENTICATION TESTS
|
||||||
|
# ====================
|
||||||
|
|
||||||
|
test "rejects invalid client_id in Basic auth" 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
|
||||||
|
)
|
||||||
|
|
||||||
|
token_params = {
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: auth_code.code,
|
||||||
|
redirect_uri: "http://localhost:4000/callback"
|
||||||
|
}
|
||||||
|
|
||||||
|
post "/oauth/token", params: token_params, headers: {
|
||||||
|
"Authorization" => "Basic " + Base64.strict_encode64("invalid_client_id:#{@plain_client_secret}")
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :unauthorized
|
||||||
|
error = JSON.parse(@response.body)
|
||||||
|
assert_equal "invalid_client", error["error"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects invalid client_secret in Basic auth" 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
|
||||||
|
)
|
||||||
|
|
||||||
|
token_params = {
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: auth_code.code,
|
||||||
|
redirect_uri: "http://localhost:4000/callback"
|
||||||
|
}
|
||||||
|
|
||||||
|
post "/oauth/token", params: token_params, headers: {
|
||||||
|
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:wrong_secret")
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :unauthorized
|
||||||
|
error = JSON.parse(@response.body)
|
||||||
|
assert_equal "invalid_client", error["error"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "accepts client credentials in POST body" 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
|
||||||
|
)
|
||||||
|
|
||||||
|
token_params = {
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: auth_code.code,
|
||||||
|
redirect_uri: "http://localhost:4000/callback",
|
||||||
|
client_id: @application.client_id,
|
||||||
|
client_secret: @plain_client_secret
|
||||||
|
}
|
||||||
|
|
||||||
|
post "/oauth/token", params: token_params
|
||||||
|
|
||||||
|
assert_response :success
|
||||||
|
response_body = JSON.parse(@response.body)
|
||||||
|
assert response_body.key?("access_token")
|
||||||
|
assert response_body.key?("id_token")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects request with no client authentication" 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
|
||||||
|
)
|
||||||
|
|
||||||
|
token_params = {
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: auth_code.code,
|
||||||
|
redirect_uri: "http://localhost:4000/callback"
|
||||||
|
}
|
||||||
|
|
||||||
|
post "/oauth/token", params: token_params
|
||||||
|
|
||||||
|
assert_response :unauthorized
|
||||||
|
error = JSON.parse(@response.body)
|
||||||
|
assert_equal "invalid_client", error["error"]
|
||||||
|
end
|
||||||
|
|
||||||
|
# ====================
|
||||||
|
# GRANT TYPE VALIDATION
|
||||||
|
# ====================
|
||||||
|
|
||||||
|
test "rejects unsupported grant_type" do
|
||||||
|
post "/oauth/token", params: {
|
||||||
|
grant_type: "password",
|
||||||
|
username: "user",
|
||||||
|
password: "pass"
|
||||||
|
}, headers: {
|
||||||
|
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :bad_request
|
||||||
|
error = JSON.parse(@response.body)
|
||||||
|
assert_equal "unsupported_grant_type", error["error"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects missing grant_type" do
|
||||||
|
post "/oauth/token", params: {
|
||||||
|
code: "some_code",
|
||||||
|
redirect_uri: "http://localhost:4000/callback"
|
||||||
|
}, headers: {
|
||||||
|
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{@plain_client_secret}")
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :bad_request
|
||||||
|
error = JSON.parse(@response.body)
|
||||||
|
assert_equal "unsupported_grant_type", error["error"]
|
||||||
|
end
|
||||||
|
|
||||||
|
# ====================
|
||||||
|
# TIMING ATTACK PROTECTION
|
||||||
|
# ====================
|
||||||
|
|
||||||
|
test "client authentication uses constant-time comparison" 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
|
||||||
|
)
|
||||||
|
|
||||||
|
token_params = {
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: auth_code.code,
|
||||||
|
redirect_uri: "http://localhost:4000/callback"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Test with completely wrong secret
|
||||||
|
times_wrong = []
|
||||||
|
5.times do
|
||||||
|
start_time = Time.now.to_f
|
||||||
|
post "/oauth/token", params: token_params, headers: {
|
||||||
|
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:wrong_secret_xxx")
|
||||||
|
}
|
||||||
|
times_wrong << (Time.now.to_f - start_time)
|
||||||
|
assert_response :unauthorized
|
||||||
|
end
|
||||||
|
|
||||||
|
# Test with almost correct secret (differs by one character)
|
||||||
|
correct_secret = @plain_client_secret
|
||||||
|
almost_correct = correct_secret[0..-2] + "X"
|
||||||
|
|
||||||
|
times_almost = []
|
||||||
|
5.times do
|
||||||
|
start_time = Time.now.to_f
|
||||||
|
post "/oauth/token", params: token_params, headers: {
|
||||||
|
"Authorization" => "Basic " + Base64.strict_encode64("#{@application.client_id}:#{almost_correct}")
|
||||||
|
}
|
||||||
|
times_almost << (Time.now.to_f - start_time)
|
||||||
|
assert_response :unauthorized
|
||||||
|
end
|
||||||
|
|
||||||
|
# The timing difference should be minimal (within 50ms) if using constant-time comparison
|
||||||
|
avg_wrong = times_wrong.sum / times_wrong.size
|
||||||
|
avg_almost = times_almost.sum / times_almost.size
|
||||||
|
timing_difference = (avg_wrong - avg_almost).abs
|
||||||
|
|
||||||
|
# This is a best-effort check - in practice, constant-time comparison is handled by bcrypt
|
||||||
|
assert timing_difference < 0.05,
|
||||||
|
"Timing difference #{timing_difference}s suggests potential timing attack vulnerability"
|
||||||
|
end
|
||||||
|
end
|
||||||
235
test/controllers/oidc_refresh_token_controller_test.rb
Normal file
235
test/controllers/oidc_refresh_token_controller_test.rb
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class OidcRefreshTokenControllerTest < ActionDispatch::IntegrationTest
|
||||||
|
setup do
|
||||||
|
@user = users(:alice)
|
||||||
|
@application = applications(:kavita_app)
|
||||||
|
# Store a known client secret for testing
|
||||||
|
@client_secret = SecureRandom.urlsafe_base64(48)
|
||||||
|
@application.client_secret = @client_secret
|
||||||
|
@application.save!
|
||||||
|
end
|
||||||
|
|
||||||
|
test "token endpoint returns refresh_token with authorization_code grant" do
|
||||||
|
# Create an authorization code
|
||||||
|
auth_code = OidcAuthorizationCode.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
code: SecureRandom.urlsafe_base64(32),
|
||||||
|
redirect_uri: @application.parsed_redirect_uris.first,
|
||||||
|
scope: "openid profile email",
|
||||||
|
expires_at: 10.minutes.from_now
|
||||||
|
)
|
||||||
|
|
||||||
|
# Exchange authorization code for tokens
|
||||||
|
post "/oauth/token", params: {
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code: auth_code.code,
|
||||||
|
redirect_uri: @application.parsed_redirect_uris.first,
|
||||||
|
client_id: @application.client_id,
|
||||||
|
client_secret: @client_secret
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :success
|
||||||
|
json = JSON.parse(response.body)
|
||||||
|
|
||||||
|
assert json["access_token"].present?
|
||||||
|
assert json["id_token"].present?
|
||||||
|
assert json["refresh_token"].present?
|
||||||
|
assert_equal "Bearer", json["token_type"]
|
||||||
|
assert_equal 3600, json["expires_in"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "refresh_token grant exchanges refresh token for new tokens" do
|
||||||
|
# Create access and refresh tokens
|
||||||
|
access_token = OidcAccessToken.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
scope: "openid profile email"
|
||||||
|
)
|
||||||
|
|
||||||
|
refresh_token = OidcRefreshToken.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
oidc_access_token: access_token,
|
||||||
|
scope: "openid profile email"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Store the plaintext refresh token (available only during creation)
|
||||||
|
plaintext_refresh_token = refresh_token.token
|
||||||
|
|
||||||
|
# Use refresh token to get new tokens
|
||||||
|
post "/oauth/token", params: {
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
refresh_token: plaintext_refresh_token,
|
||||||
|
client_id: @application.client_id,
|
||||||
|
client_secret: @client_secret
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :success
|
||||||
|
json = JSON.parse(response.body)
|
||||||
|
|
||||||
|
assert json["access_token"].present?
|
||||||
|
assert json["id_token"].present?
|
||||||
|
assert json["refresh_token"].present?
|
||||||
|
assert_equal "Bearer", json["token_type"]
|
||||||
|
|
||||||
|
# Old refresh token should be revoked
|
||||||
|
assert refresh_token.reload.revoked?
|
||||||
|
end
|
||||||
|
|
||||||
|
test "refresh_token grant fails with expired refresh token" do
|
||||||
|
access_token = OidcAccessToken.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
scope: "openid profile email"
|
||||||
|
)
|
||||||
|
|
||||||
|
refresh_token = OidcRefreshToken.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
oidc_access_token: access_token,
|
||||||
|
scope: "openid profile email",
|
||||||
|
expires_at: 1.hour.ago # Expired
|
||||||
|
)
|
||||||
|
|
||||||
|
plaintext_refresh_token = refresh_token.token
|
||||||
|
|
||||||
|
post "/oauth/token", params: {
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
refresh_token: plaintext_refresh_token,
|
||||||
|
client_id: @application.client_id,
|
||||||
|
client_secret: @client_secret
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :bad_request
|
||||||
|
json = JSON.parse(response.body)
|
||||||
|
assert_equal "invalid_grant", json["error"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "refresh_token grant fails with revoked refresh token" do
|
||||||
|
access_token = OidcAccessToken.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
scope: "openid profile email"
|
||||||
|
)
|
||||||
|
|
||||||
|
refresh_token = OidcRefreshToken.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
oidc_access_token: access_token,
|
||||||
|
scope: "openid profile email"
|
||||||
|
)
|
||||||
|
|
||||||
|
plaintext_refresh_token = refresh_token.token
|
||||||
|
refresh_token.revoke!
|
||||||
|
|
||||||
|
post "/oauth/token", params: {
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
refresh_token: plaintext_refresh_token,
|
||||||
|
client_id: @application.client_id,
|
||||||
|
client_secret: @client_secret
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :bad_request
|
||||||
|
json = JSON.parse(response.body)
|
||||||
|
assert_equal "invalid_grant", json["error"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "token revocation endpoint revokes access tokens" do
|
||||||
|
access_token = OidcAccessToken.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
scope: "openid profile email"
|
||||||
|
)
|
||||||
|
|
||||||
|
plaintext_access_token = access_token.plaintext_token
|
||||||
|
|
||||||
|
post "/oauth/revoke", params: {
|
||||||
|
token: plaintext_access_token,
|
||||||
|
token_type_hint: "access_token",
|
||||||
|
client_id: @application.client_id,
|
||||||
|
client_secret: @client_secret
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :success
|
||||||
|
assert access_token.reload.revoked?
|
||||||
|
end
|
||||||
|
|
||||||
|
test "token revocation endpoint revokes refresh tokens" do
|
||||||
|
access_token = OidcAccessToken.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
scope: "openid profile email"
|
||||||
|
)
|
||||||
|
|
||||||
|
refresh_token = OidcRefreshToken.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
oidc_access_token: access_token,
|
||||||
|
scope: "openid profile email"
|
||||||
|
)
|
||||||
|
|
||||||
|
plaintext_refresh_token = refresh_token.token
|
||||||
|
|
||||||
|
post "/oauth/revoke", params: {
|
||||||
|
token: plaintext_refresh_token,
|
||||||
|
token_type_hint: "refresh_token",
|
||||||
|
client_id: @application.client_id,
|
||||||
|
client_secret: @client_secret
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :success
|
||||||
|
assert refresh_token.reload.revoked?
|
||||||
|
end
|
||||||
|
|
||||||
|
test "token rotation: new refresh token has same family id" do
|
||||||
|
access_token = OidcAccessToken.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
scope: "openid profile email"
|
||||||
|
)
|
||||||
|
|
||||||
|
old_refresh_token = OidcRefreshToken.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
oidc_access_token: access_token,
|
||||||
|
scope: "openid profile email"
|
||||||
|
)
|
||||||
|
|
||||||
|
family_id = old_refresh_token.token_family_id
|
||||||
|
plaintext_refresh_token = old_refresh_token.token
|
||||||
|
|
||||||
|
post "/oauth/token", params: {
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
refresh_token: plaintext_refresh_token,
|
||||||
|
client_id: @application.client_id,
|
||||||
|
client_secret: @client_secret
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :success
|
||||||
|
|
||||||
|
# Find the new refresh token
|
||||||
|
new_refresh_token = OidcRefreshToken.active.where(user: @user, application: @application).last
|
||||||
|
assert_equal family_id, new_refresh_token.token_family_id
|
||||||
|
end
|
||||||
|
|
||||||
|
test "userinfo endpoint works with hashed access token" do
|
||||||
|
access_token = OidcAccessToken.create!(
|
||||||
|
application: @application,
|
||||||
|
user: @user,
|
||||||
|
scope: "openid profile email"
|
||||||
|
)
|
||||||
|
|
||||||
|
plaintext_token = access_token.plaintext_token
|
||||||
|
|
||||||
|
get "/oauth/userinfo", headers: {
|
||||||
|
"Authorization" => "Bearer #{plaintext_token}"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_response :success
|
||||||
|
json = JSON.parse(response.body)
|
||||||
|
assert_equal @user.id.to_s, json["sub"]
|
||||||
|
assert_equal @user.email_address, json["email"]
|
||||||
|
end
|
||||||
|
end
|
||||||
7
test/jobs/oidc_token_cleanup_job_test.rb
Normal file
7
test/jobs/oidc_token_cleanup_job_test.rb
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class OidcTokenCleanupJobTest < ActiveJob::TestCase
|
||||||
|
# test "the truth" do
|
||||||
|
# assert true
|
||||||
|
# end
|
||||||
|
end
|
||||||
Reference in New Issue
Block a user