diff --git a/.gitignore b/.gitignore index 7334a3336..d48d75741 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ tmtags coverage rdoc pkg +tmp ## PROJECT::SPECIFIC *.gem diff --git a/Rakefile b/Rakefile index b5ff2bd03..8d2bfebf4 100644 --- a/Rakefile +++ b/Rakefile @@ -4,7 +4,7 @@ require 'term/ansicolor' include Term::ANSIColor -OMNIAUTH_GEMS = %w(oa-basic oa-core oa-oauth oa-openid omniauth) +OMNIAUTH_GEMS = %w(oa-basic oa-core oa-oauth oa-openid oa-corporate omniauth) def each_gem(action, &block) OMNIAUTH_GEMS.each_with_index do |dir, i| diff --git a/oa-core/lib/omniauth/form.rb b/oa-core/lib/omniauth/form.rb index ce13056e2..0fcecdd80 100644 --- a/oa-core/lib/omniauth/form.rb +++ b/oa-core/lib/omniauth/form.rb @@ -60,6 +60,12 @@ class Form width: 280px; } + input#identifier, input#openid_url { + background: url(http://openid.net/login-bg.gif) no-repeat; + background-position: 0 50%; + padding-left: 18px; + } + button { font-size: 22px; padding: 4px 8px; diff --git a/oa-corporate/CHANGELOG.rdoc b/oa-corporate/CHANGELOG.rdoc new file mode 100644 index 000000000..e69de29bb diff --git a/oa-corporate/Gemfile b/oa-corporate/Gemfile new file mode 100644 index 000000000..037016640 --- /dev/null +++ b/oa-corporate/Gemfile @@ -0,0 +1,9 @@ +source "http://rubygems.org" + +gem 'oa-core', :path => File.expand_path('../../oa-core/', __FILE__) + +# Will automatically pull in this gem and all its +# dependencies specified in the gemspec +gem 'oa-corporate', :path => File.expand_path("..", __FILE__) + +eval File.read(File.join(File.dirname(__FILE__), '../development_dependencies.rb')) diff --git a/oa-corporate/LICENSE.rdoc b/oa-corporate/LICENSE.rdoc new file mode 100644 index 000000000..95e0dbbf7 --- /dev/null +++ b/oa-corporate/LICENSE.rdoc @@ -0,0 +1,19 @@ +Copyright (c) 2010 James A. Rosen + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. \ No newline at end of file diff --git a/oa-corporate/README.rdoc b/oa-corporate/README.rdoc new file mode 100644 index 000000000..73cbee125 --- /dev/null +++ b/oa-corporate/README.rdoc @@ -0,0 +1,38 @@ += OmniAuth::Corporate + +OmniAuth strategies for use in your intranet. + +== Installation + +To get just corporate functionality: + + gem install oa-corporate + +For the full auth suite: + + gem install omniauth + +== Stand-Alone Example + +Use the strategy as a middleware in your application: + + require 'omniauth/corporate' + + use OmniAuth::Strategies::CAS, :server => 'http://cas.mycompany.com/cas' + +Then simply direct users to '/auth/cas' to have them sign in via your company's CAS server. +See OmniAuth::Strategies::CAS::Configuration for more configuration options. + + +== OmniAuth Builder + +If CAS is one of several authentication strategies, use the OmniAuth Builder: + + require 'omniauth/corporate' + require 'omniauth/oauth' # for Campfire + require 'openid/store/filesystem' + + use OmniAuth::Builder do + provider :cas, :server => 'http://cas.mycompany.com/cas' + provider :campfire + end diff --git a/oa-corporate/Rakefile b/oa-corporate/Rakefile new file mode 100644 index 000000000..2213eb3d0 --- /dev/null +++ b/oa-corporate/Rakefile @@ -0,0 +1,15 @@ +require 'rubygems' +require 'bundler' +Bundler.setup +require 'rake' + +require 'mg' +MG.new('oa-corporate.gemspec') + +require 'spec/rake/spectask' +Spec::Rake::SpecTask.new(:spec) do |spec| + spec.libs << '../oa-core/lib' << 'lib' << 'spec' + spec.spec_files = FileList['spec/**/*_spec.rb'] +end + +task :default => :spec diff --git a/oa-corporate/VERSION b/oa-corporate/VERSION new file mode 100644 index 000000000..6812f8122 --- /dev/null +++ b/oa-corporate/VERSION @@ -0,0 +1 @@ +0.0.3 \ No newline at end of file diff --git a/oa-corporate/lib/omniauth/corporate.rb b/oa-corporate/lib/omniauth/corporate.rb new file mode 100644 index 000000000..531cc55aa --- /dev/null +++ b/oa-corporate/lib/omniauth/corporate.rb @@ -0,0 +1,7 @@ +require 'omniauth/core' + +module OmniAuth + module Strategies + autoload :CAS, 'omniauth/strategies/cas' + end +end diff --git a/oa-corporate/lib/omniauth/strategies/cas.rb b/oa-corporate/lib/omniauth/strategies/cas.rb new file mode 100644 index 000000000..3e78d413a --- /dev/null +++ b/oa-corporate/lib/omniauth/strategies/cas.rb @@ -0,0 +1,47 @@ +require 'omniauth/corporate' + +module OmniAuth + module Strategies + class CAS + include OmniAuth::Strategy + + autoload :Configuration, 'omniauth/strategies/cas/configuration' + autoload :ServiceTicketValidator, 'omniauth/strategies/cas/service_ticket_validator' + + def initialize(app, options = {}) + super(app, options.delete(:name) || :cas) + @configuration = OmniAuth::Strategies::CAS::Configuration.new(options) + end + + protected + + def request_phase + [ + 302, + { + 'Location' => @configuration.login_url(callback_url), + 'Content-Type' => 'text/plain' + }, + ["You are being redirected to CAS for sign-in."] + ] + end + + def callback_phase + ticket = request.params['ticket'] + return fail!(:no_ticket) unless ticket + validator = ServiceTicketValidator.new(@configuration, callback_url, ticket) + @user_info = validator.user_info + return fail!(:invalid_ticket) if @user_info.nil? || @user_info.empty? + super + end + + def auth_hash + OmniAuth::Utils.deep_merge(super, { + 'uid' => @user_info.delete('user'), + 'extra' => @user_info + }) + end + + end + end +end diff --git a/oa-corporate/lib/omniauth/strategies/cas/configuration.rb b/oa-corporate/lib/omniauth/strategies/cas/configuration.rb new file mode 100644 index 000000000..5aa2459cc --- /dev/null +++ b/oa-corporate/lib/omniauth/strategies/cas/configuration.rb @@ -0,0 +1,88 @@ +require 'rack' + +module OmniAuth + module Strategies + class CAS + class Configuration + + DEFAULT_LOGIN_URL = "%s/login" + + DEFAULT_SERVICE_VALIDATE_URL = "%s/serviceValidate" + + # @param [Hash] params configuration options + # @option params [String, nil] :cas_server the CAS server root URL; probably something like + # 'http://cas.mycompany.com' or 'http://cas.mycompany.com/cas'; optional. + # @option params [String, nil] :cas_login_url (:cas_server + '/login') the URL to which to + # redirect for logins; options if :cas_server is specified, + # required otherwise. + # @option params [String, nil] :cas_service_validate_url (:cas_server + '/serviceValidate') the + # URL to use for validating service tickets; optional if :cas_server is + # specified, requred otherwise. + def initialize(params) + parse_params params + end + + # Build a CAS login URL from +service+. + # + # @param [String] service the service (a.k.a. return-to) URL + # + # @return [String] a URL like + # "http://cas.mycompany.com/login?service=..." + def login_url(service) + append_service @login_url, service + end + + # Build a service-validation URL from +service+ and +ticket+. + # + # @param [String] service the service (a.k.a. return-to) URL + # @param [String] ticket the ticket to validate + # + # @return [String] a URL like + # "http://cas.mycompany.com/serviceValidate?service=...&ticket=..." + def service_validate_url(service, ticket) + url = append_service @service_validate_url, service + url << '&ticket=' << Rack::Utils.escape(ticket) + end + + private + + def parse_params(params) + if params[:cas_server].nil? && params[:cas_login_url].nil? + raise ArgumentError.new(":cas_server or :cas_login_url MUST be provided") + end + @login_url = params[:cas_login_url] + @login_url ||= DEFAULT_LOGIN_URL % params[:cas_server] + validate_is_url 'login URL', @login_url + + if params[:cas_server].nil? && params[:cas_service_validate_url].nil? + raise ArgumentError.new(":cas_server or :cas_service_validate_url MUST be provided") + end + @service_validate_url = params[:cas_service_validate_url] + @service_validate_url ||= DEFAULT_SERVICE_VALIDATE_URL % params[:cas_server] + validate_is_url 'service-validate URL', @service_validate_url + end + + IS_NOT_URL_ERROR_MESSAGE = "%s is not a valid URL" + + def validate_is_url(name, possibly_a_url) + url = URI.parse(possibly_a_url) rescue nil + raise ArgumentError.new(IS_NOT_URL_ERROR_MESSAGE % name) unless url.kind_of?(URI::HTTP) + end + + # Adds +service+ as an URL-escaped parameter to +base+. + # + # @param [String] base the base URL + # @param [String] service the service (a.k.a. return-to) URL. + # + # @return [String] the new joined URL. + def append_service(base, service) + result = base.dup + result << (result.include?('?') ? '&' : '?') + result << 'service=' + result << Rack::Utils.escape(service) + end + + end + end + end +end diff --git a/oa-corporate/lib/omniauth/strategies/cas/service_ticket_validator.rb b/oa-corporate/lib/omniauth/strategies/cas/service_ticket_validator.rb new file mode 100644 index 000000000..69b13269e --- /dev/null +++ b/oa-corporate/lib/omniauth/strategies/cas/service_ticket_validator.rb @@ -0,0 +1,80 @@ +require 'nokogiri' + +module OmniAuth + module Strategies + class CAS + class ServiceTicketValidator + + VALIDATION_REQUEST_HEADERS = { 'Accept' => '*/*' } + + # Build a validator from a +configuration+, a + # +return_to+ URL, and a +ticket+. + # + # @param [OmniAuth::Strategies::CAS::Configuration] configuration the CAS configuration + # @param [String] return_to_url the URL of this CAS client service + # @param [String] ticket the service ticket to validate + def initialize(configuration, return_to_url, ticket) + @uri = URI.parse(configuration.service_validate_url(return_to_url, ticket)) + end + + # Request validation of the ticket from the CAS server's + # serviceValidate (CAS 2.0) function. + # + # Swallows all XML parsing errors (and returns +nil+ in those cases). + # + # @return [Hash, nil] a user information hash if the response is valid; +nil+ otherwise. + # + # @raise any connection errors encountered. + def user_info + parse_user_info(find_authentication_success(get_service_response_body)) + end + + private + + # turns an node into a Hash; + # returns nil if given nil + def parse_user_info(node) + return nil if node.nil? + node.children.inject({}) do |hash, child| + unless child.kind_of?(Nokogiri::XML::Text) || + child.name == 'cas:proxies' || + child.name == 'proxies' + hash[child.name.sub(/^cas:/, '')] = child.content + end + hash + end + end + + # finds an node in + # a body if present; returns nil + # if the passed body is nil or if there is no such node. + def find_authentication_success(body) + return nil if body.nil? || body == '' + begin + doc = Nokogiri::XML(body) + begin + doc.xpath('/cas:serviceResponse/cas:authenticationSuccess') + rescue Nokogiri::XML::XPath::SyntaxError + doc.xpath('/serviceResponse/authenticationSuccess') + end + rescue Nokogiri::XML::XPath::SyntaxError + nil + end + end + + # retrieves the XML from the CAS server + def get_service_response_body + result = '' + http = Net::HTTP.new(@uri.host, @uri.port) + http.use_ssl = @uri.port == 443 || @uri.instance_of?(URI::HTTPS) + http.start do |c| + response = c.get "#{@uri.path}?#{@uri.query}", VALIDATION_REQUEST_HEADERS + result = response.body + end + result + end + + end + end + end +end diff --git a/oa-corporate/oa-corporate.gemspec b/oa-corporate/oa-corporate.gemspec new file mode 100644 index 000000000..81135e9af --- /dev/null +++ b/oa-corporate/oa-corporate.gemspec @@ -0,0 +1,21 @@ +require 'rubygems' + +version = File.open(File.dirname(__FILE__) + '/../VERSION', 'r').read.strip + +Gem::Specification.new do |gem| + gem.name = "oa-corporate" + gem.version = version + gem.summary = %Q{Corporate strategies for OmniAuth.} + gem.description = %Q{Corporate strategies for OmniAuth.} + gem.email = "james.a.rosen@gmail.com" + gem.homepage = "http://github.com/intridea/omniauth" + gem.authors = ["James A. Rosen"] + + gem.files = Dir.glob("{lib}/**/*") + %w(README.rdoc LICENSE.rdoc CHANGELOG.rdoc) + + gem.add_dependency 'oa-core', version + gem.add_dependency 'rack', '~> 1.1.0' + gem.add_dependency 'nokogiri', '~> 1.4.2' + + eval File.read(File.join(File.dirname(__FILE__), '../development_dependencies.rb')) +end diff --git a/oa-corporate/spec/fixtures/cas_failure.xml b/oa-corporate/spec/fixtures/cas_failure.xml new file mode 100644 index 000000000..f8238a180 --- /dev/null +++ b/oa-corporate/spec/fixtures/cas_failure.xml @@ -0,0 +1,4 @@ + + + + diff --git a/oa-corporate/spec/fixtures/cas_success.xml b/oa-corporate/spec/fixtures/cas_success.xml new file mode 100644 index 000000000..5a621ff24 --- /dev/null +++ b/oa-corporate/spec/fixtures/cas_success.xml @@ -0,0 +1,8 @@ + + + psegel + Peter + Segel + 2004-07-13 + + diff --git a/oa-corporate/spec/omniauth/strategies/cas_spec.rb b/oa-corporate/spec/omniauth/strategies/cas_spec.rb new file mode 100644 index 000000000..6ed15591f --- /dev/null +++ b/oa-corporate/spec/omniauth/strategies/cas_spec.rb @@ -0,0 +1,71 @@ +require File.dirname(__FILE__) + '/../../spec_helper' +require 'cgi' + +describe OmniAuth::Strategies::CAS, :type => :strategy do + + include OmniAuth::Test::StrategyTestCase + + def strategy + @cas_server ||= 'https://cas.example.org' + [OmniAuth::Strategies::CAS, {:cas_server => @cas_server}] + end + + describe 'GET /auth/cas' do + before do + get '/auth/cas' + end + + it 'should redirect to the CAS server' do + last_response.should be_redirect + return_to = CGI.escape(last_request.url + '/callback') + last_response.headers['Location'].should == @cas_server + '/login?service=' + return_to + end + end + + describe 'GET /auth/cas/callback without a ticket' do + before do + get '/auth/cas/callback' + end + it 'should fail' do + last_response.should be_redirect + last_response.headers['Location'].should =~ /no_ticket/ + end + end + + describe 'GET /auth/cas/callback with an invalid ticket' do + before do + stub_request(:get, /^https:\/\/cas.example.org(:443)?\/serviceValidate\?ticket=9391d/). + to_return(:body => File.read(File.join(File.dirname(__FILE__), '..', '..', 'fixtures', 'cas_failure.xml'))) + get '/auth/cas/callback?ticket=9391d' + end + it 'should fail' do + last_response.should be_redirect + last_response.headers['Location'].should =~ /invalid_ticket/ + end + end + + describe 'GET /auth/cas/callback with a valid ticket' do + before do + stub_request(:get, /^https:\/\/cas.example.org(:443)?\/serviceValidate\?ticket=593af/). + to_return(:body => File.read(File.join(File.dirname(__FILE__), '..', '..', 'fixtures', 'cas_success.xml'))) + get '/auth/cas/callback?ticket=593af' + end + + sets_an_auth_hash + sets_provider_to 'cas' + sets_uid_to 'psegel' + + it 'should set additional user information' do + extra = (last_request['auth'] || {})['extra'] + extra.should be_kind_of(Hash) + extra['first-name'].should == 'Peter' + extra['last-name'].should == 'Segel' + extra['hire-date'].should == '2004-07-13' + end + + it 'should call through to the master app' do + last_response.should be_ok + last_response.body.should == 'true' + end + end +end diff --git a/oa-corporate/spec/spec_helper.rb b/oa-corporate/spec/spec_helper.rb new file mode 100644 index 000000000..32b587a4c --- /dev/null +++ b/oa-corporate/spec/spec_helper.rb @@ -0,0 +1,16 @@ +require 'rubygems' +require 'bundler' +Bundler.setup +require 'spec' +require 'spec/autorun' +require 'webmock/rspec' +require 'rack/test' +require 'omniauth/core' +require 'omniauth/test' +require 'omniauth/corporate' + +Spec::Runner.configure do |config| + config.include WebMock + config.include Rack::Test::Methods + config.extend OmniAuth::Test::StrategyMacros, :type => :strategy +end diff --git a/oa-openid/lib/omniauth/strategies/open_id.rb b/oa-openid/lib/omniauth/strategies/open_id.rb index 71901913c..6ed9bd1a2 100644 --- a/oa-openid/lib/omniauth/strategies/open_id.rb +++ b/oa-openid/lib/omniauth/strategies/open_id.rb @@ -7,6 +7,10 @@ module Strategies class OpenID include OmniAuth::Strategy + # Should be 'openid_url' + # @see http://github.com/intridea/omniauth/issues/issue/13 + IDENTIFIER_URL_PARAMETER = 'identifier' + AX = { :email => 'http://axschema.org/contact/email', :name => 'http://axschema.org/namePerson', @@ -43,7 +47,7 @@ def callback_url end def identifier - request[:identifier] + request[IDENTIFIER_URL_PARAMETER] end def request_phase @@ -55,21 +59,17 @@ def start response = openid.call(env) case env['rack.openid.response'] when Rack::OpenID::MissingResponse, Rack::OpenID::TimeoutResponse - fail :connection_failed + fail!(:connection_failed) else response end end def get_identifier - response = app.call(env) - if response[0] < 400 - response - else - OmniAuth::Form.build('OpenID Authentication') do - text_field('OpenID Identifier', 'identifier') - end.to_response - end + OmniAuth::Form.build('OpenID Authentication') do + label_field('OpenID Identifier', IDENTIFIER_URL_PARAMETER) + input_field('url', IDENTIFIER_URL_PARAMETER) + end.to_response end def callback_phase @@ -78,12 +78,11 @@ def callback_phase openid = Rack::OpenID.new(lambda{|env| [200,{},[]]}, @store) openid.call(env) resp = env.delete('rack.openid.response') - case resp.status - when :failure - fail!(:invalid_credentials) - when :success + if resp && resp.status == :success request['auth'] = auth_hash(resp) @app.call(env) + else + fail!(:invalid_credentials) end end diff --git a/oa-openid/spec/omniauth/strategies/open_id_spec.rb b/oa-openid/spec/omniauth/strategies/open_id_spec.rb index e71e157a2..f6c372554 100644 --- a/oa-openid/spec/omniauth/strategies/open_id_spec.rb +++ b/oa-openid/spec/omniauth/strategies/open_id_spec.rb @@ -1,7 +1,65 @@ -require File.expand_path(File.dirname(__FILE__) + '/../../spec_helper') +require File.dirname(__FILE__) + '/../../spec_helper' -describe OmniAuth::Strategies::OpenID do - it 'should exist' do - # do nothing +describe OmniAuth::Strategies::OpenID, :type => :strategy do + + include OmniAuth::Test::StrategyTestCase + + def strategy + [OmniAuth::Strategies::OpenID] + end + + describe '/auth/open_id without an identifier URL' do + before do + get '/auth/open_id' + end + + it 'should respond with OK' do + last_response.should be_ok + end + + it 'should respond with HTML' do + last_response.content_type.should == 'text/html' + end + + it 'should render an identifier URL input' do + last_response.body.should =~ %r{]*#{OmniAuth::Strategies::OpenID::IDENTIFIER_URL_PARAMETER}} + end + end + + describe '/auth/open_id with an identifier URL' do + before do + @identifier_url = 'http://me.example.org' + # TODO: change this mock to actually return some sort of OpenID response + stub_request(:get, @identifier_url) + get '/auth/open_id', {OmniAuth::Strategies::OpenID::IDENTIFIER_URL_PARAMETER => @identifier_url} + end + + it 'should redirect to the OpenID identity URL' do + last_response.should be_redirect + last_response.headers['Location'].should =~ %r{^#{@identifier_url}.*} + end + + it 'should tell the OpenID server to return to the callback URL' do + return_to = CGI.escape(last_request.url + '/callback') + last_response.headers['Location'].should =~ %r{[\?&]openid.return_to=#{return_to}} + end + + end + + describe 'followed by /auth/open_id/callback' do + before do + @identifier_url = 'http://me.example.org' + # TODO: change this mock to actually return some sort of OpenID response + stub_request(:get, @identifier_url) + get '/auth/open_id/callback' + end + + sets_an_auth_hash + sets_provider_to 'open_id' + sets_uid_to 'http://me.example.org' + + it 'should call through to the master app' do + last_response.body.should == 'true' + end end end diff --git a/oa-openid/spec/spec_helper.rb b/oa-openid/spec/spec_helper.rb index 5052daa41..5bc316451 100644 --- a/oa-openid/spec/spec_helper.rb +++ b/oa-openid/spec/spec_helper.rb @@ -1,10 +1,16 @@ require 'rubygems' +require 'bundler' +Bundler.setup require 'spec' require 'spec/autorun' -require 'rack/test' require 'webmock/rspec' - -include Rack::Test::Methods -include WebMock - +require 'rack/test' +require 'omniauth/core' +require 'omniauth/test' require 'omniauth/openid' + +Spec::Runner.configure do |config| + config.include WebMock + config.include Rack::Test::Methods + config.extend OmniAuth::Test::StrategyMacros, :type => :strategy +end diff --git a/omniauth/Gemfile b/omniauth/Gemfile index 8cd28d8ec..142ba22bd 100644 --- a/omniauth/Gemfile +++ b/omniauth/Gemfile @@ -1,9 +1,10 @@ source "http://rubygems.org" -gem 'oa-core', :path => File.expand_path('../../oa-core/', __FILE__) -gem 'oa-basic', :path => File.expand_path('../../oa-basic/', __FILE__) -gem 'oa-oauth', :path => File.expand_path('../../oa-oauth/', __FILE__) -gem 'oa-openid', :path => File.expand_path('../../oa-openid/', __FILE__) +gem 'oa-core', :path => File.expand_path('../../oa-core/', __FILE__) +gem 'oa-basic', :path => File.expand_path('../../oa-basic/', __FILE__) +gem 'oa-corporate', :path => File.expand_path('../../oa-openid/', __FILE__) +gem 'oa-oauth', :path => File.expand_path('../../oa-oauth/', __FILE__) +gem 'oa-openid', :path => File.expand_path('../../oa-openid/', __FILE__) # Will automatically pull in this gem and all its # dependencies specified in the gemspec diff --git a/omniauth/omniauth.gemspec b/omniauth/omniauth.gemspec index fbc94405f..9fe04b913 100644 --- a/omniauth/omniauth.gemspec +++ b/omniauth/omniauth.gemspec @@ -11,7 +11,7 @@ Gem::Specification.new do |gem| gem.files = Dir.glob("{lib}/**/*") + %w(README.rdoc LICENSE.rdoc CHANGELOG.rdoc) - %w(oa-core oa-oauth oa-basic oa-openid).each do |subgem| + %w(oa-core oa-oauth oa-basic oa-openid oa-corporate).each do |subgem| gem.add_dependency subgem, version end