#!/usr/bin/env ruby
require "bundler/inline"
gemfile do
source "https://rubygems.org"
gem "base64", "~> 0.1"
gem "erb", "~> 4.0"
gem "net-hippie", "~> 1.0"
gem "rack", "~> 3.0"
gem "rack-session", "~> 2.0"
gem "rackup", "~> 2.0"
gem "saml-kit", "~> 1.0"
gem "webrick", "~> 1.0"
end
$scheme = ENV.fetch("SCHEME", "http")
$port = ENV.fetch("PORT", 8283).to_i
$host = ENV.fetch("HOST", "localhost:#{$port}")
$idp_host = ENV.fetch("IDP_HOST", "localhost:8282")
Net::Hippie.logger = Logger.new($stdout, level: :debug)
class OnDemandRegistry < Saml::Kit::DefaultRegistry
def metadata_for(entity_id)
found = super(entity_id)
return found if found
register_url(entity_id, verify_ssl: false)
super(entity_id)
end
end
Saml::Kit.configure do |x|
x.entity_id = "#{$scheme}://#{$host}/saml/metadata.xml"
x.registry = OnDemandRegistry.new
x.logger = Logger.new("/dev/stderr")
end
module OAuth
class Client
attr_reader :client_id, :client_secret, :http, :authz_host
def initialize(authz_host, client_id, client_secret)
@authz_host = authz_host
@client_id = client_id
@client_secret = client_secret
@http = Net::Hippie::Client.new(headers: ::Net::Hippie::Client::DEFAULT_HEADERS.merge({
'Authorization' => Net::Hippie.basic_auth(client_id, client_secret),
}))
end
def [](key)
server_metadata.fetch(key)
end
def authorize_uri(state: SecureRandom.uuid, response_type: "code", response_mode: "query", scope: "openid")
[
self[:authorization_endpoint],
to_query(
client_id: client_id,
state: state,
redirect_uri: redirect_uri,
response_mode: response_mode,
response_type: response_type,
scope: scope,
)
].join("?")
end
def exchange(grant_type:, code:, code_verifier: "not_implemented")
with_http do |client|
client.post(self[:token_endpoint], body: {
grant_type: grant_type,
code: code,
code_verifier: code_verifier,
})
end
end
private
def to_query(params = {})
params.map do |(key, value)|
[key, value].join("=")
end.join("&")
end
def redirect_uri
"#{$scheme}://#{$host}/oauth/callback"
end
def with_http
http.with_retry do |client|
yield client
end
end
def server_metadata
@server_metadata ||=
with_http do |client|
response = client.get("http://#{authz_host}/.well-known/oauth-authorization-server")
JSON.parse(response.body, symbolize_names: true)
end
end
end
end
module HTTPHelpers
def current_user?(request)
request.session[:id_token]
end
def not_found
[404, { 'X-Backend-Server' => 'UI' }, []]
end
def redirect_to(location)
if location.start_with?("http")
[302, { 'Location' => location }, []]
else
[302, { 'Location' => "http://ui.example.com:8080#{location}" }, []]
end
end
end
class UI
include ::HTTPHelpers
attr_reader :oauth_client
def initialize(oauth_client)
@oauth_client = oauth_client
end
def call(env)
request = Rack::Request.new(env)
case request.request_method
when Rack::GET
case request.path
when "/index.html"
template = <<~ERB
<%- if current_user?(request) -%>
Groups
Access Token
<%= request.session[:access_token] %>
ID Token
<%= request.session[:id_token] %>
<%- else -%>
SAML Login
OIDC Login
<%- end -%>
ERB
html = ERB.new(template, trim_mode: '-').result(binding)
return [200, { 'Content-Type' => "text/html" }, [html]]
when "/groups.html"
if current_user?(request)
return get_groups(request)
else
return redirect_to("/oidc/new")
end
when /\A\/groups\/\d+\/projects.html\z/
if current_user?(request)
return get_projects(request)
else
return redirect_to("/oidc/new")
end
when "/oauth/callback"
return oauth_callback(Rack::Request.new(env))
when "/oidc/new"
return redirect_to(oauth_client.authorize_uri)
when "/saml/metadata.xml"
return metadata
when "/saml/new"
return saml_post_to_idp(Rack::Request.new(env))
else
return redirect_to("/index.html")
end
when Rack::POST
case request.path
when "/logout"
request.session.delete(:access_token)
request.session.delete(:id_token)
request.session.delete(:refresh_token)
return redirect_to("/")
when "/saml/assertions"
return saml_assertions(Rack::Request.new(env))
else
return not_found
end
end
not_found
end
private
def metadata
xml = Saml::Kit::Metadata.build_xml do |builder|
builder.embed_signature = false
builder.contact_email = 'ui@example.com'
builder.organization_name = "Acme, Inc"
builder.organization_url = "https://example.com"
builder.build_service_provider do |x|
x.name_id_formats = [Saml::Kit::Namespaces::PERSISTENT]
x.add_assertion_consumer_service("#{$scheme}://#{$host}/saml/assertions", binding: :http_post)
end
end
[200, { 'Content-Type' => "application/samlmetadata+xml" }, [xml]]
end
def oauth_callback(request)
response = oauth_client.exchange(grant_type: "authorization_code", code: request.params['code'])
if response.code == "200"
tokens = JSON.parse(response.body, symbolize_names: true)
request.session[:access_token] = tokens[:access_token]
request.session[:id_token] = tokens[:id_token]
request.session[:refresh_token] = tokens[:access_token]
template = <<~ERB
<%= response.body %>
<%= JSON.pretty_generate(request.session[:access_token]) %>
Home
Groups
ERB
html = ERB.new(template, trim_mode: '-').result(binding)
[200, { 'Content-Type' => "text/html" }, [html]]
else
[response.code, response.header, [response.body]]
end
end
def get_groups(request)
http = Net::Hippie::Client.new(headers: ::Net::Hippie::Client::DEFAULT_HEADERS.merge({
'Authorization' => Net::Hippie.bearer_auth(request.session[:access_token])
}))
response = http.get("http://api.example.com:8080/groups.json")
if response.code == "200"
groups = JSON.parse(response.body, symbolize_names: true)
template = <<~ERB
Home
Groups
| ID |
Name |
Organization ID |
Parent ID |
|
<%- groups.each do |group| -%>
| <%= group[:id] %> |
<%= group[:name] %> |
<%= group[:organization_id] %> |
<%= group[:parent_id] %> |
Projects |
<%- end -%>
ERB
html = ERB.new(template, trim_mode: '-').result(binding)
[200, { 'Content-Type' => "text/html" }, [html]]
else
[response.code, response.header, [response.body]]
end
end
def get_projects(request)
http = Net::Hippie::Client.new(headers: ::Net::Hippie::Client::DEFAULT_HEADERS.merge({
'Authorization' => Net::Hippie.bearer_auth(request.session[:access_token])
}))
response = http.get("http://api.example.com:8080/projects.json")
if response.code == "200"
projects = JSON.parse(response.body, symbolize_names: true)
template = <<~ERB
Home
Groups
| Name |
Group ID |
<%- projects.each do |project| -%>
| <%= project[:name] %> |
<%= project[:group_id] %> |
<%- end -%>
ERB
html = ERB.new(template, trim_mode: '-').result(binding)
[200, { 'Content-Type' => "text/html" }, [html]]
else
[response.code, response.header, [response.body]]
end
end
def saml_post_to_idp(request)
idp = Saml::Kit.registry.metadata_for("http://#{$idp_host}/saml/metadata.xml")
relay_state = Base64.strict_encode64(JSON.generate(redirect_to: '/dashboard'))
@saml_builder = nil
uri, saml_params = idp.login_request_for(binding: :http_post, relay_state: relay_state) do |builder|
@saml_builder = builder
end
template = <<~ERB
Sending SAML Request (SP -> IdP)
ERB
html = ERB.new(template, trim_mode: '-').result(binding)
[200, { 'Content-Type' => "text/html" }, [html]]
end
def saml_assertions(request)
sp = Saml::Kit.registry.metadata_for("#{$scheme}://#{$host}/saml/metadata.xml")
saml_binding = sp.assertion_consumer_service_for(binding: :http_post)
saml_response = saml_binding.deserialize(request.params)
raise saml_response.errors unless saml_response.valid?
template = <<~ERB
Received SAML Response
ERB
html = ERB.new(template, trim_mode: '-').result(binding)
[200, { 'Content-Type' => "text/html" }, [html]]
end
end
if __FILE__ == $0
app = Rack::Builder.new do
use Rack::CommonLogger
use Rack::Reloader
use Rack::Session::Cookie, { domain: $host.split(":", 2)[0], path: "/", secret: SecureRandom.hex(64) }
run UI.new(::OAuth::Client.new($idp_host, 'client_id', 'client_secret'))
end.to_app
Rackup::Server.start(app: app, Port: $port)
end