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
450 changes: 450 additions & 0 deletions spec/challenge_response_spec.cr

Large diffs are not rendered by default.

12 changes: 12 additions & 0 deletions spec/error_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,18 @@ module Authly
Error.expired_token
end
end

it "creates expired_challenge error" do
expect_raises(Error(400), "The challenge has expired; the client should request a new challenge") do
Error.expired_challenge
end
end

it "creates invalid_client error with 401 code" do
expect_raises(Error(401), "Client authentication failed, such as if the request contains an invalid client ID or secret.") do
Error.invalid_client
end
end
end

describe "#code" do
Expand Down
10 changes: 10 additions & 0 deletions spec/support/config_isolation.cr
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ class Authly::Configuration
@{{ ivar.id }} = from.@{{ ivar.id }}.dup
{% elsif ivar.id == "clients" %}
@clients = copy_of(from.@clients)
{% elsif ivar.id == "owners" %}
@owners = copy_of_owners(from.@owners)
{% else %}
@{{ ivar.id }} = from.@{{ ivar.id }}
{% end %}
Expand All @@ -26,6 +28,14 @@ class Authly::Configuration
copy
end

private def copy_of_owners(owners : AuthorizableOwner) : AuthorizableOwner
return owners unless owners.is_a?(Owners)

copy = Owners.new
owners.each { |owner| copy << owner }
copy
end

def snapshot : Configuration
copy = Configuration.allocate
copy.restore(self)
Expand Down
4 changes: 2 additions & 2 deletions spec/support/settings.cr
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ Authly.configure do |config|
config.authenticate_user = ->(context : HTTP::Server::Context) { context.request.headers["X-Test-User"]?.presence }
end

Authly.clients << Authly::Client.new("example", "secret", "https://www.example.com/callback", "1", "read write admin openid", "authorization_code client_credentials password refresh_token implicit")
Authly.clients << Authly::Client.new("test_client", "test_secret", "https://example.com/callback", "test_client", "read write admin openid profile email", "authorization_code client_credentials password refresh_token implicit")
Authly.clients << Authly::Client.new("example", "secret", "https://www.example.com/callback", "1", "read write admin openid", "authorization_code client_credentials password refresh_token implicit challenge_response urn:ietf:params:oauth:grant-type:challenge")
Authly.clients << Authly::Client.new("test_client", "test_secret", "https://example.com/callback", "test_client", "read write admin openid profile email", "authorization_code client_credentials password refresh_token implicit challenge_response urn:ietf:params:oauth:grant-type:challenge")
Authly.owners << Authly::Owner.new("username", "password")
Authly.owners << Authly::Owner.new("john", "secret")

Expand Down
2 changes: 2 additions & 0 deletions src/authly.cr
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ require "./authly/state_store"
require "./authly/attempt_limiter"
require "./authly/device_code_store"
require "./authly/par_store"
require "./authly/challenge_store"
require "./authly/challenge_verifier"
require "./authly/token_generator"
require "./authly/client_store"
require "./authly/dynamic_client"
Expand Down
14 changes: 14 additions & 0 deletions src/authly/authorizable_client.cr
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,19 @@ module Authly
def client_public_key(client_id : String) : String?
nil
end

def valid_client?(client_id : String) : Bool
false
end

def verify_challenge_response?(
challenge : String,
response : String,
client_id : String = "",
key_id : String? = nil,
algorithm : String? = nil,
) : Bool
false
end
end
end
12 changes: 12 additions & 0 deletions src/authly/authorizable_owner.cr
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,17 @@ module Authly
module AuthorizableOwner
abstract def authorized?(username : String, password : String) : Bool
abstract def id_token(user_id : String) : Hash(String, String | Int64)

def verify_challenge_response?(
challenge : String,
response : String,
identifier : String? = nil,
username : String? = nil,
owner_id : String? = nil,
key_id : String? = nil,
algorithm : String? = nil,
) : Bool
false
end
end
end
200 changes: 200 additions & 0 deletions src/authly/challenge_store.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
module Authly
struct Challenge
include JSON::Serializable

property challenge_id : String
property challenge : String
property client_id : String
property username : String?
property owner_id : String?
property key_id : String?
property expires_at : Time
property? consumed : Bool = false
property attempts : Int32 = 0

def initialize(
@challenge_id : String,
@challenge : String,
@client_id : String,
@expires_at : Time,
@username : String? = nil,
@owner_id : String? = nil,
@key_id : String? = nil,
@consumed : Bool = false,
@attempts : Int32 = 0,
)
end

def self.generate(
client_id : String,
ttl : Time::Span = Authly.config.challenge_ttl,
username : String? = nil,
owner_id : String? = nil,
key_id : String? = nil,
) : Challenge
new(
challenge_id: Random::Secure.hex(16),
challenge: Random::Secure.hex(32),
client_id: client_id,
expires_at: Time.utc + ttl,
username: username,
owner_id: owner_id,
key_id: key_id,
)
end

def expired? : Bool
Time.utc >= @expires_at
end

def expires_in : Int32
remaining = (@expires_at - Time.utc).total_seconds.round.to_i
remaining > 0 ? remaining : 0
end

def identifier : String?
@owner_id.presence || @username.presence || @key_id.presence
end
end

module ChallengeStore
abstract def store(challenge : Challenge) : Challenge
abstract def fetch(challenge_id : String) : Challenge?

# Atomically deletes and returns an unexpired challenge, or nil if unknown, expired, or already consumed.
abstract def consume!(challenge_id : String) : Challenge?

abstract def delete(challenge_id : String) : Bool
abstract def cleanup_expired : Nil

def store(challenge_id : String, challenge : Challenge) : Challenge
store(challenge)
end

def valid?(challenge_id : String) : Bool
!fetch(challenge_id).nil?
end

# Atomically records a verification attempt against `challenge_id`.
# Raises `Error.expired_challenge` if expired, or `Error.invalid_grant` if missing/consumed.
def record_attempt!(challenge_id : String, max_attempts : Int32 = 1) : Challenge
consume!(challenge_id) || raise Error.invalid_grant
end
end

class InMemoryChallengeStore
include ChallengeStore

MAX_ENTRIES = 50_000
SWEEP_EVERY = 64
SWEEP_THRESHOLD = 256

@store : Hash(String, Challenge)
@expired_ids : Set(String)
@writes = 0
@mutex = Mutex.new

def initialize
@store = Hash(String, Challenge).new
@expired_ids = Set(String).new
end

def store(challenge : Challenge) : Challenge
@mutex.synchronize do
@writes += 1
sweep if @writes % SWEEP_EVERY == 0 && @store.size >= SWEEP_THRESHOLD
evict_oldest if @store.size >= MAX_ENTRIES && !@store.has_key?(challenge.challenge_id)
@expired_ids.delete(challenge.challenge_id)
@store[challenge.challenge_id] = challenge
end
end

def fetch(challenge_id : String) : Challenge?
@mutex.synchronize do
data = @store[challenge_id]?
return if data.nil? || data.expired? || data.consumed?
data
end
end

def consume!(challenge_id : String) : Challenge?
@mutex.synchronize do
data = @store.delete(challenge_id)
return unless data
if data.expired?
track_expired(challenge_id)
return
end
return if data.consumed?

data.consumed = true
data.attempts += 1
data
end
end

def record_attempt!(challenge_id : String, max_attempts : Int32 = 1) : Challenge
@mutex.synchronize do
data = @store[challenge_id]?
if data
if data.expired?
@store.delete(challenge_id)
track_expired(challenge_id)
raise Error.expired_challenge
end
if data.consumed?
@store.delete(challenge_id)
raise Error.invalid_grant
end

data.attempts += 1
if max_attempts <= 1 || data.attempts >= max_attempts
data.consumed = true
@store.delete(challenge_id)
else
@store[challenge_id] = data
end
return data
end

raise Error.expired_challenge if @expired_ids.includes?(challenge_id)
raise Error.invalid_grant
end
end

def delete(challenge_id : String) : Bool
@mutex.synchronize do
@expired_ids.delete(challenge_id)
!@store.delete(challenge_id).nil?
end
end

def size : Int32
@mutex.synchronize { @store.size }
end

def cleanup_expired : Nil
@mutex.synchronize { sweep }
end

private def sweep : Nil
@store.each do |id, data|
if data.expired?
@store.delete(id)
track_expired(id)
end
end
end

private def track_expired(challenge_id : String) : Nil
@expired_ids.clear if @expired_ids.size >= MAX_ENTRIES
@expired_ids.add(challenge_id)
end

private def evict_oldest : Nil
if oldest = @store.first_key?
@store.delete(oldest)
end
end
end
end
Loading
Loading