Commit eccd12b2 authored by Matt Kasa's avatar Matt Kasa

JWT token support for Terraform Module Registry

- Add Gitlab::JWTToken and specs
- Add Gitlab::TerraformRegistryToken
- Add *_from_jwt token types to Gitlab::APIAuthentication
- Add token_param location to Gitlab::APIAuthentication

Relates to https://gitlab.com/gitlab-org/gitlab/-/issues/324237
parent 5a137f95
......@@ -10,7 +10,7 @@ module Gitlab
attr_reader :location
validates :location, inclusion: { in: %i[http_basic_auth http_token] }
validates :location, inclusion: { in: %i[http_basic_auth http_token token_param] }
def initialize(location)
@location = location
......@@ -23,6 +23,8 @@ module Gitlab
extract_from_http_basic_auth request
when :http_token
extract_from_http_token request
when :token_param
extract_from_token_param request
end
end
......@@ -41,6 +43,13 @@ module Gitlab
UsernameAndPassword.new(nil, password)
end
def extract_from_token_param(request)
password = request.query_parameters['token']
return unless password.present?
UsernameAndPassword.new(nil, password)
end
end
end
end
......@@ -15,9 +15,14 @@ module Gitlab
personal_access_token
job_token
deploy_token
personal_access_token_from_jwt
deploy_token_from_jwt
job_token_from_jwt
]
}
UsernameAndPassword = ::Gitlab::APIAuthentication::TokenLocator::UsernameAndPassword
def initialize(token_type)
@token_type = token_type
validate!
......@@ -56,6 +61,15 @@ module Gitlab
when :deploy_token_with_username
resolve_deploy_token_with_username raw
when :personal_access_token_from_jwt
resolve_personal_access_token_from_jwt raw
when :deploy_token_from_jwt
resolve_deploy_token_from_jwt raw
when :job_token_from_jwt
resolve_job_token_from_jwt raw
end
end
......@@ -116,6 +130,33 @@ module Gitlab
end
end
def resolve_personal_access_token_from_jwt(raw)
with_jwt_token(raw) do |jwt_token|
break unless jwt_token['token'].is_a?(Integer)
pat = ::PersonalAccessToken.find(jwt_token['token'])
break unless pat
pat
end
end
def resolve_deploy_token_from_jwt(raw)
with_jwt_token(raw) do |jwt_token|
break unless jwt_token['token'].is_a?(String)
resolve_deploy_token(UsernameAndPassword.new(nil, jwt_token['token']))
end
end
def resolve_job_token_from_jwt(raw)
with_jwt_token(raw) do |jwt_token|
break unless jwt_token['token'].is_a?(String)
resolve_job_token(UsernameAndPassword.new(nil, jwt_token['token']))
end
end
def with_personal_access_token(raw, &block)
pat = ::PersonalAccessToken.find_by_token(raw.password)
return unless pat
......@@ -136,6 +177,13 @@ module Gitlab
yield(job)
end
def with_jwt_token(raw, &block)
jwt_token = ::Gitlab::JWTToken.decode(raw.password)
raise ::Gitlab::Auth::UnauthorizedError unless jwt_token
yield(jwt_token)
end
end
end
end
# frozen_string_literal: true
module Gitlab
class JWTToken < JSONWebToken::HMACToken
HMAC_ALGORITHM = 'SHA256'
HMAC_KEY = 'gitlab-jwt'
HMAC_EXPIRES_IN = 5.minutes.freeze
class << self
def decode(jwt)
payload = super(jwt, secret).first
new.tap do |jwt_token|
jwt_token.id = payload.delete('jti')
jwt_token.issued_at = payload.delete('iat')
jwt_token.not_before = payload.delete('nbf')
jwt_token.expire_time = payload.delete('exp')
payload.each do |key, value|
jwt_token[key] = value
end
end
rescue JWT::DecodeError, JWT::ExpiredSignature, JWT::ImmatureSignature => ex
# we want to log and return on expired and errored tokens
Gitlab::ErrorTracking.track_exception(ex)
nil
end
def secret
OpenSSL::HMAC.hexdigest(
HMAC_ALGORITHM,
::Settings.attr_encrypted_db_key_base,
HMAC_KEY
)
end
end
def initialize
super(self.class.secret)
self.expire_time = self.issued_at + HMAC_EXPIRES_IN.to_i
end
def ==(other)
self.id == other.id &&
self.payload == other.payload
end
def issued_at=(value)
super(convert_time(value))
end
def not_before=(value)
super(convert_time(value))
end
def expire_time=(value)
super(convert_time(value))
end
private
def convert_time(value)
# JSONWebToken::Token truncates subsecond precision causing comparisons to
# fail unless we truncate it here first
value = value.to_i if value.is_a?(Float)
value = Time.zone.at(value) if value.is_a?(Integer)
value
end
end
end
# frozen_string_literal: true
module Gitlab
class TerraformRegistryToken < JWTToken
class << self
def from_token(token)
new.tap do |terraform_registry_token|
terraform_registry_token['token'] = token.try(:token).presence || token.try(:id).presence
end
end
end
end
end
# frozen_string_literal: true
FactoryBot.define do
factory :jwt_token, class: 'Gitlab::JWTToken' do
skip_create
initialize_with { new }
trait :with_custom_payload do
transient do
custom_payload { {} }
end
after(:build) do |jwt, evaluator|
evaluator.custom_payload.each do |key, value|
jwt[key] = value
end
end
end
end
end
......@@ -36,7 +36,7 @@ RSpec.describe Gitlab::APIAuthentication::TokenLocator do
let(:request) { double(authorization: nil) }
it 'returns nil' do
expect(subject).to be(nil)
expect(subject).to be_nil
end
end
......@@ -59,7 +59,7 @@ RSpec.describe Gitlab::APIAuthentication::TokenLocator do
let(:request) { double(headers: {}) }
it 'returns nil' do
expect(subject).to be(nil)
expect(subject).to be_nil
end
end
......@@ -72,5 +72,26 @@ RSpec.describe Gitlab::APIAuthentication::TokenLocator do
end
end
end
context 'with :token_param' do
let(:type) { :token_param }
context 'without credentials' do
let(:request) { double(query_parameters: {}) }
it 'returns nil' do
expect(subject).to be_nil
end
end
context 'with credentials' do
let(:password) { 'bar' }
let(:request) { double(query_parameters: { 'token' => password }) }
it 'returns the credentials' do
expect(subject.password).to eq(password)
end
end
end
end
end
......@@ -160,9 +160,58 @@ RSpec.describe Gitlab::APIAuthentication::TokenResolver do
it_behaves_like 'an authorized request'
end
end
context 'with :personal_access_token_from_jwt' do
let(:type) { :personal_access_token_from_jwt }
let(:token) { personal_access_token }
context 'with valid credentials' do
let(:raw) { username_and_password_from_jwt(token.id) }
it_behaves_like 'an authorized request'
end
end
context 'with :deploy_token_from_jwt' do
let(:type) { :deploy_token_from_jwt }
let(:token) { deploy_token }
context 'with valid credentials' do
let(:raw) { username_and_password_from_jwt(token.token) }
it_behaves_like 'an authorized request'
end
end
context 'with :job_token_from_jwt' do
let(:type) { :job_token_from_jwt }
let(:token) { ci_job }
context 'with valid credentials' do
let(:raw) { username_and_password_from_jwt(token.token) }
it_behaves_like 'an authorized request'
end
context 'when the job is not running' do
let(:raw) { username_and_password_from_jwt(ci_job_done.token) }
it_behaves_like 'an unauthorized request'
end
context 'with an invalid job token' do
let(:raw) { username_and_password_from_jwt('not a valid CI job token') }
it_behaves_like 'an unauthorized request'
end
end
end
def username_and_password(username, password)
::Gitlab::APIAuthentication::TokenLocator::UsernameAndPassword.new(username, password)
end
def username_and_password_from_jwt(token)
username_and_password(nil, ::Gitlab::JWTToken.new.tap { |jwt| jwt['token'] = token }.encoded)
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::JWTToken do
it_behaves_like 'a gitlab jwt token'
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::TerraformRegistryToken do
let_it_be(:user) { create(:user) }
describe '.from_token' do
let(:jwt_token) { described_class.from_token(token) }
subject { described_class.decode(jwt_token.encoded) }
context 'with a deploy token' do
let(:deploy_token) { create(:deploy_token, username: 'deployer') }
let(:token) { deploy_token }
it 'returns the correct token' do
expect(subject['token']).to eq jwt_token['token']
end
end
context 'with a job' do
let_it_be(:job) { create(:ci_build) }
let(:token) { job }
it 'returns the correct token' do
expect(subject['token']).to eq jwt_token['token']
end
end
context 'with a personal access token' do
let(:token) { create(:personal_access_token) }
it 'returns the correct token' do
expect(subject['token']).to eq jwt_token['token']
end
end
end
it_behaves_like 'a gitlab jwt token'
end
# frozen_string_literal: true
RSpec.shared_examples 'a gitlab jwt token' do
let_it_be(:base_secret) { SecureRandom.base64(64) }
let(:jwt_secret) do
OpenSSL::HMAC.hexdigest(
'SHA256',
base_secret,
described_class::HMAC_KEY
)
end
before do
allow(Settings).to receive(:attr_encrypted_db_key_base).and_return(base_secret)
end
describe '#secret' do
subject { described_class.secret }
it { is_expected.to eq(jwt_secret) }
end
describe '#decode' do
let(:encoded_jwt_token) { jwt_token.encoded }
subject(:decoded_jwt_token) { described_class.decode(encoded_jwt_token) }
context 'with a custom payload' do
let(:personal_access_token) { create(:personal_access_token) }
let(:jwt_token) { described_class.new.tap { |jwt_token| jwt_token['token'] = personal_access_token.token } }
it 'returns the correct token' do
expect(decoded_jwt_token['token']).to eq jwt_token['token']
end
it 'returns nil and logs the exception after expiration' do
travel_to((described_class::HMAC_EXPIRES_IN + 1.minute).ago) do
encoded_jwt_token
end
expect(Gitlab::ErrorTracking).to receive(:track_exception)
.with(instance_of(JWT::ExpiredSignature))
expect(decoded_jwt_token).to be_nil
end
end
end
end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment