prx_auth 1.1.0

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,3 @@
1
+ module PrxAuth
2
+ VERSION = "1.1.0"
3
+ end
@@ -0,0 +1,63 @@
1
+ require 'json/jwt'
2
+ require 'rack/prx_auth/certificate'
3
+ require 'rack/prx_auth/token_data'
4
+ require 'prx_auth'
5
+
6
+ module Rack
7
+ class PrxAuth
8
+ INVALID_TOKEN = [
9
+ 401, {'Content-Type' => 'application/json'},
10
+ [{status: 401, error: 'Invalid JSON Web Token'}.to_json]
11
+ ]
12
+
13
+ DEFAULT_ISS = 'id.prx.org'
14
+
15
+ attr_reader :issuer
16
+
17
+ def initialize(app, options = {})
18
+ @app = app
19
+ @certificate = Certificate.new(options[:cert_location])
20
+ @issuer = options[:issuer] || DEFAULT_ISS
21
+ end
22
+
23
+ def call(env)
24
+ return @app.call(env) unless env['HTTP_AUTHORIZATION']
25
+
26
+ token = env['HTTP_AUTHORIZATION'].split[1]
27
+ claims = decode_token(token)
28
+
29
+ return @app.call(env) unless should_validate_token?(claims)
30
+
31
+ if valid?(claims, token)
32
+ env['prx.auth'] = TokenData.new(claims)
33
+ @app.call(env)
34
+ else
35
+ INVALID_TOKEN
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ def valid?(claims, token)
42
+ !expired?(claims) && @certificate.valid?(token)
43
+ end
44
+
45
+ def decode_token(token)
46
+ return {} if token.nil?
47
+
48
+ begin
49
+ JSON::JWT.decode(token, :skip_verification)
50
+ rescue JSON::JWT::InvalidFormat
51
+ {}
52
+ end
53
+ end
54
+
55
+ def expired?(claims)
56
+ Time.now.to_i > (claims['iat'] + claims['exp'])
57
+ end
58
+
59
+ def should_validate_token?(claims)
60
+ claims['iss'] == @issuer
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,55 @@
1
+ require 'json/jwt'
2
+ require 'net/http'
3
+
4
+ module Rack
5
+ class PrxAuth
6
+ class Certificate
7
+ EXPIRES_IN = 43200
8
+ DEFAULT_CERT_LOC = URI('https://id.prx.org/api/v1/certs')
9
+
10
+ attr_reader :cert_location
11
+
12
+ def initialize(cert_uri = nil)
13
+ @cert_location = cert_uri.nil? ? DEFAULT_CERT_LOC : URI(cert_uri)
14
+ end
15
+
16
+ def valid?(token)
17
+ begin
18
+ JSON::JWT.decode(token, public_key)
19
+ rescue JSON::JWT::VerificationFailed
20
+ false
21
+ else
22
+ true
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ def public_key
29
+ certificate.public_key
30
+ end
31
+
32
+ def certificate
33
+ if @certificate.nil? || needs_refresh?
34
+ @certificate = fetch
35
+ end
36
+ @certificate
37
+ end
38
+
39
+ def fetch
40
+ certs = JSON.parse(Net::HTTP.get(cert_location))
41
+ cert_string = certs['certificates'].values[0]
42
+ @refresh_at = Time.now.to_i + EXPIRES_IN
43
+ OpenSSL::X509::Certificate.new(cert_string)
44
+ end
45
+
46
+ def needs_refresh?
47
+ expired? || @refresh_at <= Time.now.to_i
48
+ end
49
+
50
+ def expired?
51
+ @certificate.not_after < Time.now
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,53 @@
1
+ require 'prx_auth/resource_map'
2
+
3
+ module Rack
4
+ class PrxAuth
5
+ class TokenData
6
+ attr_reader :scopes
7
+
8
+ def initialize(attrs = {})
9
+ @attributes = attrs
10
+
11
+ @authorized_resources = ::PrxAuth::ResourceMap.new(unpack_aur(attrs['aur'])).freeze
12
+
13
+ if attrs['scope']
14
+ @scopes = attrs['scope'].split(' ').freeze
15
+ else
16
+ @scopes = [].freeze
17
+ end
18
+ end
19
+
20
+ def resources(namespace=nil, scope=nil)
21
+ @authorized_resources.resources(namespace, scope)
22
+ end
23
+
24
+ def user_id
25
+ @attributes['sub']
26
+ end
27
+
28
+ def authorized?(resource, namespace=nil, scope=nil)
29
+ @authorized_resources.contains?(resource, namespace, scope)
30
+ end
31
+
32
+ def globally_authorized?(namespace, scope=nil)
33
+ authorized?(::PrxAuth::ResourceMap::WILDCARD_KEY, namespace, scope)
34
+ end
35
+
36
+ private
37
+
38
+ def unpack_aur(aur)
39
+ return {} if aur.nil?
40
+
41
+ aur.clone.tap do |result|
42
+ unless result['$'].nil?
43
+ result.delete('$').each do |role, resources|
44
+ resources.each do |res|
45
+ result[res.to_s] = role
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,7 @@
1
+ require 'prx_auth/version'
2
+
3
+ module Rack
4
+ class PrxAuth
5
+ VERSION = PrxAuth::VERSION
6
+ end
7
+ end
@@ -0,0 +1,32 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'prx_auth/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "prx_auth"
8
+ spec.version = PrxAuth::VERSION
9
+ spec.authors = ["Eve Asher", "Chris Rhoden"]
10
+ spec.email = ["eve@prx.org", "carhoden@gmail.com"]
11
+ spec.summary = %q{Utilites for parsing PRX JWTs and Rack middleware that verifies and attaches the token's claims to env.}
12
+ spec.description = %q{Specific to PRX. Will ignore tokens that were not issued by PRX.}
13
+ spec.homepage = "https://github.com/PRX/prx_auth"
14
+ spec.license = "MIT"
15
+
16
+ spec.files = `git ls-files -z`.split("\x0")
17
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
18
+ spec.test_files = spec.files.grep(%r{^test/})
19
+ spec.require_paths = ["lib"]
20
+
21
+ spec.required_ruby_version = '>= 2.3'
22
+
23
+ spec.add_development_dependency 'bundler', '~> 2.0'
24
+ spec.add_development_dependency 'rake', '~> 10.0'
25
+ spec.add_development_dependency 'coveralls', '~> 0'
26
+ spec.add_development_dependency 'guard'
27
+ spec.add_development_dependency 'guard-minitest'
28
+
29
+ spec.add_dependency 'rack', '>= 1.5.2'
30
+ spec.add_dependency 'json', '>= 1.8.1'
31
+ spec.add_dependency 'json-jwt', '~> 1.9.4'
32
+ end
@@ -0,0 +1,158 @@
1
+ require 'test_helper'
2
+
3
+ describe PrxAuth::ResourceMap do
4
+
5
+ def new_map(val)
6
+ PrxAuth::ResourceMap.new(val)
7
+ end
8
+
9
+ let(:map) { PrxAuth::ResourceMap.new(input) }
10
+ let(:input) { {'123' => 'admin one two three ns1:namespaced', '456' => 'member four five six' } }
11
+
12
+ describe '#authorized?' do
13
+ it 'contains scopes in list' do
14
+ assert map.contains?(123, :admin)
15
+ end
16
+
17
+ it 'does not include across aur limits' do
18
+ assert !map.contains?(123, :member)
19
+ end
20
+
21
+ it 'does not require a scope' do
22
+ assert map.contains?(123)
23
+ end
24
+
25
+ it 'does not match if it hasnt seen the resource' do
26
+ assert !map.contains?(789)
27
+ end
28
+
29
+ it 'works with namespaced scopes' do
30
+ assert map.contains?(123, :ns1, :namespaced)
31
+ end
32
+
33
+ describe 'with wildcard resource' do
34
+ let(:input) do
35
+ {
36
+ '*' => 'peek',
37
+ '123' => 'admin one two three',
38
+ '456' => 'member four five six'
39
+ }
40
+ end
41
+
42
+ it 'applies wildcard lists to queries with no matching value' do
43
+ assert map.contains?(789, :peek)
44
+ end
45
+
46
+ it 'does not scan unscoped for wildcard resources' do
47
+ assert !map.contains?(789)
48
+ end
49
+
50
+ it 'allows querying by wildcard resource directly' do
51
+ assert map.contains?('*', :peek)
52
+ assert !map.contains?('*', :admin)
53
+ end
54
+
55
+ it 'treats wildcard lists as additive to other explicit ones' do
56
+ assert map.contains?(123, :peek)
57
+ end
58
+
59
+ it 'refuses to run against wildcard with no scope' do
60
+ assert_raises ArgumentError do
61
+ map.contains?('*')
62
+ end
63
+ end
64
+ end
65
+ end
66
+
67
+ describe '#resources' do
68
+ let (:input) do
69
+ {
70
+ '*' => 'read wildcard',
71
+ '123' => 'read write buy',
72
+ '456' => 'read ns1:buy'
73
+ }
74
+ end
75
+
76
+ let (:resources) { map.resources }
77
+
78
+ it 'returns resource ids' do
79
+ assert resources.include?('123')
80
+ assert resources.include?('456')
81
+ end
82
+
83
+ it 'excludes wildcard values' do
84
+ assert !resources.include?('*')
85
+ end
86
+
87
+ it 'filters for scope' do
88
+ resources = map.resources(:write)
89
+ assert resources.include?('123')
90
+ assert !resources.include?('456')
91
+ assert !resources.include?('*')
92
+ end
93
+
94
+ it 'works with namespaces' do
95
+ resources = map.resources(:ns1, :buy)
96
+ assert resources.include?('123')
97
+ assert resources.include?('456')
98
+
99
+ resources = map.resources(:buy)
100
+ assert !resources.include?('456')
101
+ end
102
+ end
103
+
104
+ describe '#condense' do
105
+ let (:input) {{ "one" => "one two three ns1:one", "two" => "two three", "three" => "two", "*" => "two" }}
106
+ let (:json) { map.condense.as_json }
107
+
108
+ it "removes redundant values which are in the wildcard" do
109
+ assert !json["one"].include?("two")
110
+ end
111
+
112
+ it "keeps resources in the hash even if all scopes are redundant" do
113
+ assert json["three"] == ""
114
+ end
115
+ end
116
+
117
+ describe '#+' do
118
+ it 'adds values' do
119
+ map = new_map("one" => "two", "two" => "four") + new_map("one" => "three", "three" => "six")
120
+ assert map.contains?('one', :two) && map.contains?('one', :three)
121
+ assert map.contains?('two', :four) && map.contains?('three', :six)
122
+ end
123
+ end
124
+
125
+ describe '#-' do
126
+ it 'subtracts values' do
127
+ map = new_map("one" => "two three", "two" => "four") - new_map("one" => "three four")
128
+ assert map.contains?('one', :two)
129
+ assert map.contains?('two', :four)
130
+ assert !map.contains?('one', :three) && !map.contains?('one', :four)
131
+ end
132
+
133
+ it 'works on wildcards on right side of operator' do
134
+ map = new_map("one" => "two three") - new_map("*" => "two")
135
+ assert !map.contains?("one", :two)
136
+ end
137
+ end
138
+
139
+ describe '#&' do
140
+ it 'computes the intersection' do
141
+ map = (
142
+ new_map("one" => "two three", "four" => "five six", "five" => "five") &
143
+ new_map("one" => "three four", "four" => "six seven", "six" => "six")
144
+ )
145
+ assert map.contains?("one", :three) && map.contains?("four", :six)
146
+ assert !map.contains?("one", :two) && !map.contains?("four", :five)
147
+ assert !map.contains?("one", :four) && !map.contains?("four", :seven)
148
+ assert !map.contains?("five", :five) && !map.contains?("six", :six)
149
+ end
150
+
151
+ it 'works with wildcards' do
152
+ map = new_map("*" => "three wild", "one" => "four two" ) & new_map("*" => "two wild", "two" => "three four")
153
+ assert map.contains?("two", :three) && map.contains?("one", :two)
154
+ assert !map.contains?("one", :four) && !map.contains?("two", :four)
155
+ assert map.contains?("*", :wild)
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,102 @@
1
+ require 'test_helper'
2
+
3
+ describe PrxAuth::ScopeList do
4
+
5
+ def new_list(val)
6
+ PrxAuth::ScopeList.new(val)
7
+ end
8
+
9
+ let (:scopes) { 'read write sell top-up' }
10
+ let (:list) { PrxAuth::ScopeList.new(scopes) }
11
+
12
+ it 'looks up successfully for a given scope' do
13
+ assert list.contains?('write')
14
+ end
15
+
16
+ it 'scans for symbols' do
17
+ assert list.contains?(:read)
18
+ end
19
+
20
+ it 'handles hyphen to underscore conversions' do
21
+ assert list.contains?(:top_up)
22
+ end
23
+
24
+ it 'fails for contents not in the list' do
25
+ assert !list.contains?(:buy)
26
+ end
27
+
28
+ describe 'with namespace' do
29
+ let (:scopes) { 'ns1:hello ns2:goodbye aloha 1:23' }
30
+
31
+ it 'works for namespaced lookups' do
32
+ assert list.contains?(:ns1, :hello)
33
+ end
34
+
35
+ it 'fails when the wrong namespace is passed' do
36
+ assert !list.contains?(:ns1, :goodbye)
37
+ end
38
+
39
+ it 'looks up global scopes when namespaced fails' do
40
+ assert list.contains?(:ns1, :aloha)
41
+ assert list.contains?(:ns3, :aloha)
42
+ end
43
+
44
+ it 'works with non-symbol namespaces' do
45
+ assert list.contains?(1, 23)
46
+ end
47
+ end
48
+
49
+ describe '#condense' do
50
+ let (:scopes) { "ns1:foo foo ns1:bar" }
51
+ it 'removes redundant scopes based on namespace wildcards' do
52
+ assert list.condense.to_s == "foo ns1:bar"
53
+ end
54
+ end
55
+
56
+ describe '#-' do
57
+ it 'subtracts scopes' do
58
+ sl = new_list('one two') - new_list('two')
59
+ assert sl.kind_of? PrxAuth::ScopeList
60
+ assert !sl.contains?(:two)
61
+ assert sl.contains?(:one)
62
+ end
63
+
64
+ it 'works with scope wildcards' do
65
+ sl = new_list('ns1:one ns2:two') - new_list('one')
66
+ assert !sl.contains?(:ns1, :one)
67
+ end
68
+
69
+ it 'accepts nil' do
70
+ sl = new_list('one two') - nil
71
+ assert sl.contains?(:one) && sl.contains?(:two)
72
+ end
73
+ end
74
+
75
+ describe '#+' do
76
+ it 'adds scopes' do
77
+ sl = new_list('one') + new_list('two')
78
+ assert sl.kind_of? PrxAuth::ScopeList
79
+ assert sl.contains?(:one)
80
+ assert sl.contains?(:two)
81
+ end
82
+
83
+ it 'accepts nil' do
84
+ sl = new_list('one two') + nil
85
+ assert sl.contains?(:one) && sl.contains?(:two)
86
+ end
87
+ end
88
+
89
+ describe '#&' do
90
+ it 'gets the intersect of scopes' do
91
+ sl = (new_list('one two three four') & new_list('two four six'))
92
+ assert sl.kind_of? PrxAuth::ScopeList
93
+ assert sl.contains?(:two) && sl.contains?(:four)
94
+ assert !sl.contains?(:one) && !sl.contains?(:three) && !sl.contains?(:six)
95
+ end
96
+
97
+ it 'accepts nil' do
98
+ sl = new_list('one') & nil
99
+ assert !sl.contains?(:one)
100
+ end
101
+ end
102
+ end