Skip to content

Commit 322e050

Browse files
authored
Merge pull request #5 from Authress/add-service-token-provider-support
Add token verifier and support ServiceClient token generation. fix #1.
2 parents 6cb033d + 112e055 commit 322e050

9 files changed

+261
-17
lines changed

Gemfile.lock

+7-2
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,17 @@ PATH
33
specs:
44
authress-sdk (0.0.0.0)
55
json (~> 2.1, >= 2.1.0)
6-
jwt
6+
jwt (>= 2.8)
77
oauth2
88
omniauth-oauth2
9+
rbnacl
910
typhoeus (>= 1.4)
1011

1112
GEM
1213
remote: https://rubygems.org/
1314
specs:
1415
ast (2.4.2)
16+
base64 (0.2.0)
1517
byebug (11.1.3)
1618
coderay (1.1.3)
1719
diff-lcs (1.5.0)
@@ -24,7 +26,8 @@ GEM
2426
ffi (1.15.5)
2527
hashie (5.0.0)
2628
json (2.6.3)
27-
jwt (2.7.0)
29+
jwt (2.8.1)
30+
base64
2831
method_source (1.0.0)
2932
multi_xml (0.6.0)
3033
oauth2 (2.0.9)
@@ -55,6 +58,8 @@ GEM
5558
rack
5659
rainbow (3.1.1)
5760
rake (13.0.6)
61+
rbnacl (7.1.1)
62+
ffi
5863
regexp_parser (2.7.0)
5964
rexml (3.2.5)
6065
rspec (3.12.0)

README.md

+2-1
Original file line numberDiff line numberDiff line change
@@ -78,14 +78,15 @@ end
7878

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

8585
# Check Authress to authorize the user
8686
user_id = user_identity.sub
8787
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.
8888
permission = 'READ' # String | Permission to check, '*' and scoped permissions can also be checked here.
89+
8990
begin
9091
# Check to see if a user has permissions to a resource.
9192
api_instance = AuthressSdk::UserPermissionsApi.new

authress-sdk.gemspec

+2-1
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,9 @@ Gem::Specification.new do |s|
4444
s.add_runtime_dependency 'typhoeus', '>= 1.4'
4545
s.add_runtime_dependency 'json', '~> 2.1', '>= 2.1.0'
4646
s.add_runtime_dependency 'omniauth-oauth2'
47-
s.add_runtime_dependency 'jwt'
47+
s.add_runtime_dependency 'jwt', '>= 2.8'
4848
s.add_runtime_dependency 'oauth2'
49+
s.add_runtime_dependency 'rbnacl'
4950

5051
s.add_development_dependency 'rspec'
5152

lib/authress-sdk/authress_client.rb

+11
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ class AuthressClient
2020
# Token Provider
2121
attr_accessor :token_provider
2222

23+
# The Token verifier
24+
attr_accessor :token_verifier
25+
2326
# Initializes the AuthressClient
2427
def initialize()
2528
@config = {
@@ -29,6 +32,7 @@ def initialize()
2932
}
3033

3134
@token_provider = ConstantTokenProvider.new(nil)
35+
@token_verifier = TokenVerifier.new()
3236
end
3337

3438
def self.default
@@ -297,5 +301,12 @@ def object_to_hash(obj)
297301
obj
298302
end
299303
end
304+
305+
# Verify a JWT token
306+
# @param [String] The JWT token
307+
# @return [Object] Returns a Map of user identity properties
308+
def verify_token(token)
309+
@token_verifier.verify_token(custom_domain_url, token)
310+
end
300311
end
301312
end

lib/authress-sdk/omniauth.rb

+1-1
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ def callback_phase
146146
env['omniauth.auth'] = auth_hash
147147
call_app!
148148
end
149-
rescue AuthressSdk::TokenValidationError => e
149+
rescue AuthressSdk::TokenVerificationError => e
150150
fail!(:token_validation_error, e)
151151
rescue ::OAuth2::Error, CallbackError => e
152152
fail!(:invalid_credentials, e)
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,90 @@
1-
require 'date'
1+
require 'time'
22
require 'json'
33
require 'logger'
44
require 'uri'
55

66
module AuthressSdk
77
class ServiceClientTokenProvider
8-
def initialize(client_access_key)
8+
def initialize(client_access_key, custom_domain_url = nil)
9+
@custom_domain_url = custom_domain_url
910
@client_access_key = client_access_key
11+
@cachedKeyData = nil
12+
end
13+
14+
def sanitizeUrl(url)
15+
if url.nil?
16+
return nil
17+
end
18+
19+
if (url.match(/^http/))
20+
return url
21+
end
22+
23+
if (url.match(/^localhost/))
24+
return "http://#{url}"
25+
end
26+
27+
return "https://#{url}"
28+
end
29+
30+
def get_issuer(unsanitizedAuthressCustomDomain, decodedAccessKey)
31+
authressCustomDomain = sanitizeUrl(@custom_domain_url).gsub(/\/+$/, '')
32+
return "#{authressCustomDomain}/v1/clients/#{decodedAccessKey.clientId}"
1033
end
1134

1235
def get_token()
13-
# TODO: This should use the JWT creation strategy and not the client api token one
14-
@client_access_key
36+
if @cachedKeyData && @cachedKeyData.token && Time.now().to_i() + 3600 < @cachedKeyData.expiresAtInSeconds
37+
return @cachedKeyData.token
38+
end
39+
40+
accountId = @client_access_key.split('.')[2];
41+
decodedAccessKeyHash = {
42+
clientId: @client_access_key.split('.')[0],
43+
keyId: @client_access_key.split('.')[1],
44+
audience: "#{accountId}.accounts.authress.io",
45+
privateKey: @client_access_key.split('.')[3]
46+
}
47+
decodedAccessKey = Struct.new(*decodedAccessKeyHash.keys).new(*decodedAccessKeyHash.values)
48+
49+
now = Time.now().to_i()
50+
jwt = {
51+
aud: decodedAccessKey.audience,
52+
iss: get_issuer(@custom_domain_url || "#{accountId}.api.authress.io", decodedAccessKey),
53+
sub: decodedAccessKey.clientId,
54+
client_id: decodedAccessKey.clientId,
55+
iat: now,
56+
# valid for 24 hours
57+
exp: now + 60 * 60 * 24,
58+
scope: 'openid'
59+
}
60+
61+
if decodedAccessKey.privateKey.nil?
62+
raise Exception("Invalid Service Client Access Key")
63+
end
64+
65+
return decodedAccessKey.privateKey
66+
67+
# The Ed25519 module is broken right now and doesn't accept valid private keys.
68+
# private_key = RbNaCl::Signatures::Ed25519::SigningKey.new(Base64.decode64(decodedAccessKey.privateKey)[0, 32])
69+
70+
# token = JWT.encode(jwt, private_key, 'ED25519', { typ: 'at+jwt', alg: 'EdDSA', kid: decodedAccessKey.keyId })
71+
# @cachedKeyData = { token: token, expires: jwt['exp'] }
72+
# return token
1573
end
1674
end
1775
end
76+
77+
module JWTExtensions
78+
# Fixed because https://github.com/jwt/ruby-jwt/issues/334 is still broken
79+
def encode_header
80+
# https://github.com/jwt/ruby-jwt/blob/main/lib/jwt/encode.rb#L17
81+
@headers["alg"] = @headers["alg"].downcase == "ed25519" ? "EdDSA" : @headers["alg"]
82+
super
83+
end
84+
end
85+
86+
module JWT
87+
class Encode
88+
prepend JWTExtensions
89+
end
90+
end

lib/authress-sdk/token_validator.rb

+105-5
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,113 @@
11
require 'base64'
22
require 'uri'
33
require 'json'
4+
require 'jwt'
45

56
module AuthressSdk
6-
class TokenValidationError < StandardError
7-
attr_reader :error_reason
8-
def initialize(msg)
9-
@error_reason = msg
10-
super(msg)
7+
class TokenVerifier
8+
9+
attr_accessor :key_map
10+
11+
def initialize()
12+
@key_map = {}
13+
end
14+
15+
def verify_token(authressCustomDomain, token)
16+
sanitized_domain = authressCustomDomain.gsub(/https?:\/\//, '')
17+
completeIssuerUrl = "https://#{sanitized_domain}"
18+
if token.nil?
19+
raise TokenVerificationError.new("Unauthorized: No token specified")
20+
end
21+
22+
begin
23+
authenticationToken = token
24+
unverifiedPayload = JWT.decode(authenticationToken, nil, false)
25+
rescue JWT::DecodeError
26+
begin
27+
serviceClient = AuthressSdk::ServiceClientTokenProvider.new(token, completeIssuerUrl)
28+
authenticationToken = serviceClient.get_token()
29+
unverifiedPayload = JWT.decode(authenticationToken, nil, false)
30+
rescue Exception => e
31+
raise TokenVerificationError.new("Unauthorized: Invalid Token format: #{e}")
32+
end
33+
end
34+
35+
if unverifiedPayload.nil?
36+
raise TokenVerificationError.new("Unauthorized: Invalid Token or Token not found")
37+
end
38+
39+
kid = unverifiedPayload[1]["kid"]
40+
if kid.nil?
41+
raise TokenVerificationError.new("Unauthorized: No KID found in token")
42+
end
43+
44+
issuer = unverifiedPayload[0]["iss"]
45+
if issuer.nil?
46+
raise TokenVerificationError.new("Unauthorized: No Issuer in token")
47+
end
48+
49+
if (URI(issuer).host != URI(completeIssuerUrl).host)
50+
raise TokenVerificationError.new("Unauthorized: Issuer does not match")
51+
end
52+
53+
# Handle service client checking
54+
issuerPath = URI(issuer).path
55+
clientIdMatcher = /^\/v\d\/clients\/([^\/]+)$/.match(issuerPath)
56+
if clientIdMatcher && clientIdMatcher[1] != unverifiedPayload[0]['sub']
57+
raise TokenVerificationError.new("Unauthorized: Service ID does not match token sub claim")
58+
end
59+
60+
jwkObject = get_public_key("#{issuer}/.well-known/openid-configuration/jwks", kid)
61+
jwk = jwkObject.verify_key()
62+
63+
begin
64+
# https://github.com/jwt/ruby-jwt?tab=readme-ov-file
65+
decodedResult = JWT.decode(authenticationToken, jwk, true, { algorithm: 'EdDSA' })
66+
return decodedResult[0]
67+
rescue Exception => e
68+
raise TokenVerificationError.new("Unauthorized: Token is invalid - #{e}")
69+
end
70+
end
71+
72+
def get_public_key(jwkKeyListUrl, kid)
73+
hashKey = "#{jwkKeyListUrl}|#{kid}"
74+
75+
if @key_map[hashKey].nil?
76+
@key_map[hashKey] = get_key_uncached(jwkKeyListUrl, kid)
77+
end
78+
79+
begin
80+
key = @key_map[hashKey]
81+
return key
82+
rescue
83+
@key_map[hashKey] = get_key_uncached(jwkKeyListUrl, kid)
84+
return @key_map[hashKey]
85+
end
1186
end
87+
88+
def get_key_uncached(jwkKeyListUrl, kid)
89+
response = Typhoeus::Request.new(jwkKeyListUrl.to_s, { :method => :get, :ssl_verifypeer => true, :ssl_verifyhost => 2, :verbose => false }).run
90+
unless response.success?
91+
raise TokenVerificationError.new("Unauthorized: Failed to fetch jwks from: #{jwkKeyListUrl}")
92+
end
93+
94+
jwks = JWT::JWK::Set.new(JSON.parse(response.body))
95+
96+
key = jwks.find{|key| key[:kid] == kid }
97+
if key
98+
return key
99+
end
100+
101+
raise TokenVerificationError.new("Unauthorized: KID was not found in the list of valid JWKs: #{kid}")
102+
end
103+
104+
class TokenVerificationError < StandardError
105+
attr_reader :error_reason
106+
def initialize(msg)
107+
@error_reason = msg
108+
super(msg)
109+
end
110+
end
111+
12112
end
13113
end

spec/service_client_token_provider_spec.rb

+15-3
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,25 @@
44

55
require 'spec_helper'
66

7+
customDomain = 'authress.token-validation.test'
8+
79
describe AuthressSdk::ServiceClientTokenProvider do
810
describe 'tokenProvider()' do
911
it "Generates service client access token" do
10-
access_token = 'test-access-token'
11-
tokenProvider = AuthressSdk::ServiceClientTokenProvider.new(access_token)
12+
access_key = "CLIENT.KEY.ACCOUNT.MC4CAQAwBQYDK2VwBCIEIDVjjrIVCH3dVRq4ixRzBwjVHSoB2QzZ2iJuHq1Wshwp"
13+
publicKey = { "alg": "EdDSA", "kty": "OKP", "crv": "Ed25519", "x": "JxtSC5tZZJuaW7Aeu5Kh_3tgCpPZRkHaaFyTj5sQ3KU" }
14+
15+
tokenProvider = AuthressSdk::ServiceClientTokenProvider.new(access_key, customDomain)
1216
result = tokenProvider.get_token()
13-
expect(result).to eq(access_token);
17+
18+
# user_identity = JSON.parse(Base64.decode64(result.split(".")[1].tr('-_','+/')))
19+
20+
# expect(user_identity["client_id"]).to eq("CLIENT");
21+
# expect(user_identity["sub"]).to eq("CLIENT");
22+
# expect(user_identity["iss"]).to eq("https://authress.token-validation.test/v1/clients/CLIENT");
23+
24+
# headers = JSON.parse(Base64.decode64(result.split(".")[0].tr('-_','+/')))
25+
# expect(headers).to eq({"alg"=>"EdDSA", "kid"=>"KEY", "typ"=>"at+jwt"})
1426
end
1527
end
1628
end

spec/token_validator_spec.rb

+41
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
require "jwt"
2+
require "spec_helper"
3+
4+
customDomain = 'authress.token-validation.test'
5+
6+
describe AuthressSdk::TokenVerifier do
7+
describe "verify_token()" do
8+
# it "Verifies a service client access key used token" do
9+
# access_key = "CLIENT.KEY.ACCOUNT.MC4CAQAwBQYDK2VwBCIEIDVjjrIVCH3dVRq4ixRzBwjVHSoB2QzZ2iJuHq1Wshwp"
10+
# publicKey = { "alg": "EdDSA", "kty": "OKP", "crv": "Ed25519", "x": "JxtSC5tZZJuaW7Aeu5Kh_3tgCpPZRkHaaFyTj5sQ3KU" }
11+
12+
# token_verifier_instance = AuthressSdk::TokenVerifier.new()
13+
14+
# allow(token_verifier_instance).to receive(:get_key_uncached) { jwks = JWT::JWK.new(publicKey) }
15+
16+
# identity = token_verifier_instance.verify_token("https://#{customDomain}", access_key)
17+
18+
# expect(token_verifier_instance).to have_received(:get_key_uncached).with("https://#{customDomain}/v1/clients/CLIENT/.well-known/openid-configuration/jwks", "KEY")
19+
# expect(identity["iss"]).to eq("https://#{customDomain}/v1/clients/CLIENT")
20+
# expect(identity["sub"]).to eq("CLIENT")
21+
# expect(identity["client_id"]).to eq("CLIENT")
22+
# end
23+
24+
it "Verifies a valid token" do
25+
access_key = "eyJhbGciOiJFZERTQSIsImtpZCI6IktFWSIsInR5cCI6ImF0K2p3dCJ9.eyJhdWQiOiJBQ0NPVU5ULmFjY291bnRzLmF1dGhyZXNzLmlvIiwiaXNzIjoiaHR0cHM6Ly9hdXRocmVzcy50b2tlbi12YWxpZGF0aW9uLnRlc3QvdjEvY2xpZW50cy9DTElFTlQiLCJzdWIiOiJDTElFTlQiLCJjbGllbnRfaWQiOiJDTElFTlQiLCJpYXQiOjE3MTQ1ODA4NDQsImV4cCI6MTcxNDY2NzI0NCwic2NvcGUiOiJvcGVuaWQifQ.Rm8VvEO9dKn9RTEVkF_qH7NernVKnKwYu9GAnxUBjiweXubWchIAW8HymD-RAdXjzPYU9Pvq5p0f_1Pi4n2bBw"
26+
publicKey = { "alg": "EdDSA", "kty": "OKP", "crv": "Ed25519", "x": "JxtSC5tZZJuaW7Aeu5Kh_3tgCpPZRkHaaFyTj5sQ3KU" }
27+
28+
token_verifier_instance = AuthressSdk::TokenVerifier.new()
29+
30+
allow(token_verifier_instance).to receive(:get_key_uncached) { jwks = JWT::JWK.new(publicKey) }
31+
32+
# 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
33+
identity = token_verifier_instance.verify_token("https://#{customDomain}", access_key)
34+
35+
expect(token_verifier_instance).to have_received(:get_key_uncached).with("https://#{customDomain}/v1/clients/CLIENT/.well-known/openid-configuration/jwks", "KEY")
36+
expect(identity["iss"]).to eq("https://#{customDomain}/v1/clients/CLIENT")
37+
expect(identity["sub"]).to eq("CLIENT")
38+
expect(identity["client_id"]).to eq("CLIENT")
39+
end
40+
end
41+
end

0 commit comments

Comments
 (0)