diff --git a/app/controllers/api/v1/authenticated/api_keys_controller.rb b/app/controllers/api/v1/authenticated/api_keys_controller.rb index 724557196..a9bb2ec72 100644 --- a/app/controllers/api/v1/authenticated/api_keys_controller.rb +++ b/app/controllers/api/v1/authenticated/api_keys_controller.rb @@ -11,6 +11,10 @@ def index def api_key @api_key ||= current_user.api_keys.first || current_user.api_keys.create! end + + def required_doorkeeper_scopes + [ :api_key ] + end end end end diff --git a/app/controllers/api/v1/authenticated/application_controller.rb b/app/controllers/api/v1/authenticated/application_controller.rb index ce9bddfff..f4c4ec0b4 100644 --- a/app/controllers/api/v1/authenticated/application_controller.rb +++ b/app/controllers/api/v1/authenticated/application_controller.rb @@ -3,11 +3,19 @@ module V1 module Authenticated class ApplicationController < ActionController::API include Doorkeeper::Rails::Helpers - before_action :doorkeeper_authorize! + before_action :authorize_scope! before_action :ensure_api_access_allowed private + def required_doorkeeper_scopes + [] + end + + def authorize_scope! + doorkeeper_authorize!(*required_doorkeeper_scopes) + end + def current_user @current_user ||= User.find(doorkeeper_token.resource_owner_id) if doorkeeper_token end diff --git a/app/controllers/api/v1/authenticated/heartbeats_controller.rb b/app/controllers/api/v1/authenticated/heartbeats_controller.rb index 9be5c7ac1..9b92d51d9 100644 --- a/app/controllers/api/v1/authenticated/heartbeats_controller.rb +++ b/app/controllers/api/v1/authenticated/heartbeats_controller.rb @@ -25,6 +25,12 @@ def latest render json: { heartbeat: nil } end end + + private + + def required_doorkeeper_scopes + [ :heartbeats ] + end end end end diff --git a/app/controllers/api/v1/authenticated/hours_controller.rb b/app/controllers/api/v1/authenticated/hours_controller.rb index 92f8ca874..b90783a8f 100644 --- a/app/controllers/api/v1/authenticated/hours_controller.rb +++ b/app/controllers/api/v1/authenticated/hours_controller.rb @@ -16,6 +16,12 @@ def index total_seconds: total_seconds } end + + private + + def required_doorkeeper_scopes + [ :read ] + end end end end diff --git a/app/controllers/api/v1/authenticated/me_controller.rb b/app/controllers/api/v1/authenticated/me_controller.rb index 79337404b..8eadcd5fd 100644 --- a/app/controllers/api/v1/authenticated/me_controller.rb +++ b/app/controllers/api/v1/authenticated/me_controller.rb @@ -24,6 +24,12 @@ def index } } end + + private + + def required_doorkeeper_scopes + [ :profile ] + end end end end diff --git a/app/controllers/api/v1/authenticated/projects_controller.rb b/app/controllers/api/v1/authenticated/projects_controller.rb index 0c8176b49..7246ae667 100644 --- a/app/controllers/api/v1/authenticated/projects_controller.rb +++ b/app/controllers/api/v1/authenticated/projects_controller.rb @@ -18,6 +18,10 @@ def index private + def required_doorkeeper_scopes + [ :read ] + end + def project_stats_query @project_stats_query ||= ProjectStatsQuery.new( user: current_user, diff --git a/app/controllers/api/v1/authenticated/streak_controller.rb b/app/controllers/api/v1/authenticated/streak_controller.rb index 5ec78cf1a..aa5b769af 100644 --- a/app/controllers/api/v1/authenticated/streak_controller.rb +++ b/app/controllers/api/v1/authenticated/streak_controller.rb @@ -7,6 +7,12 @@ def show streak_days: current_user.streak_days } end + + private + + def required_doorkeeper_scopes + [ :read ] + end end end end diff --git a/config/initializers/doorkeeper.rb b/config/initializers/doorkeeper.rb index 013e047e9..4a1218e80 100644 --- a/config/initializers/doorkeeper.rb +++ b/config/initializers/doorkeeper.rb @@ -5,7 +5,7 @@ application_class "OauthApplication" default_scopes "profile" - optional_scopes "read", "admin" + optional_scopes "read", "heartbeats", "admin", "api_key" enforce_configured_scopes resource_owner_authenticator do @@ -29,7 +29,9 @@ enable_application_owner confirmation: false - access_token_expires_in 16.years + access_token_expires_in 1.year + + use_refresh_token reuse_access_token diff --git a/config/locales/doorkeeper.en.yml b/config/locales/doorkeeper.en.yml index e799b28fc..8c69afd66 100644 --- a/config/locales/doorkeeper.en.yml +++ b/config/locales/doorkeeper.en.yml @@ -158,5 +158,7 @@ en: title: "OAuth authorization required" scopes: profile: Access your basic profile info (user ID, emails, Slack ID, GitHub username) - read: View your Hackatime stats, projects, heartbeats, and coding activity + read: Access your Hackatime stats, projects, and streak + heartbeats: Access all your raw heartbeats (may include sensitive data) admin: Access the Admin API on your behalf (user management, trust actions, heartbeats, and other internal tools) + api_key: "SENSITIVE: Access your Hackatime API key, which can be used to fully impersonate you and get all your heartbeat data" diff --git a/db/seeds.rb b/db/seeds.rb index cd60be7f6..05b99ffd8 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -113,36 +113,21 @@ # Use the test user if we have one, otherwise fall back to User ID 1 (for other envs or if test user logic changes) app_owner = test_user || User.find_by(id: 1) -OauthApplication.find_or_create_by( +OauthApplication.find_or_create_by(uid: "BPr5VekIV-xuQ2ZhmxbGaahJ3XVd7gM83pql-HYGYxQ").update!( name: "Hackatime Desktop", owner: app_owner, redirect_uri: "hackatime://auth/callback", - uid: "BPr5VekIV-xuQ2ZhmxbGaahJ3XVd7gM83pql-HYGYxQ", - scopes: [ "profile" ], + scopes: [ "profile", "read", "heartbeats" ], confidential: false, ) if test_user && defined?(Doorkeeper) - app = OauthApplication.find_by(name: "Hackatime Desktop") - - existing_token = Doorkeeper::AccessToken.find_by(token: 'dev-api-key-12345') - - if existing_token - existing_token.update_columns( - application_id: app.id, - resource_owner_id: test_user.id, - expires_in: nil, - scopes: 'profile' - ) - else - token = Doorkeeper::AccessToken.find_or_create_by( - application_id: app.id, - resource_owner_id: test_user.id - ) do |t| - t.expires_in = nil - t.scopes = 'profile' - end - - token.update_column(:token, 'dev-api-key-12345') - end + app = OauthApplication.find_by(uid: "BPr5VekIV-xuQ2ZhmxbGaahJ3XVd7gM83pql-HYGYxQ") + + token = Doorkeeper::AccessToken.find_or_initialize_by(token: 'dev-api-key-12345') + token.application_id = app.id + token.resource_owner_id = test_user.id + token.expires_in = nil + token.scopes = 'profile read heartbeats' + token.save!(validate: false) end diff --git a/docs/oauth/oauth-apps.md b/docs/oauth/oauth-apps.md index aabbc9cca..f886faa6f 100644 --- a/docs/oauth/oauth-apps.md +++ b/docs/oauth/oauth-apps.md @@ -29,7 +29,9 @@ Scopes control what data your app can access. Request only the scopes you need. | Scope | Description | Granted by Default | |-------|-------------|-------------------| | `profile` | Access basic profile information (user ID, email addresses, Slack ID, GitHub username, trust factor) | Yes | -| `read` | View basic info about the user's Hackatime account | No | +| `read` | Access your Hackatime stats, projects, and streak | No | +| `heartbeats` | Access all your raw heartbeats (may include sensitive data) | No | +| `api_key` | **SENSITIVE:** Access your permanent Hackatime API key, which can be used to fully impersonate you on any WakaTime-compatible client | No | | `admin` | Access the [Admin API](#admin-api-access) on the authorizing admin's behalf | No | If you don't specify any scopes, only the `profile` scope is granted. @@ -113,13 +115,14 @@ POST https://hackatime.hackclub.com/oauth/token { "access_token": "abc123...", "token_type": "Bearer", - "expires_in": 504576000, + "expires_in": 31556952, + "refresh_token": "def456...", "scope": "profile read", "created_at": 1700000000 } ``` -Access tokens are long-lived (approximately 16 years) so you typically don't need to worry about refreshing them. +Access tokens currently expire after 1 year. Use the `refresh_token` to get a new access token without re-prompting the user for consent (see [Refreshing Tokens](#refreshing-tokens) below). ### Step 4: Make API requests @@ -130,6 +133,25 @@ GET https://hackatime.hackclub.com/api/v1/authenticated/me Authorization: Bearer YOUR_ACCESS_TOKEN ``` +### Refreshing Tokens + +Access tokens currently expire after 1 year. Before that, exchange your `refresh_token` for a new access token: + +``` +POST https://hackatime.hackclub.com/oauth/token +``` + +**Parameters (form-encoded body):** + +| Parameter | Required | Description | +|-----------|----------|-------------| +| `client_id` | Yes | Your app's UID | +| `client_secret` | Yes (confidential apps) | Your app's secret | +| `grant_type` | Yes | Set to `refresh_token` | +| `refresh_token` | Yes | The refresh token from the previous token response | + +The response has the same shape as the initial token exchange, including a new `refresh_token`. Store the new refresh token and discard the old one -- refresh tokens rotate on each use. + ### Admin API access With a token that includes the `admin` scope, call Admin API endpoints the same way (instead of minting an Admin API key): @@ -225,7 +247,7 @@ Returns the user's projects with time totals. ### GET /api/v1/authenticated/heartbeats/latest -Returns the user's most recent heartbeat. +Requires the `heartbeats` scope. Returns the user's most recent heartbeat. **Response:** diff --git a/spec/requests/api/v1/authenticated_spec.rb b/spec/requests/api/v1/authenticated_spec.rb index b536d4156..231a52faa 100644 --- a/spec/requests/api/v1/authenticated_spec.rb +++ b/spec/requests/api/v1/authenticated_spec.rb @@ -1,6 +1,18 @@ require 'swagger_helper' RSpec.describe 'Api::V1::Authenticated', type: :request do + def token_with_scopes(*scopes) + application = OauthApplication.find_by!(name: 'Hackatime Desktop') + user = User.find_by!(slack_uid: 'TEST123456') + + Doorkeeper::AccessToken.create!( + application_id: application.id, + resource_owner_id: user.id, + expires_in: nil, + scopes: scopes.join(' ') + ).token + end + path '/api/v1/authenticated/me' do get('Get current user info') do tags 'OAuth2-specific' @@ -35,6 +47,11 @@ let(:Authorization) { 'Bearer invalid' } run_test! end + + response(403, 'forbidden — Returned when the token is valid but lacks the required "profile" scope.') do + let(:Authorization) { "Bearer #{token_with_scopes('read', 'heartbeats')}" } + run_test! + end end end @@ -67,6 +84,13 @@ let(:end_date) { Date.today.to_s } run_test! end + + response(403, 'forbidden — Returned when the token is valid but lacks the required "read" scope.') do + let(:Authorization) { "Bearer #{token_with_scopes('profile', 'heartbeats')}" } + let(:start_date) { 7.days.ago.to_date.to_s } + let(:end_date) { Date.today.to_s } + run_test! + end end end @@ -90,6 +114,11 @@ let(:Authorization) { 'Bearer invalid' } run_test! end + + response(403, 'forbidden — Returned when the token is valid but lacks the required "read" scope.') do + let(:Authorization) { "Bearer #{token_with_scopes('profile', 'heartbeats')}" } + run_test! + end end end @@ -153,18 +182,48 @@ let(:end_date) { nil } run_test! end + + response(403, 'forbidden — Returned when the token is valid but lacks the required "read" scope.') do + let(:Authorization) { "Bearer #{token_with_scopes('profile', 'heartbeats')}" } + let(:include_archived) { false } + let(:projects) { nil } + let(:since) { nil } + let(:until) { nil } + let(:until_date) { nil } + let(:start) { nil } + let(:end) { nil } + let(:start_date) { nil } + let(:end_date) { nil } + run_test! + end end end path '/api/v1/authenticated/api_keys' do get('Get API keys') do tags 'OAuth2-specific' - description 'Returns the API keys for the authenticated user. Requires an OAuth2 access token (Bearer header). Warning: This returns sensitive information.' + description 'Returns the API keys for the authenticated user. Requires an OAuth2 access token (Bearer header) with the "api_key" scope.' security [ { Bearer: [] } ] produces 'application/json' response(200, 'successful') do - let(:Authorization) { "Bearer dev-api-key-12345" } + let(:Authorization) do + application = OauthApplication.create!( + name: 'Api Key Scope Test App', + redirect_uri: 'https://example.com/callback', + scopes: 'api_key' + ) + user = User.find_by!(slack_uid: 'TEST123456') + + token = Doorkeeper::AccessToken.create!( + application_id: application.id, + resource_owner_id: user.id, + expires_in: nil, + scopes: 'api_key' + ).token + + "Bearer #{token}" + end schema type: :object, properties: { token: { type: :string, example: '550e8400-e29b-41d4-a716-446655440000' } @@ -224,6 +283,11 @@ let(:Authorization) { 'Bearer invalid' } run_test! end + + response(403, 'forbidden — Returned when the token is valid but lacks the required "heartbeats" scope.') do + let(:Authorization) { "Bearer #{token_with_scopes('profile', 'read')}" } + run_test! + end end end end diff --git a/swagger/v1/swagger.yaml b/swagger/v1/swagger.yaml index 306efd1b8..dd332e030 100644 --- a/swagger/v1/swagger.yaml +++ b/swagger/v1/swagger.yaml @@ -746,6 +746,9 @@ paths: '401': description: unauthorized — Returned when the OAuth access token is missing or invalid. + '403': + description: forbidden — Returned when the token is valid but lacks the + required "profile" scope. "/api/v1/authenticated/hours": get: summary: Get hours @@ -792,6 +795,9 @@ paths: or invalid (empty body), or when the authenticated user is banned (`trust_level == "red"`) via the `ensure_no_ban` before_action (which responds with a `{ "error": "Unauthorized" }` body).' + '403': + description: forbidden — Returned when the token is valid but lacks the + required "read" scope. "/api/v1/authenticated/streak": get: summary: Get streak @@ -815,6 +821,9 @@ paths: '401': description: unauthorized — Returned when the OAuth access token is missing or invalid. + '403': + description: forbidden — Returned when the token is valid but lacks the + required "read" scope. "/api/v1/authenticated/projects": get: summary: Get projects @@ -912,13 +921,16 @@ paths: '401': description: unauthorized — Returned when the OAuth access token is missing or invalid. + '403': + description: forbidden — Returned when the token is valid but lacks the + required "read" scope. "/api/v1/authenticated/api_keys": get: summary: Get API keys tags: - OAuth2-specific - description: 'Returns the API keys for the authenticated user. Requires an OAuth2 - access token (Bearer header). Warning: This returns sensitive information.' + description: Returns the API keys for the authenticated user. Requires an OAuth2 + access token (Bearer header) with the "api_key" scope. security: - Bearer: [] responses: @@ -1002,6 +1014,9 @@ paths: '401': description: unauthorized — Returned when the OAuth access token is missing or invalid. + '403': + description: forbidden — Returned when the token is valid but lacks the + required "heartbeats" scope. "/api/v1/badge/{user_id}/{project}": get: summary: Generate a shields.io coding-time badge for a project