rapid-rack 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,6 @@
1
+ RapidRack::Engine.routes.draw do
2
+ opts = Rails.application.config.rapid_rack
3
+ authenticator = opts.authenticator.constantize.new(opts)
4
+
5
+ mount authenticator => ''
6
+ end
@@ -0,0 +1 @@
1
+ require 'rapid_rack'
@@ -0,0 +1,8 @@
1
+ module RapidRack
2
+ end
3
+
4
+ require 'rapid_rack/version'
5
+ require 'rapid_rack/authenticator'
6
+ require 'rapid_rack/default_receiver'
7
+ require 'rapid_rack/redis_registry'
8
+ require 'rapid_rack/engine' if defined?(Rails)
@@ -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
@@ -0,0 +1,12 @@
1
+ module RapidRack
2
+ module RedisRegistry
3
+ def register_jti(jti)
4
+ key = "rapid_rack:jti:#{jti}"
5
+ redis.setnx(key, 1) && redis.expire(key, 60)
6
+ end
7
+
8
+ def redis
9
+ Redis.new
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,3 @@
1
+ module RapidRack
2
+ VERSION = '0.0.1'
3
+ end
@@ -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,2 @@
1
+ class TestSubject < ActiveRecord::Base
2
+ end
@@ -0,0 +1,2 @@
1
+ require ::File.expand_path('../config/environment', __FILE__)
2
+ run Rails.application
@@ -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,4 @@
1
+ ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../../../Gemfile', __FILE__)
2
+
3
+ require 'bundler/setup' if File.exist?(ENV['BUNDLE_GEMFILE'])
4
+ $LOAD_PATH.unshift File.expand_path('../../../../lib', __FILE__)
@@ -0,0 +1,5 @@
1
+ test:
2
+ adapter: sqlite3
3
+ pool: 1
4
+ timeout: 5000
5
+ database: ':memory:'
@@ -0,0 +1,2 @@
1
+ require File.expand_path('../application', __FILE__)
2
+ Rails.application.initialize!
@@ -0,0 +1,5 @@
1
+ ---
2
+ url: 'https://rapid.example.com/jwt/authnrequest/research/0vs2aoAbd5bH6HRK'
3
+ secret: '5>O+`=2x%`\=.""f,6KDxV2p|MEE*P<]'
4
+ issuer: 'https://rapid.example.com'
5
+ audience: 'https://service.example.com'
@@ -0,0 +1,3 @@
1
+ Rails.application.routes.draw do
2
+ mount RapidRack::Engine => '/auth'
3
+ end
@@ -0,0 +1,2 @@
1
+ test:
2
+ secret_key_base: 890f88caa9641d1845e85a49fd9aedbd30cf507f223dcb1cb6ccc53ba23731d
@@ -0,0 +1,7 @@
1
+ ActiveRecord::Schema.define(version: 0) do
2
+ create_table(:test_subjects, force: true) do |t|
3
+ t.string :targeted_id, null: false
4
+ t.string :name, null: false
5
+ t.string :email, null: false
6
+ end
7
+ end
@@ -0,0 +1,5 @@
1
+ class TestErrorHandler
2
+ def handle(_env, _error)
3
+ [400, {}, ['Error!']]
4
+ end
5
+ end
@@ -0,0 +1,13 @@
1
+ class TestReceiver
2
+ def receive(_env, _claims)
3
+ [200, {}, ['Permitted']]
4
+ end
5
+
6
+ def logout(_env)
7
+ [200, {}, ['Logged Out!']]
8
+ end
9
+
10
+ def register_jti(jti)
11
+ jti == 'accept'
12
+ end
13
+ 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