Skip to content
Open
Show file tree
Hide file tree
Changes from 35 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 @@ -60,3 +60,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

# Key for Revoker (https://github.com/hackclub/revoker)
HKA_REVOCATION_KEY=your_hka_revocation_key_here
54 changes: 44 additions & 10 deletions app/controllers/api/internal/revocations_controller.rb
Original file line number Diff line number Diff line change
@@ -1,24 +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?

admin_api_key.revoke!
return render_error("Token is required") unless token.present?

user = admin_api_key.user
key, user, token_type = find_key_info(token)
return render_error("Token doesn't match any supported type") unless token_type
return render_error("Token is invalid or already revoked") unless key.present?
original_key_name = key.name
return render_error("Token is invalid or already revoked") unless revoke_key(key)

render json: {
success: true,
status: "complete",
token_type: token_type,
owner_email: user.email_addresses.first&.email,
key_name: admin_api_key.name
}.compact_blank
key_name: original_key_name
}.compact_blank, status: :created
end

private

def find_key_info(token)
if token.match?(ADMIN_KEY_REGEX)
key = AdminApiKey.active.find_by(token:)
return [ key, key&.user, "Hackatime Admin API Key" ]
end

if token.match?(REGULAR_KEY_REGEX)
key = ApiKey.find_by(token:)
return [ key, key&.user, "Hackatime API Key" ]
end

[ nil, nil, nil ]
end

def revoke_key(key)
if key.is_a?(AdminApiKey)
key.revoke!
else
key.user.rotate_single_api_key!(key)
end
rescue ActiveRecord::ActiveRecordError => e
Rails.logger.error("Revocation failed for #{key.class}##{key.id}: #{e.class} #{e.message}")
false
end

def render_error(message)
render json: { success: false, error: message }, status: :unprocessable_entity
end

private def authenticate!
Expand Down
10 changes: 3 additions & 7 deletions app/controllers/settings/access_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,10 @@ def update
end

def rotate_api_key
@user.api_keys.transaction do
@user.api_keys.destroy_all
new_api_key = @user.rotate_api_keys!

new_api_key = @user.api_keys.create!(name: "Hackatime key")

PosthogService.capture(@user, "api_key_rotated")
render json: { token: new_api_key.token }, status: :ok
end
PosthogService.capture(@user, "api_key_rotated")
render json: { token: new_api_key.token }, status: :ok
rescue => e
report_error(e, message: "error rotate #{e.class.name}")
render json: { error: "cant rotate" }, status: :unprocessable_entity
Expand Down
14 changes: 14 additions & 0 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,20 @@ def create_email_signin_token(continue_param: nil)
sign_in_tokens.create!(auth_type: :email, continue_param: continue_param)
end

def rotate_api_keys!
api_keys.transaction do
api_keys.destroy_all
api_keys.create!(name: "Hackatime key")
end
end

def rotate_single_api_key!(api_key)
raise ActiveRecord::RecordNotFound unless api_key.user_id == id

api_key.update!(token: SecureRandom.uuid_v4)
api_key
end

def find_valid_token(token)
sign_in_tokens.valid.find_by(token: token)
end
Expand Down
33 changes: 27 additions & 6 deletions spec/requests/api/internal/internal_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,19 @@
parameter name: :payload, in: :body, schema: {
type: :object,
properties: {
token: { type: :string }
token: { type: :string },
submitter: { type: :string },
comment: { type: :string }
},
required: [ 'token' ]
}

response(200, 'successful') do
response(201, 'created') do
let(:Authorization) { "Bearer test_revocation_key" }
let(:payload) { { token: 'some_token' } }
let(:user) { User.create!(timezone: "UTC") }
let!(:email_address) { user.email_addresses.create!(email: "internal@example.com", source: :signing_in) }
let!(:api_key) { user.api_keys.create!(name: "Desktop") }
let(:payload) { { token: api_key.token } }

before do
ENV["HKA_REVOCATION_KEY"] = "test_revocation_key"
Expand All @@ -32,15 +37,25 @@
schema type: :object,
properties: {
success: { type: :boolean },
status: { type: :string },
token_type: { type: :string },
owner_email: { type: :string, nullable: true },
key_name: { type: :string, nullable: true }
}
run_test!
run_test! do |response|
body = JSON.parse(response.body)

expect(body["success"]).to eq(true)
expect(body["status"]).to eq("complete")
expect(body["token_type"]).to eq("Hackatime API Key")
expect(body["owner_email"]).to eq(email_address.email)
expect(body["key_name"]).to eq(api_key.name)
end
end

response(400, 'bad request') do
response(422, 'unprocessable entity') do
let(:Authorization) { "Bearer test_revocation_key" }
let(:payload) { { token: nil } }
let(:payload) { { token: SecureRandom.uuid_v4 } }

before do
ENV["HKA_REVOCATION_KEY"] = "test_revocation_key"
Expand All @@ -50,6 +65,12 @@
ENV.delete("HKA_REVOCATION_KEY")
end

schema type: :object,
properties: {
success: { type: :boolean },
error: { type: :string }
},
required: [ 'success', 'error' ]
run_test!
end
end
Expand Down
28 changes: 24 additions & 4 deletions swagger/v1/swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1889,23 +1889,39 @@ paths:
- InternalToken: []
parameters: []
responses:
'200':
description: successful
'201':
description: created
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
status:
type: string
token_type:
type: string
owner_email:
type: string
nullable: true
key_name:
type: string
nullable: true
'400':
description: bad request
'422':
description: unprocessable entity
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
error:
type: string
required:
- success
- error
requestBody:
content:
application/json:
Expand All @@ -1914,6 +1930,10 @@ paths:
properties:
token:
type: string
submitter:
type: string
comment:
type: string
required:
- token
"/api/summary":
Expand Down
96 changes: 96 additions & 0 deletions test/controllers/api/internal/revocations_controller_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
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")
email_address = user.email_addresses.create!(email: "regular@example.com", source: :signing_in)
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 :created
assert_equal true, response.parsed_body["success"]
assert_equal "complete", response.parsed_body["status"]
assert_equal "Hackatime API Key", response.parsed_body["token_type"]
assert_equal email_address.email, response.parsed_body["owner_email"]
assert_equal key.name, response.parsed_body["key_name"]

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 :unprocessable_entity
assert_equal false, response.parsed_body["success"]
assert_equal "Token is invalid or already revoked", response.parsed_body["error"]
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 :unprocessable_entity
assert_equal false, response.parsed_body["success"]
assert_equal "Token is invalid or already revoked", response.parsed_body["error"]
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 :unprocessable_entity
assert_equal false, response.parsed_body["success"]
assert_equal "Token doesn't match any supported type", response.parsed_body["error"]
end

test "revokes admin key" do
user = User.create!(timezone: "UTC")
email_address = user.email_addresses.create!(email: "admin@example.com", source: :signing_in)
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 :created
assert_equal true, response.parsed_body["success"]
assert_equal "complete", response.parsed_body["status"]
assert_equal "Hackatime Admin API Key", response.parsed_body["token_type"]

admin_key.reload
assert_equal email_address.email, response.parsed_body["owner_email"]
assert_equal "Infra", response.parsed_body["key_name"]
assert_not_nil admin_key.revoked_at
assert_includes admin_key.name, "_revoked_"
end

test "returns error for already-revoked admin key" do
user = User.create!(timezone: "UTC")
original_token = "hka_#{SecureRandom.hex(32)}"
admin_key = user.admin_api_keys.create!(name: "Infra", token: original_token)
admin_key.revoke!

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

assert_response :unprocessable_entity
assert_equal false, response.parsed_body["success"]
assert_equal "Token is invalid or already revoked", response.parsed_body["error"]
end

private

def auth_headers
{
"Authorization" => ActionController::HttpAuthentication::Token.encode_credentials(ENV.fetch("HKA_REVOCATION_KEY"))
}
end
end
26 changes: 26 additions & 0 deletions test/models/user_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,32 @@ class UserTest < ActiveSupport::TestCase
assert_equal "gruvbox_dark", metadata[:value]
end

test "rotate_api_keys! replaces existing api keys with a new one" do
user = User.create!(timezone: "UTC", slack_uid: "U#{SecureRandom.hex(8)}")
user.api_keys.create!(name: "Original key")
original_token = user.api_keys.first.token
user.api_keys.create!(name: "Secondary key")

new_api_key = user.rotate_api_keys!

assert_equal user.id, new_api_key.user_id
assert_equal "Hackatime key", new_api_key.name
assert_equal [ new_api_key.id ], user.api_keys.reload.pluck(:id)
assert_nil ApiKey.find_by(token: original_token)
end

test "rotate_api_keys! creates a key when none exists" do
user = User.create!(timezone: "UTC", slack_uid: "U#{SecureRandom.hex(8)}")

assert_equal 0, user.api_keys.count

new_api_key = user.rotate_api_keys!

assert_equal user.id, new_api_key.user_id
assert_equal "Hackatime key", new_api_key.name
assert_equal [ new_api_key.id ], user.api_keys.reload.pluck(:id)
end

test "flipper id uses the user id" do
user = User.create!(timezone: "UTC")

Expand Down