Skip to content

Commit

Permalink
Add token verifier and support ServiceClient token generation. fix #1.
Browse files Browse the repository at this point in the history
  • Loading branch information
wparad committed May 1, 2024
1 parent 203fa3f commit 112e055
Show file tree
Hide file tree
Showing 9 changed files with 261 additions and 17 deletions.
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

0 comments on commit 112e055

Please sign in to comment.