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.
@@ -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