-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add token verifier and support ServiceClient token generation. fix #1.
- Loading branch information
Showing
9 changed files
with
261 additions
and
17 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |