Skip to content
Merged
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
20 changes: 13 additions & 7 deletions lib/kinde_sdk.rb
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
require "logger"
require "action_controller/railtie"

require "kinde_sdk/version"
require "kinde_sdk/configuration"
require "kinde_sdk/client/feature_flags"
require "kinde_sdk/client/permissions"
require "kinde_sdk/controllers/auth_controller"
require "kinde_sdk/client"

require 'securerandom'
require 'oauth2'
require 'pkce_challenge'
Expand Down Expand Up @@ -40,7 +37,7 @@ def auth_url(
params = {
redirect_uri: redirect_uri,
state: SecureRandom.hex,
scope: @config.scope
scope: @config.scope,
}.merge(**kwargs)
return { url: @config.oauth_client(
client_id: client_id,
Expand Down Expand Up @@ -78,12 +75,21 @@ def fetch_tokens(
headers: { 'User-Agent' => "Kinde-SDK: Ruby/#{KindeSdk::VERSION}" }
}
params[:code_verifier] = code_verifier if code_verifier
@config.oauth_client(
token = @config.oauth_client(
client_id: client_id,
client_secret: client_secret,
domain: domain,
authorize_url: "#{domain}/oauth2/auth",
token_url: "#{domain}/oauth2/token").auth_code.get_token(code.to_s, params).to_hash
token_url: "#{domain}/oauth2/token").auth_code.get_token(code.to_s, params)

{
access_token: token.token, # The access token
id_token: token.params['id_token'], # The ID token from params
expires_at: token.expires_at, # Optional: expiration time
refresh_token: token.refresh_token, # Optional: if present
scope: token.params['scope'], # The scopes requested
token_type: token.params['token_type'] # The token type
}.compact
end

# tokens_hash #=>
Expand All @@ -96,7 +102,7 @@ def fetch_tokens(
#
# @return [KindeSdk::Client]
def client(tokens_hash)
sdk_api_client = api_client(tokens_hash["access_token"])
sdk_api_client = api_client(tokens_hash[:access_token] || tokens_hash["access_token"])
KindeSdk::Client.new(sdk_api_client, tokens_hash, @config.auto_refresh_tokens)
end

Expand Down
90 changes: 71 additions & 19 deletions lib/kinde_sdk/controllers/auth_controller.rb
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
require 'action_controller'
require 'uri'
require 'cgi'
require 'net/http'
require 'json'
require 'jwt'

module KindeSdk
class AuthController < ActionController::Base
# Add before_action to validate nonce in callback
before_action :validate_nonce, only: :callback
before_action :validate_state, only: :callback

def auth
# Generate a secure random nonce
Expand All @@ -27,20 +32,33 @@ def callback
tokens = KindeSdk.fetch_tokens(
params[:code],
code_verifier: KindeSdk.config.pkce_enabled ? session[:code_verifier] : nil
).slice(:access_token, :refresh_token, :expires_at)
).slice(:access_token, :id_token, :refresh_token, :expires_at)

session[:kinde_auth] = tokens

# Validate nonce in ID token
id_token = tokens[:id_token]
issuer = KindeSdk.config.domain
client_id = KindeSdk.config.client_id
original_nonce = session[:auth_nonce]
unless validate_nonce(id_token, original_nonce, issuer, client_id)
Rails.logger.warn("Nonce validation failed")
redirect_to "/", alert: "Invalid authentication nonce"
return
end

# Store tokens and user in session
session[:kinde_auth] = OAuth2::AccessToken.from_hash(KindeSdk.config.oauth_client, tokens).to_hash
.slice(:access_token, :refresh_token, :expires_at)
session[:kinde_user] = KindeSdk.client(tokens).oauth.get_user.to_hash

# Clear nonce and state after successful authentication
session.delete(:auth_nonce)
session.delete(:auth_state)
session.delete(:code_verifier)

redirect_to root_path
redirect_to "/"
rescue StandardError => e
Rails.logger.error("Authentication callback failed: #{e.message}")
redirect_to root_path, alert: "Authentication failed"
redirect_to "/", alert: "Authentication failed"
end

def client_credentials_auth
Expand All @@ -63,38 +81,72 @@ def logout
end

def logout_callback
Rails.logger.info("Logout callback successfully received")
reset_session
redirect_to root_path
redirect_to "/"
end

private

def validate_nonce
def validate_state
# Check if nonce and state exist in session
unless session[:auth_nonce] && session[:auth_state]
Rails.logger.warn("Missing session state or nonce")
redirect_to root_path, alert: "Invalid authentication state"
redirect_to "/", alert: "Invalid authentication state"
return
end

# Verify nonce returned matches stored nonce
returned_nonce = params[:nonce]
stored_nonce = session[:auth_nonce]
returned_state = params[:state]
stored_state = session[:auth_state]
stored_url = stored_state["redirect_url"]

unless returned_nonce.present? && returned_nonce == stored_nonce
Rails.logger.warn("Nonce validation failed: returned=#{returned_nonce}, stored=#{stored_nonce}")
redirect_to root_path, alert: "Invalid authentication nonce"
# Extract the state from the stored redirect_url
parsed_url = URI.parse(stored_url)
query_params = CGI.parse(parsed_url.query || "")
stored_state_from_url = query_params["state"]&.first

# Verify returned state matches the state extracted from the redirect_url
unless returned_state.present? && returned_state == stored_state_from_url
Rails.logger.warn("State validation failed: returned=#{returned_state}, expected=#{stored_state_from_url}")
redirect_to "/", alert: "Invalid authentication state"
return
end

# Optional: Check state age (e.g., expires after 15 minutes)
state = session[:auth_state]
if Time.current.to_i - state[:requested_at] > 900
if Time.current.to_i - stored_state["requested_at"] > 900
Rails.logger.warn("Authentication state expired")
redirect_to root_path, alert: "Authentication session expired"
redirect_to "/", alert: "Authentication session expired"
return
end
end


def validate_nonce(id_token, original_nonce, issuer, client_id)
jwks_uri = URI.parse("#{issuer}/.well-known/jwks.json")
jwks_response = Net::HTTP.get(jwks_uri)
jwks = JSON.parse(jwks_response)

decoded_token = JWT.decode(
id_token,
nil,
true,
algorithm: 'RS256',
iss: issuer,
aud: client_id,
verify_iss: true,
verify_aud: true,
jwks: { keys: jwks['keys'] }
)

payload = decoded_token[0]
nonce_from_token = payload['nonce']

nonce_from_token == original_nonce
rescue StandardError => e
Rails.logger.error("Nonce validation error: #{e.message}")
false
end


end
end
6 changes: 3 additions & 3 deletions spec/kinde_sdk_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -74,20 +74,20 @@
)
.to_return(
status: 200,
body: { "access_token": "eyJ", "expires_in": 86399, "scope": "", "token_type": "bearer" }.to_json,
body: { "access_token": "eyJ", "id_token": "test", "refresh_token": "test","expires_in": 86399, "scope": "", "token_type": "bearer" }.to_json,
headers: { "content-type" => "application/json;charset=UTF-8" }
)
end

it "calls /token url with proper body and headers" do
expect(described_class.fetch_tokens(code).keys).to eq(%w[scope token_type access_token refresh_token expires_at])
expect(described_class.fetch_tokens(code).keys.map(&:to_s)).to eq(%w[access_token id_token expires_at refresh_token scope token_type])
end

context "with redefined callback_url" do
let(:callback_url) { "another-callback" }

it "calls /token url with proper body and headers" do
expect(described_class.fetch_tokens(code).keys.size).to eq(5)
expect(described_class.fetch_tokens(code).keys.size).to eq(6)
end
end
end
Expand Down