Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add token verifier and support ServiceClient token generation. fix #1. #5

Merged
merged 1 commit into from
May 1, 2024
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
9 changes: 7 additions & 2 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,14 +74,15 @@ end

# on api route
[route('/resources/<resourceId>')]
def getResource(resourceId) {
def getResource(resourceId)
# Check Authress to authorize the user
user_identity = AuthressSdk::AuthressClient.verify_token(request.headers.get('authorization'))

# Check Authress to authorize the user
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
Expand Down
3 changes: 2 additions & 1 deletion authress-sdk.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down
11 changes: 11 additions & 0 deletions lib/authress-sdk/authress_client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -29,6 +32,7 @@ def initialize()
}

@token_provider = ConstantTokenProvider.new(nil)
@token_verifier = TokenVerifier.new()
end

def self.default
Expand Down Expand Up @@ -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
2 changes: 1 addition & 1 deletion lib/authress-sdk/omniauth.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
81 changes: 77 additions & 4 deletions lib/authress-sdk/service_client_token_provider.rb
Original file line number Diff line number Diff line change
@@ -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
110 changes: 105 additions & 5 deletions lib/authress-sdk/token_validator.rb
Original file line number Diff line number Diff line change
@@ -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
18 changes: 15 additions & 3 deletions spec/service_client_token_provider_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
41 changes: 41 additions & 0 deletions spec/token_validator_spec.rb
Original file line number Diff line number Diff line change
@@ -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
Loading