From ab0085e9c917fe95f8b80ac9f44f8b2a2dd64133 Mon Sep 17 00:00:00 2001 From: Dan Milne Date: Tue, 18 Nov 2025 20:02:45 +1100 Subject: [PATCH] More complete oidc --- app/jobs/oidc_token_cleanup_job.rb | 29 ++ app/models/oidc_refresh_token.rb | 87 ++++ ...251112114852_create_oidc_refresh_tokens.rb | 22 + ..._add_token_digest_to_oidc_access_tokens.rb | 9 + ...115845_add_token_expiry_to_applications.rb | 7 + ...4_make_oidc_access_token_token_nullable.rb | 5 + .../oidc_authorization_code_security_test.rb | 440 ++++++++++++++++++ .../oidc_refresh_token_controller_test.rb | 235 ++++++++++ test/jobs/oidc_token_cleanup_job_test.rb | 7 + 9 files changed, 841 insertions(+) create mode 100644 app/jobs/oidc_token_cleanup_job.rb create mode 100644 app/models/oidc_refresh_token.rb create mode 100644 db/migrate/20251112114852_create_oidc_refresh_tokens.rb create mode 100644 db/migrate/20251112114945_add_token_digest_to_oidc_access_tokens.rb create mode 100644 db/migrate/20251112115845_add_token_expiry_to_applications.rb create mode 100644 db/migrate/20251112120314_make_oidc_access_token_token_nullable.rb create mode 100644 test/controllers/oidc_authorization_code_security_test.rb create mode 100644 test/controllers/oidc_refresh_token_controller_test.rb create mode 100644 test/jobs/oidc_token_cleanup_job_test.rb diff --git a/app/jobs/oidc_token_cleanup_job.rb b/app/jobs/oidc_token_cleanup_job.rb new file mode 100644 index 0000000..47c72ef --- /dev/null +++ b/app/jobs/oidc_token_cleanup_job.rb @@ -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 diff --git a/app/models/oidc_refresh_token.rb b/app/models/oidc_refresh_token.rb new file mode 100644 index 0000000..bfff5f9 --- /dev/null +++ b/app/models/oidc_refresh_token.rb @@ -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 diff --git a/db/migrate/20251112114852_create_oidc_refresh_tokens.rb b/db/migrate/20251112114852_create_oidc_refresh_tokens.rb new file mode 100644 index 0000000..c31b7e2 --- /dev/null +++ b/db/migrate/20251112114852_create_oidc_refresh_tokens.rb @@ -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 diff --git a/db/migrate/20251112114945_add_token_digest_to_oidc_access_tokens.rb b/db/migrate/20251112114945_add_token_digest_to_oidc_access_tokens.rb new file mode 100644 index 0000000..10918ed --- /dev/null +++ b/db/migrate/20251112114945_add_token_digest_to_oidc_access_tokens.rb @@ -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 diff --git a/db/migrate/20251112115845_add_token_expiry_to_applications.rb b/db/migrate/20251112115845_add_token_expiry_to_applications.rb new file mode 100644 index 0000000..1636265 --- /dev/null +++ b/db/migrate/20251112115845_add_token_expiry_to_applications.rb @@ -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 diff --git a/db/migrate/20251112120314_make_oidc_access_token_token_nullable.rb b/db/migrate/20251112120314_make_oidc_access_token_token_nullable.rb new file mode 100644 index 0000000..8d6b602 --- /dev/null +++ b/db/migrate/20251112120314_make_oidc_access_token_token_nullable.rb @@ -0,0 +1,5 @@ +class MakeOidcAccessTokenTokenNullable < ActiveRecord::Migration[8.1] + def change + change_column_null :oidc_access_tokens, :token, true + end +end diff --git a/test/controllers/oidc_authorization_code_security_test.rb b/test/controllers/oidc_authorization_code_security_test.rb new file mode 100644 index 0000000..7aacd73 --- /dev/null +++ b/test/controllers/oidc_authorization_code_security_test.rb @@ -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 diff --git a/test/controllers/oidc_refresh_token_controller_test.rb b/test/controllers/oidc_refresh_token_controller_test.rb new file mode 100644 index 0000000..fe49e9a --- /dev/null +++ b/test/controllers/oidc_refresh_token_controller_test.rb @@ -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 diff --git a/test/jobs/oidc_token_cleanup_job_test.rb b/test/jobs/oidc_token_cleanup_job_test.rb new file mode 100644 index 0000000..4822f0f --- /dev/null +++ b/test/jobs/oidc_token_cleanup_job_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class OidcTokenCleanupJobTest < ActiveJob::TestCase + # test "the truth" do + # assert true + # end +end