Commit 20e1521

mo khan <mo@mokhan.ca>
2025-03-05 18:43:07
refactor: extract scheme and provide the appropriate nameid for saml transaction
1 parent 502228f
Changed files (4)
bin/rest-api → bin/api
@@ -13,6 +13,10 @@ gemfile do
   gem "webrick", "~> 1.0"
 end
 
+$scheme = ENV.fetch('SCHEME', 'http')
+$port = ENV.fetch('PORT', 8284).to_i
+$host = ENV.fetch('HOST', "localhost:#{$port}")
+
 class Project
   class << self
     def all
@@ -35,11 +39,7 @@ class Project
   end
 end
 
-class RESTAPI
-  def initialize
-    @storage = {}
-  end
-
+class API
   def call(env)
     request = Rack::Request.new(env)
     path = env['PATH_INFO']
@@ -75,32 +75,40 @@ class RESTAPI
   end
 
   def json_not_found
-    [404, { 'X-Backend-Server' => 'REST', 'Content-Type' => 'application/json' }, []]
+    http_response(code: 404)
   end
 
   def json_ok(body)
-    [200, { 'Content-Type' => 'application/json' }, [JSON.pretty_generate(body)]]
+    http_response(code: 200, body: JSON.pretty_generate(body))
   end
 
   def json_created(body)
-    [201, { 'Content-Type' => 'application/json' }, [JSON.pretty_generate(body.to_h)]]
+    http_response(code: 201, body: JSON.pretty_generate(body.to_h))
   end
 
   def json_unauthorized(permission)
-    [401, { 'Content-Type' => 'application/json' }, [JSON.pretty_generate({
+    http_response(code: 401, body: JSON.pretty_generate({
       error: {
         code: 401,
         message: "`#{permission}` is required",
       }
-    })]]
+    }))
+  end
+
+  def http_response(code:, headers: { 'Content-Type' => 'application/json' }, body: nil)
+    [
+      code,
+      headers.merge({ 'X-Backend-Server' => 'REST' }),
+      [body].compact
+    ]
   end
 end
 
 if __FILE__ == $0
   app = Rack::Builder.new do
     use Rack::Reloader
-    run RESTAPI.new
+    run API.new
   end.to_app
 
-  Rackup::Server.start(app: app, Port: ENV.fetch('PORT', 8284).to_i)
+  Rackup::Server.start(app: app, Port: $port)
 end
bin/idp
@@ -13,18 +13,44 @@ gemfile do
   gem "webrick", "~> 1.0"
 end
 
+$scheme = ENV.fetch('SCHEME', 'http')
+$port = ENV.fetch('PORT', 8282).to_i
+$host = ENV.fetch('HOST', "localhost:#{$port}")
+
+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
+
 class User
   def initialize(attributes)
     @attributes = attributes
   end
 
   def name_id_for(name_id_format)
-    @attributes[:email]
+    if name_id_format == Saml::Kit::Namespaces::EMAIL_ADDRESS
+      @attributes[:email]
+    else
+      @attributes[:id]
+    end
   end
 
   def assertion_attributes_for(request)
     {
-      custom: 'custom attribute'
+      custom: 'custom attribute',
+      email: @attributes[:email],
+      access_token: JWT.new(sub: SecureRandom.uuid, iat: Time.now.to_i).to_jwt,
     }
   end
 end
@@ -40,16 +66,12 @@ class OnDemandRegistry < Saml::Kit::DefaultRegistry
 end
 
 Saml::Kit.configure do |x|
-  x.entity_id = "http://localhost:8282/metadata.xml"
+  x.entity_id = "#{$scheme}://#{$host}/metadata.xml"
   x.registry = OnDemandRegistry.new
   x.logger = Logger.new("/dev/stderr")
 end
 
 class IdentityProvider
-  def initialize
-    @storage = {}
-  end
-
   def call(env)
     path = env['PATH_INFO']
     case env['REQUEST_METHOD']
@@ -63,7 +85,8 @@ class IdentityProvider
         return not_found
       when "/metadata.xml"
         return saml_metadata
-      when "/sessions/new"
+      when "/saml/new"
+        # TODO:: render a login page
         return saml_post_back(Rack::Request.new(env))
       when "/oauth/authorize" # RFC-6749
         return get_authorize(Rack::Request.new(env))
@@ -72,13 +95,13 @@ class IdentityProvider
       end
     when 'POST'
       case path
-      when "/sessions/new"
+      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: to_jwt(sub: SecureRandom.uuid, iat: Time.now.to_i),
+          access_token: JWT.new(sub: SecureRandom.uuid, iat: Time.now.to_i).to_jwt,
           token_type: "Bearer",
           expires_in: 3600,
           refresh_token: SecureRandom.hex(32)
@@ -94,14 +117,6 @@ class IdentityProvider
 
   private
 
-  def to_jwt(claims)
-    [
-      Base64.strict_encode64(JSON.generate({alg: "RS256", typ: "JWT"})),
-      Base64.strict_encode64(JSON.generate(claims)),
-      Base64.strict_encode64(JSON.generate({})),
-    ].join(".")
-  end
-
   # Download IDP Metadata
   #
   # GET /metadata.xml
@@ -111,7 +126,7 @@ class IdentityProvider
       builder.organization_name = "Acme, Inc"
       builder.organization_url = "https://example.com"
       builder.build_identity_provider do |x|
-        x.add_single_sign_on_service("http://localhost:8282/sessions/new", binding: :http_post)
+        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
@@ -123,9 +138,9 @@ class IdentityProvider
   # GET /.well-known/oauth-authorization-server
   def oauth_metadata
     [200, { 'Content-Type' => "application/json" }, [JSON.pretty_generate({
-      issuer: "http://localhost:8282/.well-known/oauth-authorization-server",
-      authorization_endpoint: "http://localhost:8282/oauth/authorize",
-      token_endpoint: "http://localhost:8282/oauth/token",
+      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"],
@@ -138,10 +153,10 @@ class IdentityProvider
       ui_locales_supported: ["en-US"],
       op_policy_uri: "",
       op_tos_uri: "",
-      revocation_endpoint: "http://localhost:8282/oauth/revoke", # RFC-7009
+      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: "http://localhost:8282/oauth/introspect", # RFC-7662
+      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
@@ -196,10 +211,10 @@ class IdentityProvider
   # GET /.well-known/openid-configuration
   def openid_metadata
     [200, { 'Content-Type' => "application/json" }, [JSON.pretty_generate({
-      issuer: "http://localhost:8282/.well-known/oauth-authorization-server",
-      authorization_endpoint: "http://localhost:8282/oauth/authorize",
-      token_endpoint: "http://localhost:8282/oauth/token",
-      userinfo_endpoint: "http://localhost:8282/oidc/user/",
+      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"],
@@ -254,7 +269,7 @@ class IdentityProvider
     saml_request = binding_for(request).deserialize(params)
     @builder = nil
     url, saml_params = saml_request.response_for(
-      User.new({ email: "example@example.com" }),
+      User.new({ id: SecureRandom.uuid, email: "example@example.com" }),
       binding: :http_post,
       relay_state: params[:RelayState]
     ) do |builder|
@@ -270,7 +285,7 @@ class IdentityProvider
           <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=30><%=- @builder.build.to_xml(pretty: true) -%></textarea>
+          <textarea readonly="readonly" disabled="disabled" cols=225 rows=40><%=- @builder.build.to_xml(pretty: true) -%></textarea>
           <form action="<%= url %>" method="post">
             <%- saml_params.each do |(key, value)| -%>
               <input type="hidden" name="<%= key %>" value="<%= value %>" />
@@ -303,7 +318,7 @@ class IdentityProvider
   end
 
   def binding_for(request)
-    location = "http://localhost:8282/sessions/new"
+    location = "#{$scheme}://#{$host}/saml/new"
     if request.post?
       Saml::Kit::Bindings::HttpPost
         .new(location: location)
@@ -320,5 +335,5 @@ if __FILE__ == $0
     run IdentityProvider.new
   end.to_app
 
-  Rackup::Server.start(app: app, Port: ENV.fetch('PORT', 8282).to_i)
+  Rackup::Server.start(app: app, Port: $port)
 end
bin/sp → bin/ui
@@ -14,6 +14,10 @@ gemfile do
   gem "webrick", "~> 1.0"
 end
 
+$scheme = ENV.fetch('SCHEME', 'http')
+$port = ENV.fetch('PORT', 8283).to_i
+$host = ENV.fetch('HOST', "localhost:#{$port}")
+
 class OnDemandRegistry < Saml::Kit::DefaultRegistry
   def metadata_for(entity_id)
     found = super(entity_id)
@@ -25,28 +29,21 @@ class OnDemandRegistry < Saml::Kit::DefaultRegistry
 end
 
 Saml::Kit.configure do |x|
-  x.entity_id = "http://localhost:8283/metadata.xml"
+  x.entity_id = "#{$scheme}://#{$host}/metadata.xml"
   x.registry = OnDemandRegistry.new
   x.logger = Logger.new("/dev/stderr")
 end
 
-class ServiceProvider
-  def initialize
-    @storage = {}
-  end
-
-  # Download IDP Metadata
-  #
-  # GET /metadata.xml
+class UI
   def metadata
     xml = Saml::Kit::Metadata.build_xml do |builder|
       builder.embed_signature = false
-      builder.contact_email = 'hi@example.com'
+      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::EMAIL_ADDRESS]
-        x.add_assertion_consumer_service("http://localhost:8283/assertions", binding: :http_post)
+        x.add_assertion_consumer_service("#{$scheme}://#{$host}/saml/assertions", binding: :http_post)
       end
     end
 
@@ -60,16 +57,19 @@ class ServiceProvider
       case path
       when "/metadata.xml"
         return metadata
-      when "/openid/new"
-        return redirect_to("http://localhost:8282/oauth/authorize?client_id=service-provider&state=example&redirect_uri=http://localhost:8283/oauth/callback&response_type=code&response_mode=query&scope=openid")
+      when "/oidc/new"
+        return redirect_to("http://localhost:8282/oauth/authorize?client_id=service-provider&state=example&redirect_uri=#{$scheme}://#{$host}/oauth/callback&response_type=code&response_mode=query&scope=openid")
+      when "/saml/new"
+        return saml_post_to_idp(Rack::Request.new(env))
       when "/oauth/callback"
         return oauth_callback(Rack::Request.new(env))
       else
-        return saml_post_to_idp(Rack::Request.new(env))
+        # return saml_post_to_idp(Rack::Request.new(env))
+        return redirect_to("/saml/new")
       end
     when 'POST'
       case path
-      when "/assertions"
+      when "/saml/assertions"
         return saml_assertions(Rack::Request.new(env))
       else
         return not_found
@@ -81,7 +81,7 @@ class ServiceProvider
   private
 
   def not_found
-    [404, { 'X-Backend-Server' => 'SP' }, []]
+    [404, { 'X-Backend-Server' => 'UI' }, []]
   end
 
   def redirect_to(location)
@@ -132,7 +132,7 @@ class ServiceProvider
   end
 
   def saml_assertions(request)
-    sp = Saml::Kit.registry.metadata_for('http://localhost:8283/metadata.xml')
+    sp = Saml::Kit.registry.metadata_for("#{$scheme}://#{$host}/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?
@@ -156,8 +156,8 @@ end
 if __FILE__ == $0
   app = Rack::Builder.new do
     use Rack::Reloader
-    run ServiceProvider.new
+    run UI.new
   end.to_app
 
-  Rackup::Server.start(app: app, Port: ENV.fetch('PORT', 8283).to_i)
+  Rackup::Server.start(app: app, Port: $port)
 end
README.md
@@ -13,26 +13,6 @@ Below is a recording of a SAML based service provider initiated login, displayin
 
 ![SAML Login](./screencast.webm)
 
-## Identity Provider (SAML IdP)
-
-A minimal SAML Identity Provider for testing interactions with a SAML Service Provider.
-
-1. Start the server:
-   ```sh
-   ruby ./bin/idp
-   ```
-2. Use `http://localhost:8282/metadata.xml` as your SAML IdP metadata URL.
-
-## Service Provider (SAML SP)
-
-A minimal SAML Service Provider for testing interactions with a SAML Identity Provider.
-
-1. Start the server:
-   ```sh
-   ruby ./bin/sp
-   ```
-2. Use `http://localhost:8283/metadata.xml` as your SAML SP metadata URL.
-
 ## Experiments
 
 ### Twirp + gRPC (AuthZ)