#!/usr/bin/env ruby
require "bundler/inline"
gemfile do
source "https://rubygems.org"
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.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 current_user?(request)
current_user(request)
end
def current_user(request)
::Authn::User.find(request.session[:user_id])
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' => "http://idp.example.com:8080#{location}" }, []]
end
end
module Authn
class User
include ::BCrypt
class << self
def all
@all ||= ::CSV.read(File.join(__dir__, "../db/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_username(username)
all.find 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.create(self, app: "example").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'
request.session.delete(:user_id)
return get_login(request)
end
when Rack::POST
case request.path
when '/sessions'
if (user = User.login(request.params.transform_keys(&:to_sym)))
request.session[:user_id] = user[:id]
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.session.delete(:user_id)
return http_redirect_to('/')
end
end
http_not_found
end
private
def get_login(request)
template = <<~ERB
ERB
erb = ERB.new(template, trim_mode: '-')
html = erb.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
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)
Saml::Kit::Bindings::HttpPost.new(location: "#{$scheme}://#{$host}/saml/new")
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 :read_project
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
# TODO:: Look up saml_assertion
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' # RFC7522
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(saml_assertion)
raise NotImplementedError
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
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, message: "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
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')
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
Hello, <%= current_user(request)[:username] %>
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) }
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