#!/usr/bin/env ruby
require "bundler/inline"
gemfile do
source "https://rubygems.org"
gem "declarative_policy", "~> 1.0"
gem "erb", "~> 4.0"
gem "globalid", "~> 1.0"
gem "google-protobuf", "~> 3.0"
gem "rack", "~> 3.0"
gem "rackup", "~> 2.0"
gem "saml-kit", "~> 1.0"
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", 8282).to_i
$host = ENV.fetch("HOST", "localhost:#{$port}")
module HTTPHelpers
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
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]
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
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 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
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
Recieved SAML Request
Sending SAML Response (IdP -> SP)
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)
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
class Organization
class << self
def find(id)
new
end
end
end
DeclarativePolicy.configure do
name_transformation do |name|
"::Authz::#{name}Policy"
end
end
module Authz
class OrganizationPolicy < DeclarativePolicy::Base
condition(:owner) { true }
rule { owner }.enable :create_project
end
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 Ability
def allowed(request, env)
{
result: can?(request)
}
end
private
def can?(request)
subject = subject_of(request.subject)
resource = resource_from(request.resource)
policy = DeclarativePolicy.policy_for(subject, resource)
policy.can?(request.permission.to_sym)
end
def subject_of(token)
_header, claims, _signature = from_jwt(token)
claims[:sub]
end
def resource_from(global_id)
GlobalID::Locator.locate(global_id)
end
# TODO:: validate signature
def from_jwt(token)
token
.split('.', 3)
.map { |x| JSON.parse(Base64.strict_decode64(x), symbolize_names: true) }
end
end
end
class OAuthController
include ::HTTPHelpers
def call(env)
path = env['PATH_INFO']
case env['REQUEST_METHOD']
when 'GET'
case path
when "/authorize" # RFC-6749
return get_authorize(Rack::Request.new(env))
else
return http_not_found
end
when 'POST'
case path
when "/authorize" # RFC-6749
return post_authorize(Rack::Request.new(env))
when "/token" # RFC-6749
return [200, { 'Content-Type' => "application/json" }, [JSON.pretty_generate({
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 http_not_found
else
return http_not_found
end
end
http_not_found
end
def get_authorize(request)
template = <<~ERB
Authorize?
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')
case params['response_type']
when 'code'
case params['response_mode']
when 'fragment'
return [302, { 'Location' => "#{params['redirect_uri']}#code=#{SecureRandom.uuid}&state=#{params['state']}" }, []]
when 'query'
return [302, { 'Location' => "#{params['redirect_uri']}?code=#{SecureRandom.uuid}&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)
path = env['PATH_INFO']
case env['REQUEST_METHOD']
when 'GET'
case path
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
# 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
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
run IdentityProvider.new
end.to_app
Rackup::Server.start(app: app, Port: $port)
end