How API Authentication with Doorkeeper OAuth Works in Maybe Finance

The Maybe Finance API secures every endpoint using OAuth 2.0 access tokens issued by the Doorkeeper gem, validating requests in Api::V1::BaseController to establish the current user context.

The maybe-finance/maybe repository implements a stateless API authentication with Doorkeeper OAuth to protect financial data endpoints while maintaining compatibility with the traditional session-based web interface. This Rails application uses Doorkeeper to manage token issuance and validation, ensuring that every API request carries a valid Bearer token with appropriate scopes.

OAuth Token Issuance and Resource Owner Resolution

The authentication flow begins when clients request an access token from the Doorkeeper token endpoint.

Configuring the Resource Owner Authenticator

In config/initializers/doorkeeper.rb, the resource_owner_authenticator block defines how Doorkeeper maps an incoming request to a User record. The implementation extracts the session_token from cookies and looks up the corresponding user:

resource_owner_authenticator do
  session_token = cookies.encrypted[:session_token]
  User.find_by(id: session_token&.dig("user_id"))
end

This configuration allows Doorkeeper to issue tokens bound to specific users while leveraging the existing session infrastructure.

Token Validation in API Controllers

All API controllers inherit from Api::V1::BaseController, which implements custom token validation logic rather than using Doorkeeper's default before_action helpers.

Manual Token Lookup in BaseController

The controller extracts the Bearer token from the Authorization header and performs a manual database lookup:

token_string = request.authorization&.split(" ")&.last
access_token = Doorkeeper::AccessToken.by_token(token_string)

This approach provides fine-grained control over error handling and response formatting.

Scope Verification and Expiration Checks

After retrieving the token, the controller validates three conditions:

  1. Existence: The token must exist in the database
  2. Expiration: The token must not be expired (token.expired? check)
  3. Scope: The token must include at least the read scope, or read_write for mutation endpoints

If any check fails, the controller returns a JSON error response with HTTP 401 status.

Setting Up Current User Context

When validation succeeds, the controller establishes the application context for the request:

@_doorkeeper_token = access_token
Current.user = User.find(access_token.resource_owner_id)
Current.session = Session.new(user: Current.user)

This creates a temporary Session object and sets Current.user, allowing the rest of the application to rely on these globals regardless of whether the request came from the web UI or the API.

Error Handling for Invalid Tokens

The controller overrides doorkeeper_unauthorized_render_options to ensure API clients receive JSON responses rather than HTML redirects:

def doorkeeper_unauthorized_render_options(error: nil)
  {
    json: { error: "unauthorized", message: error.description },
    status: :unauthorized
  }
end

This method returns a 401 status with a descriptive error message when token validation fails.

Route Configuration

Doorkeeper routes are mounted in config/routes.rb using the use_doorkeeper method:

use_doorkeeper do
  skip_controllers :authorizations, :applications, :authorized_applications
end

namespace :api do
  namespace :v1 do
    # API endpoints...

  end
end

This configuration exposes the standard OAuth endpoints (/oauth/token, /oauth/revoke) while skipping the authorization flow controllers that are unnecessary for a first-party API.

Practical Examples

Obtaining an Access Token

Clients authenticate using the password grant flow to receive an access token:

curl -X POST https://api.maybe.finance/oauth/token \
  -d "grant_type=password" \
  -d "username=user@example.com" \
  -d "password=secret123" \
  -d "client_id=YOUR_CLIENT_ID" \
  -d "client_secret=YOUR_CLIENT_SECRET" \
  -d "scope=read_write"

Calling a Protected Endpoint

Include the access token in the Authorization header for all API requests:

curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
     -H "Content-Type: application/json" \
     https://api.maybe.finance/api/v1/accounts

Refreshing an Access Token

When the access token expires, use the refresh token to obtain a new one:

curl -X POST https://api.maybe.finance/oauth/token \
  -d "grant_type=refresh_token" \
  -d "refresh_token=YOUR_REFRESH_TOKEN" \
  -d "client_id=YOUR_CLIENT_ID" \
  -d "client_secret=YOUR_CLIENT_SECRET"

Key Implementation Files

File Purpose
config/initializers/doorkeeper.rb Configures Doorkeeper, defines the resource_owner_authenticator block that maps session cookies to User records, and sets token expiration and scopes
config/routes.rb Mounts Doorkeeper routes via use_doorkeeper and defines the API namespace
app/controllers/api/v1/base_controller.rb Validates Bearer tokens manually, checks scopes and expiration, sets Current.user and Current.session, and returns JSON 401 errors
app/models/user.rb The resource owner model associated with OAuth tokens via resource_owner_id
app/models/api_key.rb Provides alternative API key authentication when OAuth tokens are not used

Summary

  • Doorkeeper Configuration: The resource_owner_authenticator in config/initializers/doorkeeper.rb links OAuth tokens to users via session cookies.
  • Manual Token Validation: Api::V1::BaseController performs explicit Bearer token extraction and validation using Doorkeeper::AccessToken.by_token.
  • Scope Enforcement: The API checks for read or read_write scopes before allowing access to endpoints.
  • Context Initialization: Valid tokens trigger the creation of Current.user and Current.session objects for downstream application logic.
  • JSON Error Responses: Invalid or missing tokens return HTTP 401 with JSON error bodies rather than HTML redirects.

Frequently Asked Questions

How does Doorkeeper integrate with the existing User model?

Doorkeeper uses the resource_owner_authenticator block defined in config/initializers/doorkeeper.rb to resolve the user associated with a token request. This block extracts the encrypted session_token cookie and looks up the corresponding User record, allowing Doorkeeper to issue access tokens bound to specific users while leveraging the existing session infrastructure.

What OAuth scopes are available for API tokens?

The Maybe Finance API supports two primary scopes: read and read_write. The read scope grants access to GET endpoints for retrieving financial data, while read_write is required for POST, PUT, PATCH, and DELETE operations that modify data. The Api::V1::BaseController explicitly checks these scopes during token validation.

How does the API handle expired or invalid tokens?

When a request contains an expired, revoked, or malformed token, the Api::V1::BaseController returns an HTTP 401 Unauthorized response with a JSON error body. This behavior is implemented through the overridden doorkeeper_unauthorized_render_options method, which ensures API clients receive machine-readable error messages rather than HTML redirects.

Can API keys be used instead of OAuth tokens?

Yes, the application supports an alternative authentication method via API keys defined in app/models/api_key.rb. When a request does not include a valid OAuth Bearer token, the system can fall back to validating API keys, providing flexibility for service-to-service integrations or legacy client support alongside the primary Doorkeeper OAuth implementation.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →