may i remind you, mr. wofford, you're under OAuth (#284)

This commit is contained in:
nora 2025-06-08 19:04:51 -04:00 committed by GitHub
parent 3831b52fad
commit 9dfabf49f9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 487 additions and 11 deletions

View file

@ -124,3 +124,5 @@ group :test do
end
gem "htmlcompressor", "~> 0.4.0"
gem "doorkeeper", "~> 5.8"

View file

@ -137,6 +137,8 @@ GEM
reline (>= 0.3.8)
device_detector (1.1.3)
domain_name (0.6.20240107)
doorkeeper (5.8.2)
railties (>= 5)
dotenv (3.1.8)
dotenv-rails (3.1.8)
dotenv (= 3.1.8)
@ -527,6 +529,7 @@ DEPENDENCIES
capybara
countries
debug
doorkeeper (~> 5.8)
dotenv-rails
flamegraph
geocoder

View file

@ -23,6 +23,7 @@ aside.nav {
transform: translateX(0);
transition: transform 0.3s ease;
z-index: 1000;
overflow: scroll;
}
.nav > ul {

View file

@ -0,0 +1,16 @@
module Api
module V1
module Authenticated
class ApplicationController < ActionController::API
include Doorkeeper::Rails::Helpers
before_action :doorkeeper_authorize!
private
def current_user
@current_user ||= User.find(doorkeeper_token.resource_owner_id) if doorkeeper_token
end
end
end
end
end

View file

@ -0,0 +1,14 @@
module Api
module V1
module Authenticated
class MeController < ApplicationController
def index
render json: {
emails: current_user.email_addresses&.map(&:email)|| [],
slack_id: current_user.slack_uid
}
end
end
end
end
end

View file

@ -2,7 +2,7 @@ class SessionsController < ApplicationController
def new
redirect_uri = url_for(action: :create, only_path: false)
Rails.logger.info "Starting Slack OAuth flow with redirect URI: #{redirect_uri}"
redirect_to User.authorize_url(redirect_uri, close_window: params[:close_window].present?),
redirect_to User.authorize_url(redirect_uri, close_window: params[:close_window].present?, continue_param: params[:continue]),
host: "https://slack.com",
allow_other_host: "https://slack.com"
end
@ -30,6 +30,8 @@ class SessionsController < ApplicationController
state = JSON.parse(params[:state]) rescue {}
if state["close_window"]
redirect_to close_window_path
elsif state["continue"]
redirect_to state["continue"], notice: "Successfully signed in with Slack!"
else
redirect_to root_path, notice: "Successfully signed in with Slack!"
end
@ -82,11 +84,12 @@ class SessionsController < ApplicationController
def email
email = params[:email].downcase
continue_param = params[:continue]
if Rails.env.production?
HandleEmailSigninJob.perform_later(email)
HandleEmailSigninJob.perform_later(email, continue_param)
else
HandleEmailSigninJob.perform_now(email)
HandleEmailSigninJob.perform_now(email, continue_param)
end
redirect_to root_path(sign_in_email: true), notice: "Check your email for a sign-in link!"
@ -142,7 +145,12 @@ class SessionsController < ApplicationController
if valid_token
valid_token.mark_used!
session[:user_id] = valid_token.user_id
redirect_to root_path, notice: "Successfully signed in!"
if valid_token.continue_param.present?
redirect_to valid_token.continue_param, notice: "Successfully signed in!"
else
redirect_to root_path, notice: "Successfully signed in!"
end
else
redirect_to root_path, alert: "Invalid or expired link"
end

View file

@ -76,6 +76,11 @@ class StaticPagesController < ApplicationController
end
end
def minimal_login
@continue_param = params[:continue] if params[:continue].present?
render :minimal_login, layout: "doorkeeper/application"
end
def mini_leaderboard
@leaderboard = Leaderboard.where.associated(:entries)
.where(start_date: Date.current)

View file

@ -1,7 +1,7 @@
class HandleEmailSigninJob < ApplicationJob
queue_as :latency_10s
def perform(email)
def perform(email, continue_param = nil)
email_address = ActiveRecord::Base.transaction do
EmailAddress.find_by(email: email) || begin
user = User.create!
@ -9,7 +9,7 @@ class HandleEmailSigninJob < ApplicationJob
end
end
token = email_address.user.create_email_signin_token.token
token = email_address.user.create_email_signin_token(continue_param: continue_param).token
LoopsMailer.sign_in_email(email_address.email, token).deliver_now
end
end

View file

@ -257,10 +257,11 @@ class User < ApplicationRecord
})
end
def self.authorize_url(redirect_uri, close_window: false)
def self.authorize_url(redirect_uri, close_window: false, continue_param: nil)
state = {
token: SecureRandom.hex(24),
close_window: close_window
close_window: close_window,
continue: continue_param
}.to_json
params = {
@ -405,8 +406,8 @@ class User < ApplicationRecord
heartbeats.where(source_type: :direct_entry).order(time: :desc).first
end
def create_email_signin_token
sign_in_tokens.create!(auth_type: :email)
def create_email_signin_token(continue_param: nil)
sign_in_tokens.create!(auth_type: :email, continue_param: continue_param)
end
def find_valid_token(token)

View file

@ -0,0 +1,43 @@
<header class="page-header" role="banner" style="margin: 0 auto;">
<h1><%= t('.title') %></h1>
</header>
<p class="h4">
<%= raw t('.prompt', client_name: content_tag(:strong, class: 'text-info') { @pre_auth.client.name }) %>
</p>
<% if @pre_auth.scopes.count > 0 %>
<div id="oauth-permissions">
<p><%= t('.able_to') %>:</p>
<ul class="text-info">
<% @pre_auth.scopes.each do |scope| %>
<li><%= t scope, scope: [:doorkeeper, :scopes] %></li>
<% end %>
</ul>
</div>
<% end %>
<div class="actions">
<%= form_tag oauth_authorization_path, method: :post do %>
<%= hidden_field_tag :client_id, @pre_auth.client.uid, id: nil %>
<%= hidden_field_tag :redirect_uri, @pre_auth.redirect_uri, id: nil %>
<%= hidden_field_tag :state, @pre_auth.state, id: nil %>
<%= hidden_field_tag :response_type, @pre_auth.response_type, id: nil %>
<%= hidden_field_tag :response_mode, @pre_auth.response_mode, id: nil %>
<%= hidden_field_tag :scope, @pre_auth.scope, id: nil %>
<%= hidden_field_tag :code_challenge, @pre_auth.code_challenge, id: nil %>
<%= hidden_field_tag :code_challenge_method, @pre_auth.code_challenge_method, id: nil %>
<%= submit_tag t('doorkeeper.authorizations.buttons.authorize'), class: "btn btn-success btn-lg btn-block", style: "background-color: oklch(70.03% 0.194 144.71); border: none" %>
<% end %>
<%= form_tag oauth_authorization_path, method: :delete do %>
<%= hidden_field_tag :client_id, @pre_auth.client.uid, id: nil %>
<%= hidden_field_tag :redirect_uri, @pre_auth.redirect_uri, id: nil %>
<%= hidden_field_tag :state, @pre_auth.state, id: nil %>
<%= hidden_field_tag :response_type, @pre_auth.response_type, id: nil %>
<%= hidden_field_tag :response_mode, @pre_auth.response_mode, id: nil %>
<%= hidden_field_tag :scope, @pre_auth.scope, id: nil %>
<%= hidden_field_tag :code_challenge, @pre_auth.code_challenge, id: nil %>
<%= hidden_field_tag :code_challenge_method, @pre_auth.code_challenge_method, id: nil %>
<%= submit_tag t('doorkeeper.authorizations.buttons.deny'), class: "btn btn-danger btn-lg btn-block" %>
<% end %>
</div>

View file

@ -0,0 +1,30 @@
<!DOCTYPE html>
<html>
<head>
<title><%= t('doorkeeper.layouts.application.title') %></title>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<%= stylesheet_link_tag :app %>
<%= csrf_meta_tags %>
<style>
input[type="submit"] {
max-width: 200px;
}
</style>
</head>
<body>
<main class="container">
<div style="margin 0 auto;">
<%- if flash[:notice].present? %>
<div class="alert alert-info">
<%= flash[:notice] %>
</div>
<% end -%>
<%= yield %>
</div>
</main>
</body>
</html>

View file

@ -94,5 +94,10 @@
GoodJob
<% end %>
<% end %>
<% admin_tool(nil, "li") do %>
<%= link_to oauth_applications_path, class: "nav-item #{current_page?(oauth_applications_path) ? 'active' : ''}", data: { action: "click->nav#clickLink" } do %>
OAuth2 apps
<% end %>
<% end %>
</ul>
</aside>

View file

@ -0,0 +1,11 @@
<header class="page-header" role="banner" style="margin: 0 auto;">
<h1>Log in to Hackatime</h1>
</header>
<%= link_to "Sign in with Hack Club Slack", slack_auth_path(continue: @continue_param), class: "auth-button slack" %>
<%= form_tag email_auth_path, class: "email-auth-form", data: { turbo: false } do %>
<%= hidden_field_tag :continue, @continue_param if @continue_param %>
<div class="field">
<%= email_field_tag :email, params[:email], placeholder: "Enter your email", required: true, class: "email-input" %>
</div>
<%= submit_tag "Send magic link", class: "auth-button email" %>
<% end %>

View file

@ -0,0 +1,21 @@
# frozen_string_literal: true
Doorkeeper.configure do
base_controller "ApplicationController"
resource_owner_authenticator do
current_user || redirect_to(minimal_login_path(continue: request.fullpath))
end
admin_authenticator do
if current_user
head :forbidden unless current_user.admin?
else
redirect_to sign_in_url
end
end
access_token_expires_in 16.years
reuse_access_token
end

View file

@ -14,4 +14,6 @@ Rails.configuration.to_prepare do
# Monkeypatch Airtable rate limit to be more conservative
Norairrecord::Client.send(:remove_const, :AIRTABLE_RPS_LIMIT) if Norairrecord::Client.const_defined?(:AIRTABLE_RPS_LIMIT)
Norairrecord::Client.const_set(:AIRTABLE_RPS_LIMIT, 2) # Set to 2 requests per second
Doorkeeper::ApplicationsController.layout "application" # show oauth2 admin in normal hackatime ui
end

View file

@ -0,0 +1,157 @@
en:
activerecord:
attributes:
doorkeeper/application:
name: 'Name'
redirect_uri: 'Redirect URI'
errors:
models:
doorkeeper/application:
attributes:
redirect_uri:
fragment_present: 'cannot contain a fragment.'
invalid_uri: 'must be a valid URI.'
unspecified_scheme: 'must specify a scheme.'
relative_uri: 'must be an absolute URI.'
secured_uri: 'must be an HTTPS/SSL URI.'
forbidden_uri: 'is forbidden by the server.'
scopes:
not_match_configured: "doesn't match configured on the server."
doorkeeper:
applications:
confirmations:
destroy: 'Are you sure?'
buttons:
edit: 'Edit'
destroy: 'Destroy'
submit: 'Submit'
cancel: 'Cancel'
authorize: 'Authorize'
form:
error: 'Whoops! Check your form for possible errors'
help:
confidential: 'Application will be used where the client secret can be kept confidential. Native mobile apps and Single Page Apps are considered non-confidential.'
redirect_uri: 'Use one line per URI'
blank_redirect_uri: "Leave it blank if you configured your provider to use Client Credentials, Resource Owner Password Credentials or any other grant type that doesn't require redirect URI."
scopes: 'Separate scopes with spaces. Leave blank to use the default scopes.'
edit:
title: 'Edit application'
index:
title: 'Your applications'
new: 'New Application'
name: 'Name'
callback_url: 'Callback URL'
confidential: 'Confidential?'
actions: 'Actions'
confidentiality:
'yes': 'Yes'
'no': 'No'
new:
title: 'New Application'
show:
title: 'Application: %{name}'
application_id: 'UID'
secret: 'Secret'
secret_hashed: 'Secret hashed'
scopes: 'Scopes'
confidential: 'Confidential'
callback_urls: 'Callback urls'
actions: 'Actions'
not_defined: 'Not defined'
authorizations:
buttons:
authorize: 'Authorize'
deny: 'Deny'
error:
title: 'An error has occurred'
new:
title: 'Authorization required'
prompt: 'Authorize %{client_name} to use your account?'
able_to: 'This application will be able to'
show:
title: 'Authorization code'
form_post:
title: 'Submit this form'
authorized_applications:
confirmations:
revoke: 'Are you sure?'
buttons:
revoke: 'Revoke'
index:
title: 'Your authorized applications'
application: 'Application'
created_at: 'Created At'
date_format: '%Y-%m-%d %H:%M:%S'
pre_authorization:
status: 'Pre-authorization'
errors:
messages:
# Common error messages
invalid_request:
unknown: 'The request is missing a required parameter, includes an unsupported parameter value, or is otherwise malformed.'
missing_param: 'Missing required parameter: %{value}.'
request_not_authorized: 'Request need to be authorized. Required parameter for authorizing request is missing or invalid.'
invalid_code_challenge: 'Code challenge is required.'
invalid_redirect_uri: "The requested redirect uri is malformed or doesn't match client redirect URI."
unauthorized_client: 'The client is not authorized to perform this request using this method.'
access_denied: 'The resource owner or authorization server denied the request.'
invalid_scope: 'The requested scope is invalid, unknown, or malformed.'
invalid_code_challenge_method:
zero: 'The authorization server does not support PKCE as there are no accepted code_challenge_method values.'
one: 'The code_challenge_method must be %{challenge_methods}.'
other: 'The code_challenge_method must be one of %{challenge_methods}.'
server_error: 'The authorization server encountered an unexpected condition which prevented it from fulfilling the request.'
temporarily_unavailable: 'The authorization server is currently unable to handle the request due to a temporary overloading or maintenance of the server.'
# Configuration error messages
credential_flow_not_configured: 'Resource Owner Password Credentials flow failed due to Doorkeeper.configure.resource_owner_from_credentials being unconfigured.'
resource_owner_authenticator_not_configured: 'Resource Owner find failed due to Doorkeeper.configure.resource_owner_authenticator being unconfigured.'
admin_authenticator_not_configured: 'Access to admin panel is forbidden due to Doorkeeper.configure.admin_authenticator being unconfigured.'
# Access grant errors
unsupported_response_type: 'The authorization server does not support this response type.'
unsupported_response_mode: 'The authorization server does not support this response mode.'
# Access token errors
invalid_client: 'Client authentication failed due to unknown client, no client authentication included, or unsupported authentication method.'
invalid_grant: 'The provided authorization grant is invalid, expired, revoked, does not match the redirection URI used in the authorization request, or was issued to another client.'
unsupported_grant_type: 'The authorization grant type is not supported by the authorization server.'
invalid_token:
revoked: "The access token was revoked"
expired: "The access token expired"
unknown: "The access token is invalid"
revoke:
unauthorized: "You are not authorized to revoke this token"
forbidden_token:
missing_scope: 'Access to this resource requires scope "%{oauth_scopes}".'
flash:
applications:
create:
notice: 'Application created.'
destroy:
notice: 'Application deleted.'
update:
notice: 'Application updated.'
authorized_applications:
destroy:
notice: 'Application revoked.'
layouts:
admin:
title: 'Doorkeeper'
nav:
oauth2_provider: 'OAuth2 Provider'
applications: 'Applications'
home: 'Home'
application:
title: 'OAuth authorization required'
scopes:
read: View basic info about your Hackatime account

View file

@ -8,6 +8,8 @@ class AdminConstraint
end
Rails.application.routes.draw do
use_doorkeeper
constraints AdminConstraint do
mount GoodJob::Engine => "good_job"
mount AhoyCaptain::Engine => "/ahoy_captain"
@ -56,6 +58,8 @@ Rails.application.routes.draw do
end
end
get "/minimal_login", to: "static_pages#minimal_login", as: :minimal_login
# Auth routes
get "/auth/slack", to: "sessions#new", as: :slack_auth
get "/auth/slack/callback", to: "sessions#create"
@ -127,6 +131,10 @@ Rails.application.routes.draw do
get "heartbeats/most_recent", to: "heartbeats#most_recent"
get "heartbeats", to: "heartbeats#index"
end
namespace :authenticated do
resources :me, only: [ :index ]
end
end
# wakatime compatible summary

View file

@ -0,0 +1,99 @@
# frozen_string_literal: true
class CreateDoorkeeperTables < ActiveRecord::Migration[8.0]
def change
create_table :oauth_applications do |t|
t.string :name, null: false
t.string :uid, null: false
# Remove `null: false` or use conditional constraint if you are planning to use public clients.
t.string :secret, null: false
# Remove `null: false` if you are planning to use grant flows
# that doesn't require redirect URI to be used during authorization
# like Client Credentials flow or Resource Owner Password.
t.text :redirect_uri, null: false
t.string :scopes, null: false, default: ''
t.boolean :confidential, null: false, default: true
t.timestamps null: false
end
add_index :oauth_applications, :uid, unique: true
create_table :oauth_access_grants do |t|
t.references :resource_owner, null: false
t.references :application, null: false
t.string :token, null: false
t.integer :expires_in, null: false
t.text :redirect_uri, null: false
t.string :scopes, null: false, default: ''
t.datetime :created_at, null: false
t.datetime :revoked_at
end
add_index :oauth_access_grants, :token, unique: true
add_foreign_key(
:oauth_access_grants,
:oauth_applications,
column: :application_id
)
create_table :oauth_access_tokens do |t|
t.references :resource_owner, index: true
# Remove `null: false` if you are planning to use Password
# Credentials Grant flow that doesn't require an application.
t.references :application, null: false
# If you use a custom token generator you may need to change this column
# from string to text, so that it accepts tokens larger than 255
# characters. More info on custom token generators in:
# https://github.com/doorkeeper-gem/doorkeeper/tree/v3.0.0.rc1#custom-access-token-generator
#
# t.text :token, null: false
t.string :token, null: false
t.string :refresh_token
t.integer :expires_in
t.string :scopes
t.datetime :created_at, null: false
t.datetime :revoked_at
# The authorization server MAY issue a new refresh token, in which case
# *the client MUST discard the old refresh token* and replace it with the
# new refresh token. The authorization server MAY revoke the old
# refresh token after issuing a new refresh token to the client.
# @see https://datatracker.ietf.org/doc/html/rfc6749#section-6
#
# Doorkeeper implementation: if there is a `previous_refresh_token` column,
# refresh tokens will be revoked after a related access token is used.
# If there is no `previous_refresh_token` column, previous tokens are
# revoked as soon as a new access token is created.
#
# Comment out this line if you want refresh tokens to be instantly
# revoked after use.
t.string :previous_refresh_token, null: false, default: ""
end
add_index :oauth_access_tokens, :token, unique: true
# See https://github.com/doorkeeper-gem/doorkeeper/issues/1592
if ActiveRecord::Base.connection.adapter_name == "SQLServer"
execute <<~SQL.squish
CREATE UNIQUE NONCLUSTERED INDEX index_oauth_access_tokens_on_refresh_token ON oauth_access_tokens(refresh_token)
WHERE refresh_token IS NOT NULL
SQL
else
add_index :oauth_access_tokens, :refresh_token, unique: true
end
add_foreign_key(
:oauth_access_tokens,
:oauth_applications,
column: :application_id
)
# Uncomment below to ensure a valid reference to the resource owner's table
# add_foreign_key :oauth_access_grants, <model>, column: :resource_owner_id
# add_foreign_key :oauth_access_tokens, <model>, column: :resource_owner_id
end
end

View file

@ -0,0 +1,5 @@
class AddContinueParamToSignInTokens < ActiveRecord::Migration[8.0]
def change
add_column :sign_in_tokens, :continue_param, :string
end
end

47
db/schema.rb generated
View file

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2025_05_31_120000) do
ActiveRecord::Schema[8.0].define(version: 2025_06_08_205244) do
create_schema "pganalyze"
# These are extensions that must be enabled in order to support this database
@ -299,6 +299,48 @@ ActiveRecord::Schema[8.0].define(version: 2025_05_31_120000) do
t.index ["airtable_id"], name: "index_neighborhood_ysws_submissions_on_airtable_id", unique: true
end
create_table "oauth_access_grants", force: :cascade do |t|
t.bigint "resource_owner_id", null: false
t.bigint "application_id", null: false
t.string "token", null: false
t.integer "expires_in", null: false
t.text "redirect_uri", null: false
t.string "scopes", default: "", null: false
t.datetime "created_at", null: false
t.datetime "revoked_at"
t.index ["application_id"], name: "index_oauth_access_grants_on_application_id"
t.index ["resource_owner_id"], name: "index_oauth_access_grants_on_resource_owner_id"
t.index ["token"], name: "index_oauth_access_grants_on_token", unique: true
end
create_table "oauth_access_tokens", force: :cascade do |t|
t.bigint "resource_owner_id"
t.bigint "application_id", null: false
t.string "token", null: false
t.string "refresh_token"
t.integer "expires_in"
t.string "scopes"
t.datetime "created_at", null: false
t.datetime "revoked_at"
t.string "previous_refresh_token", default: "", null: false
t.index ["application_id"], name: "index_oauth_access_tokens_on_application_id"
t.index ["refresh_token"], name: "index_oauth_access_tokens_on_refresh_token", unique: true
t.index ["resource_owner_id"], name: "index_oauth_access_tokens_on_resource_owner_id"
t.index ["token"], name: "index_oauth_access_tokens_on_token", unique: true
end
create_table "oauth_applications", force: :cascade do |t|
t.string "name", null: false
t.string "uid", null: false
t.string "secret", null: false
t.text "redirect_uri", null: false
t.string "scopes", default: "", null: false
t.boolean "confidential", default: true, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["uid"], name: "index_oauth_applications_on_uid", unique: true
end
create_table "physical_mails", force: :cascade do |t|
t.bigint "user_id", null: false
t.integer "mission_type", null: false
@ -401,6 +443,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_05_31_120000) do
t.datetime "used_at"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.string "continue_param"
t.index ["token"], name: "index_sign_in_tokens_on_token"
t.index ["user_id"], name: "index_sign_in_tokens_on_user_id"
end
@ -463,6 +506,8 @@ ActiveRecord::Schema[8.0].define(version: 2025_05_31_120000) do
add_foreign_key "leaderboard_entries", "leaderboards"
add_foreign_key "leaderboard_entries", "users"
add_foreign_key "mailing_addresses", "users"
add_foreign_key "oauth_access_grants", "oauth_applications", column: "application_id"
add_foreign_key "oauth_access_tokens", "oauth_applications", column: "application_id"
add_foreign_key "physical_mails", "users"
add_foreign_key "project_repo_mappings", "repositories"
add_foreign_key "project_repo_mappings", "users"