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
6 changes: 4 additions & 2 deletions server/app/controllers/api/v1/auth_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ class AuthController < ApplicationController
skip_after_action :verify_authorized

def login
result = Login.call(params:)
app_context = request.headers["X-App-Context"]
result = Login.call(params:, app_context:)
if result.success?
# Treating the token as a resource in terms of JSON API response
render json: {
Expand All @@ -21,7 +22,8 @@ def login
}
}, status: :ok
else
render_error(message: result.error, status: :unauthorized)
status = result.error&.include?("X-App-Context") ? :bad_request : :unauthorized
render_error(message: result.error, status:)
end
end

Expand Down
1 change: 1 addition & 0 deletions server/app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
class ApplicationController < ActionController::API
include Devise::Controllers::Helpers
include ExceptionHandler
include EmbeddedContextRestriction
include ScriptVault::Tracker
include Pundit::Authorization
before_action :authenticate_user!
Expand Down
94 changes: 94 additions & 0 deletions server/app/controllers/concerns/embedded_context_restriction.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# frozen_string_literal: true

module EmbeddedContextRestriction
extend ActiveSupport::Concern

included do
before_action :restrict_embedded_context_apis
end

private

# Restrict APIs for embedded context tokens
def restrict_embedded_context_apis
return unless embedded_context_token?
return if embedded_context_allowed?

render_error(
message: "This API endpoint is not available for embedded context tokens",
status: :forbidden
)
end

def embedded_context_token?
@embedded_context_token ||= app_context_from_token == "embed"
end

def app_context_from_token
return nil unless user_signed_in?

token = extract_bearer_token
return nil if token.blank?

decode_token_and_extract_app_context(token)
end

def extract_bearer_token
auth_header = request.headers["Authorization"]
return nil unless auth_header&.start_with?("Bearer ")

auth_header.split(" ").last
end

def decode_token_and_extract_app_context(token)
secret = Devise::JWT.config.secret
algorithm = Devise::JWT.config.algorithm || Warden::JWTAuth.config.algorithm
decode_key = Devise::JWT.config.decoding_secret || secret

decoded = JWT.decode(token, decode_key, true, algorithm:)
decoded[0]["app_context"]
rescue JWT::DecodeError, JWT::ExpiredSignature, JWT::VerificationError
nil
end

def embedded_context_allowed?
allowed_endpoints = embedded_context_allowed_endpoints
current_controller = controller_path
current_action = action_name.to_sym

allowed_endpoints.any? do |endpoint|
controller_match = endpoint[:controller]
action_match = endpoint[:action]

controller_matches = controller_match.nil? || current_controller == controller_match.to_s
action_matches = action_match.nil? || current_action == action_match

controller_matches && action_matches
end
end

def embedded_context_allowed_endpoints
[
# UsersController - me action
{ controller: "api/v1/users", action: :me },
# DataAppSessionsController - all APIs
{ controller: "enterprise/api/v1/data_app_sessions", action: nil },
# DataAppsController - specific actions
{ controller: "enterprise/api/v1/data_apps", action: :index },
{ controller: "enterprise/api/v1/data_apps", action: :show },
{ controller: "enterprise/api/v1/data_apps", action: :fetch_data },
{ controller: "enterprise/api/v1/data_apps", action: :fetch_data_stream },
{ controller: "enterprise/api/v1/data_apps", action: :write_data },
# WorkflowsController - specific actions
{ controller: "enterprise/api/v1/agents/workflows", action: :index },
{ controller: "enterprise/api/v1/agents/workflows", action: :show },
{ controller: "enterprise/api/v1/agents/workflows", action: :run },
# MessageFeedbacksController - all APIs
{ controller: "enterprise/api/v1/message_feedbacks", action: nil },
# FeedbacksController - all APIs
{ controller: "enterprise/api/v1/feedbacks", action: nil },
# CustomVisualComponentController - show action
{ controller: "enterprise/api/v1/custom_visual_component", action: :show }
]
end
end
29 changes: 27 additions & 2 deletions server/app/interactors/authentication/login.rb
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
# frozen_string_literal: true

# app/interactors/authentication/login.rb
module Authentication
class Login
include Interactor

def call
validate_app_context
return if context.failure?

begin
user = User.find_by(email: context.params[:email])
rescue StandardError => e
Rails.logger.error("Login Interactor Exception: #{e.message}")
Rails.logger.error("Login failed: #{e.message}")
Utils::ExceptionReporter.report(e)
context.fail!(error: "An error occurred while finding the user.")
return
Expand All @@ -29,6 +31,14 @@ def authenticate(user)

private

def validate_app_context
app_context = context.app_context
return if app_context.blank?
return if app_context == "embed"

context.fail!(error: "Invalid X-App-Context value. Only 'embed' is supported.")
end

def handle_failed_attempt(user)
if user
user.increment_failed_attempts
Expand Down Expand Up @@ -64,12 +74,27 @@ def user_verified_or_verification_disabled?(user)
end

def issue_token_and_update_user(user)
app_context = context.app_context
token, payload = Warden::JWTAuth::UserEncoder.new.call(user, :user, nil)

# If app_context is present and equals 'embed', add it to the token payload
token = add_app_context_to_token(token, app_context) if app_context.present? && app_context == "embed"

user.update!(unique_id: SecureRandom.uuid) if user.unique_id.nil?
user.update!(jti: payload["jti"])
context.token = token
end

def add_app_context_to_token(original_token, app_context)
secret = Devise::JWT.config.secret
algorithm = Devise::JWT.config.algorithm || Warden::JWTAuth.config.algorithm
decode_key = Devise::JWT.config.decoding_secret || secret

decoded_payload = JWT.decode(original_token, decode_key, true, algorithm:)[0]
decoded_payload["app_context"] = app_context
JWT.encode(decoded_payload, secret, algorithm)
end

def handle_account_locked
context.fail!(error: "Account is locked due to multiple login attempts. Please retry after sometime")
end
Expand Down
72 changes: 72 additions & 0 deletions server/spec/controllers/api/v1/auth_controller_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ def response_errors
end

describe "POST #login" do
let(:password) { "Password@123" }
let(:confirmed_user) { create(:user, password:, password_confirmation: password, confirmed_at: Time.current) }

context "with valid parameters" do
it "logs in a user and returns a token" do
user.confirm
Expand All @@ -87,6 +90,75 @@ def response_errors
end
end

context "with X-App-Context header" do
context "when X-App-Context is 'embed'" do
before do
request.headers["X-App-Context"] = "embed"
end

it "logs in a user and returns a token with app_context" do
post :login, params: { email: confirmed_user.email, password: }

expect(response).to have_http_status(:ok)
token = response_data["attributes"]["token"]
expect(token).not_to be_nil

# Verify token includes app_context
secret = Devise::JWT.config.secret
decoded = JWT.decode(token, secret, true, algorithm: "HS256")
expect(decoded[0]["app_context"]).to eq("embed")
end

it "preserves standard JWT claims in the token" do
post :login, params: { email: confirmed_user.email, password: }

token = response_data["attributes"]["token"]
secret = Devise::JWT.config.secret
decoded = JWT.decode(token, secret, true, algorithm: "HS256")
payload = decoded[0]

expect(payload["sub"]).to be_present
expect(payload["scp"]).to be_present
expect(payload["jti"]).to be_present
expect(payload["exp"]).to be_present
end
end

context "when X-App-Context is not provided" do
it "logs in a user and returns a token without app_context" do
post :login, params: { email: confirmed_user.email, password: }

expect(response).to have_http_status(:ok)
token = response_data["attributes"]["token"]
expect(token).not_to be_nil

# Verify token does not include app_context
secret = Devise::JWT.config.secret
decoded = JWT.decode(token, secret, true, algorithm: "HS256")
expect(decoded[0]["app_context"]).to be_nil
end
end

context "when X-App-Context is empty string" do
before do
request.headers["X-App-Context"] = ""
end

it "logs in a user and returns a token without app_context" do
post :login, params: { email: confirmed_user.email, password: }

expect(response).to have_http_status(:ok)
token = response_data["attributes"]["token"]
expect(token).not_to be_nil

# Verify token does not include app_context
secret = Devise::JWT.config.secret
decoded = JWT.decode(token, secret, true, algorithm: "HS256")
expect(decoded[0]["app_context"]).to be_nil
end
end
end

context "with invalid parameters" do
it "does not log in a user and returns an error" do
post :login, params: { email: "wrong", password: "wrong" }
Expand Down
Loading
Loading