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