main
  1#!/usr/bin/env ruby
  2
  3require "bundler/inline"
  4
  5gemfile do
  6  source "https://rubygems.org"
  7
  8  gem "base64", "~> 0.1"
  9  gem "erb", "~> 4.0"
 10  gem "net-hippie", "~> 1.0"
 11  gem "rack", "~> 3.0"
 12  gem "rack-session", "~> 2.0"
 13  gem "rackup", "~> 2.0"
 14  gem "saml-kit", "1.4.0"
 15  gem "webrick", "~> 1.0"
 16end
 17
 18$scheme = ENV.fetch("SCHEME", "http")
 19$port = ENV.fetch("PORT", 8283).to_i
 20$host = ENV.fetch("HOST", "localhost:#{$port}")
 21$idp_host = ENV.fetch("IDP_HOST", "localhost:8282")
 22
 23Net::Hippie.logger = Logger.new($stdout, level: :debug)
 24
 25class OnDemandRegistry < Saml::Kit::DefaultRegistry
 26  def metadata_for(entity_id)
 27    found = super(entity_id)
 28    return found if found
 29
 30    register_url(entity_id, verify_ssl: false)
 31    super(entity_id)
 32  end
 33end
 34
 35Saml::Kit.configure do |x|
 36  x.entity_id = "#{$scheme}://#{$host}/saml/metadata.xml"
 37  x.registry = OnDemandRegistry.new
 38  x.logger = Logger.new("/dev/stderr")
 39end
 40
 41module OAuth
 42  class Client
 43    attr_reader :client_id, :client_secret, :http, :authz_host
 44
 45    def initialize(authz_host, client_id, client_secret)
 46      @authz_host = authz_host
 47      @client_id = client_id
 48      @client_secret = client_secret
 49      @http = Net::Hippie::Client.new(headers: ::Net::Hippie::Client::DEFAULT_HEADERS.merge({
 50        'Authorization' => Net::Hippie.basic_auth(client_id, client_secret),
 51      }))
 52    end
 53
 54    def [](key)
 55      server_metadata.fetch(key)
 56    end
 57
 58    def authorize_uri(redirect_uri:, state: SecureRandom.uuid, response_type: "code", response_mode: "query", scope: "openid")
 59      [
 60        self[:authorization_endpoint],
 61        to_query(
 62          client_id: client_id,
 63          state: state,
 64          redirect_uri: redirect_uri,
 65          response_mode: response_mode,
 66          response_type: response_type,
 67          scope: scope,
 68        )
 69      ].join("?")
 70    end
 71
 72    def exchange(grant_type, params = {})
 73      with_http do |client|
 74        client.post(self[:token_endpoint], body: body_for(grant_type, params))
 75      end
 76    end
 77
 78    private
 79
 80    def body_for(grant_type, params)
 81      case grant_type
 82      when "authorization_code"
 83        {
 84          grant_type: grant_type,
 85          code: params.fetch(:code),
 86          code_verifier: params.fetch(:code_verifier, "not_implemented"),
 87        }
 88      when "urn:ietf:params:oauth:grant-type:saml2-bearer"
 89        {
 90          grant_type: grant_type,
 91          assertion: params.fetch(:assertion),
 92        }
 93      else
 94        raise NotImplementedError.new(grant_type)
 95      end
 96    end
 97
 98    def to_query(params = {})
 99      params.map do |(key, value)|
100        [key, value].join("=")
101      end.join("&")
102    end
103
104    def with_http
105      http.with_retry do |client|
106        yield client
107      end
108    end
109
110    def server_metadata
111      @server_metadata ||=
112        with_http do |client|
113          response = client.get("http://#{authz_host}/.well-known/oauth-authorization-server")
114          JSON.parse(response.body, symbolize_names: true)
115        end
116    end
117  end
118end
119
120module HTTPHelpers
121  def current_user?(request)
122    request.session[:access_token]
123  end
124
125  def not_found
126    [404, { 'X-Backend-Server' => 'UI' }, []]
127  end
128
129  def redirect_to(location)
130    if location.start_with?("http")
131      [302, { 'Location' => location }, []]
132    else
133      [302, { 'Location' => "#{$scheme}://#{$host}#{location}" }, []]
134    end
135  end
136
137  def with_layout(bind)
138    template = <<~ERB
139      <!doctype html>
140      <html lang="en">
141        <head>
142          <title>UI</title>
143          <meta charset="utf-8">
144          <meta name="viewport" content="width=device-width, initial-scale=1">
145          <meta name="color-scheme" content="light dark">
146          <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
147          <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.colors.min.css">
148        </head>
149        <body>
150          <main class="container">
151            #{yield}
152          </main>
153        </body>
154      </html>
155    ERB
156    ERB.new(template, trim_mode: '-').result(bind)
157  end
158end
159
160class UI
161  include ::HTTPHelpers
162
163  attr_reader :oauth_client
164
165  def initialize(oauth_client)
166    @oauth_client = oauth_client
167  end
168
169  def call(env)
170    request = Rack::Request.new(env)
171    case request.request_method
172    when Rack::GET
173      case request.path
174      when "/index.html"
175        return get_index(request)
176      when "/groups.html"
177        if current_user?(request)
178          return get_groups(request)
179        else
180          return redirect_to("/oidc/new")
181        end
182      when /\A\/groups\/\d+\/projects.html\z/
183        if current_user?(request)
184          return get_projects(request)
185        else
186          return redirect_to("/oidc/new")
187        end
188      when "/oauth/callback"
189        return oauth_callback(Rack::Request.new(env))
190      when "/oidc/new"
191        return redirect_to(oauth_client.authorize_uri(
192          redirect_uri: "#{request.base_url}/oauth/callback"
193        ))
194      when "/saml/metadata.xml"
195        return metadata
196      when "/saml/new"
197        return saml_post_to_idp(Rack::Request.new(env))
198      else
199        return redirect_to("/index.html")
200      end
201    when Rack::POST
202      case request.path
203      when "/logout"
204        request.session.delete(:access_token)
205        request.session.delete(:id_token)
206        request.session.delete(:refresh_token)
207        return redirect_to("/")
208      when "/saml/assertions"
209        return saml_assertions(Rack::Request.new(env))
210      else
211        return not_found
212      end
213    end
214    not_found
215  end
216
217  private
218
219  def metadata
220    xml = Saml::Kit::Metadata.build_xml do |builder|
221      builder.embed_signature = false
222      builder.contact_email = 'ui@example.com'
223      builder.organization_name = "Acme, Inc"
224      builder.organization_url = "https://example.com"
225      builder.build_service_provider do |x|
226        x.name_id_formats = [Saml::Kit::Namespaces::PERSISTENT]
227        x.add_assertion_consumer_service("#{$scheme}://#{$host}/saml/assertions", binding: :http_post)
228      end
229    end
230
231    [200, { 'Content-Type' => "application/samlmetadata+xml" }, [xml]]
232  end
233
234  def get_index(request)
235    html = with_layout(binding) do
236      <<~ERB
237        <%- if current_user?(request) -%>
238          <nav>
239            <ul>
240              <li><a href="http://#{$idp_host}/">IdP</a></li>
241              <li><strong>UI</strong></li>
242            </ul>
243            <ul>
244              <li><a href="/index.html">Home</a></li>
245              <li><a href="/groups.html">Groups</a></li>
246              <li>
247                <form action="/logout" method="post">
248                  <input type="submit" value="Logout" />
249                </form>
250              </li>
251            </ul>
252          </nav>
253          <h1>Access Token</h1>
254          <pre><%= request.session[:access_token] %></pre>
255
256          <h1>ID Token</h1>
257          <pre><%= request.session[:id_token] %></pre>
258        <%- else -%>
259          <nav>
260            <ul>
261              <li><a href="http://#{$idp_host}/">IdP</a></li>
262              <li><strong>UI</strong></li>
263            </ul>
264            <ul>
265              <li><a href="/saml/new">SAML Login</a></li>
266              <li><a href="/oidc/new">OIDC Login</a></li>
267            </ul>
268          </nav>
269        <%- end -%>
270      ERB
271    end
272    [200, { "Content-Type" => "text/html" }, [html]]
273  end
274
275  def oauth_callback(request)
276    response = oauth_client.exchange("authorization_code", code: request.params['code'])
277    if response.code == "200"
278      tokens = JSON.parse(response.body, symbolize_names: true)
279      request.session[:access_token] = tokens[:access_token]
280      request.session[:id_token] = tokens[:id_token]
281      request.session[:refresh_token] = tokens[:access_token]
282
283      html = with_layout(binding) do
284        <<~ERB
285          <nav>
286            <ul>
287              <li><a href="http://#{$idp_host}/">IdP</a></li>
288              <li><strong>UI</strong></li>
289            </ul>
290            <ul>
291              <li><a href="/index.html">Home</a></li>
292              <li><a href="/groups.html">Groups</a></li>
293              <li>
294                <form action="/logout" method="post">
295                  <input type="submit" value="Logout" />
296                </form>
297              </li>
298            </ul>
299          </nav>
300          <h1>Access Token</h1>
301          <pre style="display: none;"><%= response.body %></pre>
302          <pre><%= JSON.pretty_generate(request.session[:access_token]) %></pre>
303        ERB
304      end
305      [200, { 'Content-Type' => "text/html" }, [html]]
306    else
307      [response.code, response.header, [response.body]]
308    end
309  end
310
311  def get_groups(request)
312    http = Net::Hippie::Client.new(headers: ::Net::Hippie::Client::DEFAULT_HEADERS.merge({
313      'Authorization' => Net::Hippie.bearer_auth(request.session[:access_token])
314    }))
315
316    response = http.get("http://api.example.com:8080/groups.json")
317    if response.code == "200"
318      groups = JSON.parse(response.body, symbolize_names: true)
319      html = with_layout(binding) do
320        <<~ERB
321            <nav>
322              <ul>
323                <li><a href="http://#{$idp_host}/">IdP</a></li>
324                <li><strong>UI</strong></li>
325              </ul>
326              <ul>
327                <li><a href="/index.html">Home</a></li>
328                <li><a href="/groups.html">Groups</a></li>
329                <li>
330                  <form action="/logout" method="post">
331                    <input type="submit" value="Logout" />
332                  </form>
333                </li>
334              </ul>
335            </nav>
336
337            <h1>Groups</h1>
338            <table>
339              <thead>
340                <tr>
341                  <th>ID</th>
342                  <th>Name</th>
343                  <th>Organization ID</th>
344                  <th>Parent ID</th>
345                  <th>&nbsp;</th>
346                </tr>
347              </thead>
348              <tbody>
349              <%- groups.each do |group| -%>
350                <tr>
351                  <td><%= group[:id] %></td>
352                  <td><%= group[:name] %></td>
353                  <td><%= group[:organization_id] %></td>
354                  <td><%= group[:parent_id] %></td>
355                  <td><a href="/groups/<%= group[:id] %>/projects.html">Projects</a></td>
356                </tr>
357              <%- end -%>
358              </tbody>
359            </table>
360        ERB
361      end
362      [200, { 'Content-Type' => "text/html" }, [html]]
363    else
364      [response.code, response.header, [response.body]]
365    end
366  end
367
368  def get_projects(request)
369    http = Net::Hippie::Client.new(headers: ::Net::Hippie::Client::DEFAULT_HEADERS.merge({
370      'Authorization' => Net::Hippie.bearer_auth(request.session[:access_token])
371    }))
372
373    response = http.get("http://api.example.com:8080/projects.json")
374    if response.code == "200"
375      projects = JSON.parse(response.body, symbolize_names: true)
376
377      html = with_layout(binding) do
378        <<~ERB
379          <nav>
380            <ul>
381              <li><a href="http://#{$idp_host}/">IdP</a></li>
382              <li><strong>UI</strong></li>
383            </ul>
384            <ul>
385              <li><a href="/index.html">Home</a></li>
386              <li><a href="/groups.html">Groups</a></li>
387              <li>
388                <form action="/logout" method="post">
389                  <input type="submit" value="Logout" />
390                </form>
391              </li>
392            </ul>
393          </nav>
394
395          <h1>Projects</h1>
396          <table>
397            <thead>
398              <tr>
399                <th>Name</th>
400                <th>Group ID</th>
401              </tr>
402            </thead>
403            <tbody>
404            <%- projects.each do |project| -%>
405              <tr>
406                <td><%= project[:name] %></td>
407                <td><%= project[:group_id] %></td>
408              </tr>
409            <%- end -%>
410            </tbody>
411          </table>
412        ERB
413      end
414      [200, { 'Content-Type' => "text/html" }, [html]]
415    else
416      [response.code, response.header, [response.body]]
417    end
418  end
419
420  def saml_post_to_idp(request)
421    idp = Saml::Kit.registry.metadata_for("http://#{$idp_host}/saml/metadata.xml")
422    relay_state = Base64.strict_encode64(JSON.generate(redirect_to: '/dashboard'))
423
424    @saml_builder = nil
425    uri, saml_params = idp.login_request_for(binding: :http_post, relay_state: relay_state) do |builder|
426      @saml_builder = builder
427    end
428
429    html = with_layout(binding) do
430      <<~ERB
431        <h2>Sending SAML Request (SP -> IdP)</h2>
432        <textarea readonly="readonly" disabled="disabled" cols=225 rows=8><%=- @saml_builder.to_xml(pretty: true) -%></textarea>
433
434        <form id="idp-form" action="<%= uri %>" method="post">
435          <%- saml_params.each do |(key, value)| -%>
436            <input type="hidden" name="<%= key %>" value="<%= value %>" />
437          <%- end -%>
438          <input id="submit-button" type="submit" value="Continue" />
439        </form>
440      ERB
441    end
442    [200, { 'Content-Type' => "text/html" }, [html]]
443  end
444
445  def saml_assertions(request)
446    sp = Saml::Kit.registry.metadata_for("#{request.base_url}/saml/metadata.xml")
447    saml_binding = sp.assertion_consumer_service_for(binding: :http_post)
448    saml_response = saml_binding.deserialize(request.params)
449    raise saml_response.errors unless saml_response.valid?
450
451    assertion = Base64.strict_encode64(saml_response.assertion.to_xml)
452    response = oauth_client.exchange(
453      "urn:ietf:params:oauth:grant-type:saml2-bearer",
454      assertion: assertion,
455    )
456    if response.code == "200"
457      tokens = JSON.parse(response.body, symbolize_names: true)
458      request.session[:access_token] = tokens[:access_token]
459      request.session[:refresh_token] = tokens[:access_token]
460
461      html = with_layout(binding) do
462        <<~ERB
463        <nav>
464          <ul>
465            <li><a href="http://#{$idp_host}/">IdP</a></li>
466            <li><strong>UI</strong></li>
467          </ul>
468          <ul>
469            <li><a href="/index.html">Home</a></li>
470            <li><a href="/groups.html">Groups</a></li>
471            <li>
472              <form action="/logout" method="post">
473                <input type="submit" value="Logout" />
474              </form>
475            </li>
476          </ul>
477        </nav>
478
479        <h2>Received SAML Response</h2>
480        <textarea readonly="readonly" disabled="disabled" cols=220 rows=40><%=- saml_response.to_xml(pretty: true) -%></textarea>
481        <pre id="raw-saml-response" style="display: none;"><%= request.params["SAMLResponse"] %></pre>
482        <pre id="xml-saml-assertion" style="display: none;"><%= saml_response.assertion.to_xml(pretty: true) %></pre>
483        <pre id="access-token" style="display: none;"><%= JSON.pretty_generate(request.session[:access_token]) %></pre>
484        ERB
485      end
486      [200, { 'Content-Type' => "text/html" }, [html]]
487    else
488      [response.code, response.header, [response.body]]
489    end
490  end
491end
492
493if __FILE__ == $0
494  app = Rack::Builder.new do
495    use Rack::CommonLogger
496    use Rack::Reloader
497    use Rack::Session::Cookie, { domain: $host.split(":", 2)[0], path: "/", secret: SecureRandom.hex(64) }
498
499    run UI.new(::OAuth::Client.new($idp_host, 'client_id', 'client_secret'))
500  end.to_app
501
502  Rackup::Server.start(app: app, Port: $port)
503end