Skip to content

Add JSON API endpoints and personal access tokens#34

Open
etagwerker wants to merge 2 commits intomainfrom
add-api-endpoints-and-personal-access-tokens
Open

Add JSON API endpoints and personal access tokens#34
etagwerker wants to merge 2 commits intomainfrom
add-api-endpoints-and-personal-access-tokens

Conversation

@etagwerker
Copy link
Copy Markdown
Member

Summary

  • Adds a personal_access_tokens table (hashed via SHA256; the raw token is shown once at creation)
  • Adds a token management UI at /personal_access_tokens (list, create, revoke), linked from the nav bar
  • Adds JSON responses to existing endpoints, authenticated via Authorization: Bearer <token>:
    • GET /links.json (supports ?domain=)
    • GET /links/:id.json (link + social_media_snippets, no shares)
    • GET /links/:link_id/shares.json
  • HTML requests continue to use Devise; CSRF protection is skipped only for JSON
  • 401 is returned for missing/invalid tokens; last_used_at is updated on success

Out of scope

  • API write endpoints
  • Token scopes / expiration enforcement (expires_at column exists but is not yet enforced)
  • Pagination

Test plan

  • bundle exec rspec spec/models/personal_access_token_spec.rb (model: digest, authenticate, raw token visible only once)
  • bundle exec rspec spec/requests/api_links_spec.rb (JSON endpoints with token auth + 401 cases)
  • bundle exec rspec spec/controllers spec/models (existing suite still green)
  • Manually create a token in the UI and confirm curl -H "Authorization: Bearer <token>" http://localhost:3000/links.json returns links
  • Confirm browser flow (HTML) still works and signs in via Devise

🤖 Generated with Claude Code

Adds a personal_access_tokens table (hashed via SHA256, raw token shown
once at creation) and a token management UI under
/personal_access_tokens. JSON requests on /links, /links/:id, and
/links/:id/shares now authenticate via Authorization: Bearer <token>
while HTML requests continue to use Devise.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@etagwerker etagwerker marked this pull request as ready for review April 29, 2026 19:55
@etagwerker etagwerker requested a review from Copilot April 29, 2026 20:31
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds personal access tokens and exposes read-only JSON endpoints for links and shares, enabling programmatic access via Authorization: Bearer <token> while keeping existing HTML flows on Devise.

Changes:

  • Introduces personal_access_tokens model/table with SHA256 digests, last_used_at, and basic validations.
  • Adds UI to create/list/revoke tokens and links it from the navigation bar.
  • Adds JSON responses for LinksController#index/show and SharesController#index, plus request/model test coverage.

Reviewed changes

Copilot reviewed 14 out of 14 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
app/controllers/application_controller.rb Adds JSON-vs-HTML authentication switch (PAT vs Devise) and skips CSRF for JSON.
app/controllers/links_controller.rb Adds respond_to JSON for listing/showing links and social snippets.
app/controllers/shares_controller.rb Adds JSON response for shares index under a link.
app/controllers/personal_access_tokens_controller.rb Adds token management actions (index/create/destroy).
app/models/personal_access_token.rb Implements token generation + digesting + .authenticate.
app/models/user.rb Adds has_many :personal_access_tokens.
app/views/personal_access_tokens/index.html.erb Token management page + one-time raw token display.
app/views/shared/_navigation_bar.html.erb Adds “API Tokens” nav link.
config/routes.rb Adds routes for token management UI.
db/migrate/20260429134259_create_personal_access_tokens.rb Creates personal_access_tokens table + unique index.
db/schema.rb Reflects the new table, indexes, and FK.
spec/factories/personal_access_tokens.rb Adds PAT factory.
spec/models/personal_access_token_spec.rb Tests digesting/authentication and token one-time visibility.
spec/requests/api_links_spec.rb Request specs for JSON endpoints + token auth and last_used_at.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +14 to +19
def authenticate_request!
if request.format.json?
authenticate_with_personal_access_token!
else
authenticate_user!
end
Comment on lines +27 to +33
pat.update_column(:last_used_at, Time.current)
sign_in pat.user, store: false
else
render json: { error: "Unauthorized" }, status: :unauthorized
end
end

Comment thread app/controllers/personal_access_tokens_controller.rb Outdated
Comment on lines +62 to +66
it 'returns the link with its social_media_snippets and no shares' do
get "/links/#{fastruby_link.id}.json", headers: auth_headers

expect(response).to have_http_status(:ok)
body = JSON.parse(response.body)
Copy link
Copy Markdown
Member Author

@etagwerker etagwerker left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security/auth review notes. Inline suggestions below for the items that map to specific lines. A few items didn't fit inline:

  • Rate limiting: no Rack::Attack (or similar) on the auth path. 256 bits of entropy makes brute force a non-issue, but defense in depth if a token ever leaks via logs/screenshots is worth a follow-up issue.
  • Cookie-authenticated browsers and JSON URLs: with authenticate_request! routing all JSON through Bearer, a logged-in user opening /links.json directly in the browser now gets 401. Is that intentional, or should cookie auth still work for JSON when there's no Bearer header?
  • Suggested tests to add: a request spec confirming cookie-only auth returns 401 from /links.json (so the bearer path can never silently fall back to cookies), and once expires_at is enforced, a test for an expired token failing auth.

@@ -1,9 +1,38 @@
class ApplicationController < ActionController::Base
before_action :authenticate_user!
skip_forgery_protection if: -> { request.format.json? }
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we narrow this so CSRF stays enabled whenever the request is cookie-authenticated? Today it's safe because the JSON path requires a Bearer token, but if a future endpoint accepts JSON with cookie auth (or opts out of authenticate_request!), CSRF would silently disappear for it.

Suggested change
skip_forgery_protection if: -> { request.format.json? }
skip_forgery_protection if: -> { request.authorization.to_s.match?(/\ABearer\s/i) }

pat = PersonalAccessToken.authenticate(raw_token)

if pat
pat.update_column(:last_used_at, Time.current)
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This writes to the row on every authenticated API call, so a busy token would hot-spot. Could we debounce the same way Devise :trackable does?

Suggested change
pat.update_column(:last_used_at, Time.current)
if pat.last_used_at.nil? || pat.last_used_at < 1.minute.ago
pat.update_column(:last_used_at, Time.current)
end


if pat
pat.update_column(:last_used_at, Time.current)
sign_in pat.user, store: false
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: any reason to use Devise's sign_in here instead of just setting @current_user? Even with store: false, sign_in runs Warden's set_user callbacks. Harmless today, but it couples API auth to whichever Devise modules are enabled on User. If :trackable, :lockable, or :timeoutable get added later, every API request would mutate user state.

Comment on lines +34 to +37
def bearer_token
header = request.headers["Authorization"].to_s
header.start_with?("Bearer ") ? header.split(" ", 2).last : nil
end
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two small parsing edges:

  • start_with?("Bearer ") is case-sensitive, but RFC 6750 says the scheme is case-insensitive.
  • "Bearer abc" (two spaces) returns " abc" with a leading space, so a near-valid header fails auth in a way that looks like a wrong token.
Suggested change
def bearer_token
header = request.headers["Authorization"].to_s
header.start_with?("Bearer ") ? header.split(" ", 2).last : nil
end
def bearer_token
request.authorization.to_s[/\ABearer\s+(.+)\z/i, 1]&.strip
end

Comment on lines +14 to +17
def self.authenticate(raw_token)
return nil if raw_token.blank?
find_by(token_digest: digest_for(raw_token))
end
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we enforce expires_at here so the column isn't a trap? Right now anyone setting expires_at (in the DB, or via a future UI) will think the token is disabled, but authenticate returns it anyway.

Suggested change
def self.authenticate(raw_token)
return nil if raw_token.blank?
find_by(token_digest: digest_for(raw_token))
end
def self.authenticate(raw_token)
return nil if raw_token.blank?
pat = find_by(token_digest: digest_for(raw_token))
return nil if pat && pat.expires_at && pat.expires_at <= Time.current
pat
end

Alternative: drop expires_at from the migration until enforcement lands, so the field doesn't exist in a half-working state.


def assign_token
return if token_digest.present?
@token = SecureRandom.hex(32)
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: prefix the token with something recognizable so GitHub's secret scanning (and similar tools) can flag it if it leaks into a public repo. Costs nothing and pays off the first time someone pastes one into a gist.

Suggested change
@token = SecureRandom.hex(32)
@token = "lib_#{SecureRandom.hex(32)}"

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants