Skip to content

Commit

Permalink
--wip--
Browse files Browse the repository at this point in the history
  • Loading branch information
eliasjpr committed Oct 11, 2024
1 parent b2c4f54 commit 6f53cb9
Show file tree
Hide file tree
Showing 9 changed files with 169 additions and 47 deletions.
2 changes: 1 addition & 1 deletion spec/spec_helper.cr
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,4 @@ require "faker"
require "../src/authly"
require "./support/settings"

BASE_URI = "http://0.0.0.0:4000"

39 changes: 33 additions & 6 deletions spec/support/handlers_spec.cr
Original file line number Diff line number Diff line change
@@ -1,21 +1,49 @@
# Spec tests for Authly's OAuth Handlers
require "./spec_helper"
require "../spec_helper"
require "http/server"

module Authly
describe "AuthorizationHandler" do
xit "returns authorization code with valid client_id and redirect_uri" do
response = HTTP::Client.get("#{BASE_URI}/oauth/authorize?client_id=1&redirect_uri=https://www.example.com/callback&response_type=code")
it "returns authorization code with valid client_id and redirect_uri after user consent and includes state" do
state = "test_state"
response = HTTP::Client.get("#{BASE_URI}/oauth/authorize?client_id=1&redirect_uri=https://www.example.com/callback&response_type=code&state=#{state}&consent=approved")

response.status_code.should eq 302
response.headers["Location"].should_not be_nil
response.headers["Location"].should contain(URI.encode_path("code="))
response.headers["Location"].should contain(URI.encode_path("state=#{state}"))
end

xit "returns 401 for invalid client_id or redirect_uri" do
response = HTTP::Client.get("#{BASE_URI}/oauth/authorize?client_id=invalid&redirect_uri=invalid")
it "renders consent page if user consent is not provided" do
state = "test_state"
response = HTTP::Client.get("#{BASE_URI}/oauth/authorize?client_id=1&redirect_uri=https://www.example.com/callback&response_type=code&state=#{state}")

response.status_code.should eq 200
response.body.should contain("Authorization Request")
response.body.should contain("Approve")
response.body.should contain("Deny")
end

it "returns 401 for invalid client_id or redirect_uri" do
state = "test_state"
response = HTTP::Client.get("#{BASE_URI}/oauth/authorize?client_id=invalid&redirect_uri=invalid&state=#{state}&consent=approved")
response.status_code.should eq 401
response.body.should eq "This client is not authorized to use the requested grant type"
end

it "returns 400 for invalid state parameter" do
state = "invalid_state"
response = HTTP::Client.get("#{BASE_URI}/oauth/authorize?client_id=1&redirect_uri=https://www.example.com/callback&response_type=code&state=#{state}&consent=approved")

response.status_code.should eq 400
response.body.should eq "Invalid state parameter"
end

it "stores state parameter during initial request" do
state = "test_state"
response = HTTP::Client.get("#{BASE_URI}/oauth/authorize?client_id=1&redirect_uri=https://www.example.com/callback&response_type=code&state=#{state}")
STATE_STORE.valid?(state).should eq true
end
end

describe "TokenHandler" do
Expand All @@ -30,7 +58,6 @@ module Authly
})
response.status_code.should eq 200
body = JSON.parse(response.body)
body["access_token"]
body["access_token"].should_not be_nil
end

Expand Down
4 changes: 4 additions & 0 deletions spec/support/settings.cr
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
secret_key = "4bce37fbb1542a68dddba2da22635beca9d814cb3424c461fcc8876904ad39c1"
BASE_URI = "http://0.0.0.0:4000"
STATE_STORE = Authly::InMemoryStateStore.new

Authly.configure do |config|
config.secret_key = secret_key
config.public_key = secret_key
config.state_store = STATE_STORE
end

Authly.clients << Authly::Client.new("example", "secret", "https://www.example.com/callback", "1")
Expand Down
4 changes: 3 additions & 1 deletion spec/support/test_server.cr
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
require "http/server"
require "../src/authly"
require "../../src/authly"
require "./settings"

server = HTTP::Server.new([
Authly::OAuthHandler.new,
])
server.bind_tcp "0.0.0.0", 4000
puts "Listening on http://0.0.0.0:4000"

server.listen
1 change: 1 addition & 0 deletions src/authly/configuration.cr
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,6 @@ module Authly
property token_store : TokenStore = InMemoryStore.new
property algorithm : JWT::Algorithm = JWT::Algorithm::HS256
property token_strategy : Symbol = :jwt
property state_store : StateStore = InMemoryStateStore.new
end
end
40 changes: 1 addition & 39 deletions src/authly/handler.cr
Original file line number Diff line number Diff line change
@@ -1,42 +1,7 @@
require "http/server/handler"
require "./handlers/*"

module Authly
module ResponseHelper
def self.write(context, status_code, content_type = "text/plain", body = "")
context.response.status_code = status_code
context.response.content_type = content_type
context.response.print body
end
end

class AuthorizationHandler
def self.handle(context)
params = context.request.query_params
client_id = params.fetch("client_id", "")
redirect_uri = params.fetch("redirect_uri", "")
response_type = params.fetch("response_type", "")
scope = params.fetch("scope", "")
state = params.fetch("state", "")
code_challenge = params.fetch("code_challenge", "")
challenge_method = params.fetch("code_challenge_method", "")
user_id = params.fetch("user_id", "")

authorization_code = Authly.code(
response_type,
client_id,
redirect_uri,
scope,
code_challenge,
challenge_method,
user_id).to_s

context.response.headers["Location"] = "#{redirect_uri}?code=#{authorization_code}&state=#{state}"
ResponseHelper.write(context, 302)
rescue e : Error
ResponseHelper.write(context, e.code, "text/plain", e.message)
end
end

class AccessTokenHandler
def self.handle(context)
# Extracting request parameters
Expand Down Expand Up @@ -68,7 +33,6 @@ module Authly
ResponseHelper.write(context, e.code, "text/plain", e.message)
end
end

class IntrospectHandler
def self.handle(context)
if context.request.method == "POST"
Expand All @@ -85,7 +49,6 @@ module Authly
ResponseHelper.write(context, 400, "text/plain", e.message)
end
end

class RevokeHandler
def self.handle(context)
unless context.request.method == "POST"
Expand All @@ -103,7 +66,6 @@ module Authly
ResponseHelper.write(context, 400, "text/plain", e.message)
end
end

class OAuthHandler
include HTTP::Handler

Expand Down
88 changes: 88 additions & 0 deletions src/authly/handlers/authorization_handler.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
require "http/server/handler"
require "URI"

module Authly
class AuthorizationHandler
STATE_STORE = Authly.config.state_store

def self.handle(context)
params = context.request.query_params
client_id = params.fetch("client_id", "")
redirect_uri = params.fetch("redirect_uri", "")
response_type = params.fetch("response_type", "")
scope = params.fetch("scope", "")
code_challenge = params.fetch("code_challenge", "")
challenge_method = params.fetch("code_challenge_method", "")
user_id = params.fetch("user_id", "")
state = params.fetch("state", "")

# Store the state parameter to verify later
STATE_STORE.store(state)

# Check if user has given consent
unless user_has_given_consent?(context)
# Render consent page where the user can approve or deny the requested access
render_consent_page(context, client_id, scope, state)
return
end

# Verify the state parameter to prevent CSRF attacks
unless STATE_STORE.valid?(state)
ResponseHelper.write(context, 400, "text/plain", "Invalid state parameter")
return
end


# Generate authorization code after user consent
authorization_code = Authly.code(
response_type,
client_id,
redirect_uri,
scope,
code_challenge,
challenge_method,
user_id
).to_s

# Redirect the user-agent back to the redirect_uri with the code and state
redirect_location = URI.parse(redirect_uri)
redirect_location.query = URI.encode_path("code=#{authorization_code}&state=#{state}")
ResponseHelper.redirect(context, redirect_location.to_s)
rescue e : Error
ResponseHelper.write(context, e.code, "text/plain", e.message)
end

private def self.user_has_given_consent?(context)
# Logic to determine if the user has already given consent
# This can be done by checking session or context information
consent = context.request.query_params["consent"]?
consent == "approved"
end

private def self.render_consent_page(context, client_id, scope, state = SecureRandom.hex(32))
# Render a simple consent page where the user can approve or deny the requested access
ResponseHelper.write(context, 200, "text/html", <<-HTML)
<html>
<body>
<h1>Authorization Request</h1>
<p>Client ID: #{client_id}</p>
<p>Requested Scopes: #{scope}</p>
<form action="/authorize" method="get">
<input type="hidden" name="client_id" value="#{client_id}">
<input type="hidden" name="scope" value="#{scope}">
<input type="hidden" name="state" value="#{state}">
<input type="hidden" name="redirect_uri" value="#{context.request.query_params["redirect_uri"]}">
<button type="submit" name="consent" value="approved">Approve</button>
<button type="submit" name="consent" value="denied">Deny</button>
</form>
</body>
</html>
HTML
end

private def self.valid_state?(state)
# Verify that the state parameter exists in the store
STATE_STORE.valid?(state) == true
end
end
end
14 changes: 14 additions & 0 deletions src/authly/handlers/response_helper.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
module Authly
module ResponseHelper
def self.write(context, status_code, content_type = "text/plain", body = "")
context.response.status_code = status_code
context.response.content_type = content_type
context.response.write body.not_nil!.to_slice
end

def self.redirect(context, location)
context.response.status_code = 302
context.response.headers["Location"] = location
end
end
end
24 changes: 24 additions & 0 deletions src/authly/state_store.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
module Authly
module StateStore
abstract def store(state : String)
abstract def valid?(state : String) : Bool
end

class InMemoryStateStore
include StateStore

@store : Hash(String, Bool)

def initialize
@store = Hash(String, Bool).new
end

def store(state : String)
@store[state] = true
end

def valid?(state : String) : Bool
@store[state] == true
end
end
end

0 comments on commit 6f53cb9

Please sign in to comment.