rrx_api
Advanced tools
| # frozen_string_literal: true | ||
| module RrxApi | ||
| # Controller concern providing authentication plumbing. | ||
| # | ||
| # Include this in your ApplicationController (it is included in RrxApi::Controller | ||
| # by default). Then call +authenticate!+ on any controller that requires auth: | ||
| # | ||
| # class Api::V1::PostsController < ApplicationController | ||
| # authenticate! | ||
| # end | ||
| # | ||
| # Override +authenticated_user+ in your ApplicationController to resolve the | ||
| # current user from the request (token, API key, etc.). The default implementation | ||
| # returns +nil+, which means all requests are unauthenticated unless overridden. | ||
| # | ||
| # In test environments, requests may pass an +X-Test-User-Id+ header instead of | ||
| # a real token. Override +test_user+ if your User model has a different lookup. | ||
| module Authenticatable | ||
| extend ActiveSupport::Concern | ||
| AUTH_HEADER = 'Authorization' | ||
| BEARER_TOKEN = 'Bearer' | ||
| included do | ||
| rescue_from RrxApi::Auth::TokenExpiredError do | ||
| render json: { error: 'token_expired' }, status: :unauthorized | ||
| end | ||
| end | ||
| class_methods do | ||
| # Prepend a before_action that calls +authenticate_user!+. | ||
| # @param only [Array<Symbol>, nil] | ||
| # @param except [Array<Symbol>, nil] | ||
| def authenticate!(only: nil, except: nil) | ||
| prepend_before_action :authenticate_user!, only: only, except: except | ||
| end | ||
| end | ||
| # Returns the authenticated user or +nil+. | ||
| # In test env the +X-Test-User-Id+ header short-circuits real auth. | ||
| def current_user | ||
| @current_user ||= (Rails.env.test? ? test_user : nil) || authenticated_user | ||
| end | ||
| private | ||
| # Render 401 unless current_user is present. | ||
| def authenticate_user! | ||
| unauthorized! unless current_user | ||
| end | ||
| # Render a standardised 401 response. | ||
| # @param message [String] | ||
| def unauthorized!(message: 'Not authenticated') | ||
| render json: { error: message }, status: :unauthorized | ||
| end | ||
| # Resolve the current user from the request. Override this in your | ||
| # ApplicationController — e.g. look up by API key or bearer token. | ||
| # @return [Object, nil] | ||
| def authenticated_user | ||
| nil | ||
| end | ||
| # Yields the bearer token string if an +Authorization: Bearer <token>+ header is present. | ||
| def with_bearer_token | ||
| parts = request.headers[AUTH_HEADER]&.split(' ', 2) | ||
| yield parts[1] if parts&.first == BEARER_TOKEN | ||
| end | ||
| # Returns a user identified by the +X-Test-User-Id+ request header. | ||
| # Only active in the test environment. Override if your lookup differs. | ||
| # @return [Object, nil] | ||
| def test_user | ||
| nil | ||
| end | ||
| end | ||
| end |
| # frozen_string_literal: true | ||
| module ArelQuery | ||
| extend ActiveSupport::Concern | ||
| module Cast | ||
| def self.included(mod) | ||
| mod.class_eval do | ||
| alias_method :create_cast, :cast if method_defined?(:cast) | ||
| def cast(type) | ||
| ::Arel::Nodes::NamedFunction.new "CAST", [self.as(type)] | ||
| end | ||
| end | ||
| end | ||
| end | ||
| class WithHelper | ||
| attr_reader :name | ||
| def initialize(name, relation) | ||
| raise ArgumentError, 'Name must be a Symbol or String' unless name.is_a?(Symbol) || name.is_a?(String) | ||
| raise ArgumentError, 'Relation must be an Arel node or select manager' unless relation.is_a?(Arel::Nodes::Node) || relation.is_a?(Arel::SelectManager) | ||
| @name = name | ||
| @relation = relation | ||
| end | ||
| def [](column) | ||
| table[column] | ||
| end | ||
| def table | ||
| @table ||= Arel::Table.new(@name) | ||
| end | ||
| def to_cte | ||
| Arel::Nodes::Cte.new(@name, @relation) | ||
| end | ||
| end | ||
| class ArelHelper | ||
| attr_reader :model | ||
| delegate :star, :sql, to: Arel | ||
| delegate :project, :where, :from, :join, :outer_join, :group, :order, :alias, to: :@table | ||
| delegate :with, to: :from | ||
| def initialize(model) | ||
| @model = model | ||
| @table = model.arel_table if model.respond_to?(:arel_table) | ||
| end | ||
| def table(name = nil) | ||
| if name | ||
| Arel::Table.new(name) | ||
| elsif @table | ||
| @table | ||
| else | ||
| raise ArgumentError, 'No table available. Provide a table name.' | ||
| end | ||
| end | ||
| alias t table | ||
| def literal(value) | ||
| Arel::Nodes::SqlLiteral.new(value.to_s) | ||
| end | ||
| alias l literal | ||
| alias lit literal | ||
| def string(value) | ||
| literal "'#{value}'" | ||
| end | ||
| alias s string | ||
| alias str string | ||
| # @return [Arel::SelectManager] | ||
| def select(...) | ||
| Arel::SelectManager.new(...) | ||
| end | ||
| def as(what, alias_name) | ||
| raise ArgumentError, 'Alias name must be a Symbol or String' unless alias_name.is_a?(Symbol) || alias_name.is_a?(String) | ||
| Arel::Nodes::As.new(what, literal(alias_name)) | ||
| end | ||
| def for_with(name, relation) | ||
| WithHelper.new(name, relation) | ||
| end | ||
| def results(query) | ||
| connection.select_all(query) | ||
| end | ||
| def rows(query) | ||
| results(query).to_a | ||
| end | ||
| def connection | ||
| ActiveRecord::Base.connection | ||
| end | ||
| def respond_to_missing?(_method_name, _include_private = false) | ||
| true | ||
| end | ||
| FUNCTION_METHOD_PATTERN = /\A[a-z]+(_[a-z]+)*\z/ | ||
| def method_missing(method_name, *args, &block) | ||
| node_class = "Arel::Nodes::#{method_name.to_s.camelize}".safe_constantize | ||
| if node_class | ||
| self.class.define_method method_name do |*method_args| | ||
| node_class.new(*method_args) | ||
| end | ||
| elsif @table&.respond_to?(method_name) | ||
| return @table.send(method_name, *args) | ||
| elsif method_name.to_s =~ FUNCTION_METHOD_PATTERN | ||
| self.class.define_method method_name do |*method_args| | ||
| Arel::Nodes::NamedFunction.new(method_name.to_s, method_args) | ||
| end | ||
| else | ||
| return super | ||
| end | ||
| send(method_name, *args) | ||
| end | ||
| def column(name) | ||
| @table[name] | ||
| end | ||
| alias col column | ||
| alias c column | ||
| def [](it) | ||
| case it | ||
| when Symbol | ||
| @table[it] | ||
| when String | ||
| string(it) | ||
| else | ||
| literal(it) | ||
| end | ||
| end | ||
| end | ||
| class_methods do | ||
| def aq(*args) | ||
| @arel_helper ||= ArelHelper.new(self) | ||
| args.empty? ? @arel_helper : @arel_helper.sql(*args) | ||
| end | ||
| end | ||
| included do | ||
| def aq(...) | ||
| self.class.aq(...) | ||
| end | ||
| end | ||
| end | ||
| ActiveSupport.on_load :active_record do | ||
| class ::Arel::SelectManager | ||
| def table | ||
| @ast.cores[0].source.left | ||
| end | ||
| def join_to(other, from: :id, to: :id, on: nil, type: :inner) | ||
| on ||= table[from].eq(other[to]) | ||
| join_node_class = case type | ||
| when :inner then Arel::Nodes::InnerJoin | ||
| when :left, :outer then Arel::Nodes::OuterJoin | ||
| when :right then Arel::Nodes::RightOuterJoin | ||
| when :full then Arel::Nodes::FullOuterJoin | ||
| else raise ArgumentError, "Unknown join type: #{type.inspect}" | ||
| end | ||
| other = other.table if other.respond_to?(:table) | ||
| join(other, join_node_class).on(on) | ||
| end | ||
| end | ||
| ::Arel::Nodes::Node.include(ArelQuery::Cast) | ||
| ::Arel::Attributes::Attribute.include(ArelQuery::Cast) | ||
| end |
| # frozen_string_literal: true | ||
| module RrxApi | ||
| module Auth | ||
| class TokenExpiredError < StandardError | ||
| def initialize | ||
| super('User token expired') | ||
| end | ||
| end | ||
| # Abstract authentication provider base class. | ||
| # Subclass and override {#id_from_token} (and optionally {#user_info}) to implement a provider. | ||
| class Base | ||
| # Verify a bearer token and return the provider user ID. | ||
| # @param _token [String] | ||
| # @return [String] | ||
| def id_from_token(_token) | ||
| raise NotImplementedError, "#{self.class}#id_from_token is not implemented" | ||
| end | ||
| # Fetch user info for a given provider UID (optional). | ||
| # @param _uid [String] | ||
| # @return [Object, nil] | ||
| def user_info(_uid) | ||
| nil | ||
| end | ||
| end | ||
| end | ||
| end |
| # frozen_string_literal: true | ||
| require_relative 'base' | ||
| module RrxApi | ||
| module Auth | ||
| # Firebase authentication provider. | ||
| # | ||
| # Requires the +firebase-admin-sdk+ gem. It is intentionally not listed as a | ||
| # dependency of rrx_api — add it to your application's Gemfile when you want | ||
| # Firebase auth: | ||
| # | ||
| # gem 'firebase-admin-sdk' | ||
| # | ||
| # Configuration is read from +RrxConfig.firebase+ (project_id, client_email, | ||
| # private_key, etc.). | ||
| class Firebase < Base | ||
| def id_from_token(id_token) | ||
| require_firebase! | ||
| verified = auth.verify_id_token(id_token) | ||
| verified['sub'] | ||
| rescue LoadError | ||
| raise | ||
| rescue => e | ||
| # Lazily match the expired-token error so this file can be parsed before | ||
| # the firebase-admin-sdk gem is loaded. | ||
| raise TokenExpiredError if e.class.name == 'Firebase::Admin::Auth::ExpiredTokenError' | ||
| raise | ||
| end | ||
| def user_info(uid) | ||
| require_firebase! | ||
| auth.get_user(uid) | ||
| end | ||
| private | ||
| def require_firebase! | ||
| require 'firebase-admin-sdk' | ||
| rescue LoadError | ||
| raise LoadError, | ||
| "firebase-admin-sdk is required for Firebase authentication. " \ | ||
| "Add `gem 'firebase-admin-sdk'` to your Gemfile." | ||
| end | ||
| def credentials | ||
| @credentials ||= ::Firebase::Admin::Credentials.from_json(config.to_json) | ||
| end | ||
| def app | ||
| @app ||= begin | ||
| app_config = ::Firebase::Admin::Config.new( | ||
| project_id: config.project_id, | ||
| service_account_id: config.client_email | ||
| ) | ||
| ::Firebase::Admin::App.new(credentials:, config: app_config) | ||
| end | ||
| end | ||
| def auth | ||
| @auth ||= ::Firebase::Admin::Auth::Client.new(app) | ||
| end | ||
| def config | ||
| RrxConfig.firebase | ||
| end | ||
| end | ||
| end | ||
| end |
@@ -6,2 +6,3 @@ # frozen_string_literal: true | ||
| include AbstractController::Helpers | ||
| include RrxApi::Authenticatable | ||
@@ -8,0 +9,0 @@ abstract! |
@@ -7,2 +7,4 @@ # frozen_string_literal: true | ||
| include ArelQuery | ||
| before_create :set_new_id | ||
@@ -9,0 +11,0 @@ |
+1
-1
@@ -99,3 +99,3 @@ PATH | ||
| rails-html-sanitizer (~> 1.6) | ||
| active_record_query_trace (1.8.2) | ||
| active_record_query_trace (1.9) | ||
| activerecord (>= 6.0.0) | ||
@@ -102,0 +102,0 @@ activejob (8.0.2) |
@@ -10,2 +10,4 @@ require 'rails' | ||
| require 'actionpack/action_caching' | ||
| require_relative 'auth/base' | ||
| require_relative 'auth/firebase' | ||
@@ -17,2 +19,23 @@ module RrxApi | ||
| config.cors_origins = [] | ||
| # Checks whether +source+ matches any of the configured CORS origins. | ||
| # Each entry in +cors_origins+ may be: | ||
| # - a String for exact match (e.g. "https://app.example.com") | ||
| # - a String with a leading wildcard (e.g. "*.example.com" matches "https://foo.example.com") | ||
| # - a Regexp (e.g. /\.example\.com\z/) | ||
| def self.cors_origin_allowed?(source, origins) | ||
| origins.any? do |origin| | ||
| case origin | ||
| when Regexp then origin.match?(source) | ||
| when String | ||
| if origin.start_with?('*.') | ||
| # Wildcard subdomain: *.example.com matches any scheme+subdomain of example.com | ||
| suffix = origin[1..] # => ".example.com" | ||
| source.end_with?(suffix) | ||
| else | ||
| source == origin | ||
| end | ||
| end | ||
| end | ||
| end | ||
| config.healthcheck = nil | ||
@@ -45,3 +68,3 @@ config.healthcheck_route = 'healthcheck' | ||
| else | ||
| app.config.cors_origins.include?(source) | ||
| Engine.cors_origin_allowed?(source, app.config.cors_origins) | ||
| end | ||
@@ -48,0 +71,0 @@ end |
| # frozen_string_literal: true | ||
| module RrxApi | ||
| VERSION = '8.0.2' | ||
| VERSION = '8.0.3' | ||
| DEPENDENCY_VERSION = "~> #{VERSION}" | ||
| RAILS_VERSION = DEPENDENCY_VERSION | ||
| end |