summaryrefslogtreecommitdiff
path: root/bin
diff options
context:
space:
mode:
Diffstat (limited to 'bin')
-rwxr-xr-xbin/api182
-rwxr-xr-xbin/idp895
-rwxr-xr-xbin/ui503
3 files changed, 0 insertions, 1580 deletions
diff --git a/bin/api b/bin/api
deleted file mode 100755
index 180aa874..00000000
--- a/bin/api
+++ /dev/null
@@ -1,182 +0,0 @@
-#!/usr/bin/env ruby
-
-require 'bundler/inline'
-
-gemfile do
- source 'https://rubygems.org'
-
- gem "csv", "~> 3.0"
- gem "declarative_policy", "~> 1.0"
- gem "erb", "~> 4.0"
- gem "globalid", "~> 1.0"
- gem "google-protobuf", "~> 3.0"
- gem "json", "~> 2.0"
- gem "logger", "~> 1.0"
- gem "rack", "~> 3.0"
- gem "rackup", "~> 2.0"
- gem "securerandom", "~> 0.1"
- gem "twirp", "~> 1.0"
- gem "webrick", "~> 1.0"
-end
-
-lib_path = Pathname.new(__FILE__).parent.parent.join('lib').realpath.to_s
-$LOAD_PATH.unshift(lib_path) unless $LOAD_PATH.include?(lib_path)
-
-require 'authx/rpc'
-
-$scheme = ENV.fetch("SCHEME", "http")
-$port = ENV.fetch("PORT", 8284).to_i
-$host = ENV.fetch("HOST", "localhost:#{$port}")
-
-class Entity
- class << self
- def all
- @items ||= ::CSV.read(File.join(__dir__, "../db/api/#{self.name.downcase}s.csv"), headers: true).map do |row|
- new(row.to_h.transform_keys(&:to_sym))
- end
- end
-
- def create!(attributes)
- new({ id: SecureRandom.uuid }.merge(attributes)).tap do |item|
- all << item
- end
- end
- end
-
- def initialize(attributes = {})
- @attributes = attributes
- end
-
- def id
- self[:id]
- end
-
- def [](attribute)
- @attributes.fetch(attribute.to_sym)
- end
-
- def to_h
- @attributes
- end
-
- def to_gid
- ::GlobalID.create(self, app: "example")
- end
-end
-
-class Organization < Entity
- class << self
- def default
- @default ||= all.find { |organization| organization[:name] == "default" }
- end
- end
-end
-
-class Group < Entity
-end
-
-class Project < Entity
-end
-
-module HTTPHelpers
- def authorized?(request, permission, resource)
- raise [permission, resource].inspect if resource.nil?
- authorization = Rack::Auth::AbstractRequest.new(request.env)
- return false unless authorization.provided?
-
- response = rpc.allowed({
- subject: authorization.params,
- permission: permission,
- resource: resource.to_gid.to_s,
- }, headers: { 'Authorization' => "Bearer #{authorization.params}"})
- puts [response&.data&.result, permission, resource.to_gid.to_s].inspect
- response.error.nil? && response.data.result
- end
-
- def json_not_found
- http_response(code: 404)
- end
-
- def json_ok(body)
- http_response(code: 200, body: JSON.pretty_generate(body))
- end
-
- def json_created(body)
- http_response(code: 201, body: JSON.pretty_generate(body.to_h))
- end
-
- def json_unauthorized(permission, resource)
- http_response(code: 401, body: JSON.pretty_generate({
- error: {
- code: 401,
- message: "`#{permission}` is required on `#{resource.to_gid}`",
- }
- }))
- end
-
- def http_response(code:, headers: { 'Content-Type' => 'application/json' }, body: nil)
- [
- code,
- headers.merge({ 'X-Backend-Server' => 'REST' }),
- [body].compact
- ]
- end
-end
-
-class API
- include HTTPHelpers
-
- attr_reader :rpc
-
- def initialize
- @rpc = ::Authx::Rpc::AbilityClient.new("http://idp.example.com:8080/twirp")
- end
-
- def call(env)
- request = Rack::Request.new(env)
- case request.request_method
- when Rack::GET
- case request.path
- when "/organizations", "/organizations.json"
- return json_ok(Organization.all.map(&:to_h))
- when "/groups", "/groups.json"
- resource = Organization.default
- if authorized?(request, :read_group, resource)
- return json_ok(Group.all.map(&:to_h))
- else
- return json_unauthorized(:read_group, resource)
- end
- when "/projects", "/projects.json"
- resource = Organization.default
- if authorized?(request, :read_project, resource)
- return json_ok(Project.all.map(&:to_h))
- else
- return json_unauthorized(:read_project, resource)
- end
- end
- when Rack::POST
- case request.path
- when "/projects", "/projects.json"
- resource = Organization.default
- if authorized?(request, :create_project, resource)
- return json_created(Project.create!(JSON.parse(request.body.read, symbolize_names: true)))
- else
- return json_unauthorized(:create_project, resource)
- end
- end
- end
- json_not_found
- end
-
- private
-end
-
-if __FILE__ == $0
- app = Rack::Builder.new do
- use Rack::CommonLogger
- use Rack::Reloader
- run API.new
- end.to_app
-
- Rackup::Server.start(app: app, Port: $port)
-end
diff --git a/bin/idp b/bin/idp
deleted file mode 100755
index 62462deb..00000000
--- a/bin/idp
+++ /dev/null
@@ -1,895 +0,0 @@
-#!/usr/bin/env ruby
-
-require "bundler/inline"
-
-gemfile do
- source "https://rubygems.org"
-
- gem "base64", "~> 0.1"
- gem "bcrypt", "~> 3.0"
- gem "csv", "~> 3.0"
- gem "declarative_policy", "~> 1.0"
- gem "erb", "~> 4.0"
- gem "globalid", "~> 1.0"
- gem "google-protobuf", "~> 3.0"
- gem "rack", "~> 3.0"
- gem "rack-session", "~> 2.0"
- gem "rackup", "~> 2.0"
- gem "saml-kit", "1.4.0"
- gem "twirp", "~> 1.0"
- gem "warden", "~> 1.0"
- gem "webrick", "~> 1.0"
-end
-
-lib_path = Pathname.new(__FILE__).parent.parent.join('lib').realpath.to_s
-$LOAD_PATH.unshift(lib_path) unless $LOAD_PATH.include?(lib_path)
-
-require 'authx/rpc'
-
-$scheme = ENV.fetch("SCHEME", "http")
-$port = ENV.fetch("PORT", 8282).to_i
-$host = ENV.fetch("HOST", "localhost:#{$port}")
-
-DeclarativePolicy.configure do
- name_transformation do |name|
- "::Authz::#{name}Policy"
- end
-end
-
-Warden::Manager.serialize_into_session do |user|
- user.id
-end
-
-Warden::Manager.serialize_from_session do |id|
- ::Authn::User.find(id)
-end
-
-Warden::Strategies.add(:password) do
- def valid?
- params['username'] && params['password']
- end
-
- def authenticate!
- user = ::Authn::User.login(params.transform_keys(&:to_sym))
- user.nil? ? fail!("Could not log in") : success!(user)
- end
-end
-
-module HTTPHelpers
- def current_user?(request)
- request.env['warden'].authenticated?
- end
-
- def current_user(request)
- request.env['warden'].user
- end
-
- def default_headers
- {
- 'X-Powered-By' => 'IdP'
- }
- end
-
- def http_not_found
- [404, default_headers, []]
- end
-
- def http_ok(headers = {}, body = nil)
- [200, default_headers.merge(headers), [body]]
- end
-
- def http_redirect_to(location)
- [302, { 'Location' => "#{$scheme}://#{$host}#{location}" }, []]
- end
-end
-
-module Authn
- class User
- include ::BCrypt
-
- class << self
- def all
- @all ||= ::CSV.read(File.join(__dir__, "../db/idp/users.csv"), headers: true).map do |row|
- new(row.to_h.transform_keys(&:to_sym))
- end
- end
-
- def find(id)
- all.find do |user|
- user[:id] == id
- end
- end
-
- def find_by
- all.find do |user|
- yield user
- end
- end
-
- def find_by_email(email)
- find_by do |user|
- user[:email] == email
- end
- end
-
- def find_by_username(username)
- find_by do |user|
- user[:username] == username
- end
- end
-
- def login(params = {})
- user = find_by_username(params[:username])
- user&.valid_password?(params[:password]) ? user : nil
- end
- end
-
- attr_reader :id
-
- def initialize(attributes)
- @attributes = attributes
- @id = self[:id]
- end
-
- def [](attribute)
- @attributes.fetch(attribute.to_sym)
- end
-
- def name_id_for(name_id_format)
- if name_id_format == Saml::Kit::Namespaces::EMAIL_ADDRESS
- self[:email]
- else
- self[:id]
- end
- end
-
- def create_access_token
- ::Authz::JWT.new(
- sub: to_global_id.to_s,
- auth_time: Time.now.to_i,
- email: self[:email],
- username: self[:username],
- )
- end
-
- def create_id_token
- ::Authz::JWT.new(sub: to_global_id.to_s)
- end
-
- def assertion_attributes_for(request)
- {
- email: self[:email],
- }
- end
-
- def valid_password?(entered_password)
- ::BCrypt::Password.new(self[:password_digest]) == entered_password
- end
-
- def to_global_id
- ::GlobalID.new(
- ::URI::GID.build(
- app: "example",
- model_name: "User",
- model_id: id,
- params: {}
- )
- ).to_s
- end
- end
-
- 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
-
- class SessionsController
- include ::HTTPHelpers
-
- def call(env)
- request = Rack::Request.new(env)
- case request.request_method
- when Rack::GET
- case request.path
- when '/sessions/new'
- return get_login(request)
- end
- when Rack::POST
- case request.path
- when '/sessions'
- if (user = env['warden'].authenticate(:password))
- path = request.params["redirect_back"] ? request.params["redirect_back"] : "/"
- return http_redirect_to(path)
- else
- return http_redirect_to("/sessions/new")
- end
- when '/sessions/delete'
- request.env['warden'].logout
- return http_redirect_to('/')
- end
- end
-
- http_not_found
- end
-
- private
-
- def get_login(request)
- template = <<~ERB
- <!doctype html>
- <html lang="en" data-theme="light">
- <head>
- <title>IdP</title>
- <meta charset="utf-8">
- <meta name="viewport" content="width=device-width, initial-scale=1">
- <meta name="color-scheme" content="light dark">
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
- </head>
- <body>
- <main class="container">
- <nav>
- <ul>
- <li><strong>IdP</strong></li>
- <li><a href="http://ui.example.com:8080/">UI</a></li>
- </ul>
- </nav>
-
- <form id="login-form" action="/sessions" method="post">
- <fieldset>
- <label>
- Username
- <input type="input" placeholder="Username" id="username" name="username" value="" />
- </label>
- <label>
- Password
- <input type="password" placeholder="Password" id="password" name="password" value="" />
- </label>
- </fieldset>
-
- <input type="hidden" name="redirect_back" value="<%= request.params["redirect_back"] %>" />
- <input type="submit" id="login-button" value="Login" />
- </form>
- </main>
- </body>
- </html>
- ERB
- html = ERB.new(template, trim_mode: '-').result(binding)
- [200, { 'Content-Type' => "text/html" }, [html]]
- end
- end
-
- class SAMLController
- include ::HTTPHelpers
-
- def initialize(scheme, host)
- Saml::Kit.configure do |x|
- x.entity_id = "#{$scheme}://#{$host}/saml/metadata.xml"
- x.registry = OnDemandRegistry.new
- x.logger = Logger.new("/dev/stderr")
- end
-
- @saml_metadata = Saml::Kit::Metadata.build do |builder|
- builder.contact_email = 'hi@example.com'
- builder.organization_name = "Acme, Inc"
- builder.organization_url = "#{scheme}://#{host}"
- builder.build_identity_provider do |x|
- x.add_single_sign_on_service("#{scheme}://#{host}/saml/new", binding: :http_post)
- x.name_id_formats = [Saml::Kit::Namespaces::PERSISTENT, Saml::Kit::Namespaces::EMAIL_ADDRESS]
- x.attributes << :email
- end
- end
- end
-
- def call(env)
- request = Rack::Request.new(env)
- case request.request_method
- when Rack::GET
- case request.path
- when "/saml/continue"
- if current_user?(request)
- saml_params = request.session[:saml_params]
- return saml_post_back(request, current_user(request), saml_params)
- else
- return http_redirect_to("/sessions/new?redirect_back=/saml/continue")
- end
- when "/saml/metadata.xml"
- return http_ok(
- { 'Content-Type' => "application/samlmetadata+xml" },
- saml_metadata.to_xml(pretty: true)
- )
- end
- when Rack::POST
- case request.path
- when "/saml/new"
- saml_params = saml_params_from(request)
-
- if current_user?(request)
- return saml_post_back(request, current_user(request), saml_params)
- else
- request.session[:saml_params] = saml_params
- return http_redirect_to("/sessions/new?redirect_back=/saml/continue")
- end
- end
- end
-
- http_not_found
- end
-
- private
-
- attr_reader :saml_metadata
-
- def saml_post_back(request, user, saml_params)
- saml_request = binding_for(request).deserialize(saml_params)
-
- @builder = nil
- url, saml_params = saml_request.response_for(
- user,
- binding: :http_post,
- relay_state: saml_params[:RelayState]
- ) { |builder| @builder = builder }
- template = <<~ERB
- <!doctype html>
- <html lang="en" data-theme="light">
- <head>
- <title>IdP</title>
- <meta charset="utf-8">
- <meta name="viewport" content="width=device-width, initial-scale=1">
- <meta name="color-scheme" content="light dark">
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
- </head>
- <body>
- <main class="container">
- <nav>
- <ul>
- <li><strong>IdP</strong></li>
- <li><a href="http://ui.example.com:8080/">UI</a></li>
- </ul>
- </nav>
-
- <h2>Recieved SAML Request</h2>
- <textarea readonly="readonly" disabled="disabled" cols=225 rows=8><%=- saml_request.to_xml(pretty: true) -%></textarea>
-
- <h2>Sending SAML Response (IdP -> SP)</h2>
- <textarea readonly="readonly" disabled="disabled" cols=225 rows=32><%=- @builder.build.to_xml(pretty: true) -%></textarea>
- <form id="postback-form" action="<%= url %>" method="post">
- <%- saml_params.each do |(key, value)| -%>
- <input type="hidden" name="<%= key %>" value="<%= value %>" />
- <%- end -%>
- <input id="submit-button" type="submit" value="Continue" />
- </form>
- </main>
- </body>
- </html>
- ERB
- erb = ERB.new(template, trim_mode: '-')
- html = erb.result(binding)
- [200, { 'Content-Type' => "text/html" }, [html]]
- 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?("&amp;") ? "&amp;" : "&"
- Hash[query_string.split(on).map { |x| x.split("=", 2) }].symbolize_keys
- end
- end
-
- def binding_for(request)
- Saml::Kit::Bindings::HttpPost.new(location: "#{$scheme}://#{$host}/saml/new")
- end
- end
-end
-
-class Organization
- class << self
- def find(id)
- new
- end
- end
-end
-
-module Authz
- class OrganizationPolicy < DeclarativePolicy::Base
- condition(:owner) { true }
-
- rule { owner }.enable :read_project
- rule { owner }.enable :read_group
- rule { owner }.enable :create_project
- end
-
- class JWT
- class << self
- # TODO:: validate signature
- def decode(encoded)
- _header, body, _signature = encoded
- .split('.', 3)
- .map { |x| JSON.parse(Base64.strict_decode64(x), symbolize_names: true) rescue {} }
- new(body)
- end
- end
-
- attr_reader :claims
-
- def initialize(claims)
- now = Time.now.to_i
- @claims = {
- iss: "#{$scheme}://#{$host}",
- iat: now,
- aud: "",
- nbf: now,
- jti: SecureRandom.uuid,
- exp: now + 3600,
- }.merge(claims)
- end
-
- def [](claim)
- claims.fetch(claim)
- end
-
- def active?
- now = Time.now.to_i
- self[:nbf] <= now && now < self[:exp]
- end
-
- def to_jwt
- [
- Base64.strict_encode64(JSON.generate(alg: "none")),
- Base64.strict_encode64(JSON.generate(claims)),
- ""
- ].join(".")
- end
- end
-
- module Rpc
- class Ability
- def allowed(request, env)
- {
- result: can?(request)
- }
- end
-
- private
-
- def can?(request)
- subject = subject_of(request.subject)
- resource = resource_from(request.resource)
- permission = request.permission.to_sym
-
- policy = DeclarativePolicy.policy_for(subject, resource)
- policy.can?(permission)
- rescue StandardError => error
- puts error.inspect
- false
- end
-
- def subject_of(encoded_token)
- token = ::Authz::JWT.decode(encoded_token)
- token&.claims[:sub]
- end
-
- def resource_from(global_id)
- GlobalID::Locator.locate(global_id)
- end
- end
- end
-
- class AuthorizationGrant
- class << self
- def all
- @all ||= []
- end
-
- def find_by(params)
- case params[:grant_type]
- when 'authorization_code'
- authorization_code_grant(params[:code], params[:code_verifier])
- when 'refresh_token'
- refresh_grant(params[:refresh_token])
- when 'client_credentials'
- client_credentials_grant(params)
- when 'password'
- password_grant(params[:username], params[:password])
- when "urn:ietf:params:oauth:grant-type:saml2-bearer" # RFC-7522
- saml_assertion_grant(params[:assertion])
- when 'urn:ietf:params:oauth:grant-type:jwt-bearer' # RFC7523
- jwt_bearer_grant(params)
- end
- end
-
- # TODO:: implement `code_verifier` param
- def authorization_code_grant(code, code_verifier)
- all.find do |grant|
- grant.active? && grant.code == code
- end
- end
-
- def refresh_grant(refresh_token)
- raise NotImplementedError
- end
-
- def client_credential_grant(params)
- raise NotImplementedError
- end
-
- def password_grant(username, password)
- raise NotImplementedError
- end
-
- def saml_assertion_grant(encoded_saml_assertion)
- xml = Base64.decode64(encoded_saml_assertion)
- # TODO:: Validate signature and prevent assertion reuse
- saml_assertion = Saml::Kit::Document.to_saml_document(xml)
-
- user = ::Authn::User.find_by_email(saml_assertion.name_id) ||
- ::Authn::User.find(saml_assertion.name_id)
- new(user, saml_assertion: saml_assertion)
- end
-
- def jwt_bearer_grant(params)
- raise NotImplementedError
- end
-
- def create!(user, params = {})
- new(user, params).tap do |grant|
- all << grant
- end
- end
- end
-
- attr_reader :code, :user, :params
-
- def initialize(user, params = {})
- @user = user
- @params = params
- @code = SecureRandom.uuid
- @exchanged_at = nil
- end
-
- def active?
- @exchanged_at.nil?
- end
-
- def inactive?
- !active?
- end
-
- def create_access_token
- raise "Invalid code" unless active?
-
- user.create_access_token.tap do
- @exchanged_at = Time.now
- end
- end
-
- def exchange
- {
- access_token: create_access_token.to_jwt,
- token_type: "Bearer",
- issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
- expires_in: 3600,
- refresh_token: SecureRandom.hex(32)
- }.tap do |body|
- if params["scope"]&.include?("openid")
- body[:id_token] = user.create_id_token.to_jwt
- end
- end
- rescue StandardError => error
- {
- error: error.message,
- error_description: error.backtrace,
- }
- end
- end
-
- class OAuthController
- include ::HTTPHelpers
-
- def call(env)
- request = Rack::Request.new(env)
-
- case request.request_method
- when Rack::GET
- case request.path
- when "/oauth/authorize/continue"
- if current_user?(request)
- return get_authorize(request.session[:oauth_params])
- end
- when "/oauth/authorize" # RFC-6749
- oauth_params = request.params.slice('client_id', 'scope', 'redirect_uri', 'response_mode', 'response_type', 'state', 'code_challenge_method', 'code_challenge')
- if current_user?(request)
- return get_authorize(oauth_params)
- else
- request.session[:oauth_params] = oauth_params
- return http_redirect_to("/sessions/new?redirect_back=/oauth/authorize/continue")
- end
- else
- return http_not_found
- end
- when Rack::POST
- case request.path
- when "/oauth/authorize" # RFC-6749
- return post_authorize(request)
- when "/oauth/introspect" # RFC-7662
- params = request.content_type == "application/json" ? JSON.parse(request.body.read, symbolize_names: true) : Hash[URI.decode_www_form(request.body.read)].transform_keys(&:to_sym)
- return post_introspect(params.slice(:token, :token_type_hint))
- when "/oauth/token" # RFC-6749
- params = request.content_type == "application/json" ? JSON.parse(request.body.read, symbolize_names: true) : Hash[URI.decode_www_form(request.body.read)].transform_keys(&:to_sym)
- grant = AuthorizationGrant.find_by(params)
-
- return [404, { "Content-Type" => "application/json" }, [JSON.pretty_generate(error: 404, error_description: "Not Found")]] if grant.nil? || grant.inactive?
- return [200, { "Content-Type" => "application/json" }, [JSON.pretty_generate(grant.exchange)]]
- when "/oauth/revoke" # RFC-7009
- # TODO:: Revoke the JWT token and make it ineligible for usage
- return http_not_found
- else
- return http_not_found
- end
- end
- http_not_found
- end
-
- private
-
- def post_introspect(params)
- token = ::Authz::JWT.decode(params[:token])
- return [200, { "Content-Type" => "application/json" }, [JSON.pretty_generate(token.claims.merge(active: token.active?))]]
- end
-
- def get_authorize(oauth_params)
- template = <<~ERB
- <!doctype html>
- <html lang="en" data-theme="light">
- <head>
- <title>IdP</title>
- <meta charset="utf-8">
- <meta name="viewport" content="width=device-width, initial-scale=1">
- <meta name="color-scheme" content="light dark">
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
- </head>
- <body>
- <main class="container">
- <nav>
- <ul>
- <li><strong>IdP</strong></li>
- <li><a href="http://ui.example.com:8080/">UI</a></li>
- </ul>
- </nav>
-
- <h1>Authorize?</h1>
- <p>Client ID: <%= oauth_params['client_id'] %></p>
- <form id="authorize-form" action="/oauth/authorize" method="post">
- <input type="hidden" name="client_id" value="<%= oauth_params['client_id'] %>" />
- <input type="hidden" name="scope" value="<%= oauth_params['scope'] %>" />
- <input type="hidden" name="redirect_uri" value="<%= oauth_params['redirect_uri'] %>" />
- <input type="hidden" name="response_mode" value="<%= oauth_params['response_mode'] %>" />
- <input type="hidden" name="response_type" value="<%= oauth_params['response_type'] %>" />
- <input type="hidden" name="state" value="<%= oauth_params['state'] %>" />
- <input type="hidden" name="code_challenge_method" value="<%= oauth_params['code_challenge_method'] %>" />
- <input type="hidden" name="code_challenge" value="<%= oauth_params['code_challenge'] %>" />
- <input id="submit-button" type="submit" value="Authorize" />
- </form>
- </main>
- </body>
- </html>
- ERB
- html = ERB.new(template, trim_mode: '-').result(binding)
- [200, { 'Content-Type' => "text/html" }, [html]]
- end
-
- def post_authorize(request)
- params = request.params.slice('client_id', 'redirect_uri', 'response_type', 'response_mode', 'state', 'code_challenge_method', 'code_challenge', 'scope')
- grant = AuthorizationGrant.create!(current_user(request), params)
- case params['response_type']
- when 'code'
- case params['response_mode']
- when 'fragment'
- return [302, { 'Location' => "#{params['redirect_uri']}#code=#{grant.code}&state=#{params['state']}" }, []]
- when 'query'
- return [302, { 'Location' => "#{params['redirect_uri']}?code=#{grant.code}&state=#{params['state']}" }, []]
- else
- # TODO:: form post
- end
- when 'token'
- return http_not_found
- else
- return http_not_found
- end
- end
- end
-end
-
-class IdentityProvider
- include ::HTTPHelpers
-
- def call(env)
- request = Rack::Request.new(env)
-
- case request.request_method
- when Rack::GET
- case request.path
- when '/'
- if current_user?(request)
- return get_dashboard(request)
- else
- return http_redirect_to("/sessions/new")
- end
- when '/.well-known/openid-configuration'
- return openid_metadata
- when '/.well-known/oauth-authorization-server'
- return oauth_metadata
- when '/.well-known/webfinger' # RFC-7033
- return http_not_found
- else
- return http_not_found
- end
- end
- http_not_found
- end
-
- private
-
- def get_dashboard(request)
- template = <<~ERB
- <!doctype html>
- <html lang="en" data-theme="light">
- <head>
- <title>IdP</title>
- <meta charset="utf-8">
- <meta name="viewport" content="width=device-width, initial-scale=1">
- <meta name="color-scheme" content="light dark">
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
- </head>
- <body>
- <main class="container">
- <nav>
- <ul>
- <li><strong>IdP</strong></li>
- <li><a href="http://ui.example.com:8080/">UI</a></li>
- </ul>
- <ul>
- <li><a href="/">Home</a></li>
- <li><a href="http://ui.example.com:8080/groups.html">Groups</a></li>
- <li>
- <form action="/sessions/delete" method="post">
- <input type="submit" value="logout" />
- </form>
- </li>
- </ul>
- </nav>
-
- <h1> Hello, <%= current_user(request)[:username] %></h1>
- </main>
- </body>
- </html>
- ERB
- erb = ERB.new(template, trim_mode: '-')
- html = erb.result(binding)
- [200, { 'Content-Type' => "text/html" }, [html]]
- end
-
- # GET /.well-known/oauth-authorization-server
- def oauth_metadata
- [200, { 'Content-Type' => "application/json" }, [JSON.pretty_generate({
- issuer: "#{$scheme}://#{$host}/.well-known/oauth-authorization-server",
- authorization_endpoint: "#{$scheme}://#{$host}/oauth/authorize",
- token_endpoint: "#{$scheme}://#{$host}/oauth/token",
- jwks_uri: "", # RFC-7517
- registration_endpoint: "", # RFC-7591
- scopes_supported: ["openid", "profile", "email"],
- response_types_supported: ["code", "code id_token", "id_token", "token id_token"],
- response_modes_supported: ["query", "fragment", "form_post"],
- grant_types_supported: ["authorization_code", "implicit"], # RFC-7591
- token_endpoint_auth_methods_supported: ["client_secret_basic"], # RFC-7591
- token_endpoint_auth_signing_alg_values_supported: ["RS256"],
- service_documentation: "",
- ui_locales_supported: ["en-US"],
- op_policy_uri: "",
- op_tos_uri: "",
- revocation_endpoint: "#{$scheme}://#{$host}/oauth/revoke", # RFC-7009
- revocation_endpoint_auth_methods_supported: ["client_secret_basic"],
- revocation_endpoint_auth_signing_alg_values_supported: ["RS256"],
- introspection_endpoint: "#{$scheme}://#{$host}/oauth/introspect", # RFC-7662
- introspection_endpoint_auth_methods_supported: ["client_secret_basic"],
- introspection_endpoint_auth_signing_alg_values_supported: ["RS256"],
- code_challenge_methods_supported: [], # RFC-7636
- })]]
- end
-
- # GET /.well-known/openid-configuration
- def openid_metadata
- [200, { 'Content-Type' => "application/json" }, [JSON.pretty_generate({
- issuer: "#{$scheme}://#{$host}/.well-known/oauth-authorization-server",
- authorization_endpoint: "#{$scheme}://#{$host}/oauth/authorize",
- token_endpoint: "#{$scheme}://#{$host}/oauth/token",
- userinfo_endpoint: "#{$scheme}://#{$host}/oidc/user/",
- jwks_uri: "", # RFC-7517
- registration_endpoint: nil,
- scopes_supported: ["openid", "profile", "email"],
- response_types_supported: ["code", "code id_token", "id_token", "token id_token"],
- response_modes_supported: ["query", "fragment", "form_post"],
- grant_types_supported: ["authorization_code", "implicit"], # RFC-7591
- acr_values_supported: [],
- subject_types_supported: ["pairwise", "public"],
- id_token_signing_alg_values_supported: ["RS256"],
- id_token_encryption_alg_values_supported: [],
- id_token_encryption_enc_values_supported: [],
- userinfo_signing_alg_values_supported: ["RS256"],
- userinfo_encryption_alg_values_supported: [],
- userinfo_encryption_enc_values_supported: [],
- request_object_signing_alg_values_supported: ["none", "RS256"],
- request_object_encryption_alg_values_supported: [],
- request_object_encryption_enc_values_supported: [],
- token_endpoint_auth_methods_supported: ["client_secret_post", "client_secret_basic", "client_secret_jwt", "private_key_jwt"],
- token_endpoint_auth_signing_alg_values_supported: [],
- display_values_supported: [],
- claim_types_supported: ["normal", "aggregated", "distributed"],
- claims_supported: [
- "acr",
- "auth_time",
- "email",
- "email_verified",
- "family_name",
- "given_name",
- "iss",
- "locale",
- "name",
- "nickname",
- "picture",
- "profile",
- "sub",
- "website"
- ],
- service_documentation: nil,
- claims_locales_supported: [],
- ui_locales_supported: ["en-US"],
- claims_parameter_supported: false,
- request_parameter_supported: false,
- request_uri_paramater_supported: false,
- require_request_uri_registration: false,
- op_policy_uri: "",
- op_tos_uri: "",
- })]]
- 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) }
- use ::Warden::Manager do |config|
- config.default_scope = :user
- config.default_strategies :password
- end
-
- map "/twirp" do
- # https://github.com/arthurnn/twirp-ruby/wiki/Service-Handlers
- run ::Authx::Rpc::AbilityService.new(::Authz::Rpc::Ability.new)
- end
- map "/oauth" do
- run ::Authz::OAuthController.new
- end
-
- map "/saml" do
- run Authn::SAMLController.new($scheme, $host)
- end
-
- map "/sessions" do
- run Authn::SessionsController.new
- end
- run IdentityProvider.new
- end.to_app
-
- Rackup::Server.start(app: app, Port: $port)
-end
diff --git a/bin/ui b/bin/ui
deleted file mode 100755
index 0c1e4c4c..00000000
--- a/bin/ui
+++ /dev/null
@@ -1,503 +0,0 @@
-#!/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.4.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(redirect_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, params = {})
- with_http do |client|
- client.post(self[:token_endpoint], body: body_for(grant_type, params))
- end
- end
-
- private
-
- def body_for(grant_type, params)
- case grant_type
- when "authorization_code"
- {
- grant_type: grant_type,
- code: params.fetch(:code),
- code_verifier: params.fetch(:code_verifier, "not_implemented"),
- }
- when "urn:ietf:params:oauth:grant-type:saml2-bearer"
- {
- grant_type: grant_type,
- assertion: params.fetch(:assertion),
- }
- else
- raise NotImplementedError.new(grant_type)
- end
- end
-
- def to_query(params = {})
- params.map do |(key, value)|
- [key, value].join("=")
- end.join("&")
- 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[:access_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' => "#{$scheme}://#{$host}#{location}" }, []]
- end
- end
-
- def with_layout(bind)
- template = <<~ERB
- <!doctype html>
- <html lang="en">
- <head>
- <title>UI</title>
- <meta charset="utf-8">
- <meta name="viewport" content="width=device-width, initial-scale=1">
- <meta name="color-scheme" content="light dark">
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.colors.min.css">
- </head>
- <body>
- <main class="container">
- #{yield}
- </main>
- </body>
- </html>
- ERB
- ERB.new(template, trim_mode: '-').result(bind)
- 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"
- return get_index(request)
- 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(
- redirect_uri: "#{request.base_url}/oauth/callback"
- ))
- 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 get_index(request)
- html = with_layout(binding) do
- <<~ERB
- <%- if current_user?(request) -%>
- <nav>
- <ul>
- <li><a href="http://#{$idp_host}/">IdP</a></li>
- <li><strong>UI</strong></li>
- </ul>
- <ul>
- <li><a href="/index.html">Home</a></li>
- <li><a href="/groups.html">Groups</a></li>
- <li>
- <form action="/logout" method="post">
- <input type="submit" value="Logout" />
- </form>
- </li>
- </ul>
- </nav>
- <h1>Access Token</h1>
- <pre><%= request.session[:access_token] %></pre>
-
- <h1>ID Token</h1>
- <pre><%= request.session[:id_token] %></pre>
- <%- else -%>
- <nav>
- <ul>
- <li><a href="http://#{$idp_host}/">IdP</a></li>
- <li><strong>UI</strong></li>
- </ul>
- <ul>
- <li><a href="/saml/new">SAML Login</a></li>
- <li><a href="/oidc/new">OIDC Login</a></li>
- </ul>
- </nav>
- <%- end -%>
- ERB
- end
- [200, { "Content-Type" => "text/html" }, [html]]
- end
-
- def oauth_callback(request)
- response = oauth_client.exchange("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]
-
- html = with_layout(binding) do
- <<~ERB
- <nav>
- <ul>
- <li><a href="http://#{$idp_host}/">IdP</a></li>
- <li><strong>UI</strong></li>
- </ul>
- <ul>
- <li><a href="/index.html">Home</a></li>
- <li><a href="/groups.html">Groups</a></li>
- <li>
- <form action="/logout" method="post">
- <input type="submit" value="Logout" />
- </form>
- </li>
- </ul>
- </nav>
- <h1>Access Token</h1>
- <pre style="display: none;"><%= response.body %></pre>
- <pre><%= JSON.pretty_generate(request.session[:access_token]) %></pre>
- ERB
- end
- [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)
- html = with_layout(binding) do
- <<~ERB
- <nav>
- <ul>
- <li><a href="http://#{$idp_host}/">IdP</a></li>
- <li><strong>UI</strong></li>
- </ul>
- <ul>
- <li><a href="/index.html">Home</a></li>
- <li><a href="/groups.html">Groups</a></li>
- <li>
- <form action="/logout" method="post">
- <input type="submit" value="Logout" />
- </form>
- </li>
- </ul>
- </nav>
-
- <h1>Groups</h1>
- <table>
- <thead>
- <tr>
- <th>ID</th>
- <th>Name</th>
- <th>Organization ID</th>
- <th>Parent ID</th>
- <th>&nbsp;</th>
- </tr>
- </thead>
- <tbody>
- <%- groups.each do |group| -%>
- <tr>
- <td><%= group[:id] %></td>
- <td><%= group[:name] %></td>
- <td><%= group[:organization_id] %></td>
- <td><%= group[:parent_id] %></td>
- <td><a href="/groups/<%= group[:id] %>/projects.html">Projects</a></td>
- </tr>
- <%- end -%>
- </tbody>
- </table>
- ERB
- end
- [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)
-
- html = with_layout(binding) do
- <<~ERB
- <nav>
- <ul>
- <li><a href="http://#{$idp_host}/">IdP</a></li>
- <li><strong>UI</strong></li>
- </ul>
- <ul>
- <li><a href="/index.html">Home</a></li>
- <li><a href="/groups.html">Groups</a></li>
- <li>
- <form action="/logout" method="post">
- <input type="submit" value="Logout" />
- </form>
- </li>
- </ul>
- </nav>
-
- <h1>Projects</h1>
- <table>
- <thead>
- <tr>
- <th>Name</th>
- <th>Group ID</th>
- </tr>
- </thead>
- <tbody>
- <%- projects.each do |project| -%>
- <tr>
- <td><%= project[:name] %></td>
- <td><%= project[:group_id] %></td>
- </tr>
- <%- end -%>
- </tbody>
- </table>
- ERB
- end
- [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
-
- html = with_layout(binding) do
- <<~ERB
- <h2>Sending SAML Request (SP -> IdP)</h2>
- <textarea readonly="readonly" disabled="disabled" cols=225 rows=8><%=- @saml_builder.to_xml(pretty: true) -%></textarea>
-
- <form id="idp-form" action="<%= uri %>" method="post">
- <%- saml_params.each do |(key, value)| -%>
- <input type="hidden" name="<%= key %>" value="<%= value %>" />
- <%- end -%>
- <input id="submit-button" type="submit" value="Continue" />
- </form>
- ERB
- end
- [200, { 'Content-Type' => "text/html" }, [html]]
- end
-
- def saml_assertions(request)
- sp = Saml::Kit.registry.metadata_for("#{request.base_url}/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?
-
- assertion = Base64.strict_encode64(saml_response.assertion.to_xml)
- response = oauth_client.exchange(
- "urn:ietf:params:oauth:grant-type:saml2-bearer",
- assertion: assertion,
- )
- if response.code == "200"
- tokens = JSON.parse(response.body, symbolize_names: true)
- request.session[:access_token] = tokens[:access_token]
- request.session[:refresh_token] = tokens[:access_token]
-
- html = with_layout(binding) do
- <<~ERB
- <nav>
- <ul>
- <li><a href="http://#{$idp_host}/">IdP</a></li>
- <li><strong>UI</strong></li>
- </ul>
- <ul>
- <li><a href="/index.html">Home</a></li>
- <li><a href="/groups.html">Groups</a></li>
- <li>
- <form action="/logout" method="post">
- <input type="submit" value="Logout" />
- </form>
- </li>
- </ul>
- </nav>
-
- <h2>Received SAML Response</h2>
- <textarea readonly="readonly" disabled="disabled" cols=220 rows=40><%=- saml_response.to_xml(pretty: true) -%></textarea>
- <pre id="raw-saml-response" style="display: none;"><%= request.params["SAMLResponse"] %></pre>
- <pre id="xml-saml-assertion" style="display: none;"><%= saml_response.assertion.to_xml(pretty: true) %></pre>
- <pre id="access-token" style="display: none;"><%= JSON.pretty_generate(request.session[:access_token]) %></pre>
- ERB
- end
- [200, { 'Content-Type' => "text/html" }, [html]]
- else
- [response.code, response.header, [response.body]]
- end
- 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