Commit 5ad8108

mo khan <mo@mokhan.ca>
2025-03-11 22:16:56
refactor: separate authn/authz code into separate modules
1 parent de1cf9c
Changed files (1)
bin
bin/idp
@@ -25,41 +25,146 @@ $scheme = ENV.fetch("SCHEME", "http")
 $port = ENV.fetch("PORT", 8282).to_i
 $host = ENV.fetch("HOST", "localhost:#{$port}")
 
-class JWT
-  attr_reader :claims
+module HTTPHelpers
+  def default_headers
+    {
+      'X-Powered-By' => 'IDP'
+    }
+  end
 
-  def initialize(claims)
-    @claims = claims
+  def http_not_found
+    [404, default_headers, []]
   end
 
-  def to_jwt
-    [
-      Base64.strict_encode64(JSON.generate({alg: "RS256", typ: "JWT"})),
-      Base64.strict_encode64(JSON.generate(claims)),
-      Base64.strict_encode64(JSON.generate({})),
-    ].join(".")
+  def http_ok(headers = {}, body = nil)
+    [200, default_headers.merge(headers), [body]]
   end
 end
 
-class User
-  def initialize(attributes)
-    @attributes = attributes
-  end
+module Authn
+  class User
+    def initialize(attributes)
+      @attributes = attributes
+    end
 
-  def name_id_for(name_id_format)
-    if name_id_format == Saml::Kit::Namespaces::EMAIL_ADDRESS
-      @attributes[:email]
-    else
-      @attributes[:id]
+    def name_id_for(name_id_format)
+      if name_id_format == Saml::Kit::Namespaces::EMAIL_ADDRESS
+        @attributes[:email]
+      else
+        @attributes[:id]
+      end
+    end
+
+    def assertion_attributes_for(request)
+      {
+        email: @attributes[:email],
+        access_token: ::Authz::JWT.new(sub: SecureRandom.uuid, iat: Time.now.to_i).to_jwt,
+      }
     end
   end
 
-  def assertion_attributes_for(request)
-    {
-      custom: 'custom attribute',
-      email: @attributes[:email],
-      access_token: JWT.new(sub: SecureRandom.uuid, iat: Time.now.to_i).to_jwt,
-    }
+  class SAMLController
+    include ::HTTPHelpers
+
+    def initialize(scheme, host)
+      @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
+          x.attributes << :access_token
+        end
+      end
+    end
+
+    def call(env)
+      path = env['PATH_INFO']
+      case env['REQUEST_METHOD']
+      when 'GET'
+        case path
+        when "/metadata.xml"
+          return http_ok(
+            { 'Content-Type' => "application/samlmetadata+xml" },
+            saml_metadata.to_xml(pretty: true)
+          )
+        when "/new"
+          # TODO:: render a login page
+          return saml_post_back(Rack::Request.new(env))
+        end
+      when 'POST'
+        case path
+        when "/new"
+          return saml_post_back(Rack::Request.new(env))
+        end
+      end
+
+      http_not_found
+    end
+
+    private
+
+    attr_reader :saml_metadata
+
+    def saml_post_back(request)
+      params = saml_params_from(request)
+      saml_request = binding_for(request).deserialize(params)
+      @builder = nil
+      url, saml_params = saml_request.response_for(
+        User.new({ id: SecureRandom.uuid, email: "example@example.com" }),
+        binding: :http_post,
+        relay_state: params[:RelayState]
+      ) do |builder|
+        builder.embed_signature = true
+        @builder = builder
+      end
+      template = <<~ERB
+        <!doctype html>
+        <html>
+          <head><title></title></head>
+          <body>
+            <h2>Recieved SAML Request</h2>
+            <textarea readonly="readonly" disabled="disabled" cols=225 rows=6><%=- saml_request.to_xml(pretty: true) -%></textarea>
+
+            <h2>Sending SAML Response (IdP -> SP)</h2>
+            <textarea readonly="readonly" disabled="disabled" cols=225 rows=40><%=- @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="Submit" />
+            </form>
+          </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)
+      location = "#{$scheme}://#{$host}/saml/new"
+      if request.post?
+        Saml::Kit::Bindings::HttpPost.new(location: location)
+      else
+        Saml::Kit::Bindings::HttpRedirect.new(location: location)
+      end
+    end
   end
 end
 
@@ -99,12 +204,26 @@ class Organization
   end
 end
 
-module Authx
+module Authz
+  class JWT
+    attr_reader :claims
+
+    def initialize(claims)
+      @claims = claims
+    end
+
+    def to_jwt
+      [
+        Base64.strict_encode64(JSON.generate({alg: "RS256", typ: "JWT"})),
+        Base64.strict_encode64(JSON.generate(claims)),
+        Base64.strict_encode64(JSON.generate({})),
+      ].join(".")
+    end
+  end
+
   module Rpc
     class AbilityHandler
       def allowed(request, env)
-        puts [request, env, can?(request)].inspect
-
         {
           result: can?(request)
         }
@@ -139,6 +258,8 @@ module Authx
 end
 
 class IdentityProvider
+  include ::HTTPHelpers
+
   def call(env)
     path = env['PATH_INFO']
     case env['REQUEST_METHOD']
@@ -149,57 +270,35 @@ class IdentityProvider
       when '/.well-known/oauth-authorization-server'
         return oauth_metadata
       when '/.well-known/webfinger' # RFC-7033
-        return not_found
-      when "/saml/metadata.xml"
-        return saml_metadata
-      when "/saml/new"
-        # TODO:: render a login page
-        return saml_post_back(Rack::Request.new(env))
+        return http_not_found
       when "/oauth/authorize" # RFC-6749
         return get_authorize(Rack::Request.new(env))
       else
-        return not_found
+        return http_not_found
       end
     when 'POST'
       case path
-      when "/saml/new"
-        return saml_post_back(Rack::Request.new(env))
       when "/oauth/authorize" # RFC-6749
         return post_authorize(Rack::Request.new(env))
       when "/oauth/token" # RFC-6749
         return [200, { 'Content-Type' => "application/json" }, [JSON.pretty_generate({
-          access_token: JWT.new(sub: SecureRandom.uuid, iat: Time.now.to_i).to_jwt,
+          access_token: ::Authz::JWT.new(sub: SecureRandom.uuid, iat: Time.now.to_i).to_jwt,
           token_type: "Bearer",
           issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
           expires_in: 3600,
           refresh_token: SecureRandom.hex(32)
         })]]
       when "/oauth/revoke" # RFC-7009
-        return not_found
+        return http_not_found
       else
-        return not_found
+        return http_not_found
       end
     end
-    not_found
+    http_not_found
   end
 
   private
 
-  def saml_metadata
-    xml = Saml::Kit::Metadata.build_xml do |builder|
-      builder.contact_email = 'hi@example.com'
-      builder.organization_name = "Acme, Inc"
-      builder.organization_url = "https://example.com"
-      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::EMAIL_ADDRESS]
-        x.attributes << :Username
-      end
-    end
-
-    [200, { 'Content-Type' => "application/samlmetadata+xml" }, [xml]]
-  end
-
   # GET /.well-known/oauth-authorization-server
   def oauth_metadata
     [200, { 'Content-Type' => "application/json" }, [JSON.pretty_generate({
@@ -267,9 +366,9 @@ class IdentityProvider
       end
 
     when 'token'
-      return not_found
+      return http_not_found
     else
-      return not_found
+      return http_not_found
     end
   end
 
@@ -328,70 +427,6 @@ class IdentityProvider
       op_tos_uri: "",
     })]]
   end
-
-  def saml_post_back(request)
-    params = saml_params_from(request)
-    saml_request = binding_for(request).deserialize(params)
-    @builder = nil
-    url, saml_params = saml_request.response_for(
-      User.new({ id: SecureRandom.uuid, email: "example@example.com" }),
-      binding: :http_post,
-      relay_state: params[:RelayState]
-    ) do |builder|
-      builder.embed_signature = true
-      @builder = builder
-    end
-    template = <<~ERB
-      <!doctype html>
-      <html>
-        <head><title></title></head>
-        <body>
-          <h2>Recieved SAML Request</h2>
-          <textarea readonly="readonly" disabled="disabled" cols=225 rows=6><%=- saml_request.to_xml(pretty: true) -%></textarea>
-
-          <h2>Sending SAML Response (IdP -> SP)</h2>
-          <textarea readonly="readonly" disabled="disabled" cols=225 rows=40><%=- @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="Submit" />
-          </form>
-        </body>
-      </html>
-    ERB
-    erb = ERB.new(template, trim_mode: '-')
-    html = erb.result(binding)
-    [200, { 'Content-Type' => "text/html" }, [html]]
-  end
-
-  def not_found
-    [404, { 'X-Backend-Server' => 'IDP' }, []]
-  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)
-    location = "#{$scheme}://#{$host}/saml/new"
-    if request.post?
-      Saml::Kit::Bindings::HttpPost
-        .new(location: location)
-    else
-      Saml::Kit::Bindings::HttpRedirect
-        .new(location: location)
-    end
-  end
 end
 
 if __FILE__ == $0
@@ -400,9 +435,12 @@ if __FILE__ == $0
     use Rack::Reloader
     map "/twirp" do
       # https://github.com/arthurnn/twirp-ruby/wiki/Service-Handlers
-      run ::Authx::Rpc::AbilityService.new(::Authx::Rpc::AbilityHandler.new)
+      run ::Authz::Rpc::AbilityService.new(::Authz::Rpc::AbilityHandler.new)
     end
 
+    map "/saml" do
+      run Authn::SAMLController.new($scheme, $host)
+    end
     run IdentityProvider.new
   end.to_app