rapid-rack 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.gitignore +23 -0
- data/.rspec +3 -0
- data/.rubocop.yml +6 -0
- data/.simplecov +3 -0
- data/Gemfile +4 -0
- data/Guardfile +16 -0
- data/LICENSE +202 -0
- data/README.md +165 -0
- data/Rakefile +8 -0
- data/config/routes.rb +6 -0
- data/lib/rapid-rack.rb +1 -0
- data/lib/rapid_rack.rb +8 -0
- data/lib/rapid_rack/authenticator.rb +121 -0
- data/lib/rapid_rack/default_receiver.rb +30 -0
- data/lib/rapid_rack/engine.rb +33 -0
- data/lib/rapid_rack/redis_registry.rb +12 -0
- data/lib/rapid_rack/version.rb +3 -0
- data/rapid-rack.gemspec +36 -0
- data/spec/dummy/app/models/test_subject.rb +2 -0
- data/spec/dummy/config.ru +2 -0
- data/spec/dummy/config/application.rb +24 -0
- data/spec/dummy/config/boot.rb +4 -0
- data/spec/dummy/config/database.yml +5 -0
- data/spec/dummy/config/environment.rb +2 -0
- data/spec/dummy/config/rapidconnect.yml +5 -0
- data/spec/dummy/config/routes.rb +3 -0
- data/spec/dummy/config/secrets.yml +2 -0
- data/spec/dummy/db/schema.rb +7 -0
- data/spec/dummy/lib/test_error_handler.rb +5 -0
- data/spec/dummy/lib/test_receiver.rb +13 -0
- data/spec/lib/rapid_rack/authenticator_spec.rb +26 -0
- data/spec/lib/rapid_rack/default_receiver_spec.rb +115 -0
- data/spec/lib/rapid_rack/engine_spec.rb +92 -0
- data/spec/lib/rapid_rack/redis_registry_spec.rb +27 -0
- data/spec/spec_helper.rb +45 -0
- data/spec/support/authenticator_examples.rb +216 -0
- data/spec/support/temporary_test_class.rb +8 -0
- metadata +296 -0
data/config/routes.rb
ADDED
data/lib/rapid-rack.rb
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
require 'rapid_rack'
|
data/lib/rapid_rack.rb
ADDED
@@ -0,0 +1,121 @@
|
|
1
|
+
require 'json/jwt'
|
2
|
+
require 'rack/utils'
|
3
|
+
|
4
|
+
module RapidRack
|
5
|
+
class Authenticator
|
6
|
+
def initialize(opts)
|
7
|
+
@url = opts[:url]
|
8
|
+
@receiver = opts[:receiver].try(:constantize)
|
9
|
+
fail('A receiver must be configured for rapid_rack') if @receiver.nil?
|
10
|
+
@secret = opts[:secret]
|
11
|
+
@issuer = opts[:issuer]
|
12
|
+
@audience = opts[:audience]
|
13
|
+
@error_handler = opts[:error_handler].try(:constantize).try(:new) || self
|
14
|
+
end
|
15
|
+
|
16
|
+
def call(env)
|
17
|
+
sym = DISPATCH[env['PATH_INFO']]
|
18
|
+
return send(sym, env) if sym
|
19
|
+
|
20
|
+
[404, {}, ["Not found: #{env['PATH_INFO']}"]]
|
21
|
+
end
|
22
|
+
|
23
|
+
def handle(_env, _exception)
|
24
|
+
[
|
25
|
+
400, { 'Content-Type' => 'text/plain' }, [
|
26
|
+
'Sorry, your attempt to log in to this service was not successful. ',
|
27
|
+
'Please contact the service owner for assistance, and include the ',
|
28
|
+
'link you used to access this service.'
|
29
|
+
]
|
30
|
+
]
|
31
|
+
end
|
32
|
+
|
33
|
+
private
|
34
|
+
|
35
|
+
InvalidClaim = Class.new(StandardError)
|
36
|
+
private_constant :InvalidClaim
|
37
|
+
|
38
|
+
DISPATCH = {
|
39
|
+
'/login' => :initiate,
|
40
|
+
'/jwt' => :callback,
|
41
|
+
'/logout' => :terminate
|
42
|
+
}
|
43
|
+
private_constant :DISPATCH
|
44
|
+
|
45
|
+
def initiate(env)
|
46
|
+
return method_not_allowed unless method?(env, 'GET')
|
47
|
+
|
48
|
+
[302, { 'Location' => @url }, []]
|
49
|
+
end
|
50
|
+
|
51
|
+
def callback(env)
|
52
|
+
return method_not_allowed unless method?(env, 'POST')
|
53
|
+
params = Rack::Utils.parse_query(env['rack.input'].read)
|
54
|
+
|
55
|
+
with_claims(env, params['assertion']) do |claims|
|
56
|
+
receiver.receive(env, claims)
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
def terminate(env)
|
61
|
+
return method_not_allowed unless method?(env, 'GET')
|
62
|
+
|
63
|
+
receiver.logout(env)
|
64
|
+
end
|
65
|
+
|
66
|
+
def with_claims(env, assertion)
|
67
|
+
claims = JSON::JWT.decode(assertion, @secret)
|
68
|
+
validate_claims(claims)
|
69
|
+
yield claims
|
70
|
+
rescue JSON::JWT::Exception => e
|
71
|
+
@error_handler.handle(env, e)
|
72
|
+
rescue InvalidClaim => e
|
73
|
+
@error_handler.handle(env, e)
|
74
|
+
end
|
75
|
+
|
76
|
+
def validate_claims(claims)
|
77
|
+
reject_claim_if(claims, 'aud') { |v| v != @audience }
|
78
|
+
reject_claim_if(claims, 'iss') { |v| v != @issuer }
|
79
|
+
reject_claim_if(claims, 'typ') { |v| v != 'authnresponse' }
|
80
|
+
reject_claim_if(claims, 'jti', &method(:replayed?))
|
81
|
+
reject_claim_if(claims, 'nbf', &:zero?)
|
82
|
+
reject_claim_if(claims, 'nbf', &method(:future?))
|
83
|
+
reject_claim_if(claims, 'exp', &method(:expired?))
|
84
|
+
reject_claim_if(claims, 'iat', &method(:skewed?))
|
85
|
+
end
|
86
|
+
|
87
|
+
def replayed?(jti)
|
88
|
+
!receiver.register_jti(jti)
|
89
|
+
end
|
90
|
+
|
91
|
+
def skewed?(iat)
|
92
|
+
(iat - Time.now.to_i).abs > 60
|
93
|
+
end
|
94
|
+
|
95
|
+
def expired?(exp)
|
96
|
+
Time.at(exp) < Time.now
|
97
|
+
end
|
98
|
+
|
99
|
+
def future?(nbf)
|
100
|
+
Time.at(nbf) > Time.now
|
101
|
+
end
|
102
|
+
|
103
|
+
def reject_claim_if(claims, key)
|
104
|
+
val = claims[key]
|
105
|
+
fail(InvalidClaim, "nil #{key}") unless val
|
106
|
+
fail(InvalidClaim, "bad #{key}: #{val}") if yield(val)
|
107
|
+
end
|
108
|
+
|
109
|
+
def method?(env, method)
|
110
|
+
env['REQUEST_METHOD'] == method
|
111
|
+
end
|
112
|
+
|
113
|
+
def method_not_allowed
|
114
|
+
[405, {}, ['Method not allowed']]
|
115
|
+
end
|
116
|
+
|
117
|
+
def receiver
|
118
|
+
@receiver.new
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
module RapidRack
|
2
|
+
module DefaultReceiver
|
3
|
+
def receive(env, claims)
|
4
|
+
attrs = map_attributes(claims['https://aaf.edu.au/attributes'])
|
5
|
+
store_id(env, subject(attrs).id)
|
6
|
+
finish(env)
|
7
|
+
end
|
8
|
+
|
9
|
+
def map_attributes(attrs)
|
10
|
+
attrs
|
11
|
+
end
|
12
|
+
|
13
|
+
def store_id(env, id)
|
14
|
+
env['rack.session']['subject_id'] = id
|
15
|
+
end
|
16
|
+
|
17
|
+
def finish(_env)
|
18
|
+
redirect_to('/')
|
19
|
+
end
|
20
|
+
|
21
|
+
def redirect_to(url)
|
22
|
+
[302, { 'Location' => url }, []]
|
23
|
+
end
|
24
|
+
|
25
|
+
def logout(env)
|
26
|
+
env['rack.session'].clear
|
27
|
+
redirect_to('/')
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
require 'yaml'
|
2
|
+
|
3
|
+
module RapidRack
|
4
|
+
class Engine < ::Rails::Engine
|
5
|
+
isolate_namespace RapidRack
|
6
|
+
|
7
|
+
configure do
|
8
|
+
config.rapid_rack = OpenStruct.new
|
9
|
+
end
|
10
|
+
|
11
|
+
initializer 'rapid_rack.build_rack_application' do
|
12
|
+
config.rapid_rack = OpenStruct.new(configuration)
|
13
|
+
config.rapid_rack.authenticator = authenticator
|
14
|
+
end
|
15
|
+
|
16
|
+
def configuration
|
17
|
+
return @configuration if @configuration
|
18
|
+
|
19
|
+
file = Rails.root.join('config/rapidconnect.yml')
|
20
|
+
fail("Missing configuration: #{file}") unless File.exist?(file)
|
21
|
+
|
22
|
+
opts_from_file = YAML.load_file(file).symbolize_keys
|
23
|
+
opts_from_app = config.rapid_rack.to_h
|
24
|
+
|
25
|
+
@configuration = opts_from_file.merge(opts_from_app)
|
26
|
+
end
|
27
|
+
|
28
|
+
def authenticator
|
29
|
+
return 'RapidRack::MockAuthenticator' if configuration[:development_mode]
|
30
|
+
'RapidRack::Authenticator'
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
data/rapid-rack.gemspec
ADDED
@@ -0,0 +1,36 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'rapid_rack/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = 'rapid-rack'
|
8
|
+
spec.version = RapidRack::VERSION
|
9
|
+
spec.authors = ['Shaun Mangelsdorf']
|
10
|
+
spec.email = ['s.mangelsdorf@gmail.com']
|
11
|
+
spec.summary = 'Rack middleware for AAF Rapid Connect authentication.'
|
12
|
+
spec.homepage = 'https://github.com/ausaccessfed/rapid-rack'
|
13
|
+
spec.license = 'Apache-2.0'
|
14
|
+
|
15
|
+
spec.files = `git ls-files -z`.split("\x0")
|
16
|
+
spec.executables = spec.files.grep(/^bin\//) { |f| File.basename(f) }
|
17
|
+
spec.test_files = spec.files.grep(/^(test|spec|features)\//)
|
18
|
+
spec.require_paths = ['lib']
|
19
|
+
|
20
|
+
spec.add_dependency 'json-jwt'
|
21
|
+
|
22
|
+
spec.add_development_dependency 'bundler', '~> 1.6'
|
23
|
+
spec.add_development_dependency 'rake'
|
24
|
+
spec.add_development_dependency 'rspec-rails'
|
25
|
+
spec.add_development_dependency 'capybara'
|
26
|
+
spec.add_development_dependency 'simplecov'
|
27
|
+
spec.add_development_dependency 'rails', '~> 4.1.7'
|
28
|
+
spec.add_development_dependency 'sqlite3'
|
29
|
+
spec.add_development_dependency 'fakeredis'
|
30
|
+
spec.add_development_dependency 'redis'
|
31
|
+
|
32
|
+
spec.add_development_dependency 'guard'
|
33
|
+
spec.add_development_dependency 'guard-rspec'
|
34
|
+
spec.add_development_dependency 'guard-rubocop'
|
35
|
+
spec.add_development_dependency 'guard-bundler'
|
36
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
require File.expand_path('../boot', __FILE__)
|
2
|
+
|
3
|
+
require 'active_record/railtie'
|
4
|
+
require 'action_controller/railtie'
|
5
|
+
require 'action_view/railtie'
|
6
|
+
|
7
|
+
Bundler.require(*Rails.groups)
|
8
|
+
require 'rapid_rack'
|
9
|
+
|
10
|
+
require_relative '../lib/test_receiver'
|
11
|
+
require_relative '../lib/test_error_handler'
|
12
|
+
|
13
|
+
module Dummy
|
14
|
+
class Application < Rails::Application
|
15
|
+
config.cache_classes = true
|
16
|
+
config.eager_load = false
|
17
|
+
|
18
|
+
config.consider_all_requests_local = true
|
19
|
+
config.action_dispatch.show_exceptions = false
|
20
|
+
|
21
|
+
config.rapid_rack.receiver = 'TestReceiver'
|
22
|
+
config.rapid_rack.error_handler = 'TestErrorHandler'
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
require 'rack/lobster'
|
2
|
+
|
3
|
+
module RapidRack
|
4
|
+
RSpec.describe Authenticator, type: :feature do
|
5
|
+
def build_app(prefix)
|
6
|
+
opts = { url: url, receiver: receiver, secret: secret,
|
7
|
+
issuer: issuer, audience: audience, error_handler: handler }
|
8
|
+
Rack::Builder.new do
|
9
|
+
use Rack::Lint
|
10
|
+
map(prefix) { run Authenticator.new(opts) }
|
11
|
+
run Rack::Lobster.new
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
let(:prefix) { '/auth' }
|
16
|
+
let(:issuer) { 'https://rapid.example.com' }
|
17
|
+
let(:audience) { 'https://service.example.com' }
|
18
|
+
let(:url) { 'https://rapid.example.com/jwt/authnrequest/research/abcd1234' }
|
19
|
+
let(:secret) { '1234abcd' }
|
20
|
+
let(:app) { build_app(prefix) }
|
21
|
+
|
22
|
+
subject { last_response }
|
23
|
+
|
24
|
+
it_behaves_like 'an authenticator'
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,115 @@
|
|
1
|
+
module RapidRack
|
2
|
+
RSpec.describe DefaultReceiver do
|
3
|
+
let(:creator) { double }
|
4
|
+
let(:overrides) { Module.new }
|
5
|
+
|
6
|
+
subject do
|
7
|
+
Class.new.tap do |klass|
|
8
|
+
class <<klass
|
9
|
+
attr_accessor :creator
|
10
|
+
delegate :subject, to: :creator
|
11
|
+
end
|
12
|
+
klass.send(:extend, described_class)
|
13
|
+
klass.send(:extend, overrides)
|
14
|
+
klass.creator = creator
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
let(:env) { { 'rack.session' => session } }
|
19
|
+
let(:session) { {} }
|
20
|
+
let(:claims) { { 'https://aaf.edu.au/attributes' => attrs } }
|
21
|
+
let(:attrs) do
|
22
|
+
{}
|
23
|
+
end
|
24
|
+
|
25
|
+
let(:authenticated_subject) { double(id: 1) }
|
26
|
+
|
27
|
+
context '#receive' do
|
28
|
+
before do
|
29
|
+
allow(creator).to receive(:subject).with(anything)
|
30
|
+
.and_return(authenticated_subject)
|
31
|
+
end
|
32
|
+
|
33
|
+
def run
|
34
|
+
subject.receive(env, claims)
|
35
|
+
end
|
36
|
+
|
37
|
+
it 'passes the attributes through to the subject method' do
|
38
|
+
expect(creator).to receive(:subject).with(attrs)
|
39
|
+
.and_return(authenticated_subject)
|
40
|
+
|
41
|
+
run
|
42
|
+
end
|
43
|
+
|
44
|
+
it 'sets the session key' do
|
45
|
+
expect { run }.to change { session['subject_id'] }.from(nil).to(1)
|
46
|
+
end
|
47
|
+
|
48
|
+
it 'redirects to a default location' do
|
49
|
+
expect(run).to eq([302, { 'Location' => '/' }, []])
|
50
|
+
end
|
51
|
+
|
52
|
+
context 'with an overridden `map_attributes` method' do
|
53
|
+
let(:overrides) do
|
54
|
+
Module.new do
|
55
|
+
def map_attributes(_)
|
56
|
+
{ 'remapped' => true }
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
it 'passes the mapped attributes through to the subject method' do
|
62
|
+
expect(creator).to receive(:subject).with('remapped' => true)
|
63
|
+
.and_return(authenticated_subject)
|
64
|
+
|
65
|
+
run
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
context 'with an overridden `store_id` method' do
|
70
|
+
let(:overrides) do
|
71
|
+
Module.new do
|
72
|
+
def store_id(env, id)
|
73
|
+
env['rack.session']['blarghn'] = id
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
it 'sets the configured session key' do
|
79
|
+
expect { run }.to change { session['blarghn'] }.from(nil).to(1)
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
context 'with an overridden `finish` method' do
|
84
|
+
let(:overrides) do
|
85
|
+
Module.new do
|
86
|
+
def finish(env)
|
87
|
+
header = { 'Location' => "/#{env['rack.session']['subject_id']}" }
|
88
|
+
[204, header, []]
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
it 'responds using the overridden method' do
|
94
|
+
expect(run).to eq([204, { 'Location' => '/1' }, []])
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
context '#logout' do
|
100
|
+
let(:session) { { 'something' => 'x' } }
|
101
|
+
|
102
|
+
def run
|
103
|
+
subject.logout(env)
|
104
|
+
end
|
105
|
+
|
106
|
+
it 'clears the session' do
|
107
|
+
expect { run }.to change { session }.to({})
|
108
|
+
end
|
109
|
+
|
110
|
+
it 'redirects to a default location' do
|
111
|
+
expect(run).to eq([302, { 'Location' => '/' }, []])
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|
115
|
+
end
|