From 5073197e470fb9c6cf9f0840d530c4153f776168 Mon Sep 17 00:00:00 2001 From: "Elias J. Perez" Date: Tue, 8 Oct 2024 19:27:07 -0400 Subject: [PATCH] Refactor token handling with strategy pattern Replaced direct JWT encoding/decoding calls with a strategy pattern using a `TokenManager` class and `TokenProvider` interface. This introduces support for multiple token types (JWT and opaque) and centralizes token management. Enhanced configuration to handle different token types and improved error handling with detailed messages for unsupported operations. This change aims to increase code flexibility and maintainability, allowing easy addition of new token strategies in the future. --- spec/authly_spec.cr | 4 +- src/authly.cr | 51 +++++---------- src/authly/access_token.cr | 4 +- src/authly/code.cr | 2 +- src/authly/configuration.cr | 1 + src/authly/error.cr | 5 ++ src/authly/grant.cr | 4 +- src/authly/grants/refresh_token.cr | 2 +- src/authly/token_manager.cr | 41 ++++++++++++ src/authly/token_provider.cr | 100 +++++++++++++++++++++++++++++ 10 files changed, 171 insertions(+), 43 deletions(-) create mode 100644 src/authly/token_manager.cr create mode 100644 src/authly/token_provider.cr diff --git a/spec/authly_spec.cr b/spec/authly_spec.cr index 793533b..8dc8eb8 100644 --- a/spec/authly_spec.cr +++ b/spec/authly_spec.cr @@ -24,7 +24,7 @@ describe Authly do code: code) if id_token = token.id_token - id_token_decoded = Authly.jwt_decode(id_token).first + id_token_decoded = Authly.decode_token(id_token).first token.should be_a Authly::AccessToken id_token_decoded["user_id"].should eq "username" @@ -122,7 +122,7 @@ describe Authly do describe ".introspect" do it "returns active token" do a_token = Authly::AccessToken.new(client_id, scope) - expected_token = Authly.jwt_decode(a_token.access_token).first + expected_token = Authly.decode_token(a_token.access_token).first token = Authly.introspect(a_token.access_token) token.should eq({ diff --git a/src/authly.cr b/src/authly.cr index 17531ec..8fe0ca9 100644 --- a/src/authly.cr +++ b/src/authly.cr @@ -33,52 +33,33 @@ module Authly Grant.new(grant_type, **args).token end - def self.jwt_encode(payload) - JWT.encode(payload, config.security.secret_key, config.security.algorithm) + def self.encode_token(payload) + token_manager = TokenManager.new + token_manager.encode(payload) end - def self.jwt_decode(token, secret_key = config.security.public_key) - JWT.decode token, secret_key, config.security.algorithm + def self.decode_token(token) + token_manager = TokenManager.new + token_manager.decode(token) end - def self.revoke(jti) - Authly.config.providers.jti_provider.revoke(jti) + def self.revoke(token) + token_manager = TokenManager.new + token_manager.revoke(token) end - def self.revoked?(jti) - Authly.config.providers.jti_provider.revoked?(jti) + def self.revoke?(token) + token_manager = TokenManager.new + token_manager.revoke?(token) end def self.valid?(token) - decoded_token, _header = jwt_decode(token) - jti = decoded_token["jti"].to_s - return false if revoked?(jti) - - exp = decoded_token["exp"].to_s.to_i - Time.utc.to_unix < exp - rescue e : JWT::DecodeError - Log.error { "Invalid token - #{e.message}" } - false + token_manager = TokenManager.new + token_manager.valid?(token) end def self.introspect(token : String) - # Decode the JWT, verify the signature and expiration - payload, _header = jwt_decode(token) - - # Check if the token is expired (exp claim is typically in seconds since epoch) - if Time.local.to_unix > payload["exp"].to_s.to_i - return {active: false, exp: payload["exp"]} - end - - # Return authly access token - { - active: true, - scope: payload["scope"], - cid: payload["cid"], - exp: payload["exp"], - sub: payload["sub"], - } - rescue JWT::DecodeError - {active: false} + token_manager = TokenManager.new + token_manager.introspect(token) end end diff --git a/src/authly/access_token.cr b/src/authly/access_token.cr index e3e0967..e2f7ebd 100644 --- a/src/authly/access_token.cr +++ b/src/authly/access_token.cr @@ -28,7 +28,7 @@ module Authly end private def generate_token - Authly.jwt_encode({ + Authly.encode_token({ "sub" => Random::Secure.hex(32), "iss" => Authly.config.issuer, "cid" => @client_id, @@ -40,7 +40,7 @@ module Authly end def refresh_token - Authly.jwt_encode({ + Authly.encode_token({ "sub" => @client_id, "name" => "refresh token", "iat" => Time.utc.to_unix, diff --git a/src/authly/code.cr b/src/authly/code.cr index 403b3b5..6c30b6a 100644 --- a/src/authly/code.cr +++ b/src/authly/code.cr @@ -22,7 +22,7 @@ module Authly end def jwt - Authly.jwt_encode({ + Authly.encode_token({ "code" => code, "challenge" => challenge, "method" => method, diff --git a/src/authly/configuration.cr b/src/authly/configuration.cr index 053c0f4..f825d4e 100644 --- a/src/authly/configuration.cr +++ b/src/authly/configuration.cr @@ -26,6 +26,7 @@ module Authly property security : SecurityConfiguration = SecurityConfiguration.new property ttl : TTLConfiguration = TTLConfiguration.new property providers : ProvidersConfiguration = ProvidersConfiguration.new + property token_type : String = "jwt" # Singleton instance @@instance : Configuration? diff --git a/src/authly/error.cr b/src/authly/error.cr index fecae9d..7eee29c 100644 --- a/src/authly/error.cr +++ b/src/authly/error.cr @@ -11,9 +11,14 @@ module Authly unauthorized_client: "This client is not authorized to use the requested grant type", unsupported_grant_type: "Invalid or unknown grant type", access_denied: "The user or authorization server denied the request", + unsupported_token_type: "The authorization server does not support the revocation of the presented token type", } class Error(Code) < Exception + def self.unsupported_token_type + raise Error(400).new(:unsupported_token_type) + end + def self.owner_credentials raise Error(400).new(:owner_credentials) end diff --git a/src/authly/grant.cr b/src/authly/grant.cr index 3ae9a9f..fb9c7c9 100644 --- a/src/authly/grant.cr +++ b/src/authly/grant.cr @@ -61,12 +61,12 @@ module Authly private def generate_id_token if scope.includes? "openid" - Authly.jwt_encode Authly.owners.id_token auth_code["user_id"].as_s + Authly.encode_token Authly.owners.id_token auth_code["user_id"].as_s end end private def auth_code - Authly.jwt_decode(@code).first + Authly.decode_token(@code).first end private def scope : String diff --git a/src/authly/grants/refresh_token.cr b/src/authly/grants/refresh_token.cr index 3f634f3..5f842ef 100644 --- a/src/authly/grants/refresh_token.cr +++ b/src/authly/grants/refresh_token.cr @@ -14,7 +14,7 @@ module Authly end private def validate_refresh_token! - Authly.jwt_decode(@refresh_token) + Authly.decode_token(@refresh_token) rescue e raise Error.invalid_grant end diff --git a/src/authly/token_manager.cr b/src/authly/token_manager.cr new file mode 100644 index 0000000..4961d0c --- /dev/null +++ b/src/authly/token_manager.cr @@ -0,0 +1,41 @@ +module Authly + # Token Management Class + class TokenManager + @token_provider : TokenProvider + + TOKEN_PROVIDER = { + "jwt" => JWTTokenProvider.new, + "opaque" => OpaqueTokenProvider.new + } + + def initialize + @token_provider = TOKEN_PROVIDER.fetch(Authly.config.token_type) do + raise Error.unsupported_token_type + end + end + + def encode(payload) : String + @token_provider.encode(payload) + end + + def decode(token : String) + @token_provider.decode(token) + end + + def valid?(token : String) : Bool + @token_provider.valid?(token) + end + + def revoke(token : String) + @token_provider.revoke(token) + end + + def revoke?(token : String) : Bool + @token_provider.revoke?(token) + end + + def introspect(token : String) + @token_provider.introspect(token) + end + end +end diff --git a/src/authly/token_provider.cr b/src/authly/token_provider.cr new file mode 100644 index 0000000..8cfd8fc --- /dev/null +++ b/src/authly/token_provider.cr @@ -0,0 +1,100 @@ +module Authly + module TokenProvider + abstract def encode(payload : Hash(String, String | Bool | Int64)) : String + + abstract def decode(token : String) : Hash(String, String | Bool | Int64) + + abstract def valid?(token : String) : Bool + + abstract def revoke(token : String) + + abstract def revoke?(token : String) : Bool + + abstract def introspect(token : String) : Hash(String, String | Bool | Int64) + end + + # JWT Token Provider + class JWTTokenProvider + include TokenProvider + + def encode(payload : Hash(String, String | Bool | Int64)) : String + JWT.encode(payload, Authly.config.security.secret_key, Authly.config.security.algorithm) + end + + def decode(token : String) : Hash(String, String | Bool | Int64) + decoded_token, _header = JWT.decode(token, Authly.config.security.public_key, Authly.config.security.algorithm) + decoded_token + end + + def valid?(token : String) : Bool + decoded_token, _header = JWT.decode(token, Authly.config.security.public_key, Authly.config.security.algorithm) + exp = decoded_token["exp"].to_s.to_i + Time.utc.to_unix < exp + rescue JWT::DecodeError + false + end + + def revoke(token : String) + Authly.config.providers.jti_provider.revoke(token) + end + + def revoke?(token : String) : Bool + Authly.config.providers.jti_provider.revoked?(token) + end + + def introspect(token : String) : Hash(String, String | Bool | Int64) + payload = decode(token) + active = valid?(token) + { + "active" => active, + "scope" => payload["scope"], + "cid" => payload["cid"], + "exp" => payload["exp"], + "sub" => payload["sub"] + } + rescue JWT::DecodeError + {"active" => false} + end + end + + # Opaque Token Provider + class OpaqueTokenProvider + include TokenProvider + + def encode(payload : Hash(String, String | Bool | Int64)) : String + token = Random::Secure.hex(32) + Authly.config.providers.jti_provider.store(token, payload) + token + end + + def decode(token : String) : Hash(String, String | Bool | Int64) + Authly.config.providers.jti_provider.fetch(token) || raise Error.invalid_token + end + + def valid?(token : String) : Bool + !Authly.config.providers.jti_provider.revoked?(token) + end + + def revoke(token : String) + Authly.config.providers.jti_provider.revoke(token) + end + + def revoke?(token : String) : Bool + Authly.config.providers.jti_provider.revoked?(token) + end + + def introspect(token : String) : Hash(String, String | Bool | Int64) + payload = decode(token) + active = valid?(token) + { + "active" => active, + "scope" => payload["scope"], + "cid" => payload["cid"], + "exp" => payload["exp"], + "sub" => payload["sub"] + } + rescue Error + {"active" => false} + end + end +end