Skip to content

Commit f496d73

Browse files
Add CLI authentication feature and enhance API token management (#243)
* Add CLI authentication feature and enhance API token management - Introduced CLI authentication flow with a new `CliAuthController` and associated views. - Added a `cli_login.rb` script for token generation via CLI. - Updated `ApiToken` model to track token source (web, cli, mobile) with new enum and scopes. - Enhanced `README.md` with CLI authentication instructions and added a dedicated documentation file. - Updated API token views to display the source of tokens. - Added new routes for CLI authorization. - Implemented policy checks to ensure only authenticated users can authorize CLI access. * Remove `token_type` column from `api_tokens` table to simplify schema and improve data management. * Update `httparty` gem to version `0.24.2` and adjust `bigdecimal` dependency in `Gemfile.lock` for compatibility. * Refactor URL opening in `cli_login.rb` for improved OS compatibility - Updated system calls to use array syntax for opening URLs, enhancing cross-platform functionality. * Remove migration file for `token_type` column from `api_tokens` table to maintain schema consistency. * Add `faraday` gem to address CVE-2026-25765 (SSRF vulnerability) in `Gemfile` * Update `faraday` gem version constraint in `Gemfile` and `Gemfile.lock` to prevent upgrades beyond 3.0, addressing CVE-2026-25765 (SSRF vulnerability). * Enhance API token management by adding source filtering options in the index view - Implemented filtering of API tokens by source (CLI, Web) in the `ApiTokensController#index` action. - Updated the index view to include links for filtering tokens based on their source, improving user experience. * Add authorization checks in `CliAuthController` for new and create actions - Implemented policy authorization for the `new` and `create` actions in the `CliAuthController`, ensuring that only authorized users can access these endpoints. * Enhance error handling in `CliAuthController` and implement user token limit in `ApiToken` model - Updated error handling in the `create` action of `CliAuthController` to provide more informative feedback on token creation failures. - Added a validation in the `ApiToken` model to enforce a maximum limit of active CLI tokens per user, improving token management and user experience.
1 parent b4030ce commit f496d73

File tree

17 files changed

+881
-13
lines changed

17 files changed

+881
-13
lines changed

Gemfile

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,10 @@ gem 'pgvector'
8989

9090
gem 'dotenv-rails', groups: %i[development test]
9191

92-
gem 'httparty'
92+
gem 'httparty', '>= 0.24.0'
93+
94+
# Security: Fix CVE-2026-25765 (SSRF vulnerability)
95+
gem 'faraday', '>= 2.14.1', '< 3.0'
9396

9497
# Markdown renderer
9598
gem 'redcarpet'

Gemfile.lock

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ GEM
8383
base64 (0.3.0)
8484
bcrypt (3.1.20)
8585
benchmark (0.4.1)
86-
bigdecimal (3.2.2)
86+
bigdecimal (4.0.1)
8787
bindex (0.8.1)
8888
bootsnap (1.18.6)
8989
msgpack (~> 1.2)
@@ -136,7 +136,7 @@ GEM
136136
railties (>= 6.1.0)
137137
faker (3.5.2)
138138
i18n (>= 1.8.11, < 2)
139-
faraday (2.13.2)
139+
faraday (2.14.1)
140140
faraday-net_http (>= 2.0, < 3.5)
141141
json
142142
logger
@@ -189,7 +189,7 @@ GEM
189189
builder (>= 2.1.2)
190190
rexml (~> 3.0)
191191
hashie (5.0.0)
192-
httparty (0.23.1)
192+
httparty (0.24.2)
193193
csv
194194
mini_mime (>= 1.0.0)
195195
multi_xml (>= 0.5.2)
@@ -246,8 +246,8 @@ GEM
246246
minitest (5.25.5)
247247
msgpack (1.8.0)
248248
multi_json (1.16.0)
249-
multi_xml (0.7.2)
250-
bigdecimal (~> 3.1)
249+
multi_xml (0.8.1)
250+
bigdecimal (>= 3.1, < 5)
251251
multipart-post (2.4.1)
252252
mutex_m (0.3.0)
253253
neighbor (0.6.0)
@@ -550,9 +550,10 @@ DEPENDENCIES
550550
dotenv-rails
551551
factory_bot_rails
552552
faker
553+
faraday (>= 2.14.1, < 3.0)
553554
google-api-client
554555
googleauth
555-
httparty
556+
httparty (>= 0.24.0)
556557
importmap-rails
557558
jbuilder
558559
kaminari

README.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1147,9 +1147,18 @@ The library allows document owners to keep their documents separate from each ot
11471147
Get the id of the library from the URL.
11481148

11491149
### 2. Get an API Token
1150-
1. Open /api_tokens in the ui.
1150+
1151+
**Option A: Web UI**
1152+
1. Open /api_tokens in the UI.
11511153
2. Create a token.
11521154

1155+
**Option B: CLI Authentication (Recommended)**
1156+
1. Run `ruby scripts/cli_login.rb` from the project directory.
1157+
2. Your browser will open to authorize the CLI.
1158+
3. Token is automatically saved to `~/.fack/credentials`.
1159+
1160+
For more details, see [CLI Authentication Guide](docs/CLI_AUTHENTICATION.md).
1161+
11531162
### 3. Locate the Directory with your Documents
11541163
You can clone your doc repo or have an directory anywhere on your computer.
11551164

app/controllers/api_tokens_controller.rb

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,18 @@ class ApiTokensController < ApplicationController
66
# GET /api_tokens or /api_tokens.json
77
def index
88
@api_tokens = policy_scope(ApiToken).order(last_used: :desc)
9+
10+
# Filter by source if provided
11+
case params[:source]
12+
when 'cli'
13+
@api_tokens = @api_tokens.cli_tokens
14+
when 'web'
15+
@api_tokens = @api_tokens.web_tokens
16+
# when 'mobile'
17+
# @api_tokens = @api_tokens.where(source: 'mobile')
18+
# 'all' or nil shows all tokens
19+
end
20+
921
authorize ApiToken
1022
end
1123

@@ -41,6 +53,7 @@ def edit
4153
def create
4254
@api_token = ApiToken.new(api_token_params)
4355
@api_token.user_id = current_user.id if @api_token.user_id.nil?
56+
@api_token.source = 'web' # Mark as web-created token
4457
authorize @api_token
4558

4659
respond_to do |format|
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
# frozen_string_literal: true
2+
3+
class CliAuthController < ApplicationController
4+
skip_before_action :verify_authenticity_token, only: [:create]
5+
6+
# GET /cli/authorize?state=RANDOM&port=9090
7+
def new
8+
# User must be logged in to authorize CLI
9+
unless current_user
10+
session[:return_to] = request.original_url
11+
redirect_to new_session_path, notice: 'Please login to authorize CLI'
12+
return
13+
end
14+
15+
authorize :cli_auth, :new?
16+
17+
@state = params[:state]
18+
@port = params[:port] || '9090'
19+
20+
# Validate state parameter exists
21+
unless @state.present?
22+
render plain: 'Error: Missing state parameter', status: :bad_request
23+
return
24+
end
25+
26+
# Validate port
27+
unless valid_port?(@port)
28+
render plain: 'Error: Invalid port number', status: :bad_request
29+
return
30+
end
31+
32+
# Show authorization page
33+
end
34+
35+
# POST /cli/authorize
36+
def create
37+
authorize :cli_auth, :create?
38+
39+
@state = params[:state]
40+
@port = params[:port] || '9090'
41+
42+
# Validate state parameter exists
43+
unless @state.present?
44+
render plain: 'Error: Missing state parameter', status: :bad_request
45+
return
46+
end
47+
48+
# Validate state format (should be alphanumeric/hex, prevent injection)
49+
unless valid_state?(@state)
50+
render plain: 'Error: Invalid state parameter format', status: :bad_request
51+
return
52+
end
53+
54+
# Validate port
55+
unless valid_port?(@port)
56+
render plain: 'Error: Invalid port number', status: :bad_request
57+
return
58+
end
59+
60+
# Create new API token for CLI
61+
@api_token = ApiToken.new(
62+
user: current_user,
63+
name: "CLI Token - #{Time.current.strftime('%Y-%m-%d %H:%M')}",
64+
source: 'cli'
65+
)
66+
67+
if @api_token.save
68+
# Redirect to localhost with token
69+
# URL encode state and token to prevent injection
70+
redirect_url = "http://127.0.0.1:#{@port}/callback?token=#{CGI.escape(@api_token.token)}&state=#{CGI.escape(@state)}"
71+
redirect_to redirect_url, allow_other_host: true
72+
else
73+
error_message = @api_token.errors.full_messages.first || 'Failed to create token'
74+
flash[:error] = error_message
75+
render :new, status: :unprocessable_entity
76+
end
77+
end
78+
79+
private
80+
81+
def valid_port?(port)
82+
# Only allow ports between 1024 and 65535 (non-privileged ports)
83+
port.to_i.between?(1024, 65535)
84+
end
85+
86+
def valid_state?(state)
87+
# State should be alphanumeric/hex string, reasonable length (32-128 chars typical)
88+
# Prevents URL injection attacks
89+
state.present? && state.match?(/\A[a-zA-Z0-9]+\z/) && state.length.between?(16, 256)
90+
end
91+
end

app/models/api_token.rb

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,39 @@
11
# frozen_string_literal: true
22

33
class ApiToken < ApplicationRecord
4+
MAX_TOKENS_PER_USER = 2
5+
46
validates :token, presence: true, uniqueness: true
57
validates :name, presence: true
8+
validate :user_token_limit, on: :create
69

710
before_validation :generate_token, on: :create
811

912
belongs_to :user
1013
# encrypts :token, deterministic: true
1114

15+
# Source tracking: where was this token created?
16+
enum :source, { web: 'web', cli: 'cli', mobile: 'mobile' }, prefix: true
17+
18+
# Scopes for filtering
19+
scope :cli_tokens, -> { where(source: 'cli') }
20+
scope :web_tokens, -> { where(source: 'web') }
21+
1222
private
1323

1424
def generate_token
1525
self.token = Digest::MD5.hexdigest(SecureRandom.hex)
1626
self.active = true
1727
end
28+
29+
def user_token_limit
30+
return unless user_id.present?
31+
# Only apply limit to CLI tokens
32+
return unless source == 'cli'
33+
34+
active_cli_token_count = user.api_tokens.where(active: true, source: 'cli').count
35+
if active_cli_token_count >= MAX_TOKENS_PER_USER
36+
errors.add(:base, "Maximum of #{MAX_TOKENS_PER_USER} active CLI tokens allowed. Please delete an existing CLI token first.")
37+
end
38+
end
1839
end

app/models/user.rb

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ class User < ApplicationRecord
1616
has_many :assistant_users
1717
has_many :assistants, through: :assistant_users
1818
has_many :comments, dependent: :destroy
19+
has_many :api_tokens, dependent: :destroy
1920

2021
# Recently viewed items feature
2122
has_many :viewed_items, dependent: :destroy
@@ -55,6 +56,18 @@ def recently_viewed(viewable_type:, limit: 5)
5556
.limit(limit)
5657
end
5758

59+
# Returns CLI tokens for this user
60+
# @return [ActiveRecord::Relation<ApiToken>] the CLI tokens
61+
def cli_tokens
62+
api_tokens.cli_tokens
63+
end
64+
65+
# Returns web tokens for this user
66+
# @return [ActiveRecord::Relation<ApiToken>] the web tokens
67+
def web_tokens
68+
api_tokens.web_tokens
69+
end
70+
5871
private
5972

6073
def password_strength

app/policies/cli_auth_policy.rb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# frozen_string_literal: true
2+
3+
class CliAuthPolicy < ApplicationPolicy
4+
# Anyone who is authenticated can authorize CLI access
5+
def new?
6+
user.present?
7+
end
8+
9+
def create?
10+
user.present?
11+
end
12+
end

app/views/api_tokens/_api_token.html.erb

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,25 @@
2222
<span class="text-stone-600"><%= api_token.user.email %></span>
2323
<% end %>
2424

25+
<%= render 'shared/labelled_field', label: "Source" do %>
26+
<% if api_token.source_cli? %>
27+
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
28+
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
29+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
30+
</svg>
31+
CLI
32+
</span>
33+
<% elsif api_token.source_mobile? %>
34+
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
35+
Mobile
36+
</span>
37+
<% else %>
38+
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
39+
Web
40+
</span>
41+
<% end %>
42+
<% end %>
43+
2544
<%= render 'shared/labelled_field', label: "Last used" do %>
2645
<span class="<%= 'font-bold text-green-500' if api_token.last_used && api_token.last_used > 7.days.ago %>">
2746
<% if api_token.last_used %>

app/views/api_tokens/index.html.erb

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,25 @@
66
<%= render partial: 'shared/button_group', locals: { buttons: { "New" => new_api_token_path } } %>
77
<% end %>
88
</div>
9+
<div class="flex justify-start items-center mb-3">
10+
<div class="flex items-center">
11+
<% source_filter = params[:source] %>
12+
<%= link_to 'All', api_tokens_path, class: "p-3 mr-4 pb-1 border-b-2 hover:border-sky-500 #{'border-sky-500' if source_filter.blank?}" %>
13+
<%= link_to 'CLI', api_tokens_path(source: 'cli'), class: "p-3 mr-4 pb-1 border-b-2 hover:border-sky-500 #{'border-sky-500' if source_filter == 'cli'}" %>
14+
<%= link_to 'Web', api_tokens_path(source: 'web'), class: "p-3 mr-4 pb-1 border-b-2 hover:border-sky-500 #{'border-sky-500' if source_filter == 'web'}" %>
15+
</div>
16+
</div>
917
<ul id="api_tokens" class="min-w-full list-none p-0">
10-
<li class="font-bold grid grid-cols-1 sm:grid-cols-4 text-xs text-stone-800 bg-stone-200 py-2 rounded-t-lg">
11-
<!-- Use grid-cols-1 for mobile and sm:grid-cols-5 for small screens and up -->
18+
<li class="font-bold grid grid-cols-1 sm:grid-cols-5 text-xs text-stone-800 bg-stone-200 py-2 rounded-t-lg">
1219
<div class="p-2">
1320
NAME
1421
</div>
1522
<div class="p-2">
1623
USER
1724
</div>
25+
<div class="p-2">
26+
SOURCE
27+
</div>
1828
<div class="p-2">
1929
LAST USED
2030
</div>
@@ -23,14 +33,28 @@
2333
</div>
2434
</li>
2535
<% @api_tokens.each_with_index do |api_token, index| %>
26-
<li class="<%= 'border-t border-stone-300' unless index == 0 %> font-light grid grid-cols-1 sm:grid-cols-4 justify-between py-2 text-sm bg-white hover:bg-stone-100 <%= 'rounded-b-lg' if index == @api_tokens.length - 1 %>">
27-
<!-- Adjustments here similar to the header row -->
36+
<li class="<%= 'border-t border-stone-300' unless index == 0 %> font-light grid grid-cols-1 sm:grid-cols-5 justify-between py-2 text-sm bg-white hover:bg-stone-100 <%= 'rounded-b-lg' if index == @api_tokens.length - 1 %>">
2837
<div class="p-2">
2938
<%= link_to api_token.name, api_token_path(api_token), class: "text-sky-600 font-light" %>
3039
</div>
3140
<div class="p-2">
3241
<span class="text-stone-500 text-sm"><%= api_token.user.email %></span>
3342
</div>
43+
<div class="p-2">
44+
<% if api_token.source_cli? %>
45+
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800">
46+
CLI
47+
</span>
48+
<% elsif api_token.source_mobile? %>
49+
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">
50+
Mobile
51+
</span>
52+
<% else %>
53+
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-800">
54+
Web
55+
</span>
56+
<% end %>
57+
</div>
3458
<div class="p-2 <%= 'font-bold text-green-500' if api_token.last_used && api_token.last_used > 7.days.ago %>">
3559
<% if api_token.last_used %>
3660
<%= time_ago_in_words(api_token.last_used) %> ago

0 commit comments

Comments
 (0)