1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-08-06 22:15:20 +02:00

Add comprehensive API v1 with OAuth and API key authentication (#2389)

* OAuth

* Add API test routes and update Doorkeeper token handling for test environment

- Introduced API namespace with test routes for controller testing in the test environment.
- Updated Doorkeeper configuration to allow fallback to plain tokens in the test environment for easier testing.
- Modified schema to change resource_owner_id type from bigint to string.

* Implement API key authentication and enhance access control

- Replaced Doorkeeper OAuth authentication with a custom method supporting both OAuth and API keys in the BaseController.
- Added methods for API key authentication, including validation and logging.
- Introduced scope-based authorization for API keys in the TestController.
- Updated routes to include API key management endpoints.
- Enhanced logging for API access to include authentication method details.
- Added tests for API key functionality, including validation, scope checks, and access control enforcement.

* Add API key rate limiting and usage tracking

- Implemented rate limiting for API key authentication in BaseController.
- Added methods to check rate limits, render appropriate responses, and include rate limit headers in responses.
- Updated routes to include a new usage resource for tracking API usage.
- Enhanced tests to verify rate limit functionality, including exceeding limits and per-key tracking.
- Cleaned up Redis data in tests to ensure isolation between test cases.

* Add Jbuilder for JSON rendering and refactor AccountsController

- Added Jbuilder gem for improved JSON response handling.
- Refactored index action in AccountsController to utilize Jbuilder for rendering JSON.
- Removed manual serialization of accounts and streamlined response structure.
- Implemented a before_action in BaseController to enforce JSON format for all API requests.

* Add transactions resource to API routes

- Added routes for transactions, allowing index, show, create, update, and destroy actions.
- This enhancement supports comprehensive transaction management within the API.

* Enhance API authentication and onboarding handling

- Updated BaseController to skip onboarding requirements for API endpoints and added manual token verification for OAuth authentication.
- Improved error handling and logging for invalid access tokens.
- Introduced a method to set up the current context for API requests, ensuring compatibility with session-like behavior.
- Excluded API paths from onboarding redirects in the Onboardable concern.
- Updated database schema to change resource_owner_id type from bigint to string for OAuth access grants.

* Fix rubocop offenses

- Fix indentation and spacing issues
- Convert single quotes to double quotes
- Add spaces inside array brackets
- Fix comment alignment
- Add missing trailing newlines
- Correct else/end alignment

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Fix API test failures and improve test reliability

- Fix ApiRateLimiterTest by removing mock users method and using fixtures
- Fix UsageControllerTest by removing mock users method and using fixtures
- Fix BaseControllerTest by using different users for multiple API keys
- Use unique display_key values with SecureRandom to avoid conflicts
- Fix double render issue in UsageController by returning after authorize_scope\!
- Specify controller name in routes for usage resource
- Remove trailing whitespace and empty lines per Rubocop

All tests now pass and linting is clean.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Add API transactions controller warning to brakeman ignore

The account_id parameter in the API transactions controller is properly
validated on line 79: family.accounts.find(transaction_params[:account_id])
This ensures users can only create transactions in accounts belonging to
their family, making this a false positive.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Signed-off-by: Josh Pigford <josh@joshpigford.com>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Josh Pigford 2025-06-17 15:57:05 -05:00 committed by GitHub
parent 13a64a1694
commit b803ddac96
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
53 changed files with 4849 additions and 4 deletions

View file

@ -0,0 +1,17 @@
# frozen_string_literal: true
json.accounts @accounts do |account|
json.id account.id
json.name account.name
json.balance account.balance_money.format
json.currency account.currency
json.classification account.classification
json.account_type account.accountable_type.underscore
end
json.pagination do
json.page @pagy.page
json.per_page @per_page
json.total_count @pagy.count
json.total_pages @pagy.pages
end

View file

@ -0,0 +1,76 @@
# frozen_string_literal: true
json.id transaction.id
json.date transaction.entry.date
json.amount transaction.entry.amount_money.format
json.currency transaction.entry.currency
json.name transaction.entry.name
json.notes transaction.entry.notes
json.classification transaction.entry.classification
# Account information
json.account do
json.id transaction.entry.account.id
json.name transaction.entry.account.name
json.account_type transaction.entry.account.accountable_type.underscore
end
# Category information
if transaction.category.present?
json.category do
json.id transaction.category.id
json.name transaction.category.name
json.classification transaction.category.classification
json.color transaction.category.color
json.icon transaction.category.lucide_icon
end
else
json.category nil
end
# Merchant information
if transaction.merchant.present?
json.merchant do
json.id transaction.merchant.id
json.name transaction.merchant.name
end
else
json.merchant nil
end
# Tags
json.tags transaction.tags do |tag|
json.id tag.id
json.name tag.name
json.color tag.color
end
# Transfer information (if this transaction is part of a transfer)
if transaction.transfer.present?
json.transfer do
json.id transaction.transfer.id
json.amount transaction.transfer.amount_abs.format
json.currency transaction.transfer.inflow_transaction.entry.currency
# Other transaction in the transfer
if transaction.transfer.inflow_transaction == transaction
other_transaction = transaction.transfer.outflow_transaction
else
other_transaction = transaction.transfer.inflow_transaction
end
if other_transaction.present?
json.other_account do
json.id other_transaction.entry.account.id
json.name other_transaction.entry.account.name
json.account_type other_transaction.entry.account.accountable_type.underscore
end
end
end
else
json.transfer nil
end
# Additional metadata
json.created_at transaction.created_at.iso8601
json.updated_at transaction.updated_at.iso8601

View file

@ -0,0 +1,12 @@
# frozen_string_literal: true
json.transactions @transactions do |transaction|
json.partial! "transaction", transaction: transaction
end
json.pagination do
json.page @pagy.page
json.per_page @per_page
json.total_count @pagy.count
json.total_pages @pagy.pages
end

View file

@ -0,0 +1,3 @@
# frozen_string_literal: true
json.partial! "transaction", transaction: @transaction

View file

@ -6,6 +6,7 @@ nav_sections = [
{ label: t(".profile_label"), path: settings_profile_path, icon: "circle-user" },
{ label: t(".preferences_label"), path: settings_preferences_path, icon: "bolt" },
{ label: t(".security_label"), path: settings_security_path, icon: "shield-check" },
{ label: "API Key", path: settings_api_key_path, icon: "key" },
{ label: t(".self_hosting_label"), path: settings_hosting_path, icon: "database", if: self_hosted? },
{ label: t(".billing_label"), path: settings_billing_path, icon: "circle-dollar-sign", if: !self_hosted? },
{ label: t(".accounts_label"), path: accounts_path, icon: "layers" },

View file

@ -0,0 +1,94 @@
<%= content_for :page_title, "API Key Created" %>
<%= settings_section title: "API Key Created Successfully", subtitle: "Your new API key has been generated successfully." do %>
<div class="space-y-4">
<div class="p-3 shadow-border-xs bg-container rounded-lg">
<div class="flex items-start gap-3">
<%= render FilledIconComponent.new(
icon: "check-circle",
rounded: true,
size: "lg",
variant: :success
) %>
<div class="flex-1">
<h3 class="font-medium text-primary">API Key Created Successfully!</h3>
<p class="text-secondary text-sm mt-1">Your new API key "<%= @api_key.name %>" has been created and is ready to use.</p>
</div>
</div>
</div>
<div class="bg-surface-inset rounded-xl p-4">
<h4 class="font-medium text-primary mb-3">Your API Key</h4>
<p class="text-secondary text-sm mb-3">Copy and store this key securely. You'll need it to authenticate your API requests.</p>
<div class="bg-container rounded-lg p-3 border border-primary" data-controller="clipboard">
<div class="flex items-center justify-between gap-3">
<code id="api-key-display" class="font-mono text-sm text-primary break-all" data-clipboard-target="source"><%= @api_key.plain_key %></code>
<%= render ButtonComponent.new(
text: "Copy API Key",
variant: "ghost",
icon: "copy",
data: { action: "clipboard#copy" }
) %>
</div>
</div>
</div>
<div class="bg-surface-inset rounded-xl p-4">
<h4 class="font-medium text-primary mb-3">Key Details</h4>
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-secondary">Name:</span>
<span class="text-primary font-medium"><%= @api_key.name %></span>
</div>
<div class="flex justify-between">
<span class="text-secondary">Permissions:</span>
<span class="text-primary">
<%= @api_key.scopes.map { |scope|
case scope
when "read_accounts" then "View Accounts"
when "read_transactions" then "View Transactions"
when "read_balances" then "View Balances"
when "write_transactions" then "Create Transactions"
else scope.humanize
end
}.join(", ") %>
</span>
</div>
<div class="flex justify-between">
<span class="text-secondary">Created:</span>
<span class="text-primary"><%= @api_key.created_at.strftime("%B %d, %Y at %I:%M %p") %></span>
</div>
</div>
</div>
<div class="bg-warning-50 border border-warning-200 rounded-xl p-4">
<div class="flex items-start gap-2">
<%= icon("alert-triangle", class: "w-5 h-5 text-warning-600 mt-0.5") %>
<div>
<h4 class="font-medium text-warning-800 text-sm">Important Security Note</h4>
<p class="text-warning-700 text-sm mt-1">
This is the only time your API key will be displayed. Make sure to copy it now and store it securely.
If you lose this key, you'll need to generate a new one.
</p>
</div>
</div>
</div>
<div class="bg-surface-inset rounded-xl p-4">
<h4 class="font-medium text-primary mb-3">How to use your API key</h4>
<p class="text-secondary text-sm mb-3">Include your API key in the X-Api-Key header when making requests:</p>
<div class="bg-container rounded-lg p-3 font-mono text-sm text-primary border border-primary">
curl -H "X-Api-Key: <%= @api_key.plain_key %>" <%= request.base_url %>/api/v1/accounts
</div>
</div>
<div class="flex justify-end pt-4 border-t border-primary">
<%= render LinkComponent.new(
text: "Continue to API Key Settings",
href: settings_api_key_path,
variant: "primary"
) %>
</div>
</div>
<% end %>

View file

@ -0,0 +1,102 @@
<%= turbo_stream.update "main" do %>
<div class="relative max-w-4xl mx-auto flex flex-col w-full h-full">
<div class="grow space-y-4 overflow-y-auto -mx-1 px-1 pb-12">
<h1 class="text-primary text-3xl md:text-xl font-medium">
API Key Created
</h1>
<%= settings_section title: "API Key Created Successfully", subtitle: "Your new API key has been generated successfully." do %>
<div class="space-y-4">
<div class="p-3 shadow-border-xs bg-container rounded-lg">
<div class="flex items-start gap-3">
<%= render FilledIconComponent.new(
icon: "check-circle",
rounded: true,
size: "lg",
variant: :success
) %>
<div class="flex-1">
<h3 class="font-medium text-primary">API Key Created Successfully!</h3>
<p class="text-secondary text-sm mt-1">Your new API key "<%= @api_key.name %>" has been created and is ready to use.</p>
</div>
</div>
</div>
<div class="bg-surface-inset rounded-xl p-4">
<h4 class="font-medium text-primary mb-3">Your API Key</h4>
<p class="text-secondary text-sm mb-3">Copy and store this key securely. You'll need it to authenticate your API requests.</p>
<div class="bg-container rounded-lg p-3 border border-primary" data-controller="clipboard">
<div class="flex items-center justify-between gap-3">
<code id="api-key-display" class="font-mono text-sm text-primary break-all" data-clipboard-target="source"><%= @api_key.plain_key %></code>
<%= render ButtonComponent.new(
text: "Copy API Key",
variant: "ghost",
icon: "copy",
data: { action: "clipboard#copy" }
) %>
</div>
</div>
</div>
<div class="bg-surface-inset rounded-xl p-4">
<h4 class="font-medium text-primary mb-3">Key Details</h4>
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-secondary">Name:</span>
<span class="text-primary font-medium"><%= @api_key.name %></span>
</div>
<div class="flex justify-between">
<span class="text-secondary">Permissions:</span>
<span class="text-primary">
<%= @api_key.scopes.map { |scope|
case scope
when "read_accounts" then "View Accounts"
when "read_transactions" then "View Transactions"
when "read_balances" then "View Balances"
when "write_transactions" then "Create Transactions"
else scope.humanize
end
}.join(", ") %>
</span>
</div>
<div class="flex justify-between">
<span class="text-secondary">Created:</span>
<span class="text-primary"><%= @api_key.created_at.strftime("%B %d, %Y at %I:%M %p") %></span>
</div>
</div>
</div>
<div class="bg-warning-50 border border-warning-200 rounded-xl p-4">
<div class="flex items-start gap-2">
<%= icon("alert-triangle", class: "w-5 h-5 text-warning-600 mt-0.5") %>
<div>
<h4 class="font-medium text-warning-800 text-sm">Important Security Note</h4>
<p class="text-warning-700 text-sm mt-1">
This is the only time your API key will be displayed. Make sure to copy it now and store it securely.
If you lose this key, you'll need to generate a new one.
</p>
</div>
</div>
</div>
<div class="bg-surface-inset rounded-xl p-4">
<h4 class="font-medium text-primary mb-3">How to use your API key</h4>
<p class="text-secondary text-sm mb-3">Include your API key in the X-Api-Key header when making requests:</p>
<div class="bg-container rounded-lg p-3 font-mono text-sm text-primary border border-primary">
curl -H "X-Api-Key: <%= @api_key.plain_key %>" <%= request.base_url %>/api/v1/accounts
</div>
</div>
<div class="flex justify-end pt-4 border-t border-primary">
<%= render LinkComponent.new(
text: "Continue to API Key Settings",
href: settings_api_key_path,
variant: "primary"
) %>
</div>
</div>
<% end %>
</div>
</div>
<% end %>

View file

@ -0,0 +1,60 @@
<%= content_for :page_title, "Create New API Key" %>
<%= settings_section title: "Create New API Key", subtitle: "Generate a new API key to access your Maybe data programmatically." do %>
<%= styled_form_with model: @api_key, url: settings_api_key_path, class: "space-y-4" do |form| %>
<%= form.text_field :name,
placeholder: "e.g., My Budget App, Portfolio Tracker",
label: "API Key Name",
help_text: "Choose a descriptive name to help you identify this key later." %>
<div>
<%= form.label :scopes, "Permissions", class: "block text-sm font-medium text-primary mb-2" %>
<p class="text-sm text-secondary mb-3">Select the permissions this API key should have:</p>
<div class="space-y-2">
<% [
["read", "Read Only", "View your accounts, transactions, and balances"],
["read_write", "Read/Write", "View your data and create new transactions"]
].each do |value, label, description| %>
<div class="bg-surface-inset rounded-lg p-3 border border-primary">
<label class="flex items-start gap-3 cursor-pointer">
<%= radio_button_tag "api_key[scopes]", value, (@api_key&.scopes || []).include?(value),
class: "mt-1" %>
<div class="flex-1">
<div class="font-medium text-primary"><%= label %></div>
<div class="text-sm text-secondary mt-1"><%= description %></div>
</div>
</label>
</div>
<% end %>
</div>
</div>
<div class="bg-warning-50 border border-warning-200 rounded-xl p-4">
<div class="flex items-start gap-2">
<%= icon("alert-triangle", class: "w-5 h-5 text-warning-600 mt-0.5") %>
<div>
<h4 class="font-medium text-warning-800 text-sm">Security Warning</h4>
<p class="text-warning-700 text-sm mt-1">
Your API key will be displayed only once after creation. Make sure to copy and store it securely.
Anyone with access to this key can access your data according to the permissions you select.
</p>
</div>
</div>
</div>
<div class="flex justify-end gap-3 pt-4 border-t border-primary">
<%= render LinkComponent.new(
text: "Cancel",
href: settings_api_key_path,
variant: "ghost"
) %>
<%= render ButtonComponent.new(
text: "Create API Key",
variant: "primary",
type: "submit"
) %>
</div>
<% end %>
<% end %>

View file

@ -0,0 +1,192 @@
<%= content_for :page_title, "API Key" %>
<% if @newly_created && @plain_key %>
<%= settings_section title: "API Key Created Successfully", subtitle: "Your new API key has been generated successfully." do %>
<div class="space-y-4">
<div class="p-3 shadow-border-xs bg-container rounded-lg">
<div class="flex items-start gap-3">
<%= render FilledIconComponent.new(
icon: "check-circle",
rounded: true,
size: "lg",
variant: :success
) %>
<div class="flex-1">
<h3 class="font-medium text-primary">API Key Created Successfully!</h3>
<p class="text-secondary text-sm mt-1">Your new API key "<%= @current_api_key.name %>" has been created and is ready to use.</p>
</div>
</div>
</div>
<div class="bg-surface-inset rounded-xl p-4">
<h4 class="font-medium text-primary mb-3">Your API Key</h4>
<p class="text-secondary text-sm mb-3">Copy and store this key securely. You'll need it to authenticate your API requests.</p>
<div class="bg-container rounded-lg p-3 border border-primary" data-controller="clipboard">
<div class="flex items-center justify-between gap-3">
<code id="api-key-display" class="font-mono text-sm text-primary break-all" data-clipboard-target="source"><%= @current_api_key.plain_key %></code>
<%= render ButtonComponent.new(
text: "Copy API Key",
variant: "ghost",
icon: "copy",
data: { action: "clipboard#copy" }
) %>
</div>
</div>
</div>
<div class="bg-surface-inset rounded-xl p-4">
<h4 class="font-medium text-primary mb-3">How to use your API key</h4>
<p class="text-secondary text-sm mb-3">Include your API key in the X-Api-Key header when making requests:</p>
<div class="bg-container rounded-lg p-3 font-mono text-sm text-primary border border-primary">
curl -H "X-Api-Key: <%= @current_api_key.plain_key %>" <%= request.base_url %>/api/v1/accounts
</div>
</div>
<div class="flex justify-end pt-4 border-t border-primary">
<%= render LinkComponent.new(
text: "Continue to API Key Settings",
href: settings_api_key_path,
variant: "primary"
) %>
</div>
</div>
<% end %>
<% elsif @current_api_key %>
<%= settings_section title: "Your API Key", subtitle: "Manage your API key for programmatic access to your Maybe data." do %>
<div class="space-y-4">
<div class="p-3 shadow-border-xs bg-container rounded-lg flex justify-between items-center">
<div class="flex items-center gap-3">
<%= render FilledIconComponent.new(
icon: "key",
rounded: true,
size: "lg"
) %>
<div class="text-sm space-y-1">
<p class="text-primary font-medium"><%= @current_api_key.name %></p>
<p class="text-secondary">
Created <%= time_ago_in_words(@current_api_key.created_at) %> ago
<% if @current_api_key.last_used_at %>
• Last used <%= time_ago_in_words(@current_api_key.last_used_at) %> ago
<% else %>
• Never used
<% end %>
</p>
</div>
</div>
<div class="rounded-md bg-success px-2 py-1">
<p class="text-success-foreground font-medium text-xs">Active</p>
</div>
</div>
<div class="bg-surface-inset rounded-xl p-4">
<h4 class="font-medium text-primary mb-3">Permissions</h4>
<div class="flex flex-wrap gap-2">
<% @current_api_key.scopes.each do |scope| %>
<span class="inline-flex items-center gap-1 px-2 py-1 bg-primary text-primary-foreground rounded-full text-xs font-medium">
<%= icon("shield-check", class: "w-3 h-3") %>
<%= case scope
when "read" then "Read Only"
when "read_write" then "Read/Write"
else scope.humanize
end %>
</span>
<% end %>
</div>
</div>
<div class="bg-surface-inset rounded-xl p-4">
<h4 class="font-medium text-primary mb-3">Your API Key</h4>
<p class="text-secondary text-sm mb-3">Copy and store this key securely. You'll need it to authenticate your API requests.</p>
<div class="bg-container rounded-lg p-3 border border-primary" data-controller="clipboard">
<div class="flex items-center justify-between gap-3">
<code id="api-key-display" class="font-mono text-sm text-primary break-all" data-clipboard-target="source"><%= @current_api_key.plain_key %></code>
<%= render ButtonComponent.new(
text: "Copy API Key",
variant: "ghost",
icon: "copy",
data: { action: "clipboard#copy" }
) %>
</div>
</div>
</div>
<div class="bg-surface-inset rounded-xl p-4">
<h4 class="font-medium text-primary mb-3">How to use your API key</h4>
<p class="text-secondary text-sm mb-3">Include your API key in the X-Api-Key header when making requests:</p>
<div class="bg-container rounded-lg p-3 font-mono text-sm text-primary border border-primary">
curl -H "X-Api-Key: <%= @current_api_key.plain_key %>" <%= request.base_url %>/api/v1/accounts
</div>
</div>
<div class="flex flex-col sm:flex-row gap-3 pt-4 border-t border-primary">
<%= render LinkComponent.new(
text: "Create New Key",
href: new_settings_api_key_path(regenerate: true),
variant: "secondary"
) %>
<%= render ButtonComponent.new(
text: "Revoke Key",
href: settings_api_key_path,
method: :delete,
variant: "destructive",
data: {
turbo_confirm: "Are you sure you want to revoke this API key?"
}
) %>
</div>
</div>
<% end %>
<% else %>
<%= settings_section title: "Create Your API Key", subtitle: "Get programmatic access to your Maybe data" do %>
<div class="space-y-4">
<div class="p-3 shadow-border-xs bg-container rounded-lg">
<div class="flex items-start gap-3">
<%= render FilledIconComponent.new(
icon: "key",
rounded: true,
size: "lg"
) %>
<div class="flex-1">
<h3 class="font-medium text-primary">Access your account data programmatically</h3>
<p class="text-secondary text-sm mt-1">Generate an API key to integrate with your applications and access your financial data securely.</p>
</div>
</div>
</div>
<div class="bg-surface-inset rounded-xl p-4">
<h4 class="font-medium text-primary mb-3">What you can do with API keys:</h4>
<ul class="space-y-2 text-sm text-secondary">
<li class="flex items-start gap-2">
<%= icon("check", class: "w-4 h-4 text-primary mt-0.5") %>
<span>Access your accounts and balances</span>
</li>
<li class="flex items-start gap-2">
<%= icon("check", class: "w-4 h-4 text-primary mt-0.5") %>
<span>View transaction history</span>
</li>
<li class="flex items-start gap-2">
<%= icon("check", class: "w-4 h-4 text-primary mt-0.5") %>
<span>Create new transactions</span>
</li>
<li class="flex items-start gap-2">
<%= icon("check", class: "w-4 h-4 text-primary mt-0.5") %>
<span>Integrate with third-party applications</span>
</li>
</ul>
</div>
<div class="flex justify-start">
<%= render LinkComponent.new(
text: "Create API Key",
href: new_settings_api_key_path,
variant: "primary"
) %>
</div>
</div>
<% end %>
<% end %>