From 20e152182f7137ae2c7f512d0cab1b3c846a4677 Mon Sep 17 00:00:00 2001 From: mo khan Date: Wed, 5 Mar 2025 11:43:07 -0700 Subject: refactor: extract scheme and provide the appropriate nameid for saml transaction --- README.md | 20 -------- bin/api | 114 +++++++++++++++++++++++++++++++++++++++++ bin/idp | 79 +++++++++++++++++------------ bin/rest-api | 106 -------------------------------------- bin/sp | 163 ----------------------------------------------------------- bin/ui | 163 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 324 insertions(+), 321 deletions(-) create mode 100755 bin/api delete mode 100755 bin/rest-api delete mode 100755 bin/sp create mode 100755 bin/ui diff --git a/README.md b/README.md index 4edaaaf..0217ebc 100644 --- a/README.md +++ b/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) diff --git a/bin/api b/bin/api new file mode 100755 index 0000000..23e88eb --- /dev/null +++ b/bin/api @@ -0,0 +1,114 @@ +#!/usr/bin/env ruby + +require 'bundler/inline' + +gemfile do + source 'https://rubygems.org' + + gem "erb", "~> 4.0" + gem "json", "~> 2.0" + gem "rack", "~> 3.0" + gem "rackup", "~> 2.0" + gem "securerandom", "~> 0.1" + 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 + @projects ||= [] + 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 to_h + @attributes + end +end + +class API + def call(env) + request = Rack::Request.new(env) + path = env['PATH_INFO'] + case env['REQUEST_METHOD'] + when 'GET' + case path + when '/projects.json' + return json_ok(Project.all.map(&:to_h)) + else + return json_not_found + end + when 'POST' + case path + when "/projects" + if authorized?(request, :create_project) + return json_created(Project.create!(JSON.parse(request.body.read, symbolize_names: true))) + else + return json_unauthorized(:create_project) + end + else + return json_not_found + end + end + json_not_found + end + + private + + def authorized?(request, permission) + # TODO:: Check the JWT for the appropriate claim + # Connect to the Authz RPC endpoint Ability.allowed?(subject, permission, resource) + true + 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) + 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 API.new + end.to_app + + Rackup::Server.start(app: app, Port: $port) +end diff --git a/bin/idp b/bin/idp index d568c60..026655c 100755 --- a/bin/idp +++ b/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

Sending SAML Response (IdP -> SP)

- +
<%- saml_params.each do |(key, 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 diff --git a/bin/rest-api b/bin/rest-api deleted file mode 100755 index ca0a891..0000000 --- a/bin/rest-api +++ /dev/null @@ -1,106 +0,0 @@ -#!/usr/bin/env ruby - -require 'bundler/inline' - -gemfile do - source 'https://rubygems.org' - - gem "erb", "~> 4.0" - gem "json", "~> 2.0" - gem "rack", "~> 3.0" - gem "rackup", "~> 2.0" - gem "securerandom", "~> 0.1" - gem "webrick", "~> 1.0" -end - -class Project - class << self - def all - @projects ||= [] - 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 to_h - @attributes - end -end - -class RESTAPI - def initialize - @storage = {} - end - - def call(env) - request = Rack::Request.new(env) - path = env['PATH_INFO'] - case env['REQUEST_METHOD'] - when 'GET' - case path - when '/projects.json' - return json_ok(Project.all.map(&:to_h)) - else - return json_not_found - end - when 'POST' - case path - when "/projects" - if authorized?(request, :create_project) - return json_created(Project.create!(JSON.parse(request.body.read, symbolize_names: true))) - else - return json_unauthorized(:create_project) - end - else - return json_not_found - end - end - json_not_found - end - - private - - def authorized?(request, permission) - # TODO:: Check the JWT for the appropriate claim - # Connect to the Authz RPC endpoint Ability.allowed?(subject, permission, resource) - true - end - - def json_not_found - [404, { 'X-Backend-Server' => 'REST', 'Content-Type' => 'application/json' }, []] - end - - def json_ok(body) - [200, { 'Content-Type' => 'application/json' }, [JSON.pretty_generate(body)]] - end - - def json_created(body) - [201, { 'Content-Type' => 'application/json' }, [JSON.pretty_generate(body.to_h)]] - end - - def json_unauthorized(permission) - [401, { 'Content-Type' => 'application/json' }, [JSON.pretty_generate({ - error: { - code: 401, - message: "`#{permission}` is required", - } - })]] - end -end - -if __FILE__ == $0 - app = Rack::Builder.new do - use Rack::Reloader - run RESTAPI.new - end.to_app - - Rackup::Server.start(app: app, Port: ENV.fetch('PORT', 8284).to_i) -end diff --git a/bin/sp b/bin/sp deleted file mode 100755 index f570dab..0000000 --- a/bin/sp +++ /dev/null @@ -1,163 +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 "rackup", "~> 2.0" - gem "saml-kit", "~> 1.0" - gem "webrick", "~> 1.0" -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 - -Saml::Kit.configure do |x| - x.entity_id = "http://localhost:8283/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 - def metadata - xml = Saml::Kit::Metadata.build_xml do |builder| - builder.embed_signature = false - builder.contact_email = 'hi@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) - end - end - - [200, { 'Content-Type' => "application/samlmetadata+xml" }, [xml]] - end - - def call(env) - path = env['PATH_INFO'] - case env['REQUEST_METHOD'] - when 'GET' - 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 "/oauth/callback" - return oauth_callback(Rack::Request.new(env)) - else - return saml_post_to_idp(Rack::Request.new(env)) - end - when 'POST' - case path - when "/assertions" - return saml_assertions(Rack::Request.new(env)) - else - return not_found - end - end - not_found - end - - private - - def not_found - [404, { 'X-Backend-Server' => 'SP' }, []] - end - - def redirect_to(location) - [302, { 'Location' => location }, []] - end - - def oauth_callback(request) - response = Net::Hippie.default_client.post( - "http://localhost:8282/oauth/token", - headers: { 'Authorization' => Net::Hippie.basic_auth('client_id', 'secret') }, - body: { - grant_type: "authorization_code", - code: request.params['code'], - code_verifier: "not_implemented" - } - ) - [200, { "Content-Type" => "application/json" }, [JSON.pretty_generate(request.params.merge(JSON.parse(response.body)))]] - end - - def saml_post_to_idp(request) - idp = Saml::Kit.registry.metadata_for('http://localhost:8282/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 - - template = <<~ERB - - - - -

Sending SAML Request (SP -> IdP)

- - - - <%- saml_params.each do |(key, value)| -%> - - <%- end -%> - -
- - - ERB - html = ERB.new(template, trim_mode: '-').result(binding) - [200, { 'Content-Type' => "text/html" }, [html]] - end - - def saml_assertions(request) - sp = Saml::Kit.registry.metadata_for('http://localhost:8283/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? - - template = <<~ERB - - - - -

Received SAML Response

- - - - ERB - erb = ERB.new(template, trim_mode: '-') - html = erb.result(binding) - [200, { 'Content-Type' => "text/html" }, [html]] - end -end - -if __FILE__ == $0 - app = Rack::Builder.new do - use Rack::Reloader - run ServiceProvider.new - end.to_app - - Rackup::Server.start(app: app, Port: ENV.fetch('PORT', 8283).to_i) -end diff --git a/bin/ui b/bin/ui new file mode 100755 index 0000000..278876f --- /dev/null +++ b/bin/ui @@ -0,0 +1,163 @@ +#!/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 "rackup", "~> 2.0" + gem "saml-kit", "~> 1.0" + 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) + 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}/metadata.xml" + x.registry = OnDemandRegistry.new + x.logger = Logger.new("/dev/stderr") +end + +class UI + 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::EMAIL_ADDRESS] + x.add_assertion_consumer_service("#{$scheme}://#{$host}/saml/assertions", binding: :http_post) + end + end + + [200, { 'Content-Type' => "application/samlmetadata+xml" }, [xml]] + end + + def call(env) + path = env['PATH_INFO'] + case env['REQUEST_METHOD'] + when 'GET' + case path + when "/metadata.xml" + return metadata + 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 redirect_to("/saml/new") + end + when 'POST' + case path + when "/saml/assertions" + return saml_assertions(Rack::Request.new(env)) + else + return not_found + end + end + not_found + end + + private + + def not_found + [404, { 'X-Backend-Server' => 'UI' }, []] + end + + def redirect_to(location) + [302, { 'Location' => location }, []] + end + + def oauth_callback(request) + response = Net::Hippie.default_client.post( + "http://localhost:8282/oauth/token", + headers: { 'Authorization' => Net::Hippie.basic_auth('client_id', 'secret') }, + body: { + grant_type: "authorization_code", + code: request.params['code'], + code_verifier: "not_implemented" + } + ) + [200, { "Content-Type" => "application/json" }, [JSON.pretty_generate(request.params.merge(JSON.parse(response.body)))]] + end + + def saml_post_to_idp(request) + idp = Saml::Kit.registry.metadata_for('http://localhost:8282/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 + + template = <<~ERB + + + + +

Sending SAML Request (SP -> IdP)

+ + +
+ <%- saml_params.each do |(key, value)| -%> + + <%- end -%> + +
+ + + ERB + html = ERB.new(template, trim_mode: '-').result(binding) + [200, { 'Content-Type' => "text/html" }, [html]] + end + + def saml_assertions(request) + 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? + + template = <<~ERB + + + + +

Received SAML Response

+ + + + ERB + erb = ERB.new(template, trim_mode: '-') + html = erb.result(binding) + [200, { 'Content-Type' => "text/html" }, [html]] + end +end + +if __FILE__ == $0 + app = Rack::Builder.new do + use Rack::Reloader + run UI.new + end.to_app + + Rackup::Server.start(app: app, Port: $port) +end -- cgit v1.2.3