Add JSON API endpoints and personal access tokens#34
Add JSON API endpoints and personal access tokens#34etagwerker wants to merge 2 commits intomainfrom
Conversation
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>
There was a problem hiding this comment.
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_tokensmodel/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/showandSharesController#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.
| def authenticate_request! | ||
| if request.format.json? | ||
| authenticate_with_personal_access_token! | ||
| else | ||
| authenticate_user! | ||
| end |
| pat.update_column(:last_used_at, Time.current) | ||
| sign_in pat.user, store: false | ||
| else | ||
| render json: { error: "Unauthorized" }, status: :unauthorized | ||
| end | ||
| end | ||
|
|
| 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) |
etagwerker
left a comment
There was a problem hiding this comment.
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.jsondirectly 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 onceexpires_atis 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? } | |||
There was a problem hiding this comment.
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.
| 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) |
There was a problem hiding this comment.
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?
| 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 |
There was a problem hiding this comment.
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.
| def bearer_token | ||
| header = request.headers["Authorization"].to_s | ||
| header.start_with?("Bearer ") ? header.split(" ", 2).last : nil | ||
| end |
There was a problem hiding this comment.
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.
| 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 |
| def self.authenticate(raw_token) | ||
| return nil if raw_token.blank? | ||
| find_by(token_digest: digest_for(raw_token)) | ||
| end |
There was a problem hiding this comment.
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.
| 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) |
There was a problem hiding this comment.
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.
| @token = SecureRandom.hex(32) | |
| @token = "lib_#{SecureRandom.hex(32)}" |
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Summary
personal_access_tokenstable (hashed via SHA256; the raw token is shown once at creation)/personal_access_tokens(list, create, revoke), linked from the nav barAuthorization: Bearer <token>:GET /links.json(supports?domain=)GET /links/:id.json(link +social_media_snippets, no shares)GET /links/:link_id/shares.jsonlast_used_atis updated on successOut of scope
expires_atcolumn exists but is not yet enforced)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)curl -H "Authorization: Bearer <token>" http://localhost:3000/links.jsonreturns links🤖 Generated with Claude Code