diff options
| author | mo khan <mo@mokhan.ca> | 2025-05-02 14:29:41 -0600 |
|---|---|---|
| committer | mo khan <mo@mokhan.ca> | 2025-05-02 14:29:41 -0600 |
| commit | c583bcd1473205104a1e1af812ed4976d30c7baa (patch) | |
| tree | 933edf78a4ac8aea55256e42641e56bbb4c58834 /bin | |
| parent | 91defaefca47e9cebbe92c6abf33c4423df9bc7d (diff) | |
refactor: remove anything unrelated to the authz daemon
Diffstat (limited to 'bin')
| -rwxr-xr-x | bin/api | 182 | ||||
| -rwxr-xr-x | bin/idp | 895 | ||||
| -rwxr-xr-x | bin/ui | 503 |
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?("&") ? "&" : "&" - 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> </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 |
