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> </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