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
2 changes: 1 addition & 1 deletion app/controllers/my/access_tokens_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ def destroy

private
def my_access_tokens
Current.identity.access_tokens
Current.identity.access_tokens.personal
end

def access_token_params
Expand Down
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
108 changes: 108 additions & 0 deletions app/controllers/oauth/authorizations_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
class Oauth::AuthorizationsController < Oauth::BaseController
before_action :save_oauth_return_url
before_action :require_authentication

before_action :set_client
before_action :validate_redirect_uri
before_action :validate_response_type
before_action :validate_pkce
before_action :validate_scope
before_action :validate_state

def new
@scope = params[:scope].presence || "read"
@redirect_uri = params[:redirect_uri]
@state = params[:state]
@code_challenge = params[:code_challenge]
end

def create
if params[:error] == "access_denied"
redirect_to error_redirect_uri("access_denied", "User denied the request"), allow_other_host: true
else
code = Oauth::AuthorizationCode.generate \
client_id: @client.client_id,
identity_id: Current.identity.id,
code_challenge: params[:code_challenge],
redirect_uri: params[:redirect_uri],
scope: params[:scope].presence || "read"

redirect_to success_redirect_uri(code), allow_other_host: true
end
end

private
def save_oauth_return_url
session[:return_to_after_authenticating] = request.url if request.get? && !authenticated?
end

def set_client
@client = Oauth::Client.find_by(client_id: params[:client_id])
oauth_error("invalid_request", "Unknown client") unless @client
end

def validate_redirect_uri
unless performed? || @client.allows_redirect?(params[:redirect_uri])
redirect_with_error "invalid_request", "Invalid redirect_uri"
end
end

def validate_response_type
unless performed? || params[:response_type] == "code"
redirect_with_error "unsupported_response_type", "Only 'code' response_type is supported"
end
end

def validate_pkce
unless performed? || params[:code_challenge].present?
redirect_with_error "invalid_request", "code_challenge is required"
end

unless performed? || params[:code_challenge_method] == "S256"
redirect_with_error "invalid_request", "code_challenge_method must be S256"
end
end

def validate_scope
unless performed? || @client.allows_scope?(params[:scope].presence || "read")
redirect_with_error "invalid_scope", "Requested scope is not allowed"
end
end

def validate_state
unless performed? || params[:state].present?
redirect_with_error "invalid_request", "state is required"
end
end

def redirect_with_error(error, description)
if params[:redirect_uri].present? && @client&.allows_redirect?(params[:redirect_uri])
redirect_to error_redirect_uri(error, description), allow_other_host: true
else
@error = error
@error_description = description
render :error, status: :bad_request
end
end

def success_redirect_uri(code)
build_redirect_uri params[:redirect_uri],
code: code,
state: params[:state].presence
end

def error_redirect_uri(error, description)
build_redirect_uri params[:redirect_uri],
error: error,
error_description: description,
state: params[:state].presence
end

def build_redirect_uri(base, **query_params)
uri = URI.parse(base)
query = URI.decode_www_form(uri.query || "")
query_params.compact.each { |k, v| query << [ k.to_s, v ] }
uri.query = URI.encode_www_form(query)
uri.to_s
end
end
12 changes: 12 additions & 0 deletions app/controllers/oauth/base_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
class Oauth::BaseController < ApplicationController
disallow_account_scope

private
def oauth_error(error, description = nil, status: :bad_request)
render json: { error: error, error_description: description }.compact, status: status
end

def oauth_rate_limit_exceeded
oauth_error "slow_down", "Too many requests", status: :too_many_requests
end
end
75 changes: 75 additions & 0 deletions app/controllers/oauth/clients_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
class Oauth::ClientsController < Oauth::BaseController
allow_unauthenticated_access

rate_limit to: 10, within: 1.minute, only: :create, with: :oauth_rate_limit_exceeded

before_action :validate_redirect_uris
before_action :validate_loopback_uris
before_action :validate_auth_method

def create
client = Oauth::Client.create! \
name: params[:client_name] || "MCP Client",
redirect_uris: Array(params[:redirect_uris]),
scopes: validated_scopes,
dynamically_registered: true

render json: dynamic_client_registration_response(client), status: :created
rescue ActiveRecord::RecordInvalid => e
oauth_error "invalid_client_metadata", e.message
end

private
def validate_redirect_uris
unless performed? || params[:redirect_uris].present?
oauth_error "invalid_client_metadata", "redirect_uris is required"
end
end

def validate_loopback_uris
unless performed? || all_loopback_uris?(params[:redirect_uris])
oauth_error "invalid_redirect_uri", "Only loopback redirect URIs are allowed for dynamic registration"
end
end

def validate_auth_method
unless performed? || params[:token_endpoint_auth_method].blank? || params[:token_endpoint_auth_method] == "none"
oauth_error "invalid_client_metadata", "Only 'none' token_endpoint_auth_method is supported"
end
end

def all_loopback_uris?(uris)
uris.is_a?(Array) &&
uris.all? { |uri| uri.is_a?(String) && valid_loopback_uri?(uri) }
end

def valid_loopback_uri?(uri)
parsed = URI.parse(uri)
parsed.scheme == "http" &&
Oauth::LOOPBACK_HOSTS.include?(parsed.host) &&
parsed.fragment.nil?
rescue URI::InvalidURIError
false
end

def validated_scopes
requested = case params[:scope]
when String then params[:scope].split
when Array then params[:scope].select { |s| s.is_a?(String) }
else []
end
requested.select { |s| s.presence_in %w[ read write ] }.presence || %w[ read ]
end

def dynamic_client_registration_response(client)
{
client_id: client.client_id,
client_name: client.name,
redirect_uris: client.redirect_uris,
token_endpoint_auth_method: "none",
grant_types: %w[ authorization_code ],
response_types: %w[ code ],
scope: client.scopes.join(" ")
}
end
end
18 changes: 18 additions & 0 deletions app/controllers/oauth/metadata_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
class Oauth::MetadataController < Oauth::BaseController
allow_unauthenticated_access

def show
render json: {
issuer: root_url(script_name: nil),
authorization_endpoint: new_oauth_authorization_url,
token_endpoint: oauth_token_url,
registration_endpoint: oauth_clients_url,
revocation_endpoint: oauth_revocation_url,
response_types_supported: %w[ code ],
grant_types_supported: %w[ authorization_code ],
token_endpoint_auth_methods_supported: %w[ none ],
code_challenge_methods_supported: %w[ S256 ],
scopes_supported: %w[ read write ]
}
end
end
12 changes: 12 additions & 0 deletions app/controllers/oauth/protected_resource_metadata_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
class Oauth::ProtectedResourceMetadataController < Oauth::BaseController
allow_unauthenticated_access

def show
render json: {
resource: root_url(script_name: nil),
authorization_servers: [ root_url(script_name: nil) ],
bearer_methods_supported: %w[ header ],
scopes_supported: %w[ read write ]
}
end
end
16 changes: 16 additions & 0 deletions app/controllers/oauth/revocations_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
class Oauth::RevocationsController < Oauth::BaseController
allow_unauthenticated_access

before_action :set_access_token

def create
@access_token&.destroy

head :ok # Don't behave as oracle, per RFC 7009
end

private
def set_access_token
@access_token = Identity::AccessToken.find_by(token: params.require(:token))
end
end
61 changes: 61 additions & 0 deletions app/controllers/oauth/tokens_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
class Oauth::TokensController < Oauth::BaseController
allow_unauthenticated_access

rate_limit to: 20, within: 1.minute, only: :create, with: :oauth_rate_limit_exceeded

before_action :validate_grant_type
before_action :set_auth_code
before_action :set_client
before_action :validate_pkce
before_action :validate_redirect_uri
before_action :set_identity

def create
granted = @auth_code.scope.to_s.split
permission = granted.include?("write") ? "write" : "read"
access_token = @identity.access_tokens.create! oauth_client: @client, permission: permission

render json: {
access_token: access_token.token,
token_type: "Bearer",
scope: granted.join(" ")
}
end

private
def validate_grant_type
unless params[:grant_type] == "authorization_code"
oauth_error "unsupported_grant_type", "Only authorization_code grant is supported"
end
end

def set_auth_code
unless @auth_code = Oauth::AuthorizationCode.parse(params[:code])
oauth_error "invalid_grant", "Invalid or expired authorization code"
end
end

def set_client
unless @client = Oauth::Client.find_by(client_id: @auth_code.client_id)
oauth_error "invalid_grant", "Unknown client"
end
end

def validate_pkce
unless Oauth::AuthorizationCode.valid_pkce?(@auth_code, params[:code_verifier])
oauth_error "invalid_grant", "PKCE verification failed"
end
end

def validate_redirect_uri
unless @auth_code.redirect_uri == params[:redirect_uri]
oauth_error "invalid_grant", "redirect_uri mismatch"
end
end

def set_identity
unless @identity = Identity.find_by(id: @auth_code.identity_id)
oauth_error "invalid_grant", "Identity not found"
end
end
end
4 changes: 4 additions & 0 deletions app/models/identity/access_token.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
class Identity::AccessToken < ApplicationRecord
belongs_to :identity
belongs_to :oauth_client, class_name: "Oauth::Client", optional: true

scope :personal, -> { where oauth_client_id: nil }
scope :oauth, -> { where.not oauth_client_id: nil }

has_secure_token
enum :permission, %w[ read write ].index_by(&:itself), default: :read
Expand Down
7 changes: 7 additions & 0 deletions app/models/oauth.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
module Oauth
LOOPBACK_HOSTS = %w[ 127.0.0.1 localhost ::1 [::1] ]

def self.table_name_prefix
"oauth_"
end
end
Loading