Skip to content

Latest commit

 

History

History
160 lines (132 loc) · 3.84 KB

File metadata and controls

160 lines (132 loc) · 3.84 KB

Task 006: API Token Authentication

Objective

Create ApiToken model for REST API authentication.

Prerequisites

  • Task 003 completed

Steps

1. Generate model

bin/rails g model ApiToken user:references tenant:references name:string token_digest:string prefix:string scopes:text last_used_at:datetime expires_at:datetime revoked_at:datetime
bin/rails db:migrate

2. Create model (app/models/api_token.rb)

class ApiToken < ApplicationRecord
  belongs_to :user
  belongs_to :tenant

  validates :name, presence: true
  validates :token_digest, presence: true, uniqueness: true
  validates :prefix, presence: true

  serialize :scopes, coder: JSON

  scope :active, -> { where(revoked_at: nil).where('expires_at IS NULL OR expires_at > ?', Time.current) }

  def self.generate(user:, tenant:, name:, scopes: [], expires_in: nil)
    token = SecureRandom.hex(32)
    api_token = create!(
      user: user,
      tenant: tenant,
      name: name,
      token_digest: Digest::SHA256.hexdigest(token),
      prefix: token[0..7],
      scopes: scopes,
      expires_at: expires_in ? Time.current + expires_in : nil
    )
    [api_token, token]  # Return both record and plain token
  end

  def self.find_by_token(token)
    return nil unless token.present?
    find_by(token_digest: Digest::SHA256.hexdigest(token))
  end

  def valid_for_use?
    !revoked? && !expired?
  end

  def revoked?
    revoked_at.present?
  end

  def expired?
    expires_at.present? && expires_at <= Time.current
  end

  def revoke!
    update!(revoked_at: Time.current)
  end
end

3. Factory (spec/factories/api_tokens.rb)

FactoryBot.define do
  factory :api_token do
    user
    tenant
    name { 'Test Token' }
    token_digest { Digest::SHA256.hexdigest(SecureRandom.hex(32)) }
    prefix { SecureRandom.hex(4) }
    scopes { ['read', 'write'] }

    trait :expired do
      expires_at { 1.day.ago }
    end

    trait :revoked do
      revoked_at { 1.hour.ago }
    end
  end
end

Test Cases (spec/tasks/task_006_spec.rb)

require 'rails_helper'

RSpec.describe 'Task 006: API Token', type: :task do
  describe ApiToken do
    let(:user) { create(:user) }
    let(:tenant) { create(:tenant) }

    it { expect(build(:api_token)).to be_valid }
    it { should belong_to(:user) }
    it { should belong_to(:tenant) }
    it { should validate_presence_of(:name) }

    describe '.generate' do
      it 'creates token and returns plain token' do
        api_token, plain_token = ApiToken.generate(
          user: user, tenant: tenant, name: 'My Token'
        )
        expect(api_token).to be_persisted
        expect(plain_token.length).to eq(64)
      end
    end

    describe '.find_by_token' do
      it 'finds token by plain token' do
        api_token, plain_token = ApiToken.generate(
          user: user, tenant: tenant, name: 'Test'
        )
        found = ApiToken.find_by_token(plain_token)
        expect(found).to eq(api_token)
      end

      it 'returns nil for invalid token' do
        expect(ApiToken.find_by_token('invalid')).to be_nil
      end
    end

    describe '#valid_for_use?' do
      it 'returns true for active token' do
        api_token = create(:api_token)
        expect(api_token.valid_for_use?).to be true
      end

      it 'returns false for expired token' do
        api_token = create(:api_token, :expired)
        expect(api_token.valid_for_use?).to be false
      end

      it 'returns false for revoked token' do
        api_token = create(:api_token, :revoked)
        expect(api_token.valid_for_use?).to be false
      end
    end

    describe '#revoke!' do
      it 'sets revoked_at' do
        api_token = create(:api_token)
        api_token.revoke!
        expect(api_token.revoked?).to be true
      end
    end
  end
end

Success Criteria

bundle exec rspec spec/tasks/task_006_spec.rb

✅ All tests pass