diff --git a/README.md b/README.md index c32f6cf..0caa8fa 100644 --- a/README.md +++ b/README.md @@ -201,4 +201,6 @@ the provider settings, so you will need to clear the browser's * Vault login failed. Expired or missing OAuth state. ``` - +# Decoding JWKS based tokens +To decode JWKS based tokens, set the astral.yml "jwks_url" parameter to the +jwks endpoint of your auth provider. diff --git a/app/lib/jwt/jwks_decoder.rb b/app/lib/jwt/jwks_decoder.rb new file mode 100644 index 0000000..021d3e4 --- /dev/null +++ b/app/lib/jwt/jwks_decoder.rb @@ -0,0 +1,40 @@ +require "open-uri" +module Jwt + class JwksDecoder + def initialize(url) + @url = url + end + + def configured?(config) + !@url.nil? + end + + # Decode a JWT token signed with JWKS + def decode(token) + jwks = get_jwks_keyset_from_configured_url + jwks = filter_out_non_signing_keys(jwks) + body = JWT.decode(token, nil, true, + algorithms: get_algorithms_from_keyset(jwks), + jwks: jwks)[0] + Identity.new(body) + rescue => e + Rails.logger.warn "Unable to decode token: #{e}" + nil + end + + private + + def get_jwks_keyset_from_configured_url + jwks_json = URI.open(@url) { |f| f.read } + JWT::JWK::Set.new(JSON.parse(jwks_json)) + end + + def filter_out_non_signing_keys(jwks) + jwks.filter { |k| k[:use] == "sig" } + end + + def get_algorithms_from_keyset(jwks) + jwks.map { |k| k[:alg] }.compact.uniq + end + end +end diff --git a/app/lib/jwt/secret_decoder.rb b/app/lib/jwt/secret_decoder.rb new file mode 100644 index 0000000..7cb34e7 --- /dev/null +++ b/app/lib/jwt/secret_decoder.rb @@ -0,0 +1,20 @@ +module Jwt + class SecretDecoder + def initialize(secret) + @secret = secret + end + + def configured?(config) + !@secret.nil? + end + + def decode(token) + # Decode a JWT access token using the configured base. + body = JWT.decode(token, Config[:jwt_signing_key])[0] + Identity.new(body) + rescue => e + Rails.logger.warn "Unable to decode token: #{e}" + nil + end + end +end diff --git a/app/lib/services/auth.rb b/app/lib/services/auth.rb index 963b7d0..6c07dbe 100644 --- a/app/lib/services/auth.rb +++ b/app/lib/services/auth.rb @@ -2,22 +2,11 @@ module Services class Auth class << self def authenticate!(token) - identity = decode(token) + identity = Utils::DecoderFactory.get(Config).decode(token) raise AuthError unless identity # TODO verify identity with authority? identity end - - private - - def decode(token) - # Decode a JWT access token using the configured base. - body = JWT.decode(token, Config[:jwt_signing_key])[0] - Identity.new(body) - rescue => e - Rails.logger.warn "Unable to decode token: #{e}" - nil - end end end end diff --git a/app/lib/utils/decoder_factory.rb b/app/lib/utils/decoder_factory.rb new file mode 100644 index 0000000..bad2f95 --- /dev/null +++ b/app/lib/utils/decoder_factory.rb @@ -0,0 +1,23 @@ +module Utils + class DecoderFactory + cattr_reader :decoders + class << self + # Any new decoders should be added here: + @@decoders = [ Jwt::JwksDecoder.new(Config[:jwks_url]), + Jwt::SecretDecoder.new(Config[:jwt_signing_key]) ] + + def get(config) + configured_decoders = getConfiguredDecoders(config) + if configured_decoders.length != 1 + raise "Exactly one decoder must be configured" + end + configured_decoders.first + end + + private + def getConfiguredDecoders(config) + decoders.filter { |c| c.configured?(config) } + end + end + end +end diff --git a/config/astral.yml b/config/astral.yml index 287e475..8700280 100644 --- a/config/astral.yml +++ b/config/astral.yml @@ -21,6 +21,9 @@ shared: jwt_signing_key: + # define this to allow jwks decoding of JWT's + jwks_url: + # When using AppRegistry for Domain Ownership information app_registry_addr: app_registry_token: diff --git a/test/lib/jwt/jwks_decoder_test.rb b/test/lib/jwt/jwks_decoder_test.rb new file mode 100644 index 0000000..3b06875 --- /dev/null +++ b/test/lib/jwt/jwks_decoder_test.rb @@ -0,0 +1,39 @@ +require "test_helper" +require "jwt" +require "openssl" +require "json" +require "tempfile" + +class JwksDecoderTest < ActiveSupport::TestCase + test ".decode returns correct identity" do + jwk = generate_jwks_signing_key + token = generate_jwks_token(jwk) + keyset_path = generate_jwks_keyset(jwk) + + identity = Jwt::JwksDecoder.new(keyset_path).decode(token) + assert_equal "john.doe@example.com", identity.sub + assert_equal "astral", identity.aud + end + + private + + def generate_jwks_signing_key + optional_parameters = { kid: "1", use: "sig", alg: "RS256" } + jwk = JWT::JWK.new(OpenSSL::PKey::RSA.new(2048), optional_parameters) + end + + def generate_jwks_token(jwk) + payload = { "sub"=>"john.doe@example.com", "name"=>"John Doe", "iat"=>1516239022, + "groups"=>[ "group1", "group2" ], "aud"=>"astral" } + + JWT.encode(payload, jwk.signing_key, jwk[:alg], kid: jwk[:kid]) + end + + def generate_jwks_keyset(jwk) + jwks_hash = JWT::JWK::Set.new(jwk).export + f = Tempfile.new + f.write(JSON.pretty_generate(jwks_hash)) + f.close + f.path + end +end diff --git a/test/lib/jwt/secret_decoder_test.rb b/test/lib/jwt/secret_decoder_test.rb new file mode 100644 index 0000000..be08472 --- /dev/null +++ b/test/lib/jwt/secret_decoder_test.rb @@ -0,0 +1,10 @@ +require "test_helper" + +class SecretDecoderTest < ActiveSupport::TestCase + test ".decode returns correct identity" do + identity = Jwt::SecretDecoder.new(Config[:jwt_signing_key]). + decode(jwt_authorized) + assert_equal "john.doe@example.com", identity.sub + assert_equal "astral", identity.aud + end +end diff --git a/test/lib/utils/decoder_factory_test.rb b/test/lib/utils/decoder_factory_test.rb new file mode 100644 index 0000000..4f56c11 --- /dev/null +++ b/test/lib/utils/decoder_factory_test.rb @@ -0,0 +1,29 @@ +require "test_helper" + +class DecoderFactoryTest < ActiveSupport::TestCase + test ".get returns configured decoder" do + decoders = [ UnconfiguredDecoder.new, ConfiguredDecoder.new ] + Utils::DecoderFactory.stub :decoders, decoders do + decoder = Utils::DecoderFactory.get({}) + assert decoder.instance_of?(ConfiguredDecoder) + end + end + + test ".get recognizes invalid config" do + decoders = [ ConfiguredDecoder.new, ConfiguredDecoder.new ] + Utils::DecoderFactory.stub :decoders, decoders do + assert_raises( + RuntimeError, "Exactly one decoder must be configured") do + decoder = Utils::DecoderFactory.get({}) + end + end + end + + class ConfiguredDecoder + def configured?(c) = true + end + + class UnconfiguredDecoder + def configured?(c) = false + end +end diff --git a/test/lib/clients/oidc_provider_test.rb b/test/lib/utils/oidc_provider_test.rb similarity index 100% rename from test/lib/clients/oidc_provider_test.rb rename to test/lib/utils/oidc_provider_test.rb