Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
199 changes: 199 additions & 0 deletions OAUTH_NEXT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
# Fizzy OAuth 2.1 + MCP

OAuth for Fizzy. One table, one column, a handful of small controllers. MCP support included.

---

## The Insight

Fizzy's `Identity::AccessToken` is already perfect:

```ruby
class Identity::AccessToken < ApplicationRecord
belongs_to :identity
has_secure_token
enum :permission, %w[ read write ].index_by(&:itself), default: :read

def allows?(method)
method.in?(%w[ GET HEAD ]) || write?
end
end
```

**10 lines.** Don't replace it. Extend it.

---

## What We Add

| Addition | Type | Purpose |
|----------|------|---------|
| `oauth_clients` | table | Client registry (MCP DCR, first-party) |
| `oauth_client_id` | column | Links access tokens to OAuth clients |

That's it. One table. One column.

- **PATs stay PATs** — tokens with `oauth_client_id = nil`
- **OAuth tokens are PATs with a client** — `oauth_client_id` is set
- **Bearer auth works unchanged** — the `Authentication` concern already uses `Identity::AccessToken`

---

## Authorization Codes: Stateless

No table. Rails primitives only.

```ruby
module Oauth::AuthorizationCode
Details = Data.define(:client_id, :identity_id, :code_challenge, :redirect_uri, :scope)

class << self
def generate(client_id:, identity_id:, code_challenge:, redirect_uri:, scope:)
encryptor.encrypt_and_sign(
{ c: client_id, i: identity_id, h: code_challenge, r: redirect_uri, s: scope },
expires_in: 60.seconds
)
end

def parse(code)
return nil if code.blank?
data = encryptor.decrypt_and_verify(code)
return nil if data.nil?
Details.new(
client_id: data["c"],
identity_id: data["i"],
code_challenge: data["h"],
redirect_uri: data["r"],
scope: data["s"]
)
rescue ActiveSupport::MessageEncryptor::InvalidMessage, ActiveSupport::MessageVerifier::InvalidSignature
nil
end

def valid_pkce?(code_data, code_verifier)
return false if code_data.nil? || code_verifier.blank?
expected = Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false)
ActiveSupport::SecurityUtils.secure_compare(expected, code_data.code_challenge)
end

private
def encryptor
@encryptor ||= ActiveSupport::MessageEncryptor.new(
Rails.application.key_generator.generate_key("oauth/authorization_codes", 32)
)
end
end
end
```

- 60-second TTL + PKCE-bound
- No database = no cleanup job

---

## Grants: Implicit

No `oauth_authorizations` table.

A "grant" is just "a token exists for this client + identity." Revocation = delete tokens.

"Connected Apps" UI at `/my/connected_apps`:

```ruby
# List apps
current_identity.access_tokens.where.not(oauth_client: nil).includes(:oauth_client).group_by(&:oauth_client)

# Disconnect an app (revoke all tokens for that client)
current_identity.access_tokens.where(oauth_client: client).destroy_all
```

---

## Scopes

OAuth scopes are space-delimited (e.g., `"read write"`). We map to `Identity::AccessToken#permission`:

- If `"write"` is in the scope list → `permission: "write"`
- Otherwise → `permission: "read"`

The token response returns the granted scopes as a space-delimited string.

---

## Token Lifetime

Access tokens **do not expire**. This matches PAT behavior and keeps the implementation simple:

- No refresh tokens needed
- No background jobs to clean up expired tokens
- Revocation is explicit: via `/oauth/revocation` endpoint or "Connected Apps" UI

If expiration is needed later, add an `expires_at` column to `identity_access_tokens` and return `expires_in` in the token response. The revocation endpoint already handles cleanup.

---

## Routes

```ruby
get "/.well-known/oauth-authorization-server", to: "oauth/metadata#show"
get "/.well-known/oauth-protected-resource", to: "oauth/protected_resource_metadata#show"

namespace :oauth do
resources :clients, only: :create # POST /oauth/clients (DCR)
resource :authorization, only: %i[ new create ] # GET/POST /oauth/authorization
resource :token, only: :create # POST /oauth/token
resource :revocation, only: :create # POST /oauth/revocation
end
```

Two well-known endpoints for discovery. Singular resources for OAuth protocol endpoints. Plural for the client registry.

---

## Redirect URI Matching

Per RFC 8252, loopback clients get port flexibility:

- Registered: `http://127.0.0.1:8888/callback`
- Allowed: `http://127.0.0.1:9999/callback` (different port, same path)
- Allowed: `http://localhost:7777/callback` (different loopback host)

Non-loopback clients require exact string match.

DCR clients are restricted to loopback URIs only (http, not https).

---

## Security

- **Short-lived, PKCE-bound codes**: 60 seconds, S256 only
- **Loopback-only DCR**: MCP clients must use `127.0.0.1`, `localhost`, or `[::1]`
- **PKCE required**: no "plain" method
- **Port-flexible loopback matching**: per RFC 8252
- **Rate limited**: DCR (10/min), token exchange (20/min)

---

## Standards

- RFC 6749 (OAuth 2.0)
- RFC 6750 (Bearer tokens)
- RFC 7636 (PKCE, S256 only)
- RFC 7591 (DCR subset)
- RFC 8414 (AS Discovery)
- RFC 8252 (Loopback redirects)
- RFC 9728 (Protected Resource Metadata)

---

## Why This Over "Proper OAuth"

| "Proper" OAuth | This |
|----------------|------|
| 4 tables | 1 table + 1 column |
| Migrate PATs | PATs stay |
| Stored auth codes | Stateless |
| Explicit grant table | Implicit |
| ~600 lines | ~350 lines |

Both are correct. This one is half the code.
118 changes: 118 additions & 0 deletions app/controllers/mcp_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
class McpController < ApplicationController
include Mcp::Protocol

disallow_account_scope
allow_unauthenticated_access
before_action :require_bearer_token, only: :create

def discovery
render json: {
name: "Fizzy",
description: "Kanban workflow management",
mcp_version: Mcp::PROTOCOL_VERSION,
capabilities: { tools: {}, resources: {} },
oauth: { server: oauth_authorization_server_url }
}
end

def create
case jsonrpc_method
when "initialize" then handle_initialize
when "tools/list" then handle_tools_list
when "tools/call" then handle_tools_call
when "resources/list" then handle_resources_list
when "resources/read" then handle_resources_read
else
jsonrpc_error :method_not_found
end
rescue ActiveRecord::RecordNotFound => e
jsonrpc_error :invalid_params, "Record not found: #{e.message}"
rescue ActiveRecord::RecordInvalid => e
jsonrpc_error :invalid_params, e.message
rescue ArgumentError => e
jsonrpc_error :invalid_params, e.message
end

private
def handle_initialize
client_version = jsonrpc_params[:protocolVersion]

negotiated_version = if Mcp::SUPPORTED_VERSIONS.include?(client_version)
client_version
else
Mcp::PROTOCOL_VERSION
end

jsonrpc_response({
protocolVersion: negotiated_version,
capabilities: { tools: {}, resources: {} },
serverInfo: { name: "Fizzy", title: "Fizzy Kanban", version: "1.0.0" }
})
end

def handle_tools_list
jsonrpc_response Mcp::Tools.list
end

def handle_tools_call
name = jsonrpc_params[:name]
arguments = jsonrpc_params[:arguments]&.permit!&.to_h || {}

result = Mcp::Tools.call(name, arguments, identity: Current.identity)
jsonrpc_response result
end

def handle_resources_list
jsonrpc_response Mcp::Resources.list
end

def handle_resources_read
uri = jsonrpc_params[:uri]
result = Mcp::Resources.read(uri, identity: Current.identity)
jsonrpc_response result
end

def require_bearer_token
if token = request.authorization.to_s[/\ABearer (.+)\z/i, 1]
if access_token = Identity::AccessToken.find_by(token: token)
if access_token.allows_operation?(mcp_operation)
Current.identity = access_token.identity
return
else
response.headers["WWW-Authenticate"] = %(Bearer error="insufficient_scope")
head :forbidden and return
end
end
end

response.headers["WWW-Authenticate"] = %(Bearer resource_metadata="#{oauth_protected_resource_url}")
head :unauthorized
end

def mcp_operation
case jsonrpc_method
when "tools/call" then :write
else :read
end
end

def oauth_protected_resource_url
Rails.application.routes.url_helpers.url_for \
controller: "oauth/protected_resource_metadata",
action: "show",
only_path: false,
host: request.host,
port: request.port,
protocol: request.protocol
end

def oauth_authorization_server_url
Rails.application.routes.url_helpers.url_for \
controller: "oauth/metadata",
action: "show",
only_path: false,
host: request.host,
port: request.port,
protocol: request.protocol
end
end
28 changes: 28 additions & 0 deletions app/controllers/my/connected_apps_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
class My::ConnectedAppsController < ApplicationController
before_action :set_connected_apps, only: :index
before_action :set_oauth_client, only: :destroy

def index
end

def destroy
@tokens.destroy_all

redirect_to my_connected_apps_path, notice: "#{@client.name} has been disconnected"
end

private
def set_connected_apps
tokens = oauth_tokens.includes(:oauth_client).order(:created_at)
@connected_apps = tokens.group_by(&:oauth_client).sort_by { |client, _| client.name.downcase }
end

def set_oauth_client
@tokens = oauth_tokens.where(oauth_client_id: params.require(:id))
@client = @tokens.first&.oauth_client or raise ActiveRecord::RecordNotFound
end

def oauth_tokens
Current.identity.access_tokens.oauth
end
end
16 changes: 15 additions & 1 deletion app/controllers/oauth/authorizations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,12 @@ class Oauth::AuthorizationsController < Oauth::BaseController
before_action :validate_pkce
before_action :validate_scope
before_action :validate_state
before_action :allow_oauth_redirect_in_csp

def new
@scope = params[:scope].presence || "read"
# Normalize scope: if "write" is requested, use "write" (which implies read)
requested_scopes = params[:scope].to_s.split
@scope = requested_scopes.include?("write") ? "write" : "read"
@redirect_uri = params[:redirect_uri]
@state = params[:state]
@code_challenge = params[:code_challenge]
Expand Down Expand Up @@ -105,4 +108,15 @@ def build_redirect_uri(base, **query_params)
uri.query = URI.encode_www_form(query)
uri.to_s
end

# Safari blocks form submission redirects to URLs not in form-action CSP.
# Add the validated redirect_uri to allow the OAuth callback redirect.
def allow_oauth_redirect_in_csp
return unless params[:redirect_uri].present?

redirect_origin = URI.parse(params[:redirect_uri]).then { "#{_1.scheme}://#{_1.host}:#{_1.port}" }
request.content_security_policy.form_action :self, redirect_origin
rescue URI::InvalidURIError
# Invalid URI will be caught by validate_redirect_uri
end
end
Loading