Skip to content

Commit

Permalink
✨ Support OpenSSL (ripped from gitlabhq fork)
Browse files Browse the repository at this point in the history
- OpenSSL::PKey::RSA
  - RS256
  - RS384
  - RS512
- OpenSSL::PKey::EC
  - ES256
  - ES384
  - ES512
  • Loading branch information
pboling committed Nov 30, 2023
1 parent b0e797a commit f3348c6
Show file tree
Hide file tree
Showing 6 changed files with 219 additions and 22 deletions.
3 changes: 3 additions & 0 deletions discourse-omniauth-jwt.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ Gem::Specification.new do |spec|
spec.add_development_dependency "rack-session", "~> 2.0"
spec.add_development_dependency "json"
spec.add_development_dependency "kettle-soup-cover", "~> 1.0", ">= 1.0.2"
spec.add_development_dependency "openssl", "~> 3.0"
spec.add_development_dependency "openssl-signature_algorithm", "~> 1.3"
spec.add_development_dependency "ed25519", "~> 1.3"

spec.add_dependency "jwt", "~> 2.2.1"
spec.add_dependency "omniauth", "~> 1.1"
Expand Down
35 changes: 26 additions & 9 deletions lib/omniauth/strategies/jwt.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,11 @@ class BadJwt < StandardError; end
option :secret, nil
option :decode_options, {}
option :jwks_loader
option :algorithm, nil
option :algorithm, 'HS256' # overridden by options.decode_options[:algorithms]
option :decode_options, {}
option :uid_claim, 'email'
option :required_claims, %w(name email)
option :info_map, {"name" => "name", "email" => "email"}
option :info_map, { name: "name", email: "email" }
option :auth_url, nil
option :valid_within, nil

Expand All @@ -28,27 +28,45 @@ def request_phase

def decoded
begin
secret = if defined?(OpenSSL)
case options.algorithm
when *%w[RS256 RS384 RS512]
OpenSSL::PKey::RSA.new(options.secret).public_key
when *%w[ES256 ES384 ES512]
OpenSSL::PKey::EC.new(options.secret)
when *%w[HS256 HS384 HS512]
options.secret
else
raise NotImplementedError, "Unsupported algorithm: #{options.algorithm}"
end
else
options.secret
end

# JWT.decode can handle either algorithms or algorithm, but not both.
default_algo = options.decode_options.key?(:algorithms) ? nil : options.algorithm || 'HS256'
default_algos = options.decode_options.key?(:algorithms) ? options.decode_options[:algorithms] : [options.algorithm]
@decoded ||= ::JWT.decode(
request.params['jwt'],
options.secret,
secret,
true,
options.decode_options.merge(
{
algorithm: default_algo,
algorithms: default_algos,
jwks: options.jwks_loader
}.compact
)
)[0]
rescue Exception => e
raise BadJwt.new(e.message)
raise BadJwt.new("#{e.class}: #{e.message}")
end
(options.required_claims || []).each do |field|
raise ClaimInvalid.new("Missing required '#{field}' claim.") if !@decoded.key?(field.to_s)
end
raise ClaimInvalid.new("Missing required 'iat' claim.") if options.valid_within && !@decoded["iat"]
raise ClaimInvalid.new("'iat' timestamp claim is too skewed from present.") if options.valid_within && (Time.now.to_i - @decoded["iat"]).abs > options.valid_within
if options.valid_within && (Time.now.to_i - @decoded["iat"]).abs > options.valid_within.to_i
raise ClaimInvalid, "'iat' timestamp claim is too skewed from present"
end

@decoded
end

Expand All @@ -67,9 +85,8 @@ def callback_phase
end

info do
options.info_map.inject({}) do |h,(k,v)|
options.info_map.each_with_object({}) do |(k, v), h|
h[k.to_s] = decoded[v.to_s]
h
end
end
end
Expand Down
148 changes: 135 additions & 13 deletions spec/lib/omniauth/strategies/jwt_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

describe OmniAuth::Strategies::JWT do
let(:response_json){ JSON.parse(last_response.body) }
let(:secret) { SecureRandom.hex(10) }
let(:args){ [secret, {auth_url: 'http://example.com/login'}] }
let(:rand_secret) { SecureRandom.hex(10) }
let(:args){ [rand_secret, {auth_url: 'http://example.com/login'}] }

let(:app){
the_args = args
Expand All @@ -24,63 +24,185 @@

context 'callback phase' do
it 'should decode the response' do
encoded = JWT.encode({name: 'Bob', email: 'steve@example.com'}, secret)
encoded = JWT.encode({name: 'Bob', email: 'steve@example.com'}, rand_secret)
get '/auth/jwt/callback?jwt=' + encoded
expect(response_json["info"]["email"]).to eq("steve@example.com")
end

it 'should not work without required fields' do
encoded = JWT.encode({name: 'Steve'}, secret)
encoded = JWT.encode({name: 'Steve'}, rand_secret)
get '/auth/jwt/callback?jwt=' + encoded
expect(last_response.status).to eq(302)
end

it 'should assign the uid' do
encoded = JWT.encode({name: 'Steve', email: 'dude@awesome.com'}, secret)
encoded = JWT.encode({name: 'Steve', email: 'dude@awesome.com'}, rand_secret)
get '/auth/jwt/callback?jwt=' + encoded
expect(response_json["uid"]).to eq('dude@awesome.com')
end

context 'with a non-default encoding algorithm' do
let(:args){ [secret, {auth_url: 'http://example.com/login', decode_options: { algorithms: ['HS512', 'HS256'] }}] }
let(:args){ [rand_secret, {auth_url: 'http://example.com/login', decode_options: { algorithms: ['HS512', 'HS256'] }}] }

it 'should decode the response with an allowed algorithm' do
encoded = JWT.encode({name: 'Bob', email: 'steve@example.com'}, secret, 'HS512')
encoded = JWT.encode({name: 'Bob', email: 'steve@example.com'}, rand_secret, 'HS512')
get '/auth/jwt/callback?jwt=' + encoded
expect(JSON.parse(last_response.body)["info"]["email"]).to eq("steve@example.com")

encoded = JWT.encode({name: 'Bob', email: 'steve@example.com'}, secret, 'HS256')
encoded = JWT.encode({name: 'Bob', email: 'steve@example.com'}, rand_secret, 'HS256')
get '/auth/jwt/callback?jwt=' + encoded
expect(JSON.parse(last_response.body)["info"]["email"]).to eq("steve@example.com")
end

it 'should fail decoding the response with a different algorithm' do
encoded = JWT.encode({name: 'Bob', email: 'steve@example.com'}, secret, 'HS384')
encoded = JWT.encode({name: 'Bob', email: 'steve@example.com'}, rand_secret, 'HS384')
get '/auth/jwt/callback?jwt=' + encoded
expect(last_response.headers["Location"]).to include("/auth/failure")
end
end

context 'with a :valid_within option set' do
let(:args){ [secret, {auth_url: 'http://example.com/login', valid_within: 300}] }
let(:args){ [rand_secret, {auth_url: 'http://example.com/login', valid_within: 300}] }

it 'should work if the iat key is within the time window' do
encoded = JWT.encode({name: 'Ted', email: 'ted@example.com', iat: Time.now.to_i}, secret)
encoded = JWT.encode({name: 'Ted', email: 'ted@example.com', iat: Time.now.to_i}, rand_secret)
get '/auth/jwt/callback?jwt=' + encoded
expect(last_response.status).to eq(200)
end

it 'should not work if the iat key is outside the time window' do
encoded = JWT.encode({name: 'Ted', email: 'ted@example.com', iat: Time.now.to_i + 500}, secret)
encoded = JWT.encode({name: 'Ted', email: 'ted@example.com', iat: Time.now.to_i + 500}, rand_secret)
get '/auth/jwt/callback?jwt=' + encoded
expect(last_response.status).to eq(302)
end

it 'should not work if the iat key is missing' do
encoded = JWT.encode({name: 'Ted', email: 'ted@example.com'}, secret)
encoded = JWT.encode({name: 'Ted', email: 'ted@example.com'}, rand_secret)
get '/auth/jwt/callback?jwt=' + encoded
expect(last_response.status).to eq(302)
end
end
end

describe '#decoded' do
subject { described_class.new({}) }

let(:timestamp) { Time.now.to_i }
let(:claims) do
{
id: 123,
name: "user_example",
email: "user@example.com",
iat: timestamp
}
end

let(:algorithm) { 'HS256' }
let(:secret) { rand_secret }
let(:private_key) { secret }
let(:payload) { JWT.encode(claims, private_key, algorithm) }

before do
subject.options[:secret] = secret
subject.options[:algorithm] = algorithm

# We use Rack::Request instead of ActionDispatch::Request because
# Rack::Test::Methods enables testing of this module.
expect_next_instance_of(Rack::Request) do |rack_request|
expect(rack_request).to receive(:params).and_return('jwt' => payload)
end
end

ecdsa_named_curves = {
'ES256' => 'prime256v1',
'ES384' => 'secp384r1',
'ES512' => 'secp521r1'
}.freeze

{
OpenSSL::PKey::RSA => %w[RS256 RS384 RS512],
OpenSSL::PKey::EC => %w[ES256 ES384 ES512],
String => %w[HS256 HS384 HS512]
}.each do |private_key_class, algorithms|
algorithms.each do |algorithm|
context "when the #{algorithm} algorithm is used" do
let(:algorithm) { algorithm }
let(:secret) do
# rubocop:disable Style/CaseLikeIf
if private_key_class == OpenSSL::PKey::RSA
private_key_class.generate(2048)
.to_pem
elsif private_key_class == OpenSSL::PKey::EC
private_key_class.generate(ecdsa_named_curves[algorithm])
.to_pem
else
private_key_class.new(rand_secret)
end
# rubocop:enable Style/CaseLikeIf
end

let(:private_key) { private_key_class ? private_key_class.new(secret) : secret }

it 'decodes the user information' do
result = subject.decoded

expect(result).to eq(claims.stringify_keys)
end
end
end
end

context 'required claims is missing' do
let(:claims) do
{
id: 123,
email: "user@example.com",
iat: timestamp
}
end

it 'raises error' do
expect { subject.decoded }.to raise_error(OmniAuth::Strategies::Jwt::ClaimInvalid)
end
end

context 'when valid_within is specified but iat attribute is missing in response' do
let(:claims) do
{
id: 123,
name: "user_example",
email: "user@example.com"
}
end

before do
# Omniauth config values are always strings!
subject.options[:valid_within] = (60 * 60 * 24 * 2).to_s # 2 days
end

it 'raises error' do
expect { subject.decoded }.to raise_error(OmniAuth::Strategies::Jwt::ClaimInvalid)
end
end

context 'when timestamp claim is too skewed from present' do
let(:claims) do
{
id: 123,
name: "user_example",
email: "user@example.com",
iat: timestamp - (60 * 60 * 10) # minus ten minutes
}
end

before do
# Omniauth config values are always strings!
subject.options[:valid_within] = '2' # 2 seconds
end

it 'raises error' do
expect { subject.decoded }.to raise_error(OmniAuth::Strategies::Jwt::ClaimInvalid)
end
end
end
end
8 changes: 8 additions & 0 deletions spec/spec_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,18 @@
require 'rack/test'
require 'json'
require 'omniauth'
require 'openssl'
require 'openssl/signature_algorithm'
require 'ed25519'

require 'byebug' if ENV['DEBUG'] == 'true'
# This does not require "simplecov",
# because that has a side-effect of running `.simplecov`
require 'kettle-soup-cover'

require 'support/hash'
require 'support/next_instance_of'

OmniAuth.config.logger = Logger.new('/dev/null')
# This file was generated by the `rspec --init` command. Conventionally, all
# specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`.
Expand All @@ -23,6 +30,7 @@
config.filter_run :focus

include Rack::Test::Methods
include NextInstanceOf

# Run specs in random order to surface order dependencies. If you find an
# order dependency and want to debug it, you can fix the order by providing
Expand Down
9 changes: 9 additions & 0 deletions spec/support/hash.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
class Hash
def self.stringify_keys(h)
h.is_a?(Hash) ? h.collect{|k,v| [k.to_s, stringify_keys(v)]}.to_h : h
end

def stringify_keys
self.class.stringify_keys(self)
end
end
38 changes: 38 additions & 0 deletions spec/support/next_instance_of.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# From: https://github.com/gitlabhq/gitlabhq/blob/master/gems/gitlab-rspec/lib/gitlab/rspec/next_instance_of.rb#L4
module NextInstanceOf
def expect_next_instance_of(klass, *new_args, &blk)
stub_new(expect(klass), nil, false, *new_args, &blk)
end

def expect_next_instances_of(klass, number, ordered = false, *new_args, &blk)
stub_new(expect(klass), number, ordered, *new_args, &blk)
end

def allow_next_instance_of(klass, *new_args, &blk)
stub_new(allow(klass), nil, false, *new_args, &blk)
end

def allow_next_instances_of(klass, number, ordered = false, *new_args, &blk)
stub_new(allow(klass), number, ordered, *new_args, &blk)
end

private

def stub_new(target, number, ordered = false, *new_args, &blk)
receive_new = receive(:new)
receive_new.ordered if ordered
receive_new.with(*new_args) if !(new_args.empty? || new_args.blank?)

if number.is_a?(Range)
receive_new.at_least(number.begin).times if number.begin
receive_new.at_most(number.end).times if number.end
elsif number
receive_new.exactly(number).times
end

target.to receive_new.and_wrap_original do |*original_args, **original_kwargs|
method, *original_args = original_args
method.call(*original_args, **original_kwargs).tap(&blk)
end
end
end

0 comments on commit f3348c6

Please sign in to comment.