diff --git a/README.md b/README.md index 88d4104..b25b06e 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,8 @@ Adding the SDK to your Rails app requires a few steps: Create the file `./config/auth0.yml` within your application directory with the following content: +### For client secret authentication + ```yml development: auth0_domain: @@ -60,10 +62,25 @@ development: auth0_client_secret: ``` +#### For client assertion signing key authentication + +```yml +development: + auth0_domain: + auth0_client_id: + auth0_client_assertion_signing_key: + auth0_client_assertion_signing_algorithm: +``` +**Note**: you must upload the corresponding public key to your Auth0 tenant, so that Auth0 is able to verify the JWT signature. + +client_assertion_siging_algorithm is optional and defaults to RS256. + ### Create the initializer Create a new Ruby file in `./config/initializers/auth0.rb` to configure the OmniAuth middleware: +### For client secret authentication + ```ruby AUTH0_CONFIG = Rails.application.config_for(:auth0) @@ -81,6 +98,27 @@ Rails.application.config.middleware.use OmniAuth::Builder do end ``` +#### For client assertion signing key authentication + +```ruby +AUTH0_CONFIG = Rails.application.config_for(:auth0) + +Rails.application.config.middleware.use OmniAuth::Builder do + provider( + :auth0, + AUTH0_CONFIG['auth0_client_id'], + nil, + AUTH0_CONFIG['auth0_domain'], + callback_path: '/auth/auth0/callback', + authorize_params: { + scope: 'openid profile' + } + client_assertion_signing_key: AUTH0_CONFIG[:auth0_client_assertion_signing_key], + client_assertion_signing_algorithm: AUTH0_CONFIG[:auth0_client_assertion_signing_algorithm]} + ) +end +``` + ### Create the callback controller Create a new controller `./app/controllers/auth0_controller.rb` to handle the callback from Auth0. diff --git a/lib/omniauth/auth0/jwt_token.rb b/lib/omniauth/auth0/jwt_token.rb new file mode 100644 index 0000000..4d6ceac --- /dev/null +++ b/lib/omniauth/auth0/jwt_token.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module OmniAuth + module Auth0 + # JWTToken class to generate a JWT token for client assertion + # as per the OAuth 2.0 Client Credentials Grant specification. + class JWTToken + attr_reader :client_id, :domain_url, :client_assertion_signing_key, :client_assertion_signing_algorithm + + def initialize(client_id:, domain_url:, client_assertion_signing_key:, client_assertion_signing_algorithm: nil) + @client_id = client_id + @domain_url = domain_url + @client_assertion_signing_key = client_assertion_signing_key + @client_assertion_signing_algorithm = client_assertion_signing_algorithm || 'RS256' + end + + def jwt_token + JWT.encode(jwt_payload, client_assertion_signing_key, client_assertion_signing_algorithm) + end + + private + + def jwt_payload + { + iss: client_id, + sub: client_id, + aud: File.join(domain_url, '/'), + iat: Time.now.utc.to_i, + exp: Time.now.utc.to_i + 60, + jti: SecureRandom.uuid + } + end + end + end +end diff --git a/lib/omniauth/auth0/jwt_validator.rb b/lib/omniauth/auth0/jwt_validator.rb index d7f0ecf..cb99bfe 100644 --- a/lib/omniauth/auth0/jwt_validator.rb +++ b/lib/omniauth/auth0/jwt_validator.rb @@ -272,10 +272,10 @@ def verify_org(id_token, organization) if validate_as_id org_id = id_token['org_id'] if !org_id || !org_id.is_a?(String) - raise OmniAuth::Auth0::TokenValidationError, + raise OmniAuth::Auth0::TokenValidationError, 'Organization Id (org_id) claim must be a string present in the ID token' elsif org_id != organization - raise OmniAuth::Auth0::TokenValidationError, + raise OmniAuth::Auth0::TokenValidationError, "Organization Id (org_id) claim value mismatch in the ID token; expected '#{organization}', found '#{org_id}'" end else diff --git a/lib/omniauth/strategies/auth0.rb b/lib/omniauth/strategies/auth0.rb index dd86ab5..7b10edc 100644 --- a/lib/omniauth/strategies/auth0.rb +++ b/lib/omniauth/strategies/auth0.rb @@ -4,6 +4,7 @@ require 'uri' require 'securerandom' require 'omniauth-oauth2' +require 'omniauth/auth0/jwt_token' require 'omniauth/auth0/jwt_validator' require 'omniauth/auth0/telemetry' require 'omniauth/auth0/errors' @@ -13,6 +14,8 @@ module Strategies # Auth0 OmniAuth strategy class Auth0 < OmniAuth::Strategies::OAuth2 include OmniAuth::Auth0::Telemetry + AUTHORIZATION_CODE_GRANT_TYPE = 'authorization_code' + CLIENT_ASSERTION_TYPE = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer' option :name, 'auth0' @@ -28,6 +31,8 @@ def client options.client_options.authorize_url = '/authorize' options.client_options.token_url = '/oauth/token' options.client_options.userinfo_url = '/userinfo' + setup_client_options_auth_scheme + super end @@ -100,25 +105,20 @@ def authorize_params end def build_access_token + options.token_params.merge!(client_assertion_signing_key_token_params) if client_assertion_signing_key_auth? options.token_params[:headers] = { 'Auth0-Client' => telemetry_encoded } super end # Declarative override for the request phase of authentication def request_phase - if no_client_id? - # Do we have a client_id for this Application? - fail!(:missing_client_id) - elsif no_client_secret? - # Do we have a client_secret for this Application? - fail!(:missing_client_secret) - elsif no_domain? - # Do we have a domain for this Application? - fail!(:missing_domain) - else - # All checks pass, run the Oauth2 request_phase method. - super - end + return fail!(:missing_client_id) if no_client_id? + return fail!(:missing_client_secret) if no_client_secret? + return fail!(:missing_domain) if no_domain? + return fail!(:missing_client_assertion_signing_key) if no_client_assertion_signing_key? + + # All checks pass, run the Oauth2 request_phase method. + super end def callback_phase @@ -128,10 +128,32 @@ def callback_phase end private + + def client_assertion_signing_key_auth? + options['client_assertion_signing_key'] + end + + def client_assertion_signing_key_token_params + { + grant_type: AUTHORIZATION_CODE_GRANT_TYPE, + client_assertion_type: CLIENT_ASSERTION_TYPE, + client_assertion: jwt_token, + audience: domain_url + } + end + def jwt_validator @jwt_validator ||= OmniAuth::Auth0::JWTValidator.new(options) end + def jwt_token + OmniAuth::Auth0::JWTToken.new(client_id: options.client_id, + domain_url:, + client_assertion_signing_key: options.client_assertion_signing_key, + client_assertion_signing_algorithm: options.client_assertion_signing_algorithm) + .jwt_token + end + # Parse the raw user info. def raw_info return @raw_info if @raw_info @@ -154,7 +176,7 @@ def no_client_id? # Check if the options include a client_secret def no_client_secret? - ['', nil].include?(options.client_secret) + ['', nil].include?(options.client_secret) && !options.key?('client_assertion_signing_key') end # Check if the options include a domain @@ -162,12 +184,24 @@ def no_domain? ['', nil].include?(options.domain) end + # Check if the options include a client_assertion_signing_key + def no_client_assertion_signing_key? + options.key?('client_assertion_signing_key') && ['', nil].include?(options.client_assertion_signing_key) + end + # Normalize a domain to a URL. def domain_url domain_url = URI(options.domain) domain_url = URI("https://#{domain_url}") if domain_url.scheme.nil? domain_url.to_s end + + # Setup the auth_scheme for the client options if using client assertion signing key + def setup_client_options_auth_scheme + return unless client_assertion_signing_key_auth? + + options.client_options.auth_scheme = :private_key_jwt + end end end end diff --git a/spec/omniauth/auth0/jwt_token_spec.rb b/spec/omniauth/auth0/jwt_token_spec.rb new file mode 100644 index 0000000..6721482 --- /dev/null +++ b/spec/omniauth/auth0/jwt_token_spec.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'json' +require 'jwt' + +describe OmniAuth::Auth0::JWTToken do + let(:client_id) { 'CLIENT_ID' } + let(:domain_url) { 'https://samples.auth0.com' } + let(:client_assertion_signing_key) { OpenSSL::PKey::RSA.generate(2048) } + + describe '#jwt_token' do + it 'generates a valid JWT token' do + uuid = '12345678-1234-5678-1234-567812345678' + allow(SecureRandom).to receive(:uuid).and_return(uuid) + + jwt_token = described_class.new(client_id:, + domain_url:, + client_assertion_signing_key:, + client_assertion_signing_algorithm: 'RS256') + .jwt_token + decoded_token = JWT.decode(jwt_token, client_assertion_signing_key, true, { algorithm: 'RS256' }) + + expect(decoded_token[0]['iss']).to eq(client_id) + expect(decoded_token[0]['sub']).to eq(client_id) + expect(decoded_token[0]['aud']).to eq("#{domain_url}/") + expect(decoded_token[0]['iat']).to be_within(5).of(Time.now.utc.to_i) + expect(decoded_token[0]['exp']).to eq(decoded_token[0]['iat'] + 60) + expect(decoded_token[0]['jti']).to eq(uuid) + end + + it 'defaults to RS256 algorithm if not specified' do + uuid = '12345678-1234-5678-1234-567812345678' + allow(SecureRandom).to receive(:uuid).and_return(uuid) + + jwt_token = described_class.new(client_id:, domain_url:, client_assertion_signing_key:).jwt_token + decoded_token = JWT.decode(jwt_token, client_assertion_signing_key, true, { algorithm: 'RS256' }) + + expect(decoded_token[0]['iss']).to eq(client_id) + expect(decoded_token[0]['sub']).to eq(client_id) + expect(decoded_token[0]['aud']).to eq("#{domain_url}/") + expect(decoded_token[0]['iat']).to be_within(5).of(Time.now.utc.to_i) + expect(decoded_token[0]['exp']).to eq(decoded_token[0]['iat'] + 60) + expect(decoded_token[0]['jti']).to eq(uuid) + end + end +end diff --git a/spec/omniauth/strategies/auth0_spec.rb b/spec/omniauth/strategies/auth0_spec.rb index ec91017..24ec266 100644 --- a/spec/omniauth/strategies/auth0_spec.rb +++ b/spec/omniauth/strategies/auth0_spec.rb @@ -14,6 +14,8 @@ let(:client_id) { 'CLIENT_ID' } let(:client_secret) { 'CLIENT_SECRET' } let(:domain_url) { 'https://samples.auth0.com' } + let(:client_assertion_signing_algorithm) { 'RS256' } + let(:client_assertion_signing_key) { OpenSSL::PKey::RSA.new(2048) } let(:application) do lambda do [200, {}, ['Hello.']] @@ -27,176 +29,389 @@ domain_url ) end - - describe 'client_options' do - let(:subject) { OmniAuth::Strategies::Auth0.new( + let(:auth0_client_assertion_signing_key) do + OmniAuth::Strategies::Auth0.new( application, client_id, - client_secret, - domain_url - ).client } + nil, + domain_url, + { client_assertion_signing_key:, client_assertion_signing_algorithm: } + ) + end + describe 'client_options' do + context 'when using client_secret authentication' do + let(:subject) { OmniAuth::Strategies::Auth0.new( + application, + client_id, + client_secret, + domain_url + ).client } + + context 'domain with https' do + let(:domain_url) { 'https://samples.auth0.com' } + it_behaves_like 'site has valid domain url', 'https://samples.auth0.com' + end - context 'domain with https' do - let(:domain_url) { 'https://samples.auth0.com' } - it_behaves_like 'site has valid domain url', 'https://samples.auth0.com' - end + context 'domain with http' do + let(:domain_url) { 'http://mydomain.com' } + it_behaves_like 'site has valid domain url', 'http://mydomain.com' + end - context 'domain with http' do - let(:domain_url) { 'http://mydomain.com' } - it_behaves_like 'site has valid domain url', 'http://mydomain.com' - end + context 'domain with host only' do + let(:domain_url) { 'samples.auth0.com' } + it_behaves_like 'site has valid domain url', 'https://samples.auth0.com' + end - context 'domain with host only' do - let(:domain_url) { 'samples.auth0.com' } - it_behaves_like 'site has valid domain url', 'https://samples.auth0.com' - end + it 'should have correct authorize path' do + expect(subject.options[:authorize_url]).to eq('/authorize') + end - it 'should have correct authorize path' do - expect(subject.options[:authorize_url]).to eq('/authorize') - end + it 'should have the correct userinfo path' do + expect(subject.options[:userinfo_url]).to eq('/userinfo') + end - it 'should have the correct userinfo path' do - expect(subject.options[:userinfo_url]).to eq('/userinfo') + it 'should have the correct token path' do + expect(subject.options[:token_url]).to eq('/oauth/token') + end end - it 'should have the correct token path' do - expect(subject.options[:token_url]).to eq('/oauth/token') + context 'when using client assertion signing key authentication' do + let(:subject) do + OmniAuth::Strategies::Auth0.new( + application, + client_id, + nil, + domain_url, + { client_assertion_signing_key:, client_assertion_signing_algorithm: } + ).client + end + + context 'domain with https' do + let(:domain_url) { 'https://samples.auth0.com' } + it_behaves_like 'site has valid domain url', 'https://samples.auth0.com' + end + + context 'domain with http' do + let(:domain_url) { 'http://mydomain.com' } + it_behaves_like 'site has valid domain url', 'http://mydomain.com' + end + + context 'domain with host only' do + let(:domain_url) { 'samples.auth0.com' } + it_behaves_like 'site has valid domain url', 'https://samples.auth0.com' + end + + it 'should have correct authorize path' do + expect(subject.options[:authorize_url]).to eq('/authorize') + end + + it 'should have the correct userinfo path' do + expect(subject.options[:userinfo_url]).to eq('/userinfo') + end + + it 'should have the correct token path' do + expect(subject.options[:token_url]).to eq('/oauth/token') + end + + it 'should have the correct auth_scheme' do + expect(subject.options[:auth_scheme]).to eq(:private_key_jwt) + end end end describe 'options' do - let(:subject) { auth0.options } + context 'when using client_secret authentication' do + let(:subject) { auth0.options } - it 'should have the correct client_id' do - expect(subject[:client_id]).to eq(client_id) - end + it 'should have the correct client_id' do + expect(subject[:client_id]).to eq(client_id) + end - it 'should have the correct client secret' do - expect(subject[:client_secret]).to eq(client_secret) + it 'should have the correct client secret' do + expect(subject[:client_secret]).to eq(client_secret) + end + it 'should have correct domain' do + expect(subject[:domain]).to eq(domain_url) + end end - it 'should have correct domain' do - expect(subject[:domain]).to eq(domain_url) + + context 'when using client assertion signing key authentication' do + let(:subject) { auth0_client_assertion_signing_key.options } + + it 'should have the correct client_id' do + expect(subject[:client_id]).to eq(client_id) + end + + it 'should have the correct client secret' do + expect(subject[:client_secret]).to eq(nil) + end + it 'should have correct domain' do + expect(subject[:domain]).to eq(domain_url) + end + + it 'should have the correct client_assertion_signing_key' do + expect(subject[:client_assertion_signing_key]).to eq(client_assertion_signing_key) + end end end describe 'oauth' do - it 'redirects to hosted login page' do - get 'auth/auth0' - expect(last_response.status).to eq(302) - redirect_url = last_response.headers['Location'] - expect(redirect_url).to start_with('https://samples.auth0.com/authorize') - expect(redirect_url).to have_query('response_type', 'code') - expect(redirect_url).to have_query('state') - expect(redirect_url).to have_query('client_id') - expect(redirect_url).to have_query('redirect_uri') - expect(redirect_url).not_to have_query('auth0Client') - expect(redirect_url).not_to have_query('connection') - expect(redirect_url).not_to have_query('connection_scope') - expect(redirect_url).not_to have_query('prompt') - expect(redirect_url).not_to have_query('screen_hint') - expect(redirect_url).not_to have_query('login_hint') - expect(redirect_url).not_to have_query('organization') - expect(redirect_url).not_to have_query('invitation') - end + context 'when using client_secret authentication' do + it 'redirects to hosted login page' do + get 'auth/auth0' + expect(last_response.status).to eq(302) + redirect_url = last_response.headers['Location'] + expect(redirect_url).to start_with('https://samples.auth0.com/authorize') + expect(redirect_url).to have_query('response_type', 'code') + expect(redirect_url).to have_query('state') + expect(redirect_url).to have_query('client_id') + expect(redirect_url).to have_query('redirect_uri') + expect(redirect_url).not_to have_query('auth0Client') + expect(redirect_url).not_to have_query('connection') + expect(redirect_url).not_to have_query('connection_scope') + expect(redirect_url).not_to have_query('prompt') + expect(redirect_url).not_to have_query('screen_hint') + expect(redirect_url).not_to have_query('login_hint') + expect(redirect_url).not_to have_query('organization') + expect(redirect_url).not_to have_query('invitation') + end - it 'redirects to hosted login page' do - get 'auth/auth0?connection=abcd' - expect(last_response.status).to eq(302) - redirect_url = last_response.headers['Location'] - expect(redirect_url).to start_with('https://samples.auth0.com/authorize') - expect(redirect_url).to have_query('response_type', 'code') - expect(redirect_url).to have_query('state') - expect(redirect_url).to have_query('client_id') - expect(redirect_url).to have_query('redirect_uri') - expect(redirect_url).to have_query('connection', 'abcd') - expect(redirect_url).not_to have_query('auth0Client') - expect(redirect_url).not_to have_query('connection_scope') - expect(redirect_url).not_to have_query('prompt') - expect(redirect_url).not_to have_query('screen_hint') - expect(redirect_url).not_to have_query('login_hint') - expect(redirect_url).not_to have_query('organization') - expect(redirect_url).not_to have_query('invitation') - end + it 'redirects to hosted login page' do + get 'auth/auth0?connection=abcd' + expect(last_response.status).to eq(302) + redirect_url = last_response.headers['Location'] + expect(redirect_url).to start_with('https://samples.auth0.com/authorize') + expect(redirect_url).to have_query('response_type', 'code') + expect(redirect_url).to have_query('state') + expect(redirect_url).to have_query('client_id') + expect(redirect_url).to have_query('redirect_uri') + expect(redirect_url).to have_query('connection', 'abcd') + expect(redirect_url).not_to have_query('auth0Client') + expect(redirect_url).not_to have_query('connection_scope') + expect(redirect_url).not_to have_query('prompt') + expect(redirect_url).not_to have_query('screen_hint') + expect(redirect_url).not_to have_query('login_hint') + expect(redirect_url).not_to have_query('organization') + expect(redirect_url).not_to have_query('invitation') + end - it 'redirects to the hosted login page with connection_scope' do - get 'auth/auth0?connection_scope=identity_provider_scope' - expect(last_response.status).to eq(302) - redirect_url = last_response.headers['Location'] - expect(redirect_url).to start_with('https://samples.auth0.com/authorize') - expect(redirect_url) - .to have_query('connection_scope', 'identity_provider_scope') - end + it 'redirects to the hosted login page with connection_scope' do + get 'auth/auth0?connection_scope=identity_provider_scope' + expect(last_response.status).to eq(302) + redirect_url = last_response.headers['Location'] + expect(redirect_url).to start_with('https://samples.auth0.com/authorize') + expect(redirect_url) + .to have_query('connection_scope', 'identity_provider_scope') + end - it 'redirects to hosted login page with prompt=login' do - get 'auth/auth0?prompt=login' - expect(last_response.status).to eq(302) - redirect_url = last_response.headers['Location'] - expect(redirect_url).to start_with('https://samples.auth0.com/authorize') - expect(redirect_url).to have_query('response_type', 'code') - expect(redirect_url).to have_query('state') - expect(redirect_url).to have_query('client_id') - expect(redirect_url).to have_query('redirect_uri') - expect(redirect_url).to have_query('prompt', 'login') - expect(redirect_url).not_to have_query('auth0Client') - expect(redirect_url).not_to have_query('connection') - expect(redirect_url).not_to have_query('login_hint') - expect(redirect_url).not_to have_query('organization') - expect(redirect_url).not_to have_query('invitation') - end + it 'redirects to hosted login page with prompt=login' do + get 'auth/auth0?prompt=login' + expect(last_response.status).to eq(302) + redirect_url = last_response.headers['Location'] + expect(redirect_url).to start_with('https://samples.auth0.com/authorize') + expect(redirect_url).to have_query('response_type', 'code') + expect(redirect_url).to have_query('state') + expect(redirect_url).to have_query('client_id') + expect(redirect_url).to have_query('redirect_uri') + expect(redirect_url).to have_query('prompt', 'login') + expect(redirect_url).not_to have_query('auth0Client') + expect(redirect_url).not_to have_query('connection') + expect(redirect_url).not_to have_query('login_hint') + expect(redirect_url).not_to have_query('organization') + expect(redirect_url).not_to have_query('invitation') + end - it 'redirects to hosted login page with screen_hint=signup' do - get 'auth/auth0?screen_hint=signup' - expect(last_response.status).to eq(302) - redirect_url = last_response.headers['Location'] - expect(redirect_url).to start_with('https://samples.auth0.com/authorize') - expect(redirect_url).to have_query('response_type', 'code') - expect(redirect_url).to have_query('state') - expect(redirect_url).to have_query('client_id') - expect(redirect_url).to have_query('redirect_uri') - expect(redirect_url).to have_query('screen_hint', 'signup') - expect(redirect_url).not_to have_query('auth0Client') - expect(redirect_url).not_to have_query('connection') - expect(redirect_url).not_to have_query('login_hint') - expect(redirect_url).not_to have_query('organization') - expect(redirect_url).not_to have_query('invitation') - end + it 'redirects to hosted login page with screen_hint=signup' do + get 'auth/auth0?screen_hint=signup' + expect(last_response.status).to eq(302) + redirect_url = last_response.headers['Location'] + expect(redirect_url).to start_with('https://samples.auth0.com/authorize') + expect(redirect_url).to have_query('response_type', 'code') + expect(redirect_url).to have_query('state') + expect(redirect_url).to have_query('client_id') + expect(redirect_url).to have_query('redirect_uri') + expect(redirect_url).to have_query('screen_hint', 'signup') + expect(redirect_url).not_to have_query('auth0Client') + expect(redirect_url).not_to have_query('connection') + expect(redirect_url).not_to have_query('login_hint') + expect(redirect_url).not_to have_query('organization') + expect(redirect_url).not_to have_query('invitation') + end - it 'redirects to hosted login page with organization=TestOrg and invitation=TestInvite' do - get 'auth/auth0?organization=TestOrg&invitation=TestInvite' - expect(last_response.status).to eq(302) - redirect_url = last_response.headers['Location'] - expect(redirect_url).to start_with('https://samples.auth0.com/authorize') - expect(redirect_url).to have_query('response_type', 'code') - expect(redirect_url).to have_query('state') - expect(redirect_url).to have_query('client_id') - expect(redirect_url).to have_query('redirect_uri') - expect(redirect_url).to have_query('organization', 'TestOrg') - expect(redirect_url).to have_query('invitation', 'TestInvite') - expect(redirect_url).not_to have_query('auth0Client') - expect(redirect_url).not_to have_query('connection') - expect(redirect_url).not_to have_query('connection_scope') - expect(redirect_url).not_to have_query('prompt') - expect(redirect_url).not_to have_query('screen_hint') - expect(redirect_url).not_to have_query('login_hint') + it 'redirects to hosted login page with organization=TestOrg and invitation=TestInvite' do + get 'auth/auth0?organization=TestOrg&invitation=TestInvite' + expect(last_response.status).to eq(302) + redirect_url = last_response.headers['Location'] + expect(redirect_url).to start_with('https://samples.auth0.com/authorize') + expect(redirect_url).to have_query('response_type', 'code') + expect(redirect_url).to have_query('state') + expect(redirect_url).to have_query('client_id') + expect(redirect_url).to have_query('redirect_uri') + expect(redirect_url).to have_query('organization', 'TestOrg') + expect(redirect_url).to have_query('invitation', 'TestInvite') + expect(redirect_url).not_to have_query('auth0Client') + expect(redirect_url).not_to have_query('connection') + expect(redirect_url).not_to have_query('connection_scope') + expect(redirect_url).not_to have_query('prompt') + expect(redirect_url).not_to have_query('screen_hint') + expect(redirect_url).not_to have_query('login_hint') + end + + it 'redirects to hosted login page with login_hint=example@mail.com' do + get 'auth/auth0?login_hint=example@mail.com' + expect(last_response.status).to eq(302) + redirect_url = last_response.headers['Location'] + expect(redirect_url).to start_with('https://samples.auth0.com/authorize') + expect(redirect_url).to have_query('response_type', 'code') + expect(redirect_url).to have_query('state') + expect(redirect_url).to have_query('client_id') + expect(redirect_url).to have_query('redirect_uri') + expect(redirect_url).to have_query('login_hint', 'example@mail.com') + expect(redirect_url).not_to have_query('auth0Client') + expect(redirect_url).not_to have_query('connection') + expect(redirect_url).not_to have_query('connection_scope') + expect(redirect_url).not_to have_query('prompt') + expect(redirect_url).not_to have_query('screen_hint') + expect(redirect_url).not_to have_query('organization') + expect(redirect_url).not_to have_query('invitation') + end + + it "stores session['authorize_params'] as a plain Ruby Hash" do + get '/auth/auth0' + + expect(session['authorize_params'].class).to eq(::Hash) + end end - it 'redirects to hosted login page with login_hint=example@mail.com' do - get 'auth/auth0?login_hint=example@mail.com' - expect(last_response.status).to eq(302) - redirect_url = last_response.headers['Location'] - expect(redirect_url).to start_with('https://samples.auth0.com/authorize') - expect(redirect_url).to have_query('response_type', 'code') - expect(redirect_url).to have_query('state') - expect(redirect_url).to have_query('client_id') - expect(redirect_url).to have_query('redirect_uri') - expect(redirect_url).to have_query('login_hint', 'example@mail.com') - expect(redirect_url).not_to have_query('auth0Client') - expect(redirect_url).not_to have_query('connection') - expect(redirect_url).not_to have_query('connection_scope') - expect(redirect_url).not_to have_query('prompt') - expect(redirect_url).not_to have_query('screen_hint') - expect(redirect_url).not_to have_query('organization') - expect(redirect_url).not_to have_query('invitation') + context 'when using client assertion signing key authentication' do + before do + @app = make_application(client_secret: nil, client_assertion_signing_key:, client_assertion_signing_algorithm:) + end + + it 'redirects to hosted login page' do + get 'auth/auth0' + expect(last_response.status).to eq(302) + redirect_url = last_response.headers['Location'] + expect(redirect_url).to start_with('https://samples.auth0.com/authorize') + expect(redirect_url).to have_query('response_type', 'code') + expect(redirect_url).to have_query('state') + expect(redirect_url).to have_query('client_id') + expect(redirect_url).to have_query('redirect_uri') + expect(redirect_url).not_to have_query('auth0Client') + expect(redirect_url).not_to have_query('connection') + expect(redirect_url).not_to have_query('connection_scope') + expect(redirect_url).not_to have_query('prompt') + expect(redirect_url).not_to have_query('screen_hint') + expect(redirect_url).not_to have_query('login_hint') + expect(redirect_url).not_to have_query('organization') + expect(redirect_url).not_to have_query('invitation') + end + + it 'redirects to hosted login page' do + get 'auth/auth0?connection=abcd' + expect(last_response.status).to eq(302) + redirect_url = last_response.headers['Location'] + expect(redirect_url).to start_with('https://samples.auth0.com/authorize') + expect(redirect_url).to have_query('response_type', 'code') + expect(redirect_url).to have_query('state') + expect(redirect_url).to have_query('client_id') + expect(redirect_url).to have_query('redirect_uri') + expect(redirect_url).to have_query('connection', 'abcd') + expect(redirect_url).not_to have_query('auth0Client') + expect(redirect_url).not_to have_query('connection_scope') + expect(redirect_url).not_to have_query('prompt') + expect(redirect_url).not_to have_query('screen_hint') + expect(redirect_url).not_to have_query('login_hint') + expect(redirect_url).not_to have_query('organization') + expect(redirect_url).not_to have_query('invitation') + end + + it 'redirects to the hosted login page with connection_scope' do + get 'auth/auth0?connection_scope=identity_provider_scope' + expect(last_response.status).to eq(302) + redirect_url = last_response.headers['Location'] + expect(redirect_url).to start_with('https://samples.auth0.com/authorize') + expect(redirect_url) + .to have_query('connection_scope', 'identity_provider_scope') + end + + it 'redirects to hosted login page with prompt=login' do + get 'auth/auth0?prompt=login' + expect(last_response.status).to eq(302) + redirect_url = last_response.headers['Location'] + expect(redirect_url).to start_with('https://samples.auth0.com/authorize') + expect(redirect_url).to have_query('response_type', 'code') + expect(redirect_url).to have_query('state') + expect(redirect_url).to have_query('client_id') + expect(redirect_url).to have_query('redirect_uri') + expect(redirect_url).to have_query('prompt', 'login') + expect(redirect_url).not_to have_query('auth0Client') + expect(redirect_url).not_to have_query('connection') + expect(redirect_url).not_to have_query('login_hint') + expect(redirect_url).not_to have_query('organization') + expect(redirect_url).not_to have_query('invitation') + end + + it 'redirects to hosted login page with screen_hint=signup' do + get 'auth/auth0?screen_hint=signup' + expect(last_response.status).to eq(302) + redirect_url = last_response.headers['Location'] + expect(redirect_url).to start_with('https://samples.auth0.com/authorize') + expect(redirect_url).to have_query('response_type', 'code') + expect(redirect_url).to have_query('state') + expect(redirect_url).to have_query('client_id') + expect(redirect_url).to have_query('redirect_uri') + expect(redirect_url).to have_query('screen_hint', 'signup') + expect(redirect_url).not_to have_query('auth0Client') + expect(redirect_url).not_to have_query('connection') + expect(redirect_url).not_to have_query('login_hint') + expect(redirect_url).not_to have_query('organization') + expect(redirect_url).not_to have_query('invitation') + end + + it 'redirects to hosted login page with organization=TestOrg and invitation=TestInvite' do + get 'auth/auth0?organization=TestOrg&invitation=TestInvite' + expect(last_response.status).to eq(302) + redirect_url = last_response.headers['Location'] + expect(redirect_url).to start_with('https://samples.auth0.com/authorize') + expect(redirect_url).to have_query('response_type', 'code') + expect(redirect_url).to have_query('state') + expect(redirect_url).to have_query('client_id') + expect(redirect_url).to have_query('redirect_uri') + expect(redirect_url).to have_query('organization', 'TestOrg') + expect(redirect_url).to have_query('invitation', 'TestInvite') + expect(redirect_url).not_to have_query('auth0Client') + expect(redirect_url).not_to have_query('connection') + expect(redirect_url).not_to have_query('connection_scope') + expect(redirect_url).not_to have_query('prompt') + expect(redirect_url).not_to have_query('screen_hint') + expect(redirect_url).not_to have_query('login_hint') + end + + it 'redirects to hosted login page with login_hint=example@mail.com' do + get 'auth/auth0?login_hint=example@mail.com' + expect(last_response.status).to eq(302) + redirect_url = last_response.headers['Location'] + expect(redirect_url).to start_with('https://samples.auth0.com/authorize') + expect(redirect_url).to have_query('response_type', 'code') + expect(redirect_url).to have_query('state') + expect(redirect_url).to have_query('client_id') + expect(redirect_url).to have_query('redirect_uri') + expect(redirect_url).to have_query('login_hint', 'example@mail.com') + expect(redirect_url).not_to have_query('auth0Client') + expect(redirect_url).not_to have_query('connection') + expect(redirect_url).not_to have_query('connection_scope') + expect(redirect_url).not_to have_query('prompt') + expect(redirect_url).not_to have_query('screen_hint') + expect(redirect_url).not_to have_query('organization') + expect(redirect_url).not_to have_query('invitation') + end + + it "stores session['authorize_params'] as a plain Ruby Hash" do + get '/auth/auth0' + + expect(session['authorize_params'].class).to eq(::Hash) + end end def session @@ -206,12 +421,6 @@ def session Marshal.load(decoded_session_data) end - it "stores session['authorize_params'] as a plain Ruby Hash" do - get '/auth/auth0' - - expect(session['authorize_params'].class).to eq(::Hash) - end - describe 'callback' do let(:access_token) { 'access token' } let(:expires_in) { 2000 } @@ -227,20 +436,6 @@ def session let(:email) { 'mail@mail.com' } let(:email_verified) { true } - let(:id_token) do - payload = {} - payload['sub'] = user_id - payload['iss'] = "#{domain_url}/" - payload['aud'] = client_id - payload['name'] = name - payload['nickname'] = nickname - payload['picture'] = picture - payload['email'] = email - payload['email_verified'] = email_verified - - JWT.encode payload, client_secret, 'HS256' - end - let(:oauth_response) do { access_token: access_token, @@ -260,15 +455,6 @@ def session let(:basic_user_info) { { "sub" => user_id, "name" => name } } - def stub_auth(body) - stub_request(:post, 'https://samples.auth0.com/oauth/token') - .with(headers: { 'Auth0-Client' => telemetry_value }) - .to_return( - headers: { 'Content-Type' => 'application/json' }, - body: MultiJson.encode(body) - ) - end - def stub_userinfo(body) stub_request(:get, 'https://samples.auth0.com/userinfo') .to_return( @@ -290,91 +476,299 @@ def trigger_callback MultiJson.decode(last_response.body) end - context 'basic oauth' do - before do - stub_auth(oauth_response) - stub_userinfo(basic_user_info) - trigger_callback + context 'when using client_secret authentication' do + let(:id_token) do + payload = {} + payload['sub'] = user_id + payload['iss'] = "#{domain_url}/" + payload['aud'] = client_id + payload['name'] = name + payload['nickname'] = nickname + payload['picture'] = picture + payload['email'] = email + payload['email_verified'] = email_verified + + JWT.encode payload, client_secret, 'HS256' end - it 'to succeed' do - expect(last_response.status).to eq(200) + def stub_auth(body) + stub_request(:post, 'https://samples.auth0.com/oauth/token') + .with(headers: { 'Auth0-Client' => telemetry_value }) + .to_return( + headers: { 'Content-Type' => 'application/json' }, + body: MultiJson.encode(body) + ) end - it 'has credentials' do - expect(subject['credentials']['token']).to eq(access_token) - expect(subject['credentials']['expires']).to be true - expect(subject['credentials']['expires_at']).to_not be_nil + context 'basic oauth' do + before do + stub_auth(oauth_response) + stub_userinfo(basic_user_info) + trigger_callback + end + + it 'to succeed' do + expect(last_response.status).to eq(200) + end + + it 'has credentials' do + expect(subject['credentials']['token']).to eq(access_token) + expect(subject['credentials']['expires']).to be true + expect(subject['credentials']['expires_at']).to_not be_nil + end + + it 'has basic values' do + expect(subject['provider']).to eq('auth0') + expect(subject['uid']).to eq(user_id) + expect(subject['info']['name']).to eq(name) + end + + it 'should use the user info endpoint' do + expect(subject['extra']['raw_info']).to eq(basic_user_info) + end end - it 'has basic values' do - expect(subject['provider']).to eq('auth0') - expect(subject['uid']).to eq(user_id) - expect(subject['info']['name']).to eq(name) + context 'basic oauth w/refresh token' do + before do + stub_auth(oauth_response.merge(refresh_token: refresh_token)) + stub_userinfo(basic_user_info) + trigger_callback + end + + it 'to succeed' do + expect(last_response.status).to eq(200) + end + + it 'has credentials' do + expect(subject['credentials']['token']).to eq(access_token) + expect(subject['credentials']['refresh_token']).to eq(refresh_token) + expect(subject['credentials']['expires']).to be true + expect(subject['credentials']['expires_at']).to_not be_nil + end end - it 'should use the user info endpoint' do - expect(subject['extra']['raw_info']).to eq(basic_user_info) + context 'oidc' do + before do + stub_auth(oidc_response) + trigger_callback + end + + it 'to succeed' do + expect(last_response.status).to eq(200) + end + + it 'has credentials' do + expect(subject['credentials']['token']).to eq(access_token) + expect(subject['credentials']['expires']).to be true + expect(subject['credentials']['expires_at']).to_not be_nil + expect(subject['credentials']['id_token']).to eq(id_token) + end + + it 'has basic values' do + expect(subject['provider']).to eq('auth0') + expect(subject['uid']).to eq(user_id) + end + + it 'has info' do + expect(subject['info']['name']).to eq(name) + expect(subject['info']['nickname']).to eq(nickname) + expect(subject['info']['image']).to eq(picture) + expect(subject['info']['email']).to eq(email) + end + + it 'has extra' do + expect(subject['extra']['raw_info']['email_verified']).to be true + end end end - context 'basic oauth w/refresh token' do - before do - stub_auth(oauth_response.merge(refresh_token: refresh_token)) - stub_userinfo(basic_user_info) - trigger_callback + context 'when using client assertion signing key authentication' do + let(:jwt_token) { JWT.encode({ sub: client_id }, client_assertion_signing_key, 'RS256') } + let(:valid_jwks_kid) { 'NkJCQzIyQzRBMEU4NjhGNUU4MzU4RkY0M0ZDQzkwOUQ0Q0VGNUMwQg' } + + let(:rsa_private_key) do + OpenSSL::PKey::RSA.generate 2048 end - it 'to succeed' do - expect(last_response.status).to eq(200) + let(:valid_jwks) do + { + keys: [ + { + kid: valid_jwks_kid, + x5c: [Base64.encode64(make_cert(rsa_private_key).to_der)] + } + ] + }.to_json end - it 'has credentials' do - expect(subject['credentials']['token']).to eq(access_token) - expect(subject['credentials']['refresh_token']).to eq(refresh_token) - expect(subject['credentials']['expires']).to be true - expect(subject['credentials']['expires_at']).to_not be_nil + let(:id_token) do + payload = {} + payload['sub'] = user_id + payload['iss'] = "#{domain_url}/" + payload['aud'] = client_id + payload['name'] = name + payload['nickname'] = nickname + payload['picture'] = picture + payload['email'] = email + payload['email_verified'] = email_verified + + JWT.encode payload, rsa_private_key, 'RS256', kid: valid_jwks_kid end - end - context 'oidc' do - before do - stub_auth(oidc_response) - trigger_callback + def make_cert(private_key) + cert = OpenSSL::X509::Certificate.new + cert.issuer = OpenSSL::X509::Name.parse('/C=BE/O=Auth0/OU=Auth0/CN=Auth0') + cert.subject = cert.issuer + cert.not_before = Time.now + cert.not_after = Time.now + 365 * 24 * 60 * 60 + cert.public_key = private_key.public_key + cert.serial = 0x0 + cert.version = 2 + + ef = OpenSSL::X509::ExtensionFactory.new + ef.subject_certificate = cert + ef.issuer_certificate = cert + cert.extensions = [ + ef.create_extension('basicConstraints', 'CA:TRUE', true), + ef.create_extension('subjectKeyIdentifier', 'hash') + ] + cert.add_extension ef.create_extension( + 'authorityKeyIdentifier', + 'keyid:always,issuer:always' + ) + + cert.sign private_key, OpenSSL::Digest.new('SHA1') end - it 'to succeed' do - expect(last_response.status).to eq(200) + def stub_auth(body) + stub_request(:post, "#{domain_url}/oauth/token") + .with(headers: { 'Auth0-Client' => telemetry_value }, + body: hash_including({ 'grant_type' => described_class::AUTHORIZATION_CODE_GRANT_TYPE, + 'code' => nil, + 'client_assertion_type' => described_class::CLIENT_ASSERTION_TYPE, + 'client_assertion' => jwt_token, + 'audience' => domain_url })) + .to_return( + headers: { 'Content-Type' => 'application/json' }, + body: MultiJson.encode(body) + ) end - it 'has credentials' do - expect(subject['credentials']['token']).to eq(access_token) - expect(subject['credentials']['expires']).to be true - expect(subject['credentials']['expires_at']).to_not be_nil - expect(subject['credentials']['id_token']).to eq(id_token) + def stub_expected_jwks + stub_request(:get, 'https://samples.auth0.com/.well-known/jwks.json') + .to_return( + headers: { 'Content-Type' => 'application/json' }, + body: valid_jwks, + status: 200 + ) end - it 'has basic values' do - expect(subject['provider']).to eq('auth0') - expect(subject['uid']).to eq(user_id) + def stub_jwt_token(algorithm: client_assertion_signing_algorithm) + allow(OmniAuth::Auth0::JWTToken).to receive(:new) + .with(client_id:, + domain_url:, + client_assertion_signing_key:, + client_assertion_signing_algorithm: algorithm) + .and_return(instance_double(OmniAuth::Auth0::JWTToken, jwt_token:)) end - it 'has info' do - expect(subject['info']['name']).to eq(name) - expect(subject['info']['nickname']).to eq(nickname) - expect(subject['info']['image']).to eq(picture) - expect(subject['info']['email']).to eq(email) + context 'basic oauth' do + before do + @app = make_application(client_secret: nil, client_assertion_signing_key:) + stub_jwt_token(algorithm: nil) + stub_auth(oauth_response) + stub_userinfo(basic_user_info) + trigger_callback + end + + it 'to succeed' do + expect(last_response.status).to eq(200) + end + + it 'has credentials' do + expect(subject['credentials']['token']).to eq(access_token) + expect(subject['credentials']['expires']).to be true + expect(subject['credentials']['expires_at']).to_not be_nil + end + + it 'has basic values' do + expect(subject['provider']).to eq('auth0') + expect(subject['uid']).to eq(user_id) + expect(subject['info']['name']).to eq(name) + end + + it 'should use the user info endpoint' do + expect(subject['extra']['raw_info']).to eq(basic_user_info) + end end - it 'has extra' do - expect(subject['extra']['raw_info']['email_verified']).to be true + context 'basic oauth w/refresh token' do + before do + @app = make_application(client_secret: nil, + client_assertion_signing_key:, + client_assertion_signing_algorithm:) + stub_jwt_token + stub_auth(oauth_response.merge(refresh_token:)) + stub_userinfo(basic_user_info) + trigger_callback + end + + it 'to succeed' do + expect(last_response.status).to eq(200) + end + + it 'has credentials' do + expect(subject['credentials']['token']).to eq(access_token) + expect(subject['credentials']['refresh_token']).to eq(refresh_token) + expect(subject['credentials']['expires']).to be true + expect(subject['credentials']['expires_at']).to_not be_nil + end + end + + context 'oidc' do + before do + @app = make_application(client_secret: nil, + client_assertion_signing_key:, + client_assertion_signing_algorithm:) + stub_jwt_token + stub_auth(oidc_response) + stub_expected_jwks + trigger_callback + end + + it 'to succeed' do + expect(last_response.status).to eq(200) + end + + it 'has credentials' do + expect(subject['credentials']['token']).to eq(access_token) + expect(subject['credentials']['expires']).to be true + expect(subject['credentials']['expires_at']).to_not be_nil + expect(subject['credentials']['id_token']).to eq(id_token) + end + + it 'has basic values' do + expect(subject['provider']).to eq('auth0') + expect(subject['uid']).to eq(user_id) + end + + it 'has info' do + expect(subject['info']['name']).to eq(name) + expect(subject['info']['nickname']).to eq(nickname) + expect(subject['info']['image']).to eq(picture) + expect(subject['info']['email']).to eq(email) + end + + it 'has extra' do + expect(subject['extra']['raw_info']['email_verified']).to be true + end end end end end describe 'error_handling' do - it 'fails when missing client_id' do + it 'fails when missing client_id and client_assertion_signing_key' do @app = make_application(client_id: nil) get 'auth/auth0' expect(last_response.status).to eq(302) @@ -382,7 +776,7 @@ def trigger_callback expect(redirect_url).to fail_auth_with('missing_client_id') end - it 'fails when missing client_secret' do + it 'fails when missing client_secret and client_assertion_signing_key' do @app = make_application(client_secret: nil) get 'auth/auth0' expect(last_response.status).to eq(302) @@ -397,6 +791,14 @@ def trigger_callback redirect_url = last_response.headers['Location'] expect(redirect_url).to fail_auth_with('missing_domain') end + + it 'fails when missing client_assertion_signing_key' do + @app = make_application(client_secret: nil, client_assertion_signing_key: nil) + get 'auth/auth0' + expect(last_response.status).to eq(302) + redirect_url = last_response.headers['Location'] + expect(redirect_url).to fail_auth_with('missing_client_assertion_signing_key') + end end end