prx_auth 1.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +16 -0
- data/.travis.yml +12 -0
- data/CHANGELOG.md +20 -0
- data/Gemfile +4 -0
- data/Guardfile +8 -0
- data/LICENSE +22 -0
- data/README.md +66 -0
- data/Rakefile +10 -0
- data/lib/prx_auth.rb +3 -0
- data/lib/prx_auth/resource_map.rb +124 -0
- data/lib/prx_auth/scope_list.rb +138 -0
- data/lib/prx_auth/version.rb +3 -0
- data/lib/rack/prx_auth.rb +63 -0
- data/lib/rack/prx_auth/certificate.rb +55 -0
- data/lib/rack/prx_auth/token_data.rb +53 -0
- data/lib/rack/prx_auth/version.rb +7 -0
- data/prx_auth.gemspec +32 -0
- data/test/prx_auth/resource_map_test.rb +158 -0
- data/test/prx_auth/scope_list_test.rb +102 -0
- data/test/rack/prx_auth/certificate_test.rb +130 -0
- data/test/rack/prx_auth/token_data_test.rb +101 -0
- data/test/rack/prx_auth_test.rb +97 -0
- data/test/test_helper.rb +10 -0
- metadata +187 -0
@@ -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
|
data/prx_auth.gemspec
ADDED
@@ -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
|