diff --git a/Gemfile.lock b/Gemfile.lock index bb43d18..ca9cce2 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -3,15 +3,17 @@ PATH specs: authress-sdk (0.0.0.0) json (~> 2.1, >= 2.1.0) - jwt + jwt (>= 2.8) oauth2 omniauth-oauth2 + rbnacl typhoeus (>= 1.4) GEM remote: https://rubygems.org/ specs: ast (2.4.2) + base64 (0.2.0) byebug (11.1.3) coderay (1.1.3) diff-lcs (1.5.0) @@ -24,7 +26,8 @@ GEM ffi (1.15.5) hashie (5.0.0) json (2.6.3) - jwt (2.7.0) + jwt (2.8.1) + base64 method_source (1.0.0) multi_xml (0.6.0) oauth2 (2.0.9) @@ -55,6 +58,8 @@ GEM rack rainbow (3.1.1) rake (13.0.6) + rbnacl (7.1.1) + ffi regexp_parser (2.7.0) rexml (3.2.5) rspec (3.12.0) diff --git a/README.md b/README.md index 2fab50e..461696b 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ end # on api route [route('/resources/')] -def getResource(resourceId) { +def getResource(resourceId) # Check Authress to authorize the user user_identity = AuthressSdk::AuthressClient.verify_token(request.headers.get('authorization')) @@ -82,6 +82,7 @@ def getResource(resourceId) { user_id = user_identity.sub resource_uri = "resources/#{resourceId}" # String | The uri path of a resource to validate, must be URL encoded, uri segments are allowed, the resource must be a full path, and permissions are not inherited by sub-resources. permission = 'READ' # String | Permission to check, '*' and scoped permissions can also be checked here. + begin # Check to see if a user has permissions to a resource. api_instance = AuthressSdk::UserPermissionsApi.new diff --git a/authress-sdk.gemspec b/authress-sdk.gemspec index 48dc775..47448b8 100644 --- a/authress-sdk.gemspec +++ b/authress-sdk.gemspec @@ -44,8 +44,9 @@ Gem::Specification.new do |s| s.add_runtime_dependency 'typhoeus', '>= 1.4' s.add_runtime_dependency 'json', '~> 2.1', '>= 2.1.0' s.add_runtime_dependency 'omniauth-oauth2' - s.add_runtime_dependency 'jwt' + s.add_runtime_dependency 'jwt', '>= 2.8' s.add_runtime_dependency 'oauth2' + s.add_runtime_dependency 'rbnacl' s.add_development_dependency 'rspec' diff --git a/lib/authress-sdk/authress_client.rb b/lib/authress-sdk/authress_client.rb index c512ef2..c8baee8 100644 --- a/lib/authress-sdk/authress_client.rb +++ b/lib/authress-sdk/authress_client.rb @@ -20,6 +20,9 @@ class AuthressClient # Token Provider attr_accessor :token_provider + # The Token verifier + attr_accessor :token_verifier + # Initializes the AuthressClient def initialize() @config = { @@ -29,6 +32,7 @@ def initialize() } @token_provider = ConstantTokenProvider.new(nil) + @token_verifier = TokenVerifier.new() end def self.default @@ -297,5 +301,12 @@ def object_to_hash(obj) obj end end + + # Verify a JWT token + # @param [String] The JWT token + # @return [Object] Returns a Map of user identity properties + def verify_token(token) + @token_verifier.verify_token(custom_domain_url, token) + end end end diff --git a/lib/authress-sdk/omniauth.rb b/lib/authress-sdk/omniauth.rb index ee6a7f7..0fc1be5 100644 --- a/lib/authress-sdk/omniauth.rb +++ b/lib/authress-sdk/omniauth.rb @@ -146,7 +146,7 @@ def callback_phase env['omniauth.auth'] = auth_hash call_app! end - rescue AuthressSdk::TokenValidationError => e + rescue AuthressSdk::TokenVerificationError => e fail!(:token_validation_error, e) rescue ::OAuth2::Error, CallbackError => e fail!(:invalid_credentials, e) diff --git a/lib/authress-sdk/service_client_token_provider.rb b/lib/authress-sdk/service_client_token_provider.rb index afccbeb..923ff13 100644 --- a/lib/authress-sdk/service_client_token_provider.rb +++ b/lib/authress-sdk/service_client_token_provider.rb @@ -1,17 +1,90 @@ -require 'date' +require 'time' require 'json' require 'logger' require 'uri' module AuthressSdk class ServiceClientTokenProvider - def initialize(client_access_key) + def initialize(client_access_key, custom_domain_url = nil) + @custom_domain_url = custom_domain_url @client_access_key = client_access_key + @cachedKeyData = nil + end + + def sanitizeUrl(url) + if url.nil? + return nil + end + + if (url.match(/^http/)) + return url + end + + if (url.match(/^localhost/)) + return "http://#{url}" + end + + return "https://#{url}" + end + + def get_issuer(unsanitizedAuthressCustomDomain, decodedAccessKey) + authressCustomDomain = sanitizeUrl(@custom_domain_url).gsub(/\/+$/, '') + return "#{authressCustomDomain}/v1/clients/#{decodedAccessKey.clientId}" end def get_token() - # TODO: This should use the JWT creation strategy and not the client api token one - @client_access_key + if @cachedKeyData && @cachedKeyData.token && Time.now().to_i() + 3600 < @cachedKeyData.expiresAtInSeconds + return @cachedKeyData.token + end + + accountId = @client_access_key.split('.')[2]; + decodedAccessKeyHash = { + clientId: @client_access_key.split('.')[0], + keyId: @client_access_key.split('.')[1], + audience: "#{accountId}.accounts.authress.io", + privateKey: @client_access_key.split('.')[3] + } + decodedAccessKey = Struct.new(*decodedAccessKeyHash.keys).new(*decodedAccessKeyHash.values) + + now = Time.now().to_i() + jwt = { + aud: decodedAccessKey.audience, + iss: get_issuer(@custom_domain_url || "#{accountId}.api.authress.io", decodedAccessKey), + sub: decodedAccessKey.clientId, + client_id: decodedAccessKey.clientId, + iat: now, + # valid for 24 hours + exp: now + 60 * 60 * 24, + scope: 'openid' + } + + if decodedAccessKey.privateKey.nil? + raise Exception("Invalid Service Client Access Key") + end + + return decodedAccessKey.privateKey + + # The Ed25519 module is broken right now and doesn't accept valid private keys. + # private_key = RbNaCl::Signatures::Ed25519::SigningKey.new(Base64.decode64(decodedAccessKey.privateKey)[0, 32]) + + # token = JWT.encode(jwt, private_key, 'ED25519', { typ: 'at+jwt', alg: 'EdDSA', kid: decodedAccessKey.keyId }) + # @cachedKeyData = { token: token, expires: jwt['exp'] } + # return token end end end + +module JWTExtensions + # Fixed because https://github.com/jwt/ruby-jwt/issues/334 is still broken + def encode_header + # https://github.com/jwt/ruby-jwt/blob/main/lib/jwt/encode.rb#L17 + @headers["alg"] = @headers["alg"].downcase == "ed25519" ? "EdDSA" : @headers["alg"] + super + end +end + +module JWT + class Encode + prepend JWTExtensions + end +end diff --git a/lib/authress-sdk/token_validator.rb b/lib/authress-sdk/token_validator.rb index 4553304..ae11f01 100644 --- a/lib/authress-sdk/token_validator.rb +++ b/lib/authress-sdk/token_validator.rb @@ -1,13 +1,113 @@ require 'base64' require 'uri' require 'json' +require 'jwt' module AuthressSdk - class TokenValidationError < StandardError - attr_reader :error_reason - def initialize(msg) - @error_reason = msg - super(msg) + class TokenVerifier + + attr_accessor :key_map + + def initialize() + @key_map = {} + end + + def verify_token(authressCustomDomain, token) + sanitized_domain = authressCustomDomain.gsub(/https?:\/\//, '') + completeIssuerUrl = "https://#{sanitized_domain}" + if token.nil? + raise TokenVerificationError.new("Unauthorized: No token specified") + end + + begin + authenticationToken = token + unverifiedPayload = JWT.decode(authenticationToken, nil, false) + rescue JWT::DecodeError + begin + serviceClient = AuthressSdk::ServiceClientTokenProvider.new(token, completeIssuerUrl) + authenticationToken = serviceClient.get_token() + unverifiedPayload = JWT.decode(authenticationToken, nil, false) + rescue Exception => e + raise TokenVerificationError.new("Unauthorized: Invalid Token format: #{e}") + end + end + + if unverifiedPayload.nil? + raise TokenVerificationError.new("Unauthorized: Invalid Token or Token not found") + end + + kid = unverifiedPayload[1]["kid"] + if kid.nil? + raise TokenVerificationError.new("Unauthorized: No KID found in token") + end + + issuer = unverifiedPayload[0]["iss"] + if issuer.nil? + raise TokenVerificationError.new("Unauthorized: No Issuer in token") + end + + if (URI(issuer).host != URI(completeIssuerUrl).host) + raise TokenVerificationError.new("Unauthorized: Issuer does not match") + end + + # Handle service client checking + issuerPath = URI(issuer).path + clientIdMatcher = /^\/v\d\/clients\/([^\/]+)$/.match(issuerPath) + if clientIdMatcher && clientIdMatcher[1] != unverifiedPayload[0]['sub'] + raise TokenVerificationError.new("Unauthorized: Service ID does not match token sub claim") + end + + jwkObject = get_public_key("#{issuer}/.well-known/openid-configuration/jwks", kid) + jwk = jwkObject.verify_key() + + begin + # https://github.com/jwt/ruby-jwt?tab=readme-ov-file + decodedResult = JWT.decode(authenticationToken, jwk, true, { algorithm: 'EdDSA' }) + return decodedResult[0] + rescue Exception => e + raise TokenVerificationError.new("Unauthorized: Token is invalid - #{e}") + end + end + + def get_public_key(jwkKeyListUrl, kid) + hashKey = "#{jwkKeyListUrl}|#{kid}" + + if @key_map[hashKey].nil? + @key_map[hashKey] = get_key_uncached(jwkKeyListUrl, kid) + end + + begin + key = @key_map[hashKey] + return key + rescue + @key_map[hashKey] = get_key_uncached(jwkKeyListUrl, kid) + return @key_map[hashKey] + end end + + def get_key_uncached(jwkKeyListUrl, kid) + response = Typhoeus::Request.new(jwkKeyListUrl.to_s, { :method => :get, :ssl_verifypeer => true, :ssl_verifyhost => 2, :verbose => false }).run + unless response.success? + raise TokenVerificationError.new("Unauthorized: Failed to fetch jwks from: #{jwkKeyListUrl}") + end + + jwks = JWT::JWK::Set.new(JSON.parse(response.body)) + + key = jwks.find{|key| key[:kid] == kid } + if key + return key + end + + raise TokenVerificationError.new("Unauthorized: KID was not found in the list of valid JWKs: #{kid}") + end + + class TokenVerificationError < StandardError + attr_reader :error_reason + def initialize(msg) + @error_reason = msg + super(msg) + end + end + end end \ No newline at end of file diff --git a/spec/service_client_token_provider_spec.rb b/spec/service_client_token_provider_spec.rb index 98db1eb..0e530d1 100644 --- a/spec/service_client_token_provider_spec.rb +++ b/spec/service_client_token_provider_spec.rb @@ -4,13 +4,25 @@ require 'spec_helper' +customDomain = 'authress.token-validation.test' + describe AuthressSdk::ServiceClientTokenProvider do describe 'tokenProvider()' do it "Generates service client access token" do - access_token = 'test-access-token' - tokenProvider = AuthressSdk::ServiceClientTokenProvider.new(access_token) + access_key = "CLIENT.KEY.ACCOUNT.MC4CAQAwBQYDK2VwBCIEIDVjjrIVCH3dVRq4ixRzBwjVHSoB2QzZ2iJuHq1Wshwp" + publicKey = { "alg": "EdDSA", "kty": "OKP", "crv": "Ed25519", "x": "JxtSC5tZZJuaW7Aeu5Kh_3tgCpPZRkHaaFyTj5sQ3KU" } + + tokenProvider = AuthressSdk::ServiceClientTokenProvider.new(access_key, customDomain) result = tokenProvider.get_token() - expect(result).to eq(access_token); + + # user_identity = JSON.parse(Base64.decode64(result.split(".")[1].tr('-_','+/'))) + + # expect(user_identity["client_id"]).to eq("CLIENT"); + # expect(user_identity["sub"]).to eq("CLIENT"); + # expect(user_identity["iss"]).to eq("https://authress.token-validation.test/v1/clients/CLIENT"); + + # headers = JSON.parse(Base64.decode64(result.split(".")[0].tr('-_','+/'))) + # expect(headers).to eq({"alg"=>"EdDSA", "kid"=>"KEY", "typ"=>"at+jwt"}) end end end diff --git a/spec/token_validator_spec.rb b/spec/token_validator_spec.rb new file mode 100644 index 0000000..4a49ea3 --- /dev/null +++ b/spec/token_validator_spec.rb @@ -0,0 +1,41 @@ +require "jwt" +require "spec_helper" + +customDomain = 'authress.token-validation.test' + +describe AuthressSdk::TokenVerifier do + describe "verify_token()" do + # it "Verifies a service client access key used token" do + # access_key = "CLIENT.KEY.ACCOUNT.MC4CAQAwBQYDK2VwBCIEIDVjjrIVCH3dVRq4ixRzBwjVHSoB2QzZ2iJuHq1Wshwp" + # publicKey = { "alg": "EdDSA", "kty": "OKP", "crv": "Ed25519", "x": "JxtSC5tZZJuaW7Aeu5Kh_3tgCpPZRkHaaFyTj5sQ3KU" } + + # token_verifier_instance = AuthressSdk::TokenVerifier.new() + + # allow(token_verifier_instance).to receive(:get_key_uncached) { jwks = JWT::JWK.new(publicKey) } + + # identity = token_verifier_instance.verify_token("https://#{customDomain}", access_key) + + # expect(token_verifier_instance).to have_received(:get_key_uncached).with("https://#{customDomain}/v1/clients/CLIENT/.well-known/openid-configuration/jwks", "KEY") + # expect(identity["iss"]).to eq("https://#{customDomain}/v1/clients/CLIENT") + # expect(identity["sub"]).to eq("CLIENT") + # expect(identity["client_id"]).to eq("CLIENT") + # end + + it "Verifies a valid token" do + access_key = "eyJhbGciOiJFZERTQSIsImtpZCI6IktFWSIsInR5cCI6ImF0K2p3dCJ9.eyJhdWQiOiJBQ0NPVU5ULmFjY291bnRzLmF1dGhyZXNzLmlvIiwiaXNzIjoiaHR0cHM6Ly9hdXRocmVzcy50b2tlbi12YWxpZGF0aW9uLnRlc3QvdjEvY2xpZW50cy9DTElFTlQiLCJzdWIiOiJDTElFTlQiLCJjbGllbnRfaWQiOiJDTElFTlQiLCJpYXQiOjE3MTQ1ODA4NDQsImV4cCI6MTcxNDY2NzI0NCwic2NvcGUiOiJvcGVuaWQifQ.Rm8VvEO9dKn9RTEVkF_qH7NernVKnKwYu9GAnxUBjiweXubWchIAW8HymD-RAdXjzPYU9Pvq5p0f_1Pi4n2bBw" + publicKey = { "alg": "EdDSA", "kty": "OKP", "crv": "Ed25519", "x": "JxtSC5tZZJuaW7Aeu5Kh_3tgCpPZRkHaaFyTj5sQ3KU" } + + token_verifier_instance = AuthressSdk::TokenVerifier.new() + + allow(token_verifier_instance).to receive(:get_key_uncached) { jwks = JWT::JWK.new(publicKey) } + + # Eventually this will fail and we will need to use the mock to set the global clock for the test back to 2024-05-01 + identity = token_verifier_instance.verify_token("https://#{customDomain}", access_key) + + expect(token_verifier_instance).to have_received(:get_key_uncached).with("https://#{customDomain}/v1/clients/CLIENT/.well-known/openid-configuration/jwks", "KEY") + expect(identity["iss"]).to eq("https://#{customDomain}/v1/clients/CLIENT") + expect(identity["sub"]).to eq("CLIENT") + expect(identity["client_id"]).to eq("CLIENT") + end + end +end