From 16bbd40b316015a984244f19d0d781145cd7903a Mon Sep 17 00:00:00 2001 From: "Elias J. Perez" Date: Mon, 2 Nov 2026 08:04:50 -0500 Subject: [PATCH] Add cryptographic Challenge-Response authentication and grant type --- spec/challenge_response_spec.cr | 450 ++++++++++++++++++++ spec/error_spec.cr | 12 + spec/support/config_isolation.cr | 10 + spec/support/settings.cr | 4 +- src/authly.cr | 2 + src/authly/authorizable_client.cr | 14 + src/authly/authorizable_owner.cr | 12 + src/authly/challenge_store.cr | 200 +++++++++ src/authly/challenge_verifier.cr | 280 ++++++++++++ src/authly/client.cr | 28 +- src/authly/configuration.cr | 7 +- src/authly/error.cr | 9 + src/authly/grant.cr | 29 +- src/authly/grants/challenge_response.cr | 148 +++++++ src/authly/handler.cr | 9 + src/authly/handlers/access_token_handler.cr | 32 +- src/authly/handlers/challenge_handler.cr | 75 ++++ src/authly/handlers/discovery_handler.cr | 1 + src/authly/owner.cr | 54 ++- src/authly/token_manager.cr | 16 +- 20 files changed, 1370 insertions(+), 22 deletions(-) create mode 100644 spec/challenge_response_spec.cr create mode 100644 src/authly/challenge_store.cr create mode 100644 src/authly/challenge_verifier.cr create mode 100644 src/authly/grants/challenge_response.cr create mode 100644 src/authly/handlers/challenge_handler.cr diff --git a/spec/challenge_response_spec.cr b/spec/challenge_response_spec.cr new file mode 100644 index 0000000..075ff51 --- /dev/null +++ b/spec/challenge_response_spec.cr @@ -0,0 +1,450 @@ +require "./spec_helper" +require "http/server" + +module Authly + private class ChallengeTestContext + getter io : IO::Memory + getter context : HTTP::Server::Context + + def initialize( + method : String, + path : String, + form_data : Hash(String, String)? = nil, + json_body : String? = nil, + headers : HTTP::Headers? = nil, + ) + @io = IO::Memory.new + request = HTTP::Request.new(method, path, headers: headers) + + if form_data + request.headers["Content-Type"] = "application/x-www-form-urlencoded" + request.body = HTTP::Params.encode(form_data) + elsif json_body + request.headers["Content-Type"] = "application/json" + request.body = json_body + end + + response = HTTP::Server::Response.new(@io) + @context = HTTP::Server::Context.new(request, response) + end + + def response_body : String + @context.response.close + @io.rewind + response_text = @io.gets_to_end + if match = response_text.match(/\r\n\r\n(.*)$/m) + match[1] + else + response_text + end + end + end + + describe "Challenge-Response Authentication" do + before_each do + Authly.config.challenge_store = InMemoryChallengeStore.new + Authly.config.challenge_ttl = 60.seconds + Authly.config.challenge_max_attempts = 1 + end + + describe InMemoryChallengeStore do + it "stores, fetches, and atomically consumes a challenge once" do + store = InMemoryChallengeStore.new + challenge = Challenge.generate(client_id: "1", username: "john") + store.store(challenge) + + fetched = store.fetch(challenge.challenge_id) + fetched.should_not be_nil + fetched.not_nil!.challenge.should eq challenge.challenge + fetched.not_nil!.client_id.should eq "1" + fetched.not_nil!.username.should eq "john" + store.valid?(challenge.challenge_id).should be_true + + consumed = store.consume!(challenge.challenge_id) + consumed.should_not be_nil + consumed.not_nil!.consumed?.should be_true + + # Replay attempt must return nil + store.consume!(challenge.challenge_id).should be_nil + store.fetch(challenge.challenge_id).should be_nil + store.valid?(challenge.challenge_id).should be_false + end + + it "expires challenges after TTL and cleans up expired entries" do + store = InMemoryChallengeStore.new + expired = Challenge.generate(client_id: "1", ttl: -1.second) + active = Challenge.generate(client_id: "1", ttl: 60.seconds) + + store.store(expired) + store.store(active) + + store.fetch(expired.challenge_id).should be_nil + store.consume!(expired.challenge_id).should be_nil + store.fetch(active.challenge_id).should_not be_nil + + store.store(Challenge.generate(client_id: "1", ttl: -5.seconds)) + store.cleanup_expired + store.size.should eq 1 + end + + it "automatically sweeps expired challenges when threshold is reached" do + store = InMemoryChallengeStore.new + 300.times do |i| + store.store(Challenge.new("exp-#{i}", "nonce-#{i}", "1", 1.minute.ago)) + end + 64.times do |i| + store.store(Challenge.new("act-#{i}", "nonce-#{i}", "1", 1.minute.from_now)) + end + + store.size.should eq 64 + end + end + + describe "POST /oauth/challenge" do + it "issues a cryptographically random challenge for a valid client" do + ctx = ChallengeTestContext.new("POST", "/oauth/challenge", form_data: { + "client_id" => "1", + "username" => "john", + }) + OAUTH_HANDLER.call(ctx.context) + + ctx.context.response.status_code.should eq 200 + ctx.context.response.headers["Cache-Control"]?.should eq "no-store" + + body = JSON.parse(ctx.response_body) + challenge_id = body["challenge_id"].as_s + challenge = body["challenge"].as_s + expires_in = body["expires_in"].as_i + + challenge_id.size.should eq 32 + challenge.size.should eq 64 + expires_in.should eq 60 + + stored = Authly.config.challenge_store.fetch(challenge_id) + stored.should_not be_nil + stored.not_nil!.challenge.should eq challenge + stored.not_nil!.client_id.should eq "1" + stored.not_nil!.username.should eq "john" + end + + it "accepts JSON request body and owner_id / key_id identifiers" do + ctx = ChallengeTestContext.new( + "POST", + "/oauth/challenge", + json_body: {client_id: "1", owner_id: "owner-42", key_id: "key-1"}.to_json + ) + OAUTH_HANDLER.call(ctx.context) + + ctx.context.response.status_code.should eq 200 + body = JSON.parse(ctx.response_body) + stored = Authly.config.challenge_store.fetch(body["challenge_id"].as_s).not_nil! + stored.owner_id.should eq "owner-42" + stored.key_id.should eq "key-1" + end + + it "returns 400 invalid_request when client_id is missing" do + ctx = ChallengeTestContext.new("POST", "/oauth/challenge", form_data: {} of String => String) + OAUTH_HANDLER.call(ctx.context) + + ctx.context.response.status_code.should eq 400 + JSON.parse(ctx.response_body)["error"].as_s.should eq "invalid_request" + end + + it "returns 401 invalid_client when client_id is unknown or secret is invalid" do + ctx = ChallengeTestContext.new("POST", "/oauth/challenge", form_data: { + "client_id" => "unknown-client", + }) + OAUTH_HANDLER.call(ctx.context) + + ctx.context.response.status_code.should eq 401 + JSON.parse(ctx.response_body)["error"].as_s.should eq "invalid_client" + + ctx_bad_secret = ChallengeTestContext.new("POST", "/oauth/challenge", form_data: { + "client_id" => "1", + "client_secret" => "wrong-secret", + }) + OAUTH_HANDLER.call(ctx_bad_secret.context) + + ctx_bad_secret.context.response.status_code.should eq 401 + JSON.parse(ctx_bad_secret.response_body)["error"].as_s.should eq "invalid_client" + end + end + + describe "POST /oauth/token (Challenge Verification & Token Exchange)" do + it "exchanges a valid HMAC-SHA256 client challenge response for a JWT access token" do + challenge_data = ChallengeResponse.create_challenge(client_id: "1") + signature = OpenSSL::HMAC.hexdigest(:sha256, "secret", challenge_data.challenge) + + ctx = ChallengeTestContext.new("POST", "/oauth/token", form_data: { + "grant_type" => "challenge_response", + "client_id" => "1", + "challenge_id" => challenge_data.challenge_id, + "response" => signature, + "scope" => "read write", + }) + OAUTH_HANDLER.call(ctx.context) + + ctx.context.response.status_code.should eq 200 + body = JSON.parse(ctx.response_body) + body["access_token"].as_s.should_not be_empty + body["token_type"].as_s.should eq "Bearer" + body["scope"].as_s.should eq "read write" + body["refresh_token"].as_s.should_not be_empty + + claims, _ = Authly.jwt_decode(body["access_token"].as_s) + claims["cid"].as_s.should eq "1" + claims["sub"].as_s.should eq "1" + claims["scope"].as_s.should eq "read write" + end + + it "supports urn:ietf:params:oauth:grant-type:challenge and owner HMAC verification" do + challenge_data = ChallengeResponse.create_challenge(client_id: "1", username: "john") + signature_b64 = Base64.urlsafe_encode( + OpenSSL::HMAC.digest(:sha256, "secret", challenge_data.challenge), + padding: false + ) + + ctx = ChallengeTestContext.new("POST", "/oauth/token", form_data: { + "grant_type" => "urn:ietf:params:oauth:grant-type:challenge", + "client_id" => "1", + "challenge_id" => challenge_data.challenge_id, + "response" => signature_b64, + "scope" => "read", + }) + OAUTH_HANDLER.call(ctx.context) + + ctx.context.response.status_code.should eq 200 + body = JSON.parse(ctx.response_body) + claims, _ = Authly.jwt_decode(body["access_token"].as_s) + claims["sub"].as_s.should eq Authly.owners.id_token("john")["sub"].to_s + end + + it "supports Ed25519 asymmetric signature verification" do + signing_key = Ed25519::SigningKey.new + public_key_hex = signing_key.verify_key.key_bytes.hexstring + + Authly.owners << Owner.new( + username: "ed_user", + password: "", + public_key: public_key_hex, + key_id: "ed-key-1", + id: "sub-ed-user", + ) + + challenge_data = ChallengeResponse.create_challenge(client_id: "1", key_id: "ed-key-1") + sig_bytes = signing_key.sign(challenge_data.challenge.to_slice) + response_hex = sig_bytes.hexstring + + token = Authly.access_token( + "challenge_response", + client_id: "1", + challenge_id: challenge_data.challenge_id, + response: response_hex, + scope: "read", + ) + + token.sub.should eq "sub-ed-user" + token.scope.should eq "read" + end + + it "supports RSA (OpenSSL::PKey::RSA) asymmetric signature verification" do + rsa_key = OpenSSL::PKey::RSA.new(2048) + public_pem = rsa_key.public_key.to_pem + + Authly.clients << Client.new( + name: "rsa_client", + secret: "rsa_secret", + redirect_uri: "https://example.com/cb", + id: "rsa_client", + scopes: "read write", + public_key: public_pem, + ) + + challenge_data = ChallengeResponse.create_challenge(client_id: "rsa_client") + sig = rsa_key.sign(OpenSSL::Digest.new("sha256"), challenge_data.challenge) + response_b64 = Base64.strict_encode(sig) + + token = Authly.access_token( + "challenge_response", + client_id: "rsa_client", + challenge_id: challenge_data.challenge_id, + response: response_b64, + scope: "read", + ) + + token.client_id.should eq "rsa_client" + token.sub.should eq "rsa_client" + end + + it "supports ECDSA (OpenSSL::PKey::EC P-256) DER and raw P1363 signature verification" do + ec_key = OpenSSL::PKey::EC.generate("P-256") + public_pem = ec_key.public_key.to_pem + + Authly.owners << Owner.new( + username: "ec_user", + password: "", + public_key: public_pem, + id: "sub-ec-user", + ) + + # 1. ASN.1 DER signature + c1 = ChallengeResponse.create_challenge(client_id: "1", username: "ec_user") + digest1 = OpenSSL::Digest.new("sha256").update(c1.challenge).final + der_sig = ec_key.ec_sign(digest1) + + t1 = Authly.access_token( + "challenge_response", + client_id: "1", + challenge_id: c1.challenge_id, + response: Base64.strict_encode(der_sig), + scope: "read", + ) + t1.sub.should eq "sub-ec-user" + + # 2. Raw (r || s) IEEE P1363 signature + c2 = ChallengeResponse.create_challenge(client_id: "1", username: "ec_user") + digest2 = OpenSSL::Digest.new("sha256").update(c2.challenge).final + raw_sig = JWT.asn1_to_raw(ec_key.ec_sign(digest2), ec_key) + + t2 = Authly.access_token( + "challenge_response", + client_id: "1", + challenge_id: c2.challenge_id, + response: Base64.urlsafe_encode(raw_sig, padding: false), + scope: "read", + ) + t2.sub.should eq "sub-ec-user" + end + + it "issues opaque tokens when TokenStrategy is :opaque" do + Authly.config.token_strategy = :opaque + Authly.config.token_store = InMemoryStore.new + + challenge_data = ChallengeResponse.create_challenge(client_id: "1") + signature = OpenSSL::HMAC.hexdigest(:sha256, "secret", challenge_data.challenge) + + token = Authly.access_token( + "challenge_response", + client_id: "1", + challenge_id: challenge_data.challenge_id, + response: signature, + scope: "read", + ) + + token.access_token.size.should eq 64 + Authly.valid?(token.access_token).should be_true + introspected = Authly.introspect(token.access_token) + introspected["active"].should be_true + introspected["cid"].should eq "1" + end + + it "rejects expired challenges with expired_challenge error" do + challenge_data = ChallengeResponse.create_challenge(client_id: "1", ttl: -1.second) + signature = OpenSSL::HMAC.hexdigest(:sha256, "secret", challenge_data.challenge) + + expect_raises(Error(400), ERROR_MSG[:expired_challenge]) do + Authly.access_token( + "challenge_response", + client_id: "1", + challenge_id: challenge_data.challenge_id, + response: signature, + scope: "read", + ) + end + + # Verify HTTP endpoint also returns expired_challenge + expired2 = ChallengeResponse.create_challenge(client_id: "1", ttl: -1.second) + sig2 = OpenSSL::HMAC.hexdigest(:sha256, "secret", expired2.challenge) + ctx = ChallengeTestContext.new("POST", "/oauth/token", form_data: { + "grant_type" => "challenge_response", + "client_id" => "1", + "challenge_id" => expired2.challenge_id, + "response" => sig2, + }) + OAUTH_HANDLER.call(ctx.context) + + ctx.context.response.status_code.should eq 400 + JSON.parse(ctx.response_body)["error"].as_s.should eq "expired_challenge" + end + + it "prevents replay attacks by rejecting a second exchange of the same challenge" do + challenge_data = ChallengeResponse.create_challenge(client_id: "1") + signature = OpenSSL::HMAC.hexdigest(:sha256, "secret", challenge_data.challenge) + + first = Authly.access_token( + "challenge_response", + client_id: "1", + challenge_id: challenge_data.challenge_id, + response: signature, + scope: "read", + ) + first.access_token.should_not be_empty + + # Second attempt (replay) must fail with invalid_grant + expect_raises(Error(400), ERROR_MSG[:invalid_grant]) do + Authly.access_token( + "challenge_response", + client_id: "1", + challenge_id: challenge_data.challenge_id, + response: signature, + scope: "read", + ) + end + end + + it "immediately invalidates the challenge on an invalid signature to prevent brute-force attacks" do + challenge_data = ChallengeResponse.create_challenge(client_id: "1") + valid_signature = OpenSSL::HMAC.hexdigest(:sha256, "secret", challenge_data.challenge) + + # First attempt with forged signature fails + expect_raises(Error(400), ERROR_MSG[:invalid_grant]) do + Authly.access_token( + "challenge_response", + client_id: "1", + challenge_id: challenge_data.challenge_id, + response: "00" * 32, + scope: "read", + ) + end + + # Challenge must now be invalidated; presenting the valid signature afterward must also fail + expect_raises(Error(400), ERROR_MSG[:invalid_grant]) do + Authly.access_token( + "challenge_response", + client_id: "1", + challenge_id: challenge_data.challenge_id, + response: valid_signature, + scope: "read", + ) + end + end + + it "rejects mismatched client_id and invalidates the challenge" do + challenge_data = ChallengeResponse.create_challenge(client_id: "1") + valid_signature = OpenSSL::HMAC.hexdigest(:sha256, "secret", challenge_data.challenge) + + # Attempt to redeem using "test_client" instead of "1" + expect_raises(Error(400), ERROR_MSG[:invalid_grant]) do + Authly.access_token( + "challenge_response", + client_id: "test_client", + challenge_id: challenge_data.challenge_id, + response: valid_signature, + scope: "read", + ) + end + + # Subsequent attempt by the original client must also fail because the challenge was burned + expect_raises(Error(400), ERROR_MSG[:invalid_grant]) do + Authly.access_token( + "challenge_response", + client_id: "1", + challenge_id: challenge_data.challenge_id, + response: valid_signature, + scope: "read", + ) + end + end + end + end +end diff --git a/spec/error_spec.cr b/spec/error_spec.cr index 69a8d72..66eca10 100644 --- a/spec/error_spec.cr +++ b/spec/error_spec.cr @@ -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 diff --git a/spec/support/config_isolation.cr b/spec/support/config_isolation.cr index 30344d3..6533074 100644 --- a/spec/support/config_isolation.cr +++ b/spec/support/config_isolation.cr @@ -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 %} @@ -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) diff --git a/spec/support/settings.cr b/spec/support/settings.cr index d6cfb50..f81ffb3 100644 --- a/spec/support/settings.cr +++ b/spec/support/settings.cr @@ -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") diff --git a/src/authly.cr b/src/authly.cr index 91bfef7..cc4b15c 100644 --- a/src/authly.cr +++ b/src/authly.cr @@ -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" diff --git a/src/authly/authorizable_client.cr b/src/authly/authorizable_client.cr index b94f13b..6b2eac2 100644 --- a/src/authly/authorizable_client.cr +++ b/src/authly/authorizable_client.cr @@ -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 diff --git a/src/authly/authorizable_owner.cr b/src/authly/authorizable_owner.cr index c7a5f4d..86f5d48 100644 --- a/src/authly/authorizable_owner.cr +++ b/src/authly/authorizable_owner.cr @@ -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 diff --git a/src/authly/challenge_store.cr b/src/authly/challenge_store.cr new file mode 100644 index 0000000..59501bc --- /dev/null +++ b/src/authly/challenge_store.cr @@ -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 diff --git a/src/authly/challenge_verifier.cr b/src/authly/challenge_verifier.cr new file mode 100644 index 0000000..3014e0e --- /dev/null +++ b/src/authly/challenge_verifier.cr @@ -0,0 +1,280 @@ +require "base64" +require "crypto/subtle" +require "ed25519" +require "openssl" +require "openssl/hmac" +require "openssl_ext" + +module Authly + module ChallengeVerifier + extend self + + ED25519_SPKI_PREFIX = Bytes[0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00] + + # Verifies a challenge response using asymmetric signature verification when `public_key` is provided, + # or HMAC-SHA256 when `secret` is provided. + def verify?( + challenge : String, + response : String, + public_key : String? = nil, + secret : String? = nil, + algorithm : String? = nil, + ) : Bool + return false if challenge.empty? || response.empty? + + alg = algorithm.try(&.downcase.strip) + if alg && (alg.starts_with?("hmac") || alg.starts_with?("hs")) + return false unless secret + return verify_hmac?(challenge, response, secret, hmac_digest_symbol(alg)) + end + + if key = public_key.presence + return true if verify_asymmetric?(challenge, response, key, alg) + return false unless alg.nil? && secret.presence + end + + if sec = secret.presence + return verify_hmac?(challenge, response, sec) + end + + false + end + + # Verifies an HMAC challenge response using constant-time comparison across hex and Base64 encodings. + def verify_hmac?( + challenge : String, + response : String, + secret : String, + digest_algorithm : OpenSSL::Algorithm = OpenSSL::Algorithm::SHA256, + ) : Bool + return false if challenge.empty? || response.empty? || secret.empty? + + expected_bytes = OpenSSL::HMAC.digest(digest_algorithm, secret, challenge) + expected_hex = expected_bytes.hexstring + expected_b64 = Base64.strict_encode(expected_bytes) + expected_b64url = Base64.urlsafe_encode(expected_bytes, padding: false) + expected_b64url_pad = Base64.urlsafe_encode(expected_bytes, padding: true) + + normalized = response.strip + match_hex = Crypto::Subtle.constant_time_compare(normalized.downcase, expected_hex) + match_b64 = Crypto::Subtle.constant_time_compare(normalized, expected_b64) + match_b64url = Crypto::Subtle.constant_time_compare(normalized, expected_b64url) + match_b64url_pad = Crypto::Subtle.constant_time_compare(normalized, expected_b64url_pad) + + match_hex || match_b64 || match_b64url || match_b64url_pad + end + + # Verifies an asymmetric signature (Ed25519, RSA PKCS#1 v1.5 / PSS, or ECDSA DER / raw P1363) + # against `challenge`. + def verify_asymmetric?( + challenge : String, + response : String, + public_key : String, + algorithm : String? = nil, + ) : Bool + return false if challenge.empty? || response.empty? || public_key.empty? + + signatures = decode_signatures(response.strip) + return false if signatures.empty? + + alg = algorithm.try(&.downcase.strip) + + case alg + when "ed25519", "eddsa" + verify_ed25519?(challenge, signatures, public_key) + when .try(&.starts_with?("rs")), .try(&.starts_with?("ps")), "rsa" + verify_rsa?(challenge, signatures, public_key, alg) + when .try(&.starts_with?("es")), "ecdsa", "ec" + verify_ecdsa?(challenge, signatures, public_key, alg) + else + verify_ed25519?(challenge, signatures, public_key) || + verify_ecdsa?(challenge, signatures, public_key, alg) || + verify_rsa?(challenge, signatures, public_key, alg) + end + end + + def verify_ed25519?(challenge : String, signatures : Array(Bytes), public_key : String) : Bool + pub_bytes = extract_ed25519_public_key(public_key.strip) + return false unless pub_bytes && pub_bytes.size == 32 + + message = challenge.to_slice + signatures.any? do |sig| + next false unless sig.size == 64 + verified_slice = begin + Ed25519.verify(sig, message, pub_bytes) + rescue + false + end + next true if verified_slice + + if challenge.size.even? && challenge.matches?(/\A[0-9a-fA-F]+\z/) + begin + Ed25519.verify(sig, challenge.hexbytes, pub_bytes) + rescue + false + end + else + false + end + end + end + + def verify_rsa?( + challenge : String, + signatures : Array(Bytes), + public_key : String, + algorithm : String? = nil, + ) : Bool + return false unless public_key.includes?("BEGIN") + # Avoid treating an EC PRIVATE KEY PEM as RSA + return false if public_key.includes?("EC PRIVATE KEY") + + rsa = OpenSSL::PKey::RSA.new(public_key) + digests = digest_names_for(algorithm) + + signatures.any? do |sig| + digests.any? do |digest_name| + verified_pkcs1 = begin + rsa.verify(OpenSSL::Digest.new(digest_name), sig, challenge) + rescue + false + end + next true if verified_pkcs1 + + {% if compare_versions(LibCrypto::OPENSSL_VERSION, "3.0.0") >= 0 %} + begin + rsa.verify_pss(OpenSSL::Digest.new(digest_name), sig, challenge) + rescue + false + end + {% else %} + false + {% end %} + end + end + rescue + false + end + + def verify_ecdsa?( + challenge : String, + signatures : Array(Bytes), + public_key : String, + algorithm : String? = nil, + ) : Bool + ec = load_ec_key(public_key.strip) + return false unless ec + + digest_name = ec_digest_name(ec, algorithm) + digest_bytes = OpenSSL::Digest.new(digest_name).update(challenge).final + coord_size = (ec.group_degree + 7) // 8 + + signatures.any? do |sig| + verified_der = begin + ec.ec_verify(digest_bytes, sig) + rescue + false + end + next true if verified_der + + if sig.size == coord_size * 2 + begin + asn1_sig = JWT.raw_to_asn1(sig, ec) + ec.ec_verify(digest_bytes, asn1_sig) + rescue + false + end + else + false + end + end + rescue + false + end + + private def decode_signatures(encoded : String) : Array(Bytes) + results = [] of Bytes + + if encoded.size.even? && encoded.matches?(/\A[0-9a-fA-F]+\z/) + begin + results << encoded.hexbytes + rescue + end + end + + begin + decoded = Base64.decode(encoded) + results << decoded unless decoded.empty? || results.includes?(decoded) + rescue Base64::Error + end + + results + end + + private def extract_ed25519_public_key(key : String) : Bytes? + if key.size == 64 && key.matches?(/\A[0-9a-fA-F]{64}\z/) + return key.hexbytes + end + + if key.starts_with?("-----BEGIN") + if match = key.match(/-----BEGIN [A-Z ]+-----(.+?)-----END [A-Z ]+-----/m) + der = Base64.decode(match[1]) + if der.size == 44 && der[0, 12] == ED25519_SPKI_PREFIX + return der[12, 32] + end + end + return + end + + decoded = Base64.decode(key) + decoded if decoded.size == 32 + rescue + nil + end + + private def load_ec_key(key : String) : OpenSSL::PKey::EC? + if key.starts_with?("-----BEGIN") + return if key.includes?("RSA ") + ec = OpenSSL::PKey::EC.new(key) + # Ensure the loaded key actually has a valid EC group + ec.group_degree > 0 ? ec : nil + elsif key.size == 130 && key.starts_with?("04") && key.matches?(/\A[0-9a-fA-F]+\z/) + OpenSSL::PKey::EC.from_public_bytes(key.hexbytes, "P-256") + end + rescue + nil + end + + private def ec_digest_name(ec : OpenSSL::PKey::EC, algorithm : String?) : String + case algorithm + when "es384" then "sha384" + when "es512" then "sha512" + when "es256", "es256k" + "sha256" + else + case ec.group_degree + when 384 then "sha384" + when 521 then "sha512" + else "sha256" + end + end + end + + private def digest_names_for(algorithm : String?) : Array(String) + case algorithm + when "rs256", "ps256" then ["sha256"] + when "rs384", "ps384" then ["sha384"] + when "rs512", "ps512" then ["sha512"] + else ["sha256", "sha384", "sha512"] + end + end + + private def hmac_digest_symbol(algorithm : String) : OpenSSL::Algorithm + case algorithm + when "hs384", "hmac-sha384" then OpenSSL::Algorithm::SHA384 + when "hs512", "hmac-sha512" then OpenSSL::Algorithm::SHA512 + else OpenSSL::Algorithm::SHA256 + end + end + end +end diff --git a/src/authly/client.cr b/src/authly/client.cr index c06a0b5..bd926f3 100644 --- a/src/authly/client.cr +++ b/src/authly/client.cr @@ -19,7 +19,7 @@ module Authly @redirect_uri, @id, @scopes = "read", - @grant_types = "authorization_code client_credentials password refresh_token", + @grant_types = "authorization_code client_credentials password refresh_token challenge_response urn:ietf:params:oauth:grant-type:challenge", @jarm_signing_alg = nil, @jarm_encryption_alg = nil, @jarm_encryption_enc = nil, @@ -122,5 +122,31 @@ module Authly the_client.public_key end + + def valid_client?(client_id : String) : Bool + any? { |client| client.id == client_id } + end + + def verify_challenge_response?( + challenge : String, + response : String, + client_id : String = "", + key_id : String? = nil, + algorithm : String? = nil, + ) : Bool + target_id = client_id.presence || key_id.presence + return false unless target_id + + the_client = find { |client| client.id == target_id } + return false unless the_client + + ChallengeVerifier.verify?( + challenge: challenge, + response: response, + public_key: the_client.public_key, + secret: the_client.secret, + algorithm: algorithm, + ) + end end end diff --git a/src/authly/configuration.cr b/src/authly/configuration.cr index 818fa3c..e513ad9 100644 --- a/src/authly/configuration.cr +++ b/src/authly/configuration.cr @@ -23,7 +23,7 @@ module Authly # Trusted host callback; never populated from OAuth request parameters. property issuance_options : Proc(String, String, IssuanceOptions)? = nil property? issue_refresh_tokens : Bool = true - property grant_types : Array(String) = %w[authorization_code client_credentials password refresh_token implicit urn:ietf:params:oauth:grant-type:device_code] + property grant_types : Array(String) = %w[authorization_code client_credentials password refresh_token implicit urn:ietf:params:oauth:grant-type:device_code challenge_response urn:ietf:params:oauth:grant-type:challenge] property signing_key_id : String? = nil property verification_keys : Hash(String, String) = {} of String => String @@ -112,6 +112,11 @@ module Authly property par_ttl : Time::Span = 90.seconds property? require_par : Bool = false + # Challenge-Response Authentication + property challenge_store : ChallengeStore = InMemoryChallengeStore.new + property challenge_ttl : Time::Span = 60.seconds + property challenge_max_attempts : Int32 = 1 + # JWT Secured Authorization Response Mode (JARM) property jarm_signing_key : String? = nil property jarm_signing_alg : JWT::Algorithm = JWT::Algorithm::RS256 diff --git a/src/authly/error.cr b/src/authly/error.cr index c7de344..67599ff 100644 --- a/src/authly/error.cr +++ b/src/authly/error.cr @@ -16,11 +16,20 @@ module Authly authorization_pending: "The authorization request is still pending; the client should retry the request", slow_down: "The client is polling too frequently and should slow down", expired_token: "The device code has expired; the client should request a new device authorization", + expired_challenge: "The challenge has expired; the client should request a new challenge", invalid_request_uri: "The request_uri is invalid, expired, or does not exist", invalid_request_object: "The request object is invalid or malformed", } class Error(Code) < Exception + def self.invalid_client + raise Error(401).new(:invalid_client) + end + + def self.expired_challenge + raise Error(400).new(:expired_challenge) + end + def self.invalid_token raise Error(400).new(:invalid_token) end diff --git a/src/authly/grant.cr b/src/authly/grant.cr index cd9f946..8b8518e 100644 --- a/src/authly/grant.cr +++ b/src/authly/grant.cr @@ -46,37 +46,48 @@ module Authly case grant_type when "authorization_code" AuthorizationCode.new( - client_id: args[:client_id].to_s, - client_secret: args[:client_secret].to_s, + client_id: args.fetch(:client_id, "").to_s, + client_secret: args.fetch(:client_secret, "").to_s, redirect_uri: args.fetch(:redirect_uri, "").to_s, code: args.fetch(:code, "").to_s, verifier: args.fetch(:verifier, "").to_s ) when "client_credentials" ClientCredentials.new( - client_id: args[:client_id].to_s, - client_secret: args[:client_secret].to_s, + client_id: args.fetch(:client_id, "").to_s, + client_secret: args.fetch(:client_secret, "").to_s, scope: args.fetch(:scope, "").to_s ) when "password" Password.new( - client_id: args[:client_id].to_s, - client_secret: args[:client_secret].to_s, + client_id: args.fetch(:client_id, "").to_s, + client_secret: args.fetch(:client_secret, "").to_s, username: args.fetch(:username, "").to_s, password: args.fetch(:password, "").to_s ) when "refresh_token" RefreshToken.new( - client_id: args[:client_id].to_s, - client_secret: args[:client_secret].to_s, + client_id: args.fetch(:client_id, "").to_s, + client_secret: args.fetch(:client_secret, "").to_s, refresh_token: args.fetch(:refresh_token, "").to_s, requested_scope: args.fetch(:scope, nil).try(&.to_s) ) when "urn:ietf:params:oauth:grant-type:device_code" DeviceCode.new( - client_id: args[:client_id].to_s, + client_id: args.fetch(:client_id, "").to_s, device_code: args.fetch(:device_code, "").to_s ) + when "challenge_response", "urn:ietf:params:oauth:grant-type:challenge" + ChallengeResponse.new( + client_id: args.fetch(:client_id, "").to_s, + client_secret: args.fetch(:client_secret, "").to_s, + challenge_id: args.fetch(:challenge_id, "").to_s, + response: args.fetch(:response, "").to_s, + scope: args.fetch(:scope, "").to_s, + key_id: args[:key_id]?.try(&.to_s), + algorithm: args[:algorithm]?.try(&.to_s), + grant_type: grant_type, + ) else raise Error.unsupported_grant_type end diff --git a/src/authly/grants/challenge_response.cr b/src/authly/grants/challenge_response.cr new file mode 100644 index 0000000..e0a2e24 --- /dev/null +++ b/src/authly/grants/challenge_response.cr @@ -0,0 +1,148 @@ +module Authly + class ChallengeResponse + include GrantStrategy + + GRANT_TYPES = {"challenge_response", "urn:ietf:params:oauth:grant-type:challenge"} + + getter client_id : String + getter client_secret : String + getter challenge_id : String + getter response : String + + @subject_id : String? = nil + + def initialize( + @client_id : String, + @challenge_id : String, + @response : String, + @client_secret : String = "", + @scope : String = "", + @key_id : String? = nil, + @algorithm : String? = nil, + @grant_type : String = "challenge_response", + ) + end + + def subject : String? + @subject_id || @client_id + end + + def granted_scope : String? + @scope + end + + def authorized? : Bool + validate_client! + raise Error.invalid_request if @challenge_id.empty? || @response.empty? + + challenge_data = Authly.config.challenge_store.record_attempt!( + @challenge_id, + Authly.config.challenge_max_attempts + ) + + unless challenge_data.client_id == @client_id + Authly.config.challenge_store.delete(@challenge_id) + raise Error.invalid_grant + end + + unless verify_signature?(challenge_data) + raise Error.invalid_grant + end + + Authly.config.challenge_store.delete(@challenge_id) + true + end + + def self.create_challenge( + client_id : String, + username : String? = nil, + owner_id : String? = nil, + key_id : String? = nil, + ttl : Time::Span = Authly.config.challenge_ttl, + ) : Challenge + challenge = Challenge.generate( + client_id: client_id, + ttl: ttl, + username: username, + owner_id: owner_id, + key_id: key_id, + ) + Authly.config.challenge_store.store(challenge) + end + + private def validate_client! : Nil + raise Error.invalid_client if @client_id.empty? + + if @client_secret.empty? + raise Error.invalid_client unless Authly.clients.valid_client?(@client_id) + else + raise Error.invalid_client unless Authly.clients.authorized?(@client_id, @client_secret) + end + + raise Error.unauthorized_client unless grant_type_authorized? + end + + private def grant_type_authorized? : Bool + Authly.clients.allowed_grant_type?(@client_id, @grant_type) || + Authly.clients.allowed_grant_type?(@client_id, "challenge_response") || + Authly.clients.allowed_grant_type?(@client_id, "urn:ietf:params:oauth:grant-type:challenge") + end + + private def verify_signature?(challenge_data : Challenge) : Bool + owner_id = challenge_data.owner_id.presence + username = challenge_data.username.presence + effective_key_id = @key_id.presence || challenge_data.key_id.presence + + if owner_target = (owner_id || username) + verified = Authly.owners.verify_challenge_response?( + challenge: challenge_data.challenge, + response: @response, + identifier: owner_target, + username: username, + owner_id: owner_id, + key_id: effective_key_id, + algorithm: @algorithm, + ) + @subject_id = owner_subject(owner_target) if verified + return verified + end + + if effective_key_id + if Authly.owners.verify_challenge_response?( + challenge: challenge_data.challenge, + response: @response, + identifier: effective_key_id, + key_id: effective_key_id, + algorithm: @algorithm, + ) + @subject_id = owner_subject(effective_key_id) + return true + end + end + + if Authly.clients.verify_challenge_response?( + challenge: challenge_data.challenge, + response: @response, + client_id: @client_id, + key_id: effective_key_id, + algorithm: @algorithm, + ) + @subject_id = @client_id + return true + end + + if Authly.owners.verify_challenge_response?( + challenge: challenge_data.challenge, + response: @response, + identifier: @client_id, + key_id: effective_key_id, + algorithm: @algorithm, + ) + @subject_id = @client_id + return true + end + + false + end + end +end diff --git a/src/authly/handler.cr b/src/authly/handler.cr index a97a5da..7f4cc5c 100644 --- a/src/authly/handler.cr +++ b/src/authly/handler.cr @@ -19,6 +19,8 @@ module Authly handle_token_endpoint(context) when "/oauth/par" handle_if_method(context, "POST") { PARHandler.handle(context) } + when "/oauth/challenge" + handle_challenge_endpoint(context) when "/oauth/device/code", "/device" handle_device_endpoint(context, path) when "/oauth/introspect", "/introspect" @@ -54,6 +56,13 @@ module Authly end end + private def handle_challenge_endpoint(context) : Bool + return false unless Authly.config.grant_types.includes?("challenge_response") || + Authly.config.grant_types.includes?("urn:ietf:params:oauth:grant-type:challenge") + + handle_if_method(context, "POST") { ChallengeHandler.handle(context) } + end + private def handle_if_method(context, expected_method : String, &) return false unless context.request.method == expected_method yield diff --git a/src/authly/handlers/access_token_handler.cr b/src/authly/handlers/access_token_handler.cr index dfaef98..dff60ab 100644 --- a/src/authly/handlers/access_token_handler.cr +++ b/src/authly/handlers/access_token_handler.cr @@ -5,7 +5,7 @@ module Authly params = context.request.form_params grant_type = params.fetch("grant_type", "") raise Error.unsupported_grant_type unless Authly.config.grant_types.includes?(grant_type) - client_id = ClientAuthentication.authenticate(context) + client_id = authenticate_client(context, grant_type) return unless client_id _, client_secret = ClientAuthentication.credentials(context) redirect_uri = params.fetch("redirect_uri", "") @@ -14,6 +14,11 @@ module Authly refresh_token = params.fetch("refresh_token", "") code = params.fetch("code", "") code_verifier = params.fetch("code_verifier", "") + device_code = params.fetch("device_code", "") + challenge_id = params.fetch("challenge_id", "") + response = params.fetch("response", "") + key_id = params["key_id"]? + algorithm = params["algorithm"]? scope = params["scope"]? # Extract certificate thumbprint for mTLS binding @@ -29,6 +34,11 @@ module Authly code: code, verifier: code_verifier, refresh_token: refresh_token, + device_code: device_code, + challenge_id: challenge_id, + response: response, + key_id: key_id, + algorithm: algorithm, scope: scope, cert_thumbprint: cert_thumbprint, options: Authly.config.issuance_options.try(&.call(client_id, grant_type)), @@ -40,6 +50,26 @@ module Authly ResponseHelper.write(context, e.code, "application/json", {error: e.type.to_s}.to_json) end + private def self.authenticate_client(context : HTTP::Server::Context, grant_type : String) : String? + if ChallengeResponse::GRANT_TYPES.includes?(grant_type) + has_basic = context.request.headers["Authorization"]?.try(&.matches?(/\ABasic(\s|\z)/i)) || false + client_id, client_secret = ClientAuthentication.credentials(context) + if has_basic || client_secret + return ClientAuthentication.authenticate(context) + end + + raise Error.invalid_request if client_id.nil? || client_id.empty? + return client_id if Authly.clients.valid_client?(client_id) + + context.response.headers["WWW-Authenticate"] = %(Basic realm="authly") + ResponseHelper.write(context, 401, "application/json", + {error: "invalid_client", error_description: "Client authentication failed"}.to_json) + return + end + + ClientAuthentication.authenticate(context) + end + private def self.extract_certificate_thumbprint(context : HTTP::Server::Context) : String? return unless Authly.config.require_certificate_bound_tokens? diff --git a/src/authly/handlers/challenge_handler.cr b/src/authly/handlers/challenge_handler.cr new file mode 100644 index 0000000..cd93ba3 --- /dev/null +++ b/src/authly/handlers/challenge_handler.cr @@ -0,0 +1,75 @@ +module Authly + class ChallengeHandler + def self.handle(context : HTTP::Server::Context) : Nil + params = extract_params(context) + basic_id, basic_secret = ClientAuthentication.credentials(context) + + client_id = basic_id.presence || params["client_id"]?.try(&.presence) + client_secret = basic_secret.presence || params["client_secret"]?.try(&.presence) + + raise Error.invalid_request unless client_id + + if context.request.headers["Authorization"]?.try(&.matches?(/\ABasic(\s|\z)/i)) || client_secret + raise Error.invalid_client unless client_secret && Authly.clients.authorized?(client_id, client_secret) + else + raise Error.invalid_client unless Authly.clients.valid_client?(client_id) + end + + unless Authly.clients.allowed_grant_type?(client_id, "challenge_response") || + Authly.clients.allowed_grant_type?(client_id, "urn:ietf:params:oauth:grant-type:challenge") + raise Error.unauthorized_client + end + + username = params["username"]?.try(&.presence) + owner_id = params["owner_id"]?.try(&.presence) + key_id = params["key_id"]?.try(&.presence) + + challenge_data = ChallengeResponse.create_challenge( + client_id: client_id, + username: username, + owner_id: owner_id, + key_id: key_id, + ) + + response = { + challenge_id: challenge_data.challenge_id, + challenge: challenge_data.challenge, + expires_in: Authly.config.challenge_ttl.total_seconds.to_i, + } + + context.response.headers["Cache-Control"] = "no-store" + ResponseHelper.write(context, 200, "application/json", response.to_json) + rescue e : Error + ResponseHelper.write(context, e.code, "application/json", { + error: e.type.to_s, + error_description: e.message, + }.to_json) + end + + private def self.extract_params(context : HTTP::Server::Context) : Hash(String, String) + result = Hash(String, String).new + content_type = context.request.headers["Content-Type"]? || "" + + if content_type.includes?("application/json") + begin + if body = context.request.body.try(&.gets_to_end) + if parsed = JSON.parse(body).as_h? + parsed.each do |k, v| + if str = v.as_s? + result[k] = str + end + end + end + end + rescue JSON::ParseException + end + else + context.request.form_params.each do |k, v| + result[k] = v + end + end + + result + end + end +end diff --git a/src/authly/handlers/discovery_handler.cr b/src/authly/handlers/discovery_handler.cr index 174eb25..fe61cec 100644 --- a/src/authly/handlers/discovery_handler.cr +++ b/src/authly/handlers/discovery_handler.cr @@ -14,6 +14,7 @@ module Authly introspection_endpoint: "#{base_url}/oauth/introspect", device_authorization_endpoint: "#{base_url}/oauth/device/code", pushed_authorization_request_endpoint: "#{base_url}/oauth/par", + challenge_endpoint: "#{base_url}/oauth/challenge", scopes_supported: ["openid", "profile", "email", "address", "phone", "offline_access"], response_types_supported: Authly.config.grant_types.includes?("implicit") ? ["code", "token"] : ["code"], response_modes_supported: ["query", "fragment", "form_post", "query.jwt", "fragment.jwt", "form_post.jwt", "jwt"], diff --git a/src/authly/owner.cr b/src/authly/owner.cr index 2cb7692..561123c 100644 --- a/src/authly/owner.cr +++ b/src/authly/owner.cr @@ -3,8 +3,18 @@ module Authly property id : String = Random::Secure.hex(16) property username : String property password : String + property public_key : String? = nil + property secret_key : String? = nil + property key_id : String? = nil - def initialize(@username, @password) + def initialize( + @username : String, + @password : String, + @public_key : String? = nil, + @secret_key : String? = nil, + @key_id : String? = nil, + @id : String = Random::Secure.hex(16), + ) end end @@ -26,8 +36,48 @@ module Authly end end + 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 + target = identifier.presence || owner_id.presence || username.presence || key_id.presence + if target + owner = find_owner(target) + return false unless owner + + ChallengeVerifier.verify?( + challenge: challenge, + response: response, + public_key: owner.public_key, + secret: owner.secret_key.presence || owner.password, + algorithm: algorithm, + ) + else + any? do |candidate| + ChallengeVerifier.verify?( + challenge: challenge, + response: response, + public_key: candidate.public_key, + secret: candidate.secret_key.presence || candidate.password, + algorithm: algorithm, + ) + end + end + end + + def find_owner(identifier : String) : Owner? + find do |owner| + owner.username == identifier || owner.id == identifier || owner.key_id == identifier + end + end + def id_token(user_id : String) : Hash(String, String | Int64) - user = find! { |owner| owner.username == user_id } + user = find! { |owner| owner.username == user_id || owner.id == user_id || owner.key_id == user_id } { "user_id" => user.username, diff --git a/src/authly/token_manager.cr b/src/authly/token_manager.cr index 19d994b..d6f82bf 100644 --- a/src/authly/token_manager.cr +++ b/src/authly/token_manager.cr @@ -185,28 +185,32 @@ module Authly def initialize( @config = Authly.config, - @token_manager : TokenStrategy = TokenStrategyFactory.create, + @token_manager : TokenStrategy? = nil, ) end + private def token_manager : TokenStrategy + @token_manager || TokenStrategyFactory.create + end + def revoke(token) - @token_manager.revoke(token) + token_manager.revoke(token) end def revoked?(token) - @token_manager.revoked?(token) + token_manager.revoked?(token) end def valid?(token) - @token_manager.valid?(token) + token_manager.valid?(token) end def introspect(token : String) - @token_manager.introspect(token) + token_manager.introspect(token) end def issued_to?(token : String, client_id : String) : Bool - @token_manager.issued_to?(token, client_id) + token_manager.issued_to?(token, client_id) end end end