#!/usr/bin/env ruby # Start the server by running: # # $ ruby -rwebrick main.rb require "bundler/inline" gemfile do source "https://rubygems.org" gem "rack", "~> 2.2" gem "saml-kit", "~> 1.3" end require "erb" require "rack" class Configuration def initialize @config = YAML.safe_load(IO.read("idp.yml")) end def [](key) @config.fetch(key.to_s) end end class User def initialize(attributes) @attributes = attributes end def name_id_for(name_id_format) @attributes[:email] end def assertion_attributes_for(request) { Username: @attributes[:email], MemberOf: @attributes[:member_of] } end end class OnDemandRegistry < Saml::Kit::DefaultRegistry REGEX = /\/sso\/saml\/samlconf-(?[A-Za-z0-9]+)\/metadata/ def metadata_for(entity_id) found = super(entity_id) return found if found # This is a HACK to work around the fact that the terraform # SAML metadata url is not publicly accessible. uri = URI.parse(entity_id) if uri.host.include?("terraform.io") || (uri.host.include?("ngrok.io") && !uri.path.start_with?("/users/saml/metadata")) metadata = Saml::Kit::Metadata.build do |builder| builder.entity_id = entity_id builder.build_service_provider do |x| match = uri.path.match(REGEX) x.add_assertion_consumer_service("https://#{uri.host}/sso/saml/samlconf-#{match[:uuid]}/acs", binding: :http_post) end end register(metadata) else register_url(entity_id) end super(entity_id) end end $config = Configuration.new Saml::Kit.configure do |x| x.entity_id = "https://#{$config[:host]}/metadata.xml" x.registry = OnDemandRegistry.new x.logger = Logger.new("/dev/stderr") x.add_key_pair($config[:cert], $config[:insecure_key], use: :signing) end class IdentityProvider def initialize @storage = {} end # Download IDP Metadata # # GET /metadata.xml def metadata xml = Saml::Kit::Metadata.build_xml do |builder| builder.embed_signature = false builder.contact_email = 'hi@example.com' builder.organization_name = "Acme, Inc" builder.organization_url = "https://example.com" builder.build_identity_provider do |x| x.add_single_sign_on_service("https://#{$config[:host]}/sso", binding: :http_post) x.name_id_formats = [Saml::Kit::Namespaces::EMAIL_ADDRESS] x.attributes << :MemberOf x.attributes << :Username end end [200, { 'Content-Type' => "application/samlmetadata+xml" }, [xml]] end # POST /sso # The Single Sign On Service endpoint. # It immediately generates a response using the `email` and `member_of` # configuration from the `idp.yml` file. def post_back(request) params = saml_params_from(request) saml_request = binding_for(request).deserialize(params) @builder = nil url, saml_params = saml_request.response_for( User.new($config), binding: :http_post, relay_state: params[:RelayState] ) do |builder| # HACK: The TFE Instance metadata doesn't update the # WantAssertionsSigned value to true in it's metadata # but it still validates that there should be a signed assertion. # To get around this we force the creation of the xmldsig. builder.embed_signature = true @builder = builder end template = <<~ERB

SAML Request

SAML Response

<%- saml_params.each do |(key, value)| -%> <%- end -%>
ERB erb = ERB.new(template, nil, trim_mode: '-') html = erb.result(binding) [200, { 'Content-Type' => "text/html" }, [html]] end def call(env) path = env['PATH_INFO'] case env['REQUEST_METHOD'] when 'GET' case path when "/metadata.xml" return metadata when "/sso" # This should never get hit because this IDP # only exposes a HTTP POST Binding endpoint # but Terraform Cloud defaults to always using # the HTTP-Redirect binding. return post_back(Rack::Request.new(env)) end when 'POST' return post_back(Rack::Request.new(env)) end not_found end private def not_found [404, {}, []] end def saml_params_from(request) if request.post? { "SAMLRequest" => request.params["SAMLRequest"], "RelayState" => request.params["RelayState"], } else query_string = request.query_string on = query_string.include?("&") ? "&" : "&" Hash[query_string.split(on).map { |x| x.split("=", 2) }].symbolize_keys end end def binding_for(request) location = "#{$config[:host]}/sso" if request.post? Saml::Kit::Bindings::HttpPost .new(location: location) else Saml::Kit::Bindings::HttpRedirect .new(location: location) end end end if __FILE__ == $0 app = Rack::Builder.new do use Rack::Reloader run IdentityProvider.new end.to_app Rack::Server.start(app: app, Port: 8282) end