Skip to content

Commit

Permalink
Verify the JWT signature
Browse files Browse the repository at this point in the history
  • Loading branch information
jimmyjames committed Oct 8, 2020
1 parent 860bd45 commit fd3a14f
Show file tree
Hide file tree
Showing 2 changed files with 236 additions and 30 deletions.
29 changes: 26 additions & 3 deletions lib/omniauth/auth0/jwt_validator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,17 +28,24 @@ def initialize(options, authorize_params = {})
@client_secret = options.client_secret
end

# Verify a token's signature. Only tokens signed with the RS256 or HS256 signatures are supported.
# @return array - The token's key and signing algorithm
def verify_signature(jwt)
head = token_head(jwt)

# Make sure the algorithm is supported and get the decode key.
if head[:alg] == 'RS256'
[rs256_decode_key(head[:kid]), head[:alg]]
key, alg = [rs256_decode_key(head[:kid]), head[:alg]]
elsif head[:alg] == 'HS256'
[@client_secret, head[:alg]]
key, alg = [@client_secret, head[:alg]]
else
raise OmniAuth::Auth0::TokenValidationError.new("Signature algorithm of #{head[:alg]} is not supported. Expected the ID token to be signed with RS256 or HS256")
end

# Call decode to verify the signature
JWT.decode(jwt, key, true, decode_opts(alg))

return key, alg
end

# Verify a JWT.
Expand Down Expand Up @@ -93,11 +100,27 @@ def jwks_key(key, kid)
end

private
# Get the JWT decode options. We disable the claim checks since we perform our claim validation logic
# Docs: https://github.com/jwt/ruby-jwt
# @return hash
def decode_opts(alg)
{
algorithm: alg,
verify_expiration: false,
verify_iat: false,
verify_iss: false,
verify_aud: false,
verify_jti: false,
verify_subj: false,
verify_not_before: false
}
end

def rs256_decode_key(kid)
jwks_x5c = jwks_key(:x5c, kid)

if jwks_x5c.nil?
raise OmniAuth::Auth0::TokenValidationError.new("Could not find a public key for Key ID (kid) '#{kid}''")
raise OmniAuth::Auth0::TokenValidationError.new("Could not find a public key for Key ID (kid) '#{kid}'")
end

jwks_public_cert(jwks_x5c.first)
Expand Down
237 changes: 210 additions & 27 deletions spec/omniauth/auth0/jwt_validator_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,17 @@
let(:domain) { 'samples.auth0.com' }
let(:future_timecode) { 32_503_680_000 }
let(:past_timecode) { 303_912_000 }
let(:jwks_kid) { 'NkJCQzIyQzRBMEU4NjhGNUU4MzU4RkY0M0ZDQzkwOUQ0Q0VGNUMwQg' }
let(:valid_jwks_kid) { 'NkJCQzIyQzRBMEU4NjhGNUU4MzU4RkY0M0ZDQzkwOUQ0Q0VGNUMwQg' }

let(:rsa_private_key) do
OpenSSL::PKey::RSA.generate 2048
end

let(:rsa_token_jwks) do
let(:valid_jwks) do
{
keys: [
{
kid: jwks_kid,
kid: valid_jwks_kid,
x5c: [Base64.encode64(make_cert(rsa_private_key).to_der)]
}
]
Expand Down Expand Up @@ -91,29 +91,29 @@
end
end

describe 'JWT verifier jwks_key' do
describe 'JWT verifier jwks key parsing' do
let(:jwt_validator) do
make_jwt_validator
end

before do
stub_jwks
stub_complete_jwks
end

it 'should return a key' do
expect(jwt_validator.jwks_key(:alg, jwks_kid)).to eq('RS256')
expect(jwt_validator.jwks_key(:alg, valid_jwks_kid)).to eq('RS256')
end

it 'should return an x5c key' do
expect(jwt_validator.jwks_key(:x5c, jwks_kid).length).to eq(1)
expect(jwt_validator.jwks_key(:x5c, valid_jwks_kid).length).to eq(1)
end

it 'should return nil if there is not key' do
expect(jwt_validator.jwks_key(:auth0, jwks_kid)).to eq(nil)
expect(jwt_validator.jwks_key(:auth0, valid_jwks_kid)).to eq(nil)
end

it 'should return nil if the key ID is invalid' do
expect(jwt_validator.jwks_key(:alg, "#{jwks_kid}_invalid")).to eq(nil)
expect(jwt_validator.jwks_key(:alg, "#{valid_jwks_kid}_invalid")).to eq(nil)
end
end

Expand Down Expand Up @@ -153,8 +153,24 @@
end

before do
stub_jwks
stub_dummy_jwks
stub_complete_jwks
stub_expected_jwks
end

it 'should fail when JWT is nil' do
expect do
jwt_validator.verify(nil)
end.to raise_error(an_instance_of(OmniAuth::Auth0::TokenValidationError).and having_attributes({
message: "ID token is required but missing"
}))
end

it 'should fail when JWT is not well-formed' do
expect do
jwt_validator.verify('abc.123')
end.to raise_error(an_instance_of(OmniAuth::Auth0::TokenValidationError).and having_attributes({
message: "ID token could not be decoded"
}))
end

it 'should fail with missing issuer' do
Expand Down Expand Up @@ -248,6 +264,39 @@
}))
end

it 'should pass when past expiration but within default leeway' do
exp = Time.now.to_i - 59
payload = {
iss: "https://#{domain}/",
sub: 'sub',
aud: client_id,
exp: exp,
iat: past_timecode
}

token = make_hs256_token(payload)
id_token = jwt_validator.verify(token)
expect(id_token['exp']).to eq(exp)
end

it 'should fail when past expiration and outside default leeway' do
exp = Time.now.to_i - 61
payload = {
iss: "https://#{domain}/",
sub: 'sub',
aud: client_id,
exp: exp,
iat: past_timecode
}

token = make_hs256_token(payload)
expect do
jwt_validator.verify(token)
end.to raise_error(an_instance_of(OmniAuth::Auth0::TokenValidationError).and having_attributes({
message: "Expiration time (exp) claim error in the ID token; current time (#{Time.now}) is after expiration time (#{Time.at(exp + 60)})"
}))
end

it 'should fail when missing iat' do
payload = {
iss: "https://#{domain}/",
Expand Down Expand Up @@ -377,6 +426,114 @@
}))
end

it 'should fail when “max_age” sent on the authentication request and this claim added the “max_age” value doesn’t represent a date in the future, outside the default leeway' do
now = Time.now.to_i
auth_time = now - 121
max_age = 60
payload = {
iss: "https://#{domain}/",
sub: 'sub',
aud: client_id,
exp: future_timecode,
iat: past_timecode,
auth_time: auth_time
}

token = make_hs256_token(payload)
expect do
jwt_validator.verify(token, { max_age: max_age })
# Time.at(auth_time + max_age + leeway
end.to raise_error(an_instance_of(OmniAuth::Auth0::TokenValidationError).and having_attributes({
message: "Authentication Time (auth_time) claim in the ID token indicates that too much time has passed since the last end-user authentication. Current time (#{Time.now}) is after last auth time (#{Time.at(auth_time + max_age + 60)})"
}))
end

it 'should verify when “max_age” sent on the authentication request and this claim added the “max_age” value doesn’t represent a date in the future, outside the default leeway' do
now = Time.now.to_i
auth_time = now - 119
max_age = 60
payload = {
iss: "https://#{domain}/",
sub: 'sub',
aud: client_id,
exp: future_timecode,
iat: past_timecode,
auth_time: auth_time
}

token = make_hs256_token(payload)
id_token = jwt_validator.verify(token, { max_age: max_age })
expect(id_token['auth_time']).to eq(auth_time)
end

it 'should fail for RS256 token when kid is incorrect' do
domain = 'example.org'
sub = 'abc123'
payload = {
sub: sub,
exp: future_timecode,
iss: "https://#{domain}/",
iat: past_timecode,
aud: client_id
}
invalid_kid = 'invalid-kid'
token = make_rs256_token(payload, invalid_kid)
expect do
verified_token = make_jwt_validator(opt_domain: domain).verify(token)
end.to raise_error(an_instance_of(OmniAuth::Auth0::TokenValidationError).and having_attributes({
message: "Could not find a public key for Key ID (kid) 'invalid-kid'"
}))
end

it 'should fail when RS256 token has invalid signature' do
domain = 'example.org'
sub = 'abc123'
payload = {
sub: sub,
exp: future_timecode,
iss: "https://#{domain}/",
iat: past_timecode,
aud: client_id
}
token = make_rs256_token(payload) + 'bad'
expect do
verified_token = make_jwt_validator(opt_domain: domain).verify(token)
end.to raise_error(an_instance_of(JWT::VerificationError).and having_attributes({
message: "Signature verification raised"
}))
end

it 'should fail when algorithm is not RS256 or HS256' do
payload = {
iss: "https://#{domain}/",
sub: 'abc123',
aud: client_id,
exp: future_timecode,
iat: past_timecode
}
token = JWT.encode payload, 'secret', 'HS384'
expect do
jwt_validator.verify(token)
end.to raise_error(an_instance_of(OmniAuth::Auth0::TokenValidationError).and having_attributes({
message: "Signature algorithm of HS384 is not supported. Expected the ID token to be signed with RS256 or HS256"
}))
end

it 'should fail when HS256 token has invalid signature' do
payload = {
iss: "https://#{domain}/",
sub: 'abc123',
aud: client_id,
exp: future_timecode,
iat: past_timecode
}
token = make_hs256_token(payload, 'bad_secret')
expect do
# validator is configured to use "CLIENT_SECRET" by default
jwt_validator.verify(token)
end.to raise_error(an_instance_of(JWT::VerificationError))
end

it 'should verify a valid HS256 token with multiple audiences' do
audience = [
client_id,
Expand Down Expand Up @@ -417,13 +574,44 @@
exp: future_timecode,
iss: "https://#{domain}/",
iat: past_timecode,
aud: client_id,
kid: jwks_kid
aud: client_id
}
token = make_rs256_token(payload)
verified_token = make_jwt_validator(opt_domain: domain).verify(token)
expect(verified_token['sub']).to eq(sub)
end

it 'should verify a HS256 JWT signature when calling verify signature directly' do
sub = 'abc123'
payload = {
iss: "https://#{domain}/",
sub: sub,
aud: client_id,
exp: future_timecode,
iat: past_timecode
}
token = make_hs256_token(payload)
verified_token_signature = jwt_validator.verify_signature(token)
expect(verified_token_signature[0]).to eq('CLIENT_SECRET')
expect(verified_token_signature[1]).to eq('HS256')
end

it 'should verify a RS256 JWT signature verify signature directly' do
domain = 'example.org'
sub = 'abc123'
payload = {
sub: sub,
exp: future_timecode,
iss: "https://#{domain}/",
iat: past_timecode,
aud: client_id
}
token = make_rs256_token(payload)
verified_token_signature = make_jwt_validator(opt_domain: domain).verify_signature(token)
expect(verified_token_signature.length).to be(2)
expect(verified_token_signature[0]).to be_a(OpenSSL::PKey::RSA)
expect(verified_token_signature[1]).to eq('RS256')
end
end

private
Expand All @@ -439,14 +627,16 @@ def make_jwt_validator(opt_domain: domain, opt_issuer: nil)
OmniAuth::Auth0::JWTValidator.new(opts)
end

def make_hs256_token(payload = nil)
def make_hs256_token(payload = nil, secret = nil)
payload = { sub: 'abc123' } if payload.nil?
JWT.encode payload, client_secret, 'HS256'
secret = client_secret if secret.nil?
JWT.encode payload, secret, 'HS256'
end

def make_rs256_token(payload = nil)
def make_rs256_token(payload = nil, kid = nil)
payload = { sub: 'abc123' } if payload.nil?
JWT.encode payload, rsa_private_key, 'RS256', kid: jwks_kid
kid = valid_jwks_kid if kid.nil?
JWT.encode payload, rsa_private_key, 'RS256', kid: kid
end

def make_cert(private_key)
Expand Down Expand Up @@ -474,7 +664,7 @@ def make_cert(private_key)
cert.sign private_key, OpenSSL::Digest::SHA1.new
end

def stub_jwks
def stub_complete_jwks
stub_request(:get, 'https://samples.auth0.com/.well-known/jwks.json')
.to_return(
headers: { 'Content-Type' => 'application/json' },
Expand All @@ -483,18 +673,11 @@ def stub_jwks
)
end

def stub_bad_jwks
stub_request(:get, 'https://samples.auth0.com/.well-known/jwks-bad.json')
.to_return(
status: 404
)
end

def stub_dummy_jwks
def stub_expected_jwks
stub_request(:get, 'https://example.org/.well-known/jwks.json')
.to_return(
headers: { 'Content-Type' => 'application/json' },
body: rsa_token_jwks,
body: valid_jwks,
status: 200
)
end
Expand Down

0 comments on commit fd3a14f

Please # to comment.