Skip to content
Open
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
02868bf
add new icon from bounty
matmanna Feb 10, 2026
36a158d
Merge branch 'hackclub:main' into main
matmanna Feb 17, 2026
5ff4402
Merge pull request #2 from hackclub/main
matmanna Mar 2, 2026
bfe6360
feat: add hackatime normal token revocation
matmanna Mar 2, 2026
1ad3835
chore: make linter not hate me (its always whitespace) <3
matmanna Mar 2, 2026
e0fdb0a
fix: combine both revocation apis into one (as requested by mahad)
matmanna Mar 2, 2026
c12f771
chore: add HKA_REVOCATION_KEY to .env.example
matmanna Mar 2, 2026
a7ba13f
chore: merge remote-tracking branch 'upstream/main'
matmanna Mar 3, 2026
707af3c
feat: add hackatime normal token revocation
matmanna Mar 2, 2026
331062f
chore: make linter not hate me (its always whitespace) <3
matmanna Mar 2, 2026
340009b
fix: combine both revocation apis into one (as requested by mahad)
matmanna Mar 2, 2026
30e5091
chore: add HKA_REVOCATION_KEY to .env.example
matmanna Mar 2, 2026
075f9c9
chore: merge branch 'revokable_api_tokens' of https://github.com/quac…
matmanna Mar 3, 2026
b8a4154
feat: add hackatime normal token revocation
matmanna Mar 2, 2026
353aa8d
chore: make linter not hate me (its always whitespace) <3
matmanna Mar 2, 2026
aaea827
fix: combine both revocation apis into one (as requested by mahad)
matmanna Mar 2, 2026
2e96e80
chore: add HKA_REVOCATION_KEY to .env.example
matmanna Mar 2, 2026
86c3bc9
feat: add hackatime normal token revocation
matmanna Mar 2, 2026
0d74f3f
chore: make linter not hate me (its always whitespace) <3
matmanna Mar 2, 2026
f588a2e
fix: combine both revocation apis into one (as requested by mahad)
matmanna Mar 2, 2026
f8d4f57
fix: stuff greptile suggested
matmanna Mar 8, 2026
bb5cfb3
chore: keep uptodate
matmanna Mar 8, 2026
2fb2aff
style: add final newline
matmanna Mar 8, 2026
766833d
Merge branch 'main' into revokable_api_tokens
matmanna Mar 11, 2026
082e7b8
docs: apply .env.example suggestion from @skyfallwastaken
matmanna Mar 12, 2026
8a842e5
refactor: move apikey rotation to user model
matmanna Mar 12, 2026
163301e
merge branch 'revokable_api_tokens' of https://github.com/quackclub/h…
matmanna Mar 12, 2026
d5d0cc2
style: remove unnecessary comment
matmanna Mar 12, 2026
64cfe45
fix: tests passing and inappropriate response codes
matmanna Mar 12, 2026
595c283
refactor: fix response codes
matmanna Mar 12, 2026
c21f28f
refactor: move key info request back into separate function
matmanna Mar 12, 2026
446cadf
Merge branch 'revokable_api_tokens' of github.com:quackclub/hackatime…
matmanna Mar 13, 2026
a8f4d4f
Merge branch 'main' into revokable_api_tokens
matmanna Mar 13, 2026
86eae94
fix: broken ci because of merge mistake :/
matmanna Mar 13, 2026
07e85a1
Merge branch 'main' into revokable_api_tokens
matmanna Mar 13, 2026
ea1f735
Merge branch 'main' into revokable_api_tokens
skyfallwastaken Mar 14, 2026
c1873d6
Merge branch 'revokable_api_tokens' of github.com:quackclub/hackatime…
matmanna Mar 14, 2026
8a005db
refactor: remove unnecessary test line and switch to report_error
matmanna Mar 14, 2026
34646e5
fix: returned name for admin & regular keys
matmanna Mar 15, 2026
54012e5
Merge branch 'main' into revokable_api_tokens
matmanna Mar 16, 2026
b624b9b
Merge branch 'main' into revokable_api_tokens
matmanna Mar 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,6 @@ S3_ACCESS_KEY_ID=your_s3_access_key_id_here
S3_SECRET_ACCESS_KEY=your_s3_secret_access_key_here
S3_BUCKET=your_s3_bucket_name_here
S3_ENDPOINT=https://<ACCOUNT_ID>.r2.cloudflarestorage.com

# Revocation key for token revocation
HKA_REVOCATION_KEY=your_hka_revocation_key_here
46 changes: 39 additions & 7 deletions app/controllers/api/internal/revocations_controller.rb
Original file line number Diff line number Diff line change
@@ -1,26 +1,58 @@
module Api
module Internal
class RevocationsController < Api::Internal::ApplicationController
REGULAR_KEY_REGEX = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i
ADMIN_KEY_REGEX = /\Ahka_[0-9a-f]{64}\z/

def create
token = params[:token]

return head 400 unless token.present?

admin_api_key = AdminApiKey.active.find_by(token:)

return render json: { success: false } unless admin_api_key.present?
key, user = revocable_key_and_owner(token)

admin_api_key.revoke!

user = admin_api_key.user
return render json: { success: false } unless key.present?
return render json: { success: false } unless revoke_key!(key)

render json: {
success: true,
owner_email: user.email_addresses.first&.email,
key_name: admin_api_key.name
key_name: key.name
}.compact_blank
end

private

def revocable_key_and_owner(token)
if token.match?(ADMIN_KEY_REGEX)
key = AdminApiKey.active.find_by(token:)
return [ key, key&.user ]
end

if token.match?(REGULAR_KEY_REGEX)
# TODO: ApiKey currently has no active/revoked scope.
# If one is added, prefer ApiKey.active here for consistency.
key = ApiKey.find_by(token:)
return [ key, key&.user ]
end

[ nil, nil ]
end

def revoke_key!(key)
if key.is_a?(AdminApiKey)
key.revoke!
else
key.update(
token: SecureRandom.uuid_v4,
name: "#{key.name}_revoked_#{SecureRandom.hex(8)}"
)
end
rescue ActiveRecord::ActiveRecordError => e
Rails.logger.error("Revocation failed for #{key.class}##{key.id}: #{e.class} #{e.message}")
false
end

private def authenticate!
res = authenticate_with_http_token do |token, _|
ActiveSupport::SecurityUtils.secure_compare(token, ENV["HKA_REVOCATION_KEY"])
Expand Down
97 changes: 97 additions & 0 deletions test/controllers/api/internal/revocations_controller_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
require "test_helper"

class Api::Internal::RevocationsControllerTest < ActionDispatch::IntegrationTest
setup do
@previous_revocation_key = ENV["HKA_REVOCATION_KEY"]
ENV["HKA_REVOCATION_KEY"] = "test-revocation-key"
end

teardown do
ENV["HKA_REVOCATION_KEY"] = @previous_revocation_key
end

test "revokes regular ApiKey by rolling token" do
user = User.create!(timezone: "UTC")
original_token = SecureRandom.uuid_v4
key = user.api_keys.create!(name: "Desktop", token: original_token)

post "/api/internal/revoke", params: { token: original_token }, headers: auth_headers, as: :json

assert_response :success
assert_equal true, response.parsed_body["success"]

key.reload
assert_not_equal original_token, key.token
assert_nil ApiKey.find_by(token: original_token)

post "/api/internal/revoke", params: { token: original_token }, headers: auth_headers, as: :json

assert_response :success
assert_equal({ "success" => false }, response.parsed_body)
end

test "returns success false for valid regular UUID token that does not exist" do
token = SecureRandom.uuid_v4

post "/api/internal/revoke", params: { token: token }, headers: auth_headers, as: :json

assert_response :success
assert_equal({ "success" => false }, response.parsed_body)
end

test "returns success false for token that matches neither regex" do
post "/api/internal/revoke", params: { token: "not-a-valid-token" }, headers: auth_headers, as: :json

assert_response :success
assert_equal({ "success" => false }, response.parsed_body)
end

test "returns success false when regular key revoke update fails" do
user = User.create!(timezone: "UTC")
colliding_token = SecureRandom.uuid_v4
user.api_keys.create!(name: "Existing", token: colliding_token)

key = user.api_keys.create!(name: "Desktop")
original_token = key.token

with_stubbed_uuid_v4(colliding_token) do
post "/api/internal/revoke", params: { token: original_token }, headers: auth_headers, as: :json
end

assert_response :success
assert_equal({ "success" => false }, response.parsed_body)

key.reload
assert_equal original_token, key.token
end

test "revokes admin key" do
user = User.create!(timezone: "UTC")
admin_key = user.admin_api_keys.create!(name: "Infra", token: "hka_#{SecureRandom.hex(32)}")

post "/api/internal/revoke", params: { token: admin_key.token }, headers: auth_headers, as: :json

assert_response :success
assert_equal true, response.parsed_body["success"]

admin_key.reload
assert_not_nil admin_key.revoked_at
assert_includes admin_key.name, "_revoked_"
end

private

def with_stubbed_uuid_v4(value)
original_uuid_v4 = SecureRandom.method(:uuid_v4)
SecureRandom.define_singleton_method(:uuid_v4) { value }
yield
ensure
SecureRandom.define_singleton_method(:uuid_v4, original_uuid_v4)
end

def auth_headers
{
"Authorization" => ActionController::HttpAuthentication::Token.encode_credentials(ENV.fetch("HKA_REVOCATION_KEY"))
}
end
end
Loading